- 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>
272 lines
13 KiB
PHP
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';
|
|
}
|
|
}
|