Files
KantSites/tests/InstallFlowTest.php
T
kantarci 495b26896e
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
2026-09-14 10:49:54 +02:00

220 lines
10 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;
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'));
// 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';
}
}