From 2ebfec0280d9712715bbb5b6771ac1054d0dc667 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 12 Mar 2026 15:48:07 +0000 Subject: [PATCH 1/2] fix(legacy): handle old-format tunnel-info.json in TunnelManager The tunnel refactor in legacy-cli 5.x changed the serialization format of tunnel-info.json, adding an 'id' field and using it as the outer key. Old-format data (from CLI 4.x) lacks this field, causing a TypeError when TunnelManager::unserialize() passes null to Tunnel::__construct(). Fall back to generating the ID from metadata fields when 'id' is absent. Closes CLI-117 Co-Authored-By: Claude Opus 4.6 (1M context) --- legacy/src/Service/TunnelManager.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/legacy/src/Service/TunnelManager.php b/legacy/src/Service/TunnelManager.php index f4e8dbe8..9ceb8f7f 100644 --- a/legacy/src/Service/TunnelManager.php +++ b/legacy/src/Service/TunnelManager.php @@ -190,7 +190,9 @@ private function unserialize(string $jsonData): array foreach ($data as $item) { $metadata = $item; unset($metadata['id'], $metadata['localPort'], $metadata['remoteHost'], $metadata['remotePort'], $metadata['pid']); - $tunnels[] = new Tunnel($item['id'], $item['localPort'], $item['remoteHost'], $item['remotePort'], $metadata, $item['pid']); + // Handle old-format tunnel data that lacks an 'id' field. + $id = $item['id'] ?? $this->getId($metadata); + $tunnels[] = new Tunnel($id, $item['localPort'], $item['remoteHost'], $item['remotePort'], $metadata, $item['pid']); } return $tunnels; } From 0d308a4d53f2d45ea873de1590db06c7b9e4cb74 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 12 Mar 2026 16:02:56 +0000 Subject: [PATCH 2/2] test(legacy): add TunnelManager unserialize tests Cover new-format deserialization, old (4.x) format without 'id' field, and stability of the derived ID. Uses reflection to test the private unserialize() method directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- legacy/tests/Service/TunnelManagerTest.php | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 legacy/tests/Service/TunnelManagerTest.php diff --git a/legacy/tests/Service/TunnelManagerTest.php b/legacy/tests/Service/TunnelManagerTest.php new file mode 100644 index 00000000..2112e5b9 --- /dev/null +++ b/legacy/tests/Service/TunnelManagerTest.php @@ -0,0 +1,123 @@ +createMock(Config::class); + $io = $this->createMock(Io::class); + $relationships = $this->createMock(Relationships::class); + + return new TunnelManager($config, $io, $relationships); + } + + /** + * Calls the private unserialize() method via reflection. + * + * @return Tunnel[] + */ + private function callUnserialize(TunnelManager $manager, string $json): array + { + $method = new \ReflectionMethod($manager, 'unserialize'); + + return $method->invoke($manager, $json); + } + + public function testUnserializeNewFormat(): void + { + $tunnels = $this->callUnserialize($this->createManager(), (string) json_encode([ + 'proj1--main--app--database--0' => [ + 'projectId' => 'proj1', + 'environmentId' => 'main', + 'appName' => 'app', + 'relationship' => 'database', + 'serviceKey' => 0, + 'service' => ['scheme' => 'mysql', 'host' => 'database.internal', 'port' => 3306], + 'id' => 'proj1--main--app--database--0', + 'localPort' => 30000, + 'remoteHost' => 'database.internal', + 'remotePort' => 3306, + 'pid' => 12345, + ], + ])); + + $this->assertCount(1, $tunnels); + $tunnel = $tunnels[0]; + $this->assertSame('proj1--main--app--database--0', $tunnel->id); + $this->assertSame(30000, $tunnel->localPort); + $this->assertSame('database.internal', $tunnel->remoteHost); + $this->assertSame(3306, $tunnel->remotePort); + $this->assertSame(12345, $tunnel->pid); + $this->assertSame('proj1', $tunnel->metadata['projectId']); + } + + public function testUnserializeOldFormatWithoutId(): void + { + // 4.x-style tunnel-info.json: no 'id' field in the entry. + $tunnels = $this->callUnserialize($this->createManager(), (string) json_encode([ + 'some-old-key' => [ + 'projectId' => 'abc123', + 'environmentId' => 'staging', + 'appName' => 'web', + 'relationship' => 'redis', + 'serviceKey' => 1, + 'service' => ['scheme' => 'redis', 'host' => 'redis.internal', 'port' => 6379], + 'localPort' => 30001, + 'remoteHost' => 'redis.internal', + 'remotePort' => 6379, + 'pid' => 99999, + ], + ])); + + $this->assertCount(1, $tunnels); + $tunnel = $tunnels[0]; + // The ID should be derived from metadata fields. + $this->assertSame('abc123--staging--web--redis--1', $tunnel->id); + $this->assertSame(30001, $tunnel->localPort); + $this->assertSame('redis.internal', $tunnel->remoteHost); + $this->assertSame(6379, $tunnel->remotePort); + $this->assertSame(99999, $tunnel->pid); + $this->assertSame('abc123', $tunnel->metadata['projectId']); + $this->assertSame('staging', $tunnel->metadata['environmentId']); + $this->assertSame('web', $tunnel->metadata['appName']); + $this->assertSame('redis', $tunnel->metadata['relationship']); + $this->assertSame(1, $tunnel->metadata['serviceKey']); + } + + public function testUnserializeOldFormatDerivedIdIsStable(): void + { + $json = (string) json_encode([ + 'key' => [ + 'projectId' => 'proj2', + 'environmentId' => 'dev', + 'appName' => null, + 'relationship' => 'db', + 'serviceKey' => 0, + 'service' => ['scheme' => 'pgsql', 'host' => 'pg.internal', 'port' => 5432], + 'localPort' => 30002, + 'remoteHost' => 'pg.internal', + 'remotePort' => 5432, + 'pid' => null, + ], + ]); + + $manager = $this->createManager(); + $tunnels1 = $this->callUnserialize($manager, $json); + $tunnels2 = $this->callUnserialize($manager, $json); + + $this->assertSame($tunnels1[0]->id, $tunnels2[0]->id); + // Null appName becomes empty string in the ID. + $this->assertSame('proj2--dev----db--0', $tunnels1[0]->id); + } +}