Umut: "Kannst du bitte fuer die Erstseite bzw. nach der Installation eine schoene Erstseite erstellen. Under Construction oder so ohne Details mit melde dich an usw. Am besten auch noch Logo von KantSites." starterPage() shipped two sections of instructions for the owner: "Melde dich an, oeffne sie im Editor ..." and "Die naechsten Schritte" with design, menus and settings. That stood on the only page a fresh installation serves publicly, so the visitor read it, not the owner - who gets the same steps on the last installer screen and in the admin area anyway. It is now the page such a site actually needs: the KantSites mark, the label "In Arbeit" in the accent colour, the site name as the single H1, "Hier entsteht eine neue Webpraesenz.", a short rule and the line "Erstellt mit KantSites". The section is a flex container with min-height 70vh and a gradient from @paper to @mint, so the little that is there sits in the middle of the screen; the colours are palette references and follow a design change. Everything is an ordinary block with ordinary style values and can be changed or removed in the editor. The page description is a real sentence now instead of "NOCH AUSZUFUELLEN", so a freshly installed site does not carry a placeholder into search results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
10 KiB
PHP
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('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 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';
|
|
}
|
|
}
|