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(); } public function testMissingLockFilesAreAddedButAnOwnRootFileStays(): void { // var/ gibt es nicht im Archiv, also legt die Website die Sperrdatei selbst an (ADR 116). mkdir($this->root.'/config/schutz', 0o775, true); mkdir($this->root.'/vendor', 0o775, true); foreach (['sperren', 'stamm'] as $name) { copy(\dirname(__DIR__).'/config/schutz/'.$name.'.htaccess', $this->root.'/config/schutz/'.$name.'.htaccess'); } file_put_contents($this->root.'/.htaccess', '# eigene Regeln '); Protection::reset(); Protection::ensure($this->root); self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/var/.htaccess')); self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/vendor/.htaccess')); self::assertSame('# eigene Regeln ', file_get_contents($this->root.'/.htaccess'), 'Eine eigene Datei im Projektstamm bleibt.'); Protection::reset(); } public function testTheInstallKeyIsCreatedOnceAndCheckedStrictly(): void { $key = $this->installation->installKey(); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key); self::assertSame($key, $this->installation->installKey(), 'Einmal angelegt, bleibt er.'); self::assertTrue($this->installation->acceptsKey(' '.strtoupper($key).' '), 'Abgetippt mit Leerzeichen und Großbuchstaben zählt.'); self::assertFalse($this->installation->acceptsKey(substr($key, 0, 31))); self::assertFalse($this->installation->acceptsKey('')); self::assertTrue($this->installation->acceptsProof($this->installation->keyProof())); self::assertFalse($this->installation->acceptsProof('')); self::assertFalse($this->installation->acceptsProof($key)); } 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'), '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'); 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, new MediaLibrary($this->db, $this->root.'/var/media'))); 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, new MediaLibrary($this->db, $this->root.'/var/media')))->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')); } public function testAnUnreachableDatabaseDoesNotReopenTheInstaller(): void { // Ein gedrehtes Passwort oder eine Wartung beim Hoster: die Datenbank antwortet nicht. // Frueher hiess das "nicht installiert" - und der Installer nahm von jedem Besucher eine // neue Datenbank samt Administratorkonto an. $unreachable = ConnectionFactory::create('mysql://niemand:nichts@127.0.0.1:1/weg'); $settings = new SiteSettings($unreachable, new MediaLibrary($unreachable, $this->root.'/var/media')); $broken = new Installation( $unreachable, new PageRepository($unreachable, new DocumentValidator(), $settings), new PartialRepository($unreachable, new DocumentValidator()), new MenuRepository($unreachable, $settings), $settings, new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])), $this->root, $this->root.'/var/media', 'test-secret', ); // Vor dem Datenbankschritt gibt es noch nichts zu schuetzen. self::assertFalse($broken->isInstalled()); // Sobald eine Datenbank eingetragen ist, ist eine tote Verbindung eine Stoerung. $broken->writeEnvironment(['DATABASE_URL' => 'mysql://niemand:nichts@127.0.0.1:1/weg']); self::assertTrue($broken->isInstalled()); // Die Marke der Website zaehlt auch ohne jede Verbindung - selbst ohne .env. unlink($this->root.'/.env'); self::assertFalse($broken->isInstalled()); file_put_contents($this->root.'/var/installed', gmdate('c')."\n"); self::assertTrue($broken->isInstalled()); // Eine leere SQLite-Datenbank ohne Marke bleibt dagegen der Installer-Fall. unlink($this->root.'/var/installed'); self::assertFalse($this->installation->isInstalled()); } private function make(): Installation { $settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')); $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'), $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); } public function testTheSystemCheckNamesTheSqliteVersionBeforeItMatters(): void { // Auf einem echten Hosting gefunden: SQLite war zu alt für die Tabellenfunktionen, mit // denen Doctrine das Schema liest. Die Installation brach erst im dritten Schritt ab, mit // „near "(": syntax error" - eine Meldung, die niemandem sagt, was zu tun ist. Die Fassung // gehört deshalb in die Systemprüfung, vor die Wahl der Datenbank. $version = $this->installation->sqliteVersion(); self::assertNotNull($version, 'Auf diesem Rechner gibt es pdo_sqlite - dann muss auch die Fassung lesbar sein.'); self::assertMatchesRegularExpression('/^\d+\.\d+/', $version); $namen = array_column($this->installation->checks(), 'label'); self::assertContains('SQLite-Fassung', $namen); $eintrag = null; foreach ($this->installation->checks() as $check) { if ('SQLite-Fassung' === $check['label']) { $eintrag = $check; } } self::assertNotNull($eintrag); self::assertSame($version, $eintrag['found']); // Sie ist eine Empfehlung, kein Hindernis: mit MySQL läuft die Installation auch auf einem // Server mit altem SQLite. self::assertFalse($eintrag['required']); self::assertSame(version_compare($version, Installation::MIN_SQLITE, '>='), $eintrag['ok']); } 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); } }