From 8892e4f30e6cdec6cea42a2c541766fcccc58e05 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 26 Feb 2026 21:42:42 +1300 Subject: [PATCH] (test): Add unit tests for installer module, state, and config --- .../Platform/Modules/Installer/ModuleTest.php | 317 +++++ .../Modules/Installer/Runtime/ConfigTest.php | 508 +++++++ .../Modules/Installer/Runtime/StateTest.php | 1216 +++++++++++++++++ 3 files changed, 2041 insertions(+) create mode 100644 tests/unit/Platform/Modules/Installer/ModuleTest.php create mode 100644 tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php create mode 100644 tests/unit/Platform/Modules/Installer/Runtime/StateTest.php diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php new file mode 100644 index 0000000000..6281ba3361 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -0,0 +1,317 @@ +module = new Module(); + } + + protected function tearDown(): void + { + $this->module = null; + } + + public function testModuleHasHttpService(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $this->assertCount(1, $services); + } + + public function testHttpServiceRegistersAllActions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertCount(6, $actions); + $this->assertArrayHasKey('installerView', $actions); + $this->assertArrayHasKey('installerStatus', $actions); + $this->assertArrayHasKey('installerValidate', $actions); + $this->assertArrayHasKey('installerComplete', $actions); + $this->assertArrayHasKey('installerInstall', $actions); + $this->assertArrayHasKey('installerError', $actions); + } + + public function testViewAction(): void + { + $action = $this->getAction('installerView'); + + $this->assertEquals('installerView', View::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'installerConfig', 'installerPaths']); + } + + public function testStatusAction(): void + { + $action = $this->getAction('installerStatus'); + + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_GET, $action->getHttpMethod()); + $this->assertEquals('/install/status', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'installerState']); + } + + public function testValidateAction(): void + { + $action = $this->getAction('installerValidate'); + + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/validate', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response']); + } + + public function testCompleteAction(): void + { + $action = $this->getAction('installerComplete'); + + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/complete', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'installerState', 'swooleServer']); + } + + public function testInstallAction(): void + { + $action = $this->getAction('installerInstall'); + + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); + } + + public function testErrorAction(): void + { + $action = $this->getAction('installerError'); + + $this->assertEquals('installerError', Error::getName()); + $this->assertEquals(Action::TYPE_ERROR, $action->getType()); + $this->assertActionInjects($action, ['error', 'response']); + } + + public function testRouteRegistration(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + + foreach ($services as $service) { + foreach ($service->getActions() as $action) { + $type = $action->getType(); + + if ($type === Action::TYPE_ERROR) { + $hook = \Utopia\Http\Http::error(); + } else { + $httpMethod = $action->getHttpMethod(); + $httpPath = $action->getHttpPath(); + $this->assertNotNull($httpMethod, 'HTTP method must be set for default actions'); + $this->assertNotNull($httpPath, 'HTTP path must be set for default actions'); + $hook = \Utopia\Http\Http::addRoute($httpMethod, $httpPath); + } + + $hook->desc($action->getDesc() ?? ''); + + foreach ($action->getOptions() as $option) { + if ($option['type'] === 'injection') { + $hook->inject($option['name']); + } + } + + $hook->action($action->getCallback()); + } + } + + // If we get here without exceptions, route registration succeeded + $this->assertTrue(true); + } + + // --- Module service type coverage --- + + public function testModuleHasNoTaskServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_TASK); + $this->assertEmpty($services); + } + + public function testModuleHasNoWorkerServices(): void + { + $services = $this->module->getServicesByType(Service::TYPE_WORKER); + $this->assertEmpty($services); + } + + // --- Action descriptions --- + + public function testAllDefaultActionsHaveDescriptions(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $desc = $action->getDesc(); + if ($action->getType() !== Action::TYPE_ERROR) { + $this->assertNotNull($desc, "Action '$name' should have a description"); + $this->assertNotEmpty($desc, "Action '$name' description should not be empty"); + } + } + } + + // --- Action callbacks --- + + public function testAllActionsHaveCallableCallbacks(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + foreach ($service->getActions() as $name => $action) { + $callback = $action->getCallback(); + $this->assertIsCallable($callback, "Action '$name' callback should be callable"); + } + } + + // --- Error action specifics --- + + public function testErrorActionHasNoHttpMethod(): void + { + $action = $this->getAction('installerError'); + $this->assertNull($action->getHttpMethod()); + } + + public function testErrorActionHasNoHttpPath(): void + { + $action = $this->getAction('installerError'); + $this->assertNull($action->getHttpPath()); + } + + // --- Action names are unique --- + + public function testActionNamesAreUnique(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $names = array_keys($actions); + $this->assertEquals($names, array_unique($names)); + } + + // --- Route paths are unique per method --- + + public function testRoutePathsAreUniquePerMethod(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $routes = []; + foreach ($service->getActions() as $action) { + if ($action->getType() === Action::TYPE_ERROR) { + continue; + } + $key = $action->getHttpMethod() . ' ' . $action->getHttpPath(); + $this->assertArrayNotHasKey($key, $routes, "Duplicate route: $key"); + $routes[$key] = true; + } + } + + // --- Static getName returns correct values --- + + public function testStaticGetNameValues(): void + { + $this->assertEquals('installerView', View::getName()); + $this->assertEquals('installerStatus', Status::getName()); + $this->assertEquals('installerValidate', Validate::getName()); + $this->assertEquals('installerComplete', Complete::getName()); + $this->assertEquals('installerInstall', Install::getName()); + $this->assertEquals('installerError', Error::getName()); + } + + // --- Action instances are correct types --- + + public function testActionInstanceTypes(): void + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + + $this->assertInstanceOf(View::class, $actions['installerView']); + $this->assertInstanceOf(Status::class, $actions['installerStatus']); + $this->assertInstanceOf(Validate::class, $actions['installerValidate']); + $this->assertInstanceOf(Complete::class, $actions['installerComplete']); + $this->assertInstanceOf(Install::class, $actions['installerInstall']); + $this->assertInstanceOf(Error::class, $actions['installerError']); + } + + // --- GET routes use GET method, POST routes use POST --- + + public function testGetRoutesUseGetMethod(): void + { + $getActions = ['installerView', 'installerStatus']; + foreach ($getActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_GET, + $action->getHttpMethod(), + "Action '$name' should use GET method" + ); + } + } + + public function testPostRoutesUsePostMethod(): void + { + $postActions = ['installerValidate', 'installerComplete', 'installerInstall']; + foreach ($postActions as $name) { + $action = $this->getAction($name); + $this->assertEquals( + Action::HTTP_REQUEST_METHOD_POST, + $action->getHttpMethod(), + "Action '$name' should use POST method" + ); + } + } + + // --- Validate action exposes static CSRF method --- + + public function testValidateClassHasCsrfMethod(): void + { + $this->assertTrue( + method_exists(Validate::class, 'validateCsrf'), + 'Validate class should expose validateCsrf method' + ); + } + + private function getAction(string $name): Action + { + $services = $this->module->getServicesByType(Service::TYPE_HTTP); + $service = reset($services); + $actions = $service->getActions(); + $this->assertArrayHasKey($name, $actions); + return $actions[$name]; + } + + private function assertActionInjects(Action $action, array $expectedInjections): void + { + $injections = []; + foreach ($action->getOptions() as $option) { + if ($option['type'] === 'injection') { + $injections[] = $option['name']; + } + } + $this->assertEquals($expectedInjections, $injections); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php new file mode 100644 index 0000000000..d29be7a019 --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/ConfigTest.php @@ -0,0 +1,508 @@ +assertEquals('80', $config->getDefaultHttpPort()); + $this->assertEquals('443', $config->getDefaultHttpsPort()); + $this->assertEquals('appwrite', $config->getOrganization()); + $this->assertEquals('appwrite', $config->getImage()); + $this->assertFalse($config->getNoStart()); + $this->assertFalse($config->isUpgrade()); + $this->assertFalse($config->isLocal()); + $this->assertNull($config->getHostPath()); + $this->assertNull($config->getLockedDatabase()); + $this->assertEmpty($config->getVars()); + } + + public function testConstructorWithKnownKeys(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'isUpgrade' => true, + 'organization' => 'myorg', + ]); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testConstructorWithUnknownKeysTreatsAsVars(): void + { + $vars = [ + '_APP_ENV' => 'production', + '_APP_DOMAIN' => 'example.com', + ]; + $config = new Config($vars); + + $this->assertEquals($vars, $config->getVars()); + // Defaults should remain + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + // --- apply --- + + public function testApplyAllFields(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mariadb', + 'vars' => [['name' => '_APP_ENV', 'default' => 'production']], + ]); + + $this->assertEquals('3000', $config->getDefaultHttpPort()); + $this->assertEquals('3443', $config->getDefaultHttpsPort()); + $this->assertEquals('testorg', $config->getOrganization()); + $this->assertEquals('testimage', $config->getImage()); + $this->assertTrue($config->getNoStart()); + $this->assertTrue($config->isUpgrade()); + $this->assertTrue($config->isLocal()); + $this->assertEquals('/home/user', $config->getHostPath()); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $this->assertCount(1, $config->getVars()); + } + + public function testApplyIgnoresNullAndEmptyStringValues(): void + { + $config = new Config(['defaultHttpPort' => '9090']); + + $config->apply(['defaultHttpPort' => '']); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => null]); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testApplyHostPathCanBeSetToNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + + $config->apply(['hostPath' => null]); + $this->assertNull($config->getHostPath()); + } + + public function testApplyPartialUpdate(): void + { + $config = new Config([ + 'defaultHttpPort' => '8080', + 'defaultHttpsPort' => '8443', + 'organization' => 'original', + ]); + + $config->apply(['organization' => 'updated']); + + $this->assertEquals('8080', $config->getDefaultHttpPort()); + $this->assertEquals('8443', $config->getDefaultHttpsPort()); + $this->assertEquals('updated', $config->getOrganization()); + } + + // --- toArray --- + + public function testToArrayRoundTrip(): void + { + $config = new Config(); + $config->apply([ + 'defaultHttpPort' => '3000', + 'defaultHttpsPort' => '3443', + 'organization' => 'testorg', + 'image' => 'testimage', + 'noStart' => true, + 'isUpgrade' => true, + 'isLocal' => true, + 'hostPath' => '/home/user', + 'lockedDatabase' => 'mongodb', + 'vars' => [['name' => 'KEY', 'default' => 'value']], + ]); + + $array = $config->toArray(); + + $this->assertEquals('3000', $array['defaultHttpPort']); + $this->assertEquals('3443', $array['defaultHttpsPort']); + $this->assertEquals('testorg', $array['organization']); + $this->assertEquals('testimage', $array['image']); + $this->assertTrue($array['noStart']); + $this->assertTrue($array['isUpgrade']); + $this->assertTrue($array['isLocal']); + $this->assertEquals('/home/user', $array['hostPath']); + $this->assertEquals('mongodb', $array['lockedDatabase']); + $this->assertCount(1, $array['vars']); + } + + public function testToArrayCanRecreateConfig(): void + { + $original = new Config([ + 'defaultHttpPort' => '5000', + 'isLocal' => true, + 'lockedDatabase' => 'mariadb', + ]); + + $rebuilt = new Config($original->toArray()); + + $this->assertEquals($original->getDefaultHttpPort(), $rebuilt->getDefaultHttpPort()); + $this->assertEquals($original->isLocal(), $rebuilt->isLocal()); + $this->assertEquals($original->getLockedDatabase(), $rebuilt->getLockedDatabase()); + $this->assertEquals($original->toArray(), $rebuilt->toArray()); + } + + // --- Setters and Getters --- + + public function testSetAndGetDefaultHttpPort(): void + { + $config = new Config(); + $config->setDefaultHttpPort('9090'); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testSetAndGetDefaultHttpsPort(): void + { + $config = new Config(); + $config->setDefaultHttpsPort('9443'); + $this->assertEquals('9443', $config->getDefaultHttpsPort()); + } + + public function testSetAndGetOrganization(): void + { + $config = new Config(); + $config->setOrganization('myorg'); + $this->assertEquals('myorg', $config->getOrganization()); + } + + public function testSetAndGetImage(): void + { + $config = new Config(); + $config->setImage('myimage'); + $this->assertEquals('myimage', $config->getImage()); + } + + public function testSetAndGetNoStart(): void + { + $config = new Config(); + $config->setNoStart(true); + $this->assertTrue($config->getNoStart()); + $config->setNoStart(false); + $this->assertFalse($config->getNoStart()); + } + + public function testSetAndGetIsUpgrade(): void + { + $config = new Config(); + $config->setIsUpgrade(true); + $this->assertTrue($config->isUpgrade()); + $config->setIsUpgrade(false); + $this->assertFalse($config->isUpgrade()); + } + + public function testSetAndGetIsLocal(): void + { + $config = new Config(); + $config->setIsLocal(true); + $this->assertTrue($config->isLocal()); + $config->setIsLocal(false); + $this->assertFalse($config->isLocal()); + } + + public function testSetAndGetHostPath(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + $this->assertEquals('/some/path', $config->getHostPath()); + $config->setHostPath(null); + $this->assertNull($config->getHostPath()); + } + + public function testSetAndGetLockedDatabase(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + $this->assertEquals('mariadb', $config->getLockedDatabase()); + $config->setLockedDatabase(null); + $this->assertNull($config->getLockedDatabase()); + } + + public function testSetAndGetVars(): void + { + $config = new Config(); + $vars = [ + ['name' => '_APP_ENV', 'default' => 'production'], + ['name' => '_APP_DOMAIN', 'default' => 'localhost'], + ]; + $config->setVars($vars); + $this->assertEquals($vars, $config->getVars()); + } + + // --- JSON serialization --- + + public function testJsonRoundTrip(): void + { + $config = new Config([ + 'defaultHttpPort' => '5000', + 'isUpgrade' => true, + 'lockedDatabase' => 'mongodb', + ]); + + $json = json_encode($config->toArray(), JSON_UNESCAPED_SLASHES); + $this->assertIsString($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + + $rebuilt = new Config($decoded); + $this->assertEquals($config->toArray(), $rebuilt->toArray()); + } + + // --- Constructor edge cases --- + + public function testConstructorWithEmptyArray(): void + { + $config = new Config([]); + // Empty array has no known keys, so it gets set as vars + // But empty vars is still empty + $this->assertEmpty($config->getVars()); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testConstructorWithMixedKnownAndUnknownKeys(): void + { + // If at least one known key is found, apply() is used (not setVars) + $config = new Config([ + 'defaultHttpPort' => '9090', + 'unknownKey' => 'someValue', + ]); + // Known key should be applied + $this->assertEquals('9090', $config->getDefaultHttpPort()); + // Unknown key should be silently ignored by apply() + // Vars should remain empty since containsKnownKeys returns true + $this->assertEmpty($config->getVars()); + } + + // --- apply edge cases --- + + public function testApplyWithEmptyArray(): void + { + $config = new Config(['defaultHttpPort' => '1234']); + $config->apply([]); + // Should not change anything + $this->assertEquals('1234', $config->getDefaultHttpPort()); + } + + public function testApplyBooleanCastingNoStart(): void + { + $config = new Config(); + + // Truthy int + $config->apply(['noStart' => 1]); + $this->assertTrue($config->getNoStart()); + + // Falsy int + $config->apply(['noStart' => 0]); + $this->assertFalse($config->getNoStart()); + } + + public function testApplyBooleanCastingIsUpgrade(): void + { + $config = new Config(); + + $config->apply(['isUpgrade' => 1]); + $this->assertTrue($config->isUpgrade()); + + $config->apply(['isUpgrade' => 0]); + $this->assertFalse($config->isUpgrade()); + } + + public function testApplyBooleanCastingIsLocal(): void + { + $config = new Config(); + + $config->apply(['isLocal' => 'true']); // string "true" is truthy + $this->assertTrue($config->isLocal()); + + $config->apply(['isLocal' => '']); // empty string is falsy + // But wait: the code checks $values['isLocal'] !== null first + // '' is not null, so (bool)'' = false + $this->assertFalse($config->isLocal()); + } + + public function testApplyNoStartWithNullDoesNotChange(): void + { + $config = new Config(); + $config->setNoStart(true); + $config->apply(['noStart' => null]); + // null is excluded by the null check + $this->assertTrue($config->getNoStart()); + } + + public function testApplyVarsWithNonArrayIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => 'not an array']); + // Should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyVarsWithNullIgnored(): void + { + $config = new Config(); + $config->setVars([['name' => 'KEY', 'default' => 'val']]); + + $config->apply(['vars' => null]); + // is_array(null) = false, so should not overwrite + $this->assertCount(1, $config->getVars()); + } + + public function testApplyHostPathEmptyStringBecomesNull(): void + { + $config = new Config(); + $config->setHostPath('/some/path'); + + $config->apply(['hostPath' => '']); + // Empty string is handled: !== null && !== '' is false, so sets null + $this->assertNull($config->getHostPath()); + } + + public function testApplyLockedDatabaseIgnoresEmpty(): void + { + $config = new Config(); + $config->setLockedDatabase('mariadb'); + + $config->apply(['lockedDatabase' => '']); + // hasValidStringValue returns false for empty string + $this->assertEquals('mariadb', $config->getLockedDatabase()); + } + + public function testApplyLockedDatabaseIgnoresNull(): void + { + $config = new Config(); + $config->setLockedDatabase('mongodb'); + + $config->apply(['lockedDatabase' => null]); + // hasValidStringValue returns false for null + $this->assertEquals('mongodb', $config->getLockedDatabase()); + } + + public function testApplyPortWithIntegerValue(): void + { + $config = new Config(); + $config->apply(['defaultHttpPort' => 3000]); + // (string)3000 = '3000', not empty, so it should be applied + $this->assertEquals('3000', $config->getDefaultHttpPort()); + } + + // --- toArray edge cases --- + + public function testToArrayContainsAllExpectedKeys(): void + { + $config = new Config(); + $array = $config->toArray(); + + $expectedKeys = [ + 'defaultHttpPort', + 'defaultHttpsPort', + 'organization', + 'image', + 'noStart', + 'vars', + 'isUpgrade', + 'isLocal', + 'hostPath', + 'lockedDatabase', + ]; + + foreach ($expectedKeys as $key) { + $this->assertArrayHasKey($key, $array, "Missing key: $key"); + } + $this->assertCount(count($expectedKeys), $array); + } + + public function testToArrayDefaultsMatchConstructorDefaults(): void + { + $config = new Config(); + $array = $config->toArray(); + + $this->assertEquals('80', $array['defaultHttpPort']); + $this->assertEquals('443', $array['defaultHttpsPort']); + $this->assertEquals('appwrite', $array['organization']); + $this->assertEquals('appwrite', $array['image']); + $this->assertFalse($array['noStart']); + $this->assertEmpty($array['vars']); + $this->assertFalse($array['isUpgrade']); + $this->assertFalse($array['isLocal']); + $this->assertNull($array['hostPath']); + $this->assertNull($array['lockedDatabase']); + } + + // --- Multiple apply calls --- + + public function testMultipleApplyCallsAccumulate(): void + { + $config = new Config(); + + $config->apply(['defaultHttpPort' => '1111']); + $config->apply(['defaultHttpsPort' => '2222']); + $config->apply(['organization' => 'org']); + $config->apply(['isLocal' => true]); + + $this->assertEquals('1111', $config->getDefaultHttpPort()); + $this->assertEquals('2222', $config->getDefaultHttpsPort()); + $this->assertEquals('org', $config->getOrganization()); + $this->assertTrue($config->isLocal()); + } + + public function testApplyOverwritesPreviousValues(): void + { + $config = new Config(['defaultHttpPort' => '1111']); + $this->assertEquals('1111', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '2222']); + $this->assertEquals('2222', $config->getDefaultHttpPort()); + + $config->apply(['defaultHttpPort' => '3333']); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + // --- Vars replacement (not merge) --- + + public function testSetVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->setVars([['name' => 'A', 'default' => '1']]); + $config->setVars([['name' => 'B', 'default' => '2']]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } + + public function testApplyVarsReplacesNotMerges(): void + { + $config = new Config(); + $config->apply(['vars' => [['name' => 'A', 'default' => '1']]]); + $config->apply(['vars' => [['name' => 'B', 'default' => '2']]]); + + $vars = $config->getVars(); + $this->assertCount(1, $vars); + $this->assertEquals('B', $vars[0]['name']); + } +} diff --git a/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php new file mode 100644 index 0000000000..a134b43f6e --- /dev/null +++ b/tests/unit/Platform/Modules/Installer/Runtime/StateTest.php @@ -0,0 +1,1216 @@ +tempDir = sys_get_temp_dir() . '/appwrite-installer-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + $root = dirname(__DIR__, 6); + $this->state = new State([ + 'public' => $root . '/public', + 'init' => $root . '/app/init.php', + 'views' => $root . '/app/views/install', + 'vendor' => $root . '/vendor/autoload.php', + 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php', + ]); + + // Preserve env state + $env = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->savedEnv = $env !== false ? $env : null; + } + + protected function tearDown(): void + { + // Clean up temp files + $files = glob($this->tempDir . '/*'); + if (is_array($files)) { + foreach ($files as $file) { + @unlink($file); + } + } + @rmdir($this->tempDir); + + // Clean up progress files + foreach ($this->progressFiles as $file) { + @unlink($file); + } + + // Clean up lock file + @unlink(Server::INSTALLER_LOCK_FILE); + @unlink(Server::INSTALLER_CONFIG_FILE); + + // Restore env state + if ($this->savedEnv !== null) { + putenv('APPWRITE_INSTALLER_CONFIG=' . $this->savedEnv); + } else { + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + $this->state = null; + } + + private function trackProgressFile(string $installId): void + { + $this->progressFiles[] = $this->state->progressFilePath($installId); + } + + // --- sanitizeInstallId --- + + public function testSanitizeInstallIdWithValidId(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc123')); + } + + public function testSanitizeInstallIdWithSpecialChars(): void + { + $this->assertEquals('abc123', $this->state->sanitizeInstallId('abc!@#123')); + } + + public function testSanitizeInstallIdWithHyphensAndUnderscores(): void + { + $this->assertEquals('abc-123_def', $this->state->sanitizeInstallId('abc-123_def')); + } + + public function testSanitizeInstallIdTruncatesTo64Chars(): void + { + $long = str_repeat('a', 100); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($long))); + } + + public function testSanitizeInstallIdWithEmptyString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('')); + } + + public function testSanitizeInstallIdWithNonString(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(123)); + $this->assertEquals('', $this->state->sanitizeInstallId(null)); + } + + // --- hashSensitiveValue --- + + public function testHashSensitiveValueProducesConsistentHash(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue('secret'); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueDifferentInputsDifferentHashes(): void + { + $hash1 = $this->state->hashSensitiveValue('secret1'); + $hash2 = $this->state->hashSensitiveValue('secret2'); + $this->assertNotEquals($hash1, $hash2); + } + + public function testHashSensitiveValueTrimsWhitespace(): void + { + $hash1 = $this->state->hashSensitiveValue('secret'); + $hash2 = $this->state->hashSensitiveValue(' secret '); + $this->assertEquals($hash1, $hash2); + } + + public function testHashSensitiveValueEmptyStringReturnsEmpty(): void + { + $this->assertEquals('', $this->state->hashSensitiveValue('')); + $this->assertEquals('', $this->state->hashSensitiveValue(' ')); + } + + public function testHashSensitiveValueReturnsSha256(): void + { + $hash = $this->state->hashSensitiveValue('test'); + $this->assertEquals(64, strlen($hash)); // SHA-256 produces 64 hex chars + $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash); + } + + // --- isValidPort --- + + public function testIsValidPortWithValidPorts(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('80')); + $this->assertTrue($this->state->isValidPort('443')); + $this->assertTrue($this->state->isValidPort('8080')); + $this->assertTrue($this->state->isValidPort('65535')); + } + + public function testIsValidPortWithInvalidPorts(): void + { + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + $this->assertFalse($this->state->isValidPort('-1')); + $this->assertFalse($this->state->isValidPort('abc')); + $this->assertFalse($this->state->isValidPort('')); + $this->assertFalse($this->state->isValidPort('80.5')); + $this->assertFalse($this->state->isValidPort('80abc')); + } + + public function testIsValidPortWithIntegerInput(): void + { + $this->assertTrue($this->state->isValidPort(80)); + $this->assertTrue($this->state->isValidPort(443)); + $this->assertFalse($this->state->isValidPort(0)); + } + + // --- isValidEmailAddress --- + + public function testIsValidEmailAddressWithValidEmails(): void + { + $this->assertTrue($this->state->isValidEmailAddress('user@example.com')); + $this->assertTrue($this->state->isValidEmailAddress('test.user@domain.org')); + $this->assertTrue($this->state->isValidEmailAddress('admin+tag@example.co.uk')); + } + + public function testIsValidEmailAddressWithInvalidEmails(): void + { + $this->assertFalse($this->state->isValidEmailAddress('')); + $this->assertFalse($this->state->isValidEmailAddress('notanemail')); + $this->assertFalse($this->state->isValidEmailAddress('@domain.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@')); + } + + // --- isValidPassword --- + + public function testIsValidPasswordWithValidPasswords(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertTrue($this->state->isValidPassword('abcdefgh')); + $this->assertTrue($this->state->isValidPassword('P@ssw0rd!')); + } + + public function testIsValidPasswordWithInvalidPasswords(): void + { + $this->assertFalse($this->state->isValidPassword('')); + $this->assertFalse($this->state->isValidPassword('short')); + $this->assertFalse($this->state->isValidPassword('1234567')); // 7 chars + $this->assertFalse($this->state->isValidPassword(' ')); // 8 spaces, no non-whitespace + } + + // --- isValidSecretKey --- + + public function testIsValidSecretKeyWithValidKeys(): void + { + $this->assertTrue($this->state->isValidSecretKey('a')); + $this->assertTrue($this->state->isValidSecretKey('my-secret-key')); + $this->assertTrue($this->state->isValidSecretKey(str_repeat('x', 64))); + } + + public function testIsValidSecretKeyWithInvalidKeys(): void + { + $this->assertFalse($this->state->isValidSecretKey('')); + $this->assertFalse($this->state->isValidSecretKey(str_repeat('x', 65))); + } + + // --- isValidAccountName --- + + public function testIsValidAccountNameWithValidNames(): void + { + $this->assertTrue($this->state->isValidAccountName('John')); + $this->assertTrue($this->state->isValidAccountName('a')); + } + + public function testIsValidAccountNameWithInvalidNames(): void + { + $this->assertFalse($this->state->isValidAccountName('')); + $this->assertFalse($this->state->isValidAccountName(' ')); + } + + // --- isValidAppDomainInput --- + + public function testIsValidAppDomainInputWithValidDomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('sub.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('192.168.1.1')); + } + + public function testIsValidAppDomainInputWithPort(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('localhost:8080')); + $this->assertTrue($this->state->isValidAppDomainInput('example.com:443')); + $this->assertTrue($this->state->isValidAppDomainInput('127.0.0.1:3000')); + } + + public function testIsValidAppDomainInputWithIpv6(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('[::1]')); + $this->assertTrue($this->state->isValidAppDomainInput('[::1]:8080')); + } + + public function testIsValidAppDomainInputWithInvalidDomains(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('')); + $this->assertFalse($this->state->isValidAppDomainInput(' ')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:99999')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:0')); + $this->assertFalse($this->state->isValidAppDomainInput('host:port:extra')); + } + + // --- isValidDatabaseAdapter --- + + public function testIsValidDatabaseAdapterWithValidAdapters(): void + { + $this->assertTrue($this->state->isValidDatabaseAdapter('mongodb')); + $this->assertTrue($this->state->isValidDatabaseAdapter('mariadb')); + } + + public function testIsValidDatabaseAdapterWithInvalidAdapters(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mysql')); + $this->assertFalse($this->state->isValidDatabaseAdapter('postgres')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); // case sensitive + } + + // --- progressFilePath --- + + public function testProgressFilePathFormat(): void + { + $path = $this->state->progressFilePath('test123'); + $this->assertStringContainsString('appwrite-install-test123.json', $path); + $this->assertStringStartsWith(sys_get_temp_dir(), $path); + } + + // --- readProgressFile / writeProgressFile --- + + public function testReadProgressFileReturnsDefaultForMissing(): void + { + $data = $this->state->readProgressFile('nonexistent-id-' . uniqid()); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testWriteAndReadProgressFile(): void + { + $installId = 'test-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Writing environment variables', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('steps', $data); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Writing environment variables', $data['steps'][Server::STEP_ENV_VARS]['message']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileAccumulatesSteps(): void + { + $installId = 'test-multi-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Generating compose file', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(2, $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['steps']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresPayload(): void + { + $installId = 'test-payload-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'payload' => [ + 'httpPort' => '80', + 'httpsPort' => '443', + 'database' => 'mariadb', + ], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('443', $data['payload']['httpsPort']); + $this->assertEquals('mariadb', $data['payload']['database']); + $this->assertArrayHasKey('startedAt', $data); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresErrorMessage(): void + { + $installId = 'test-error-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_ERROR, + 'message' => 'Container failed to start', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('error', $data); + $this->assertEquals('Container failed to start', $data['error']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + public function testWriteProgressFileStoresDetails(): void + { + $installId = 'test-details-' . uniqid(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done', + 'details' => ['composeFile' => '/path/to/docker-compose.yml'], + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayHasKey('details', $data); + $this->assertArrayHasKey(Server::STEP_DOCKER_COMPOSE, $data['details']); + $this->assertEquals('/path/to/docker-compose.yml', $data['details'][Server::STEP_DOCKER_COMPOSE]['composeFile']); + + // Cleanup + @unlink($this->state->progressFilePath($installId)); + } + + // --- buildConfig --- + + public function testBuildConfigReturnsConfigInstance(): void + { + // Clear env to avoid interference + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig([], false); + $this->assertInstanceOf(Config::class, $config); + } + + public function testBuildConfigAppliesOverrides(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + $config = $this->state->buildConfig(['defaultHttpPort' => '9090'], false); + $this->assertEquals('9090', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFromEnvVar(): void + { + $envData = json_encode([ + 'defaultHttpPort' => '8888', + 'isUpgrade' => true, + ]); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('8888', $config->getDefaultHttpPort()); + $this->assertTrue($config->isUpgrade()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + public function testBuildConfigOverridesEnv(): void + { + $envData = json_encode(['defaultHttpPort' => '8888']); + putenv('APPWRITE_INSTALLER_CONFIG=' . $envData); + + $config = $this->state->buildConfig(['defaultHttpPort' => '7777'], true); + $this->assertEquals('7777', $config->getDefaultHttpPort()); + + // Cleanup + putenv('APPWRITE_INSTALLER_CONFIG'); + } + + // --- sanitizeInstallId edge cases --- + + public function testSanitizeInstallIdWithOnlySpecialChars(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId('!@#$%^&*()')); + } + + public function testSanitizeInstallIdWithUnicode(): void + { + // Unicode letters are stripped byte-by-byte, only ASCII alphanum + hyphen + underscore kept + // 'é' is 2 bytes (0xC3 0xA9), both stripped => 'héllo' becomes 'hllo' + $this->assertEquals('hllo', $this->state->sanitizeInstallId('héllo')); + } + + public function testSanitizeInstallIdWithExactly64Chars(): void + { + $exact = str_repeat('b', 64); + $this->assertEquals($exact, $this->state->sanitizeInstallId($exact)); + $this->assertEquals(64, strlen($this->state->sanitizeInstallId($exact))); + } + + public function testSanitizeInstallIdWithBooleanInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId(true)); + $this->assertEquals('', $this->state->sanitizeInstallId(false)); + } + + public function testSanitizeInstallIdWithArrayInput(): void + { + $this->assertEquals('', $this->state->sanitizeInstallId([])); + } + + public function testSanitizeInstallIdPreservesCase(): void + { + $this->assertEquals('AbCdEf', $this->state->sanitizeInstallId('AbCdEf')); + } + + // --- isValidPort edge cases --- + + public function testIsValidPortBoundaryValues(): void + { + $this->assertTrue($this->state->isValidPort('1')); + $this->assertTrue($this->state->isValidPort('65535')); + $this->assertFalse($this->state->isValidPort('0')); + $this->assertFalse($this->state->isValidPort('65536')); + } + + public function testIsValidPortWithLeadingZeros(): void + { + // '080' is digits-only and parses to 80 which is in range + $this->assertTrue($this->state->isValidPort('080')); + // '00' parses to 0, which is out of range + $this->assertFalse($this->state->isValidPort('00')); + } + + public function testIsValidPortWithWhitespace(): void + { + // Contains non-digit characters + $this->assertFalse($this->state->isValidPort(' 80')); + $this->assertFalse($this->state->isValidPort('80 ')); + $this->assertFalse($this->state->isValidPort(' 80 ')); + } + + public function testIsValidPortWithNegativeNumber(): void + { + $this->assertFalse($this->state->isValidPort('-80')); + $this->assertFalse($this->state->isValidPort('-1')); + } + + public function testIsValidPortWithVeryLargeNumber(): void + { + $this->assertFalse($this->state->isValidPort('999999')); + $this->assertFalse($this->state->isValidPort('100000')); + } + + // --- isValidPassword edge cases --- + + public function testIsValidPasswordExactly8Chars(): void + { + $this->assertTrue($this->state->isValidPassword('12345678')); + $this->assertFalse($this->state->isValidPassword('1234567')); + } + + public function testIsValidPasswordWithTabsAndNewlines(): void + { + // Tabs/newlines count as whitespace, but need at least one non-whitespace + $this->assertFalse($this->state->isValidPassword("\t\t\t\t\t\t\t\t")); // 8 tabs + $this->assertTrue($this->state->isValidPassword("\t\t\t\ttest")); // mixed + } + + public function testIsValidPasswordWithMixedWhitespaceAndChars(): void + { + $this->assertTrue($this->state->isValidPassword(' a ')); // has non-whitespace + } + + // --- isValidSecretKey edge cases --- + + public function testIsValidSecretKeyExactly64Chars(): void + { + $this->assertTrue($this->state->isValidSecretKey(str_repeat('a', 64))); + } + + public function testIsValidSecretKeyWithWhitespace(): void + { + // Whitespace-only is still non-empty and <= 64 chars + $this->assertTrue($this->state->isValidSecretKey(' ')); + $this->assertTrue($this->state->isValidSecretKey(' ')); + } + + // --- isValidAppDomainInput edge cases --- + + public function testIsValidAppDomainInputWithEmptyPort(): void + { + // "host:" splits to ['host', ''] - empty port with null check + $this->assertTrue($this->state->isValidAppDomainInput('localhost:')); + } + + public function testIsValidAppDomainInputWithIpv4Address(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('10.0.0.1')); + $this->assertTrue($this->state->isValidAppDomainInput('255.255.255.255')); + $this->assertTrue($this->state->isValidAppDomainInput('0.0.0.0')); + } + + public function testIsValidAppDomainInputIpv6WithoutBrackets(): void + { + // Raw IPv6 without brackets: "::1" has two colons, so count($parts) > 2 => false + $this->assertFalse($this->state->isValidAppDomainInput('::1')); + $this->assertFalse($this->state->isValidAppDomainInput('fe80::1')); + } + + public function testIsValidAppDomainInputIpv6MalformedBrackets(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('[')); + $this->assertFalse($this->state->isValidAppDomainInput('[]')); + $this->assertFalse($this->state->isValidAppDomainInput('[invalid')); + } + + public function testIsValidAppDomainInputWithSubdomains(): void + { + $this->assertTrue($this->state->isValidAppDomainInput('a.b.c.d.example.com')); + $this->assertTrue($this->state->isValidAppDomainInput('my-app.example.io:8080')); + } + + public function testIsValidAppDomainInputWithInvalidPortNumber(): void + { + $this->assertFalse($this->state->isValidAppDomainInput('localhost:abc')); + $this->assertFalse($this->state->isValidAppDomainInput('localhost:70000')); + $this->assertFalse($this->state->isValidAppDomainInput('[::1]:70000')); + } + + // --- isValidDatabaseAdapter edge cases --- + + public function testIsValidDatabaseAdapterWithWhitespace(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter(' mongodb')); + $this->assertFalse($this->state->isValidDatabaseAdapter('mariadb ')); + } + + public function testIsValidDatabaseAdapterCaseSensitivity(): void + { + $this->assertFalse($this->state->isValidDatabaseAdapter('MongoDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MariaDB')); + $this->assertFalse($this->state->isValidDatabaseAdapter('MONGODB')); + } + + // --- readProgressFile edge cases --- + + public function testReadProgressFileWithCorruptedJson(): void + { + $installId = 'test-corrupt-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, 'not valid json {{{'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertArrayHasKey('steps', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithEmptyFile(): void + { + $installId = 'test-empty-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, ''); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertArrayHasKey('installId', $data); + $this->assertEmpty($data['steps']); + } + + public function testReadProgressFileWithJsonScalar(): void + { + $installId = 'test-scalar-' . uniqid(); + $this->trackProgressFile($installId); + $path = $this->state->progressFilePath($installId); + file_put_contents($path, '"just a string"'); + + $data = $this->state->readProgressFile($installId); + $this->assertIsArray($data); + $this->assertEmpty($data['steps']); + } + + // --- writeProgressFile edge cases --- + + public function testWriteProgressFileOverwritesExistingStep(): void + { + $installId = 'test-overwrite-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working...', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Done!', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertCount(1, $data['steps']); // Still 1 step, overwritten + $this->assertEquals(Server::STATUS_COMPLETED, $data['steps'][Server::STEP_ENV_VARS]['status']); + $this->assertEquals('Done!', $data['steps'][Server::STEP_ENV_VARS]['message']); + } + + public function testWriteProgressFileWithEmptyStep(): void + { + $installId = 'test-emptystep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => '', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step name', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Empty step name treated as falsy, should not add to steps + $this->assertEmpty($data['steps']); + } + + public function testWriteProgressFilePreservesPayloadAcrossWrites(): void + { + $installId = 'test-persist-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env done', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // Payload from first write should still be present + $this->assertArrayHasKey('payload', $data); + $this->assertEquals('80', $data['payload']['httpPort']); + $this->assertEquals('mongodb', $data['payload']['database']); + // Both steps should exist + $this->assertArrayHasKey('start', $data['steps']); + $this->assertArrayHasKey(Server::STEP_ENV_VARS, $data['steps']); + } + + public function testWriteProgressFileUpdatesTimestamp(): void + { + $installId = 'test-time-' . uniqid(); + $this->trackProgressFile($installId); + $now = time(); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'test', + 'updatedAt' => $now, + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($now, $data['updatedAt']); + } + + public function testWriteProgressFileStartedAtOnlySetOnce(): void + { + $installId = 'test-startedat-' . uniqid(); + $this->trackProgressFile($installId); + $firstTime = time() - 100; + + // First write with payload sets startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Starting', + 'updatedAt' => $firstTime, + ]); + + $data = $this->state->readProgressFile($installId); + $startedAt = $data['startedAt']; + + // Second write with payload should NOT overwrite startedAt + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80'], + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Env', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertEquals($startedAt, $data['startedAt']); + } + + // --- Global lock: reserveGlobalLock / updateGlobalLock --- + + public function testReserveGlobalLockFirstLockSucceeds(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-test-' . uniqid(); + $result = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockSameIdCanRelock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-relock-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result1); + + // Same ID can re-reserve + $result2 = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $result2); + } + + public function testReserveGlobalLockDifferentIdBlocked(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-id1-' . uniqid(); + $installId2 = 'lock-id2-' . uniqid(); + + $result1 = $this->state->reserveGlobalLock($installId1); + $this->assertEquals('ok', $result1); + + // Different ID should be blocked + $result2 = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('locked', $result2); + } + + public function testReserveGlobalLockAfterCompleted(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-done-' . uniqid(); + $installId2 = 'lock-new-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_COMPLETED); + + // After completion, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockAfterError(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-err-' . uniqid(); + $installId2 = 'lock-retry-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + $this->state->updateGlobalLock($installId1, Server::STATUS_ERROR); + + // After error, a new install should be able to lock + $result = $this->state->reserveGlobalLock($installId2); + $this->assertEquals('ok', $result); + } + + public function testReserveGlobalLockExpiredLockAllowsNew(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + + // Manually write an expired lock (updatedAt way in the past) + $expiredLock = [ + 'installId' => 'expired-lock', + 'status' => Server::STATUS_IN_PROGRESS, + 'updatedAt' => time() - 7200, // 2 hours ago, timeout is 1 hour + ]; + file_put_contents(Server::INSTALLER_LOCK_FILE, json_encode($expiredLock)); + + $newId = 'lock-after-expired-' . uniqid(); + $result = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $result); + } + + public function testUpdateGlobalLockUpdatesOwnLock(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lock-update-' . uniqid(); + + $this->state->reserveGlobalLock($installId); + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // Read lock file directly to verify + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $this->assertNotFalse($contents); + $lock = json_decode($contents, true); + $this->assertIsArray($lock); + $this->assertEquals($installId, $lock['installId']); + $this->assertEquals(Server::STATUS_COMPLETED, $lock['status']); + } + + public function testUpdateGlobalLockIgnoresDifferentId(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId1 = 'lock-owner-' . uniqid(); + $installId2 = 'lock-intruder-' . uniqid(); + + $this->state->reserveGlobalLock($installId1); + + // Attempt to update with a different ID should be silently ignored + $this->state->updateGlobalLock($installId2, Server::STATUS_COMPLETED); + + // Original lock should still be in progress + $contents = file_get_contents(Server::INSTALLER_LOCK_FILE); + $lock = json_decode($contents, true); + $this->assertEquals($installId1, $lock['installId']); + $this->assertEquals(Server::STATUS_IN_PROGRESS, $lock['status']); + } + + // --- applyEnvConfig --- + + public function testApplyEnvConfigWithConfigObject(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '5555', 'isLocal' => true]); + $this->state->applyEnvConfig($cfg); + + // Verify env var was set + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + + $decoded = json_decode($envVal, true); + $this->assertIsArray($decoded); + $this->assertEquals('5555', $decoded['defaultHttpPort']); + $this->assertTrue($decoded['isLocal']); + + // Verify config file was written + $this->assertFileExists(Server::INSTALLER_CONFIG_FILE); + $fileContents = file_get_contents(Server::INSTALLER_CONFIG_FILE); + $this->assertNotFalse($fileContents); + $fileDecoded = json_decode($fileContents, true); + $this->assertEquals('5555', $fileDecoded['defaultHttpPort']); + } + + public function testApplyEnvConfigWithArray(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $this->state->applyEnvConfig(['defaultHttpPort' => '6666']); + + $envVal = getenv('APPWRITE_INSTALLER_CONFIG'); + $this->assertNotFalse($envVal); + $decoded = json_decode($envVal, true); + $this->assertEquals('6666', $decoded['defaultHttpPort']); + } + + public function testApplyEnvConfigThenBuildConfigReadsIt(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $cfg = new Config(['defaultHttpPort' => '4444', 'isUpgrade' => true]); + $this->state->applyEnvConfig($cfg); + + // buildConfig with useEnv=true should pick up the env var + $rebuilt = $this->state->buildConfig([], true); + $this->assertEquals('4444', $rebuilt->getDefaultHttpPort()); + $this->assertTrue($rebuilt->isUpgrade()); + } + + // --- buildConfig edge cases --- + + public function testBuildConfigWithInvalidEnvJson(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=not-valid-json'); + + // Should fall back to config file (or defaults if file doesn't exist) + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + // Should get defaults since both env and file are invalid/missing + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyEnvVar(): void + { + putenv('APPWRITE_INSTALLER_CONFIG='); + + @unlink(Server::INSTALLER_CONFIG_FILE); + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigFallsBackToConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + // Write a config file + $data = json_encode(['defaultHttpPort' => '3333']); + file_put_contents(Server::INSTALLER_CONFIG_FILE, $data); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('3333', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithCorruptedConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, 'garbage data {{{'); + + $config = $this->state->buildConfig([], true); + // Should get defaults + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithEmptyConfigFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG'); + + file_put_contents(Server::INSTALLER_CONFIG_FILE, ''); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigUseEnvFalseIgnoresEnvAndFile(): void + { + putenv('APPWRITE_INSTALLER_CONFIG=' . json_encode(['defaultHttpPort' => '9999'])); + file_put_contents(Server::INSTALLER_CONFIG_FILE, json_encode(['defaultHttpPort' => '8888'])); + + $config = $this->state->buildConfig([], false); + // Neither env nor file should be used + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + public function testBuildConfigWithJsonScalarEnvVar(): void + { + // A JSON scalar (string) is not an array, so decoding succeeds but is_array fails + putenv('APPWRITE_INSTALLER_CONFIG="just a string"'); + @unlink(Server::INSTALLER_CONFIG_FILE); + + $config = $this->state->buildConfig([], true); + $this->assertEquals('80', $config->getDefaultHttpPort()); + } + + // --- hashSensitiveValue edge cases --- + + public function testHashSensitiveValueWithNewlines(): void + { + // Newlines are not stripped by trim but surrounding whitespace is + $hash1 = $this->state->hashSensitiveValue("line1\nline2"); + $hash2 = $this->state->hashSensitiveValue("line1\nline2"); + $this->assertEquals($hash1, $hash2); + $this->assertNotEmpty($hash1); + } + + public function testHashSensitiveValueWithOnlyNewline(): void + { + // A newline is not whitespace that trim() removes? Actually trim() removes \n + // "\n" trimmed becomes "" => should return '' + $this->assertEquals('', $this->state->hashSensitiveValue("\n")); + } + + // --- isValidEmailAddress edge cases --- + + public function testIsValidEmailAddressWithUnicodeLocal(): void + { + // PHP's FILTER_VALIDATE_EMAIL does not support internationalized emails + $this->assertFalse($this->state->isValidEmailAddress('ünïcödé@example.com')); + } + + public function testIsValidEmailAddressWithDoubleAt(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user@@example.com')); + } + + public function testIsValidEmailAddressWithSpaces(): void + { + $this->assertFalse($this->state->isValidEmailAddress('user @example.com')); + $this->assertFalse($this->state->isValidEmailAddress('user@ example.com')); + } + + // --- isValidAccountName edge cases --- + + public function testIsValidAccountNameWithOnlyTabs(): void + { + $this->assertFalse($this->state->isValidAccountName("\t\t")); + } + + public function testIsValidAccountNameWithMixedWhitespace(): void + { + $this->assertTrue($this->state->isValidAccountName(" a ")); + } + + // --- progressFilePath edge cases --- + + public function testProgressFilePathWithSpecialCharsInId(): void + { + // The ID would normally be sanitized before this call, but the method itself + // just concatenates + $path = $this->state->progressFilePath('test-with-special'); + $this->assertStringContainsString('appwrite-install-test-with-special.json', $path); + } + + public function testProgressFilePathWithEmptyId(): void + { + $path = $this->state->progressFilePath(''); + $this->assertStringContainsString('appwrite-install-.json', $path); + } + + // --- writeProgressFile with non-error status doesn't set error key --- + + public function testWriteProgressFileCompletedDoesNotSetError(): void + { + $installId = 'test-noerror-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'All good', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + public function testWriteProgressFileInProgressDoesNotSetError(): void + { + $installId = 'test-noerrip-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Working', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + $this->assertArrayNotHasKey('error', $data); + } + + // --- writeProgressFile with no step or empty payload --- + + public function testWriteProgressFileWithNoStep(): void + { + $installId = 'test-nostep-' . uniqid(); + $this->trackProgressFile($installId); + + $this->state->writeProgressFile($installId, [ + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'No step provided', + 'updatedAt' => time(), + ]); + + $data = $this->state->readProgressFile($installId); + // No step key means no step should be recorded + $this->assertEmpty($data['steps']); + // But updatedAt should still be set + $this->assertArrayHasKey('updatedAt', $data); + } + + // --- Full lifecycle: lock -> progress -> complete --- + + public function testFullInstallationLifecycle(): void + { + @unlink(Server::INSTALLER_LOCK_FILE); + $installId = 'lifecycle-' . uniqid(); + $this->trackProgressFile($installId); + + // 1. Reserve lock + $lockResult = $this->state->reserveGlobalLock($installId); + $this->assertEquals('ok', $lockResult); + + // 2. Write progress through multiple steps + $this->state->writeProgressFile($installId, [ + 'payload' => ['httpPort' => '80', 'database' => 'mongodb'], + 'step' => 'start', + 'status' => Server::STATUS_IN_PROGRESS, + 'message' => 'Started', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_ENV_VARS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Env vars written', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_COMPOSE, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Compose generated', + 'updatedAt' => time(), + ]); + + $this->state->writeProgressFile($installId, [ + 'step' => Server::STEP_DOCKER_CONTAINERS, + 'status' => Server::STATUS_COMPLETED, + 'message' => 'Containers started', + 'updatedAt' => time(), + ]); + + // 3. Verify progress + $data = $this->state->readProgressFile($installId); + $this->assertCount(4, $data['steps']); // start + 3 steps + $this->assertArrayHasKey('payload', $data); + $this->assertArrayHasKey('startedAt', $data); + + // 4. Complete the lock + $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + + // 5. Verify a new install can now proceed + $newId = 'lifecycle-new-' . uniqid(); + $this->trackProgressFile($newId); + $newResult = $this->state->reserveGlobalLock($newId); + $this->assertEquals('ok', $newResult); + } +}