Files
KantSites/tests/InstallFlowTest.php
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00

272 lines
13 KiB
PHP

<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The six screens, through the actual HTTP layer, from an empty webspace to a working website.
*
* The kernel here has its own project directory, a copy with only the parts a kernel reads:
* configuration, templates, migrations. Everything the installer writes - the environment file,
* the database, the marker - lands there and nowhere near the installation I work in.
*
* The point of the test is the acceptance criterion of the package: six steps in the browser,
* without a console, and afterwards the installer is gone and the website answers.
*/
final class InstallFlowTest extends TestCase
{
private string $root;
private string $database;
/** @var array<string, string> 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(
"<?php return ['table_storage' => ['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';
}
}