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')); // 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()); // 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('Diese Seite ist der Anfang.', $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 testAStepWithoutItsRequirementSendsYouBack(): void { // 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'); } /** 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); $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'; } }