root = sys_get_temp_dir().'/kant-install-'.bin2hex(random_bytes(6)); mkdir($this->root.'/var', 0o775, true); mkdir($this->root.'/public', 0o775, true); // The same migration configuration a real installation has, pointing at the real // migrations: a copy of the list would drift away from it within a week. file_put_contents($this->root.'/migrations.php', sprintf( " ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => %s], 'all_or_nothing' => false];", var_export(\dirname(__DIR__).'/migrations', true), )); $this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db'); $this->installation = $this->make(); } protected function tearDown(): void { $this->db->close(); gc_collect_cycles(); $this->removeDirectory($this->root); } public function testAnEmptyInstallationIsNotInstalledAndSaysWhatIsMissing(): void { // No database, no tables, no account: the installer has to survive all three, because it // is the only screen that exists at that moment. self::assertFalse($this->installation->isInstalled()); self::assertFalse($this->installation->schemaReady()); self::assertFalse($this->installation->siteReady()); self::assertFalse($this->installation->environmentWritten()); $checks = $this->installation->checks(); $labels = array_column($checks, 'label'); self::assertContains('PHP-Fassung', $labels); self::assertContains('Datenbanktreiber', $labels); foreach ($checks as $check) { // A failing check without a sentence about what to do is a dead end. self::assertNotSame('', $check['hint'], $check['label'].' braucht einen Hinweis.'); } // The document root of a test process is not public/, so this check has to fail - and it // has to fail without blocking, because a wrong document root is the server's business. $documentRoot = self::findCheck($checks, 'Dokumentenstamm zeigt auf public/'); self::assertFalse($documentRoot['ok']); self::assertFalse($documentRoot['required']); } public function testTheDatabaseStepProvesTheConnectionBeforeItWritesAnything(): void { $good = $this->installation->testDatabase('sqlite', ['name' => 'cms']); self::assertNull($good['error']); self::assertSame('sqlite:///%kernel.project_dir%/var/cms.db', $good['url']); // A MySQL host that does not answer must fail here and not two screens later. $bad = $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'z']); self::assertNotNull($bad['error']); self::assertStringNotContainsString('geheimespasswort', $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'geheimespasswort'])['error'], 'Das Passwort gehört in keine Fehlermeldung.'); self::assertFalse(is_file($this->root.'/.env.local'), 'Eine gescheiterte Prüfung schreibt nichts.'); } public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void { $this->installation->writeEnvironment(['APP_SECRET' => 'geheim', 'DATABASE_URL' => 'sqlite:///x.db']); self::assertTrue($this->installation->environmentWritten()); // The website step adds the address later and must not lose the database doing it. $this->installation->writeEnvironment(['SITE_ORIGIN' => 'https://example.de']); $content = (string) file_get_contents($this->root.'/.env.local'); self::assertStringContainsString('APP_SECRET="geheim"', $content); self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content); self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content); self::assertSame(1, substr_count($content, 'DATABASE_URL='), 'Kein Wert steht doppelt in der Datei.'); } public function testTheWholeInstallationRunsThroughAndThenLocksItself(): void { $pending = $this->installation->pendingMigrations(); self::assertNotEmpty($pending, 'Eine leere Datenbank hat ausstehende Migrationen.'); $applied = $this->installation->migrate(); self::assertSame(\count($pending), \count($applied)); self::assertTrue($this->installation->schemaReady()); // Running again is allowed and does nothing: a browser that timed out is not a problem. self::assertSame([], $this->installation->migrate()); $created = $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de/', 'de'); self::assertTrue($this->installation->siteReady()); self::assertContains('Startseite', $created); self::assertContains('Impressum', $created); self::assertContains('Datenschutzerklärung', $created); // The start page is published, not a draft: a fresh installation that answers 404 at its // own address would look broken. $home = $this->make()->isInstalled(); self::assertFalse($home, 'Ohne Konto gilt die Installation als unfertig.'); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db)); self::assertNotNull($pages->findPublished('home')); self::assertNotNull($pages->findPublished('impressum')); self::assertSame('https://beispiel.de', $this->settingsOrigin()); // The legal pages are linked, otherwise the consent gate and the footer stay empty. $settings = (new SiteSettings($this->db))->load(); self::assertSame('datenschutz', $settings['privacyPath']); self::assertSame('impressum', $settings['imprintPath']); self::assertSame('Beispiel GmbH', $settings['legalName']); // Running the website step twice creates nothing twice. self::assertSame([], $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de', 'de')); $this->installation->createAdmin('chefin', 'ein sehr langes passwort', 'ein sehr langes passwort'); self::assertTrue($this->installation->isInstalled(), 'Mit dem ersten Konto ist die Installation vorbei.'); $row = $this->db->fetchAssociative('SELECT username, password_hash, roles FROM cms_user'); self::assertSame('chefin', $row['username']); self::assertStringNotContainsString('passwort', $row['password_hash'], 'Das Passwort steht nirgends im Klartext.'); self::assertSame(['ROLE_ADMIN'], json_decode($row['roles'], true)); self::assertTrue( (new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']]))) ->isPasswordValid(new CmsUser('x', 'chefin', $row['password_hash'], ['ROLE_ADMIN']), 'ein sehr langes passwort'), ); // And the second account is refused, because that is what "the installer is over" means. $this->expectException(InvalidPage::class); $this->installation->createAdmin('zweite', 'noch ein langes passwort', 'noch ein langes passwort'); } #[DataProvider('badAccounts')] public function testAnAccountWithWeakInputIsRefused(string $username, string $password, string $repeat): void { $this->installation->migrate(); $this->expectException(InvalidPage::class); $this->installation->createAdmin($username, $password, $repeat); } public static function badAccounts(): iterable { yield 'zu kurzer Name' => ['ab', 'ein sehr langes passwort', 'ein sehr langes passwort']; yield 'Leerzeichen im Namen' => ['die chefin', 'ein sehr langes passwort', 'ein sehr langes passwort']; yield 'zu kurzes Passwort' => ['chefin', 'kurz', 'kurz']; yield 'Tippfehler bei der Wiederholung' => ['chefin', 'ein sehr langes passwort', 'ein sehr langes passwortt']; } #[DataProvider('badSites')] public function testAWebsiteWithBadInputIsRefused(string $name, string $origin, string $locale): void { $this->installation->migrate(); $this->expectException(InvalidPage::class); $this->installation->createSite($name, $origin, $locale); } public static function badSites(): iterable { yield 'ohne Namen' => ['', 'https://beispiel.de', 'de']; yield 'Adresse ohne Schema' => ['Beispiel', 'beispiel.de', 'de']; yield 'keine Adresse' => ['Beispiel', 'https://', 'de']; yield 'unbekannte Sprache' => ['Beispiel', 'https://beispiel.de', 'kl']; } public function testAFormFromAnotherStepOrAnotherDayDoesNotCount(): void { $issued = time(); $signature = $this->installation->sign('website', $issued); self::assertTrue($this->installation->verify('website', $issued, $signature)); self::assertFalse($this->installation->verify('konto', $issued, $signature), 'Eine Unterschrift gilt für einen Schritt.'); self::assertFalse($this->installation->verify('website', $issued - Installation::TOKEN_LIFETIME - 60, $signature)); self::assertFalse($this->installation->verify('website', $issued, 'falsch')); } private function make(): Installation { $settings = new SiteSettings($this->db); $validator = new DocumentValidator(); return new Installation( $this->db, new PageRepository($this->db, $validator, $settings), new PartialRepository($this->db, $validator), new MenuRepository($this->db, $settings), $settings, new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])), $this->root, $this->root.'/var/media', 'ein-test-geheimnis', ); } private function settingsOrigin(): string { preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env.local'), $match); return $match[1] ?? ''; } private static function findCheck(array $checks, string $label): array { foreach ($checks as $check) { if ($check['label'] === $label) { return $check; } } self::fail('Prüfung fehlt: '.$label); } private function removeDirectory(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ('.' !== $entry && '..' !== $entry) { is_dir($path.'/'.$entry) ? $this->removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry); } } @rmdir($path); } }