Cookies wie im Browser: der Nachweis des Installationsschlüssels. */ private array $cookies = []; protected function setUp(): void { $this->root = sys_get_temp_dir().'/kant-flow-'.bin2hex(random_bytes(6)); foreach (['config', 'templates', 'var', 'public'] as $directory) { mkdir($this->root.'/'.$directory, 0o775, true); } // src/ is copied as well, because the service definitions scan that directory. The // classes themselves still come from the autoloader, so this is only a list of files. foreach (['config', 'templates', 'src'] as $directory) { self::copyDirectory(\dirname(__DIR__).'/'.$directory, $this->root.'/'.$directory); } // The migrations are not copied but pointed at: a second copy of the same classes in one // process is a fatal error, and a copied migration would age away from the original. 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->database = $this->root.'/var/cms.db'; } protected function tearDown(): void { gc_collect_cycles(); self::removeDirectory($this->root); } public function testAnEmptyInstallationBecomesAWebsiteInSixSteps(): void { // 0. Whatever is asked for, the answer is the installer. A missing table is not something // to explain with a stack trace. $response = $this->request('/'); self::assertSame(302, $response->getStatusCode()); self::assertSame('/install', $response->headers->get('Location')); // 0b. Ohne Installationsschlüssel öffnet sich kein Schritt (ADR 116). self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location')); self::assertSame('/install/schluessel', $this->request('/install/datenbank')->headers->get('Location')); $this->unlock(); // 1. The system check. $check = $this->request('/install'); self::assertSame(200, $check->getStatusCode()); self::assertStringContainsString('Passt dieser Server?', $check->getContent()); // 2. The database. SQLite needs nothing but a writable var/. $database = $this->request('/install/datenbank'); $response = $this->submit('/install/datenbank', $database, ['driver' => 'sqlite', 'name' => 'cms']); self::assertSame('/install/migration', $response->headers->get('Location')); $environment = (string) file_get_contents($this->root.'/.env'); self::assertStringContainsString('DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"', $environment); self::assertStringContainsString('APP_ENV="prod"', $environment); // 3. The migrations, in this process, without a console. $migration = $this->request('/install/migration'); self::assertStringContainsString('Migration', $migration->getContent()); $response = $this->submit('/install/migration', $migration, []); self::assertSame('/install/website', $response->headers->get('Location')); self::assertNotEmpty($this->connection()->fetchAllAssociative('SELECT version FROM migration_versions')); // 4. The website, and with it the content a site cannot go live without. $website = $this->request('/install/website'); $response = $this->submit('/install/website', $website, ['name' => 'Beispiel GmbH', 'origin' => 'https://beispiel.de', 'locale' => 'de']); self::assertSame('/install/konto', $response->headers->get('Location')); self::assertSame(3, (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM page'), 'Startseite, Impressum, Datenschutz.'); // 5. The first account - and the last screen, rendered straight away, because one request // later this route no longer exists. $account = $this->request('/install/konto'); $done = $this->submit('/install/konto', $account, ['username' => 'chefin', 'password' => 'ein sehr langes passwort', 'repeat' => 'ein sehr langes passwort']); self::assertSame(200, $done->getStatusCode()); self::assertStringContainsString('Fertig.', $done->getContent()); self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent()); // Der Schlüssel hat seine Arbeit getan und liegt nicht mehr herum. self::assertFileDoesNotExist($this->root.'/var/install.key'); // 6. The installer is over, and the website answers. self::assertSame(404, $this->request('/install')->getStatusCode()); self::assertSame(404, $this->request('/install/konto')->getStatusCode()); $home = $this->request('/'); self::assertSame(200, $home->getStatusCode()); self::assertStringContainsString('Beispiel GmbH', $home->getContent()); self::assertStringContainsString('Hier entsteht eine neue Webpräsenz.', $home->getContent()); // The public page is still a public page: no script, and the header carries the name. self::assertStringContainsString("script-src 'none'", (string) $home->headers->get('Content-Security-Policy')); self::assertSame(200, $this->request('/impressum')->getStatusCode()); self::assertSame(200, $this->request('/admin/login')->getStatusCode()); } public function testTheInstallerOpensOnlyWithItsKey(): void { // Wer die frische Installation zuerst aufruft, bekommt nur die Frage nach dem Schlüssel. $page = $this->request('/install/schluessel'); self::assertSame(200, $page->getStatusCode()); self::assertStringContainsString('Installationsschlüssel', (string) $page->getContent()); $key = trim((string) file_get_contents($this->root.'/var/install.key')); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key); // Ein falscher Schlüssel öffnet nichts und setzt kein Cookie. $wrong = $this->request('/install/schluessel', 'POST', ['schluessel' => str_repeat('0', 32)]); self::assertSame(200, $wrong->getStatusCode()); self::assertStringContainsString('passt nicht', (string) $wrong->getContent()); self::assertSame([], $wrong->headers->getCookies()); // Auch ein selbst gebautes Cookie nicht. $this->cookies = ['kant_install' => hash_hmac('sha256', 'installer', 'geraten')]; self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location')); // Der Verweis von install.php trägt den Schlüssel in der Adresse; danach geht es ohne ihn weiter. $this->cookies = []; $open = $this->request('/install/schluessel?schluessel='.$key); self::assertSame('/install', $open->headers->get('Location')); $cookie = $open->headers->getCookies()[0]; self::assertSame('kant_install', $cookie->getName()); self::assertTrue($cookie->isHttpOnly()); self::assertSame('strict', $cookie->getSameSite()); self::assertSame('/install', $cookie->getPath()); self::assertNotSame($key, $cookie->getValue(), 'Im Cookie steht ein Nachweis, nicht der Schlüssel.'); $this->cookies = ['kant_install' => (string) $cookie->getValue()]; self::assertSame(200, $this->request('/install')->getStatusCode()); } public function testAStepWithoutItsRequirementSendsYouBack(): void { $this->unlock(); // Somebody who guesses the address of a later step does not get past the missing database. self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location')); self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location')); self::assertSame('/install/website', $this->request('/install/konto')->headers->get('Location')); // And a form without a valid signature changes nothing: the page comes back, unchanged. $response = $this->request('/install/datenbank', 'POST', ['driver' => 'sqlite', 'name' => 'cms', 'issued' => time(), 'signature' => 'erfunden']); self::assertSame(200, $response->getStatusCode()); self::assertFileDoesNotExist($this->root.'/.env'); } /** Gibt den Installationsschlüssel ein, wie es der Betreiber tut, und behält das Cookie. */ private function unlock(): void { $this->request('/install/schluessel'); $response = $this->request('/install/schluessel', 'POST', ['schluessel' => trim((string) file_get_contents($this->root.'/var/install.key'))]); self::assertSame('/install', $response->headers->get('Location')); $this->cookies['kant_install'] = (string) $response->headers->getCookies()[0]->getValue(); } /** Posts a form with the signature the rendered page carries, the way a browser would. */ private function submit(string $url, Response $page, array $fields): Response { preg_match('/name="issued" value="(\d+)"/', (string) $page->getContent(), $issued); preg_match('/name="signature" value="([a-f0-9]{64})"/', (string) $page->getContent(), $signature); self::assertNotEmpty($issued, 'Das Formular trägt einen Zeitpunkt.'); self::assertNotEmpty($signature, 'Das Formular trägt eine Unterschrift.'); return $this->request($url, 'POST', [...$fields, 'issued' => $issued[1], 'signature' => $signature[1]]); } private function request(string $url, string $method = 'GET', array $body = []): Response { $previous = []; foreach ([ 'APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->database), 'SITE_ORIGIN' => 'https://beispiel.de', 'MEDIA_DIRECTORY' => $this->root.'/var/media', ] as $key => $value) { $previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null]; $_SERVER[$key] = $_ENV[$key] = $value; } $kernel = new InstallTestKernel($this->root); try { $request = Request::create($url, $method, $body, $this->cookies); $response = $kernel->handle($request); $kernel->terminate($request, $response); return $response; } finally { $kernel->shutdown(); foreach ($previous as $key => [$server, $environment]) { unset($_SERVER[$key], $_ENV[$key]); if (null !== $server) { $_SERVER[$key] = $server; } if (null !== $environment) { $_ENV[$key] = $environment; } } } } private function connection(): Connection { return ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->database)); } private static function copyDirectory(string $from, string $to): void { @mkdir($to, 0o775, true); foreach (scandir($from) ?: [] as $entry) { if ('.' === $entry || '..' === $entry) { continue; } is_dir($from.'/'.$entry) ? self::copyDirectory($from.'/'.$entry, $to.'/'.$entry) : copy($from.'/'.$entry, $to.'/'.$entry); } } private static function removeDirectory(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ('.' !== $entry && '..' !== $entry) { is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry); } } @rmdir($path); } } /** A kernel that lives in a throwaway directory, so the installer writes there and not here. */ final class InstallTestKernel extends Kernel { public function __construct(private string $projectDir) { parent::__construct('test', false); } public function getProjectDir(): string { return $this->projectDir; } public function getCacheDir(): string { return $this->projectDir.'/var/cache'; } public function getLogDir(): string { return $this->projectDir.'/var/log'; } }