Files
KantSites/tests/InstallationTest.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

278 lines
13 KiB
PHP

<?php
namespace App\Tests;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Domain\CmsUser;
use App\Domain\InvalidPage;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Installation;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
/**
* The installer against an empty directory and an empty database.
*
* Not against the development installation: an installer writes an environment file, creates
* tables and puts content into them, and a test that does that where I work would be a way to
* lose an afternoon. Everything here happens in a throwaway directory with a throwaway database -
* and the migrations are the real ones, run the way the browser installer runs them, because that
* is the part that has to work on hosting without a console.
*/
final class InstallationTest extends TestCase
{
private string $root;
private Connection $db;
private Installation $installation;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-install-'.bin2hex(random_bytes(6));
mkdir($this->root.'/var', 0o775, true);
mkdir($this->root.'/public', 0o775, true);
// The same migration configuration a real installation has, pointing at the real
// migrations: a copy of the list would drift away from it within a week.
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->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db');
$this->installation = $this->make();
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
$this->removeDirectory($this->root);
}
public function testAnEmptyInstallationIsNotInstalledAndSaysWhatIsMissing(): void
{
// No database, no tables, no account: the installer has to survive all three, because it
// is the only screen that exists at that moment.
self::assertFalse($this->installation->isInstalled());
self::assertFalse($this->installation->schemaReady());
self::assertFalse($this->installation->siteReady());
self::assertFalse($this->installation->environmentWritten());
$checks = $this->installation->checks();
$labels = array_column($checks, 'label');
self::assertContains('PHP-Fassung', $labels);
self::assertContains('Datenbanktreiber', $labels);
foreach ($checks as $check) {
// A failing check without a sentence about what to do is a dead end.
self::assertNotSame('', $check['hint'], $check['label'].' braucht einen Hinweis.');
}
// The document root of a test process is not public/, so this check has to fail - and it
// has to fail without blocking, because a wrong document root is the server's business.
$documentRoot = self::findCheck($checks, 'Dokumentenstamm zeigt auf public/');
self::assertFalse($documentRoot['ok']);
self::assertFalse($documentRoot['required']);
}
public function testTheDatabaseStepProvesTheConnectionBeforeItWritesAnything(): void
{
$good = $this->installation->testDatabase('sqlite', ['name' => 'cms']);
self::assertNull($good['error']);
self::assertSame('sqlite:///%kernel.project_dir%/var/cms.db', $good['url']);
// A MySQL host that does not answer must fail here and not two screens later.
$bad = $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'z']);
self::assertNotNull($bad['error']);
self::assertStringNotContainsString('geheimespasswort', $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'geheimespasswort'])['error'], 'Das Passwort gehört in keine Fehlermeldung.');
self::assertFalse(is_file($this->root.'/.env'), 'Eine gescheiterte Prüfung schreibt nichts.');
}
public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void
{
$this->installation->writeEnvironment(['APP_SECRET' => 'geheim', 'DATABASE_URL' => 'sqlite:///x.db']);
self::assertTrue($this->installation->environmentWritten());
// The website step adds the address later and must not lose the database doing it.
$this->installation->writeEnvironment(['SITE_ORIGIN' => 'https://example.de']);
$content = (string) file_get_contents($this->root.'/.env');
self::assertStringContainsString('APP_SECRET="geheim"', $content);
self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content);
self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content);
self::assertSame(1, substr_count($content, 'DATABASE_URL='), 'Kein Wert steht doppelt in der Datei.');
}
public function testTheWholeInstallationRunsThroughAndThenLocksItself(): void
{
$pending = $this->installation->pendingMigrations();
self::assertNotEmpty($pending, 'Eine leere Datenbank hat ausstehende Migrationen.');
$applied = $this->installation->migrate();
self::assertSame(\count($pending), \count($applied));
self::assertTrue($this->installation->schemaReady());
// Running again is allowed and does nothing: a browser that timed out is not a problem.
self::assertSame([], $this->installation->migrate());
$created = $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de/', 'de');
self::assertTrue($this->installation->siteReady());
self::assertContains('Startseite', $created);
self::assertContains('Impressum', $created);
self::assertContains('Datenschutzerklärung', $created);
// The start page is published, not a draft: a fresh installation that answers 404 at its
// own address would look broken.
$home = $this->make()->isInstalled();
self::assertFalse($home, 'Ohne Konto gilt die Installation als unfertig.');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')));
self::assertNotNull($pages->findPublished('home'));
self::assertNotNull($pages->findPublished('impressum'));
self::assertSame('https://beispiel.de', $this->settingsOrigin());
// The legal pages are linked, otherwise the consent gate and the footer stay empty.
$settings = (new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media')))->load();
self::assertSame('datenschutz', $settings['privacyPath']);
self::assertSame('impressum', $settings['imprintPath']);
self::assertSame('Beispiel GmbH', $settings['legalName']);
// Running the website step twice creates nothing twice.
self::assertSame([], $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de', 'de'));
$this->installation->createAdmin('chefin', 'ein sehr langes passwort', 'ein sehr langes passwort');
self::assertTrue($this->installation->isInstalled(), 'Mit dem ersten Konto ist die Installation vorbei.');
$row = $this->db->fetchAssociative('SELECT username, password_hash, roles FROM cms_user');
self::assertSame('chefin', $row['username']);
self::assertStringNotContainsString('passwort', $row['password_hash'], 'Das Passwort steht nirgends im Klartext.');
self::assertSame(['ROLE_ADMIN'], json_decode($row['roles'], true));
self::assertTrue(
(new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])))
->isPasswordValid(new CmsUser('x', 'chefin', $row['password_hash'], ['ROLE_ADMIN']), 'ein sehr langes passwort'),
);
// And the second account is refused, because that is what "the installer is over" means.
$this->expectException(InvalidPage::class);
$this->installation->createAdmin('zweite', 'noch ein langes passwort', 'noch ein langes passwort');
}
#[DataProvider('badAccounts')]
public function testAnAccountWithWeakInputIsRefused(string $username, string $password, string $repeat): void
{
$this->installation->migrate();
$this->expectException(InvalidPage::class);
$this->installation->createAdmin($username, $password, $repeat);
}
public static function badAccounts(): iterable
{
yield 'zu kurzer Name' => ['ab', 'ein sehr langes passwort', 'ein sehr langes passwort'];
yield 'Leerzeichen im Namen' => ['die chefin', 'ein sehr langes passwort', 'ein sehr langes passwort'];
yield 'zu kurzes Passwort' => ['chefin', 'kurz', 'kurz'];
yield 'Tippfehler bei der Wiederholung' => ['chefin', 'ein sehr langes passwort', 'ein sehr langes passwortt'];
}
#[DataProvider('badSites')]
public function testAWebsiteWithBadInputIsRefused(string $name, string $origin, string $locale): void
{
$this->installation->migrate();
$this->expectException(InvalidPage::class);
$this->installation->createSite($name, $origin, $locale);
}
public static function badSites(): iterable
{
yield 'ohne Namen' => ['', 'https://beispiel.de', 'de'];
yield 'Adresse ohne Schema' => ['Beispiel', 'beispiel.de', 'de'];
yield 'keine Adresse' => ['Beispiel', 'https://', 'de'];
yield 'unbekannte Sprache' => ['Beispiel', 'https://beispiel.de', 'kl'];
}
public function testAFormFromAnotherStepOrAnotherDayDoesNotCount(): void
{
$issued = time();
$signature = $this->installation->sign('website', $issued);
self::assertTrue($this->installation->verify('website', $issued, $signature));
self::assertFalse($this->installation->verify('konto', $issued, $signature), 'Eine Unterschrift gilt für einen Schritt.');
self::assertFalse($this->installation->verify('website', $issued - Installation::TOKEN_LIFETIME - 60, $signature));
self::assertFalse($this->installation->verify('website', $issued, 'falsch'));
}
private function make(): Installation
{
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
$validator = new DocumentValidator();
return new Installation(
$this->db,
new PageRepository($this->db, $validator, $settings),
new PartialRepository($this->db, $validator),
new MenuRepository($this->db, $settings),
$settings,
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
$this->root,
$this->root.'/var/media',
'ein-test-geheimnis',
);
}
private function settingsOrigin(): string
{
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env'), $match);
return $match[1] ?? '';
}
private static function findCheck(array $checks, string $label): array
{
foreach ($checks as $check) {
if ($check['label'] === $label) {
return $check;
}
}
self::fail('Prüfung fehlt: '.$label);
}
public function testTheSystemCheckNamesTheSqliteVersionBeforeItMatters(): void
{
// Auf einem echten Hosting gefunden: SQLite war zu alt für die Tabellenfunktionen, mit
// denen Doctrine das Schema liest. Die Installation brach erst im dritten Schritt ab, mit
// „near "(": syntax error" - eine Meldung, die niemandem sagt, was zu tun ist. Die Fassung
// gehört deshalb in die Systemprüfung, vor die Wahl der Datenbank.
$version = $this->installation->sqliteVersion();
self::assertNotNull($version, 'Auf diesem Rechner gibt es pdo_sqlite - dann muss auch die Fassung lesbar sein.');
self::assertMatchesRegularExpression('/^\d+\.\d+/', $version);
$namen = array_column($this->installation->checks(), 'label');
self::assertContains('SQLite-Fassung', $namen);
$eintrag = null;
foreach ($this->installation->checks() as $check) {
if ('SQLite-Fassung' === $check['label']) {
$eintrag = $check;
}
}
self::assertNotNull($eintrag);
self::assertSame($version, $eintrag['found']);
// Sie ist eine Empfehlung, kein Hindernis: mit MySQL läuft die Installation auch auf einem
// Server mit altem SQLite.
self::assertFalse($eintrag['required']);
self::assertSame(version_compare($version, Installation::MIN_SQLITE, '>='), $eintrag['ok']);
}
private function removeDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ('.' !== $entry && '..' !== $entry) {
is_dir($path.'/'.$entry) ? $this->removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry);
}
}
@rmdir($path);
}
}