- 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>
344 lines
17 KiB
PHP
344 lines
17 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 App\Ops\Protection;
|
|
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();
|
|
}
|
|
|
|
public function testMissingLockFilesAreAddedButAnOwnRootFileStays(): void
|
|
{
|
|
// var/ gibt es nicht im Archiv, also legt die Website die Sperrdatei selbst an (ADR 116).
|
|
mkdir($this->root.'/config/schutz', 0o775, true);
|
|
mkdir($this->root.'/vendor', 0o775, true);
|
|
foreach (['sperren', 'stamm'] as $name) {
|
|
copy(\dirname(__DIR__).'/config/schutz/'.$name.'.htaccess', $this->root.'/config/schutz/'.$name.'.htaccess');
|
|
}
|
|
file_put_contents($this->root.'/.htaccess', '# eigene Regeln
|
|
');
|
|
Protection::reset();
|
|
Protection::ensure($this->root);
|
|
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/var/.htaccess'));
|
|
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/vendor/.htaccess'));
|
|
self::assertSame('# eigene Regeln
|
|
', file_get_contents($this->root.'/.htaccess'), 'Eine eigene Datei im Projektstamm bleibt.');
|
|
Protection::reset();
|
|
}
|
|
|
|
public function testTheInstallKeyIsCreatedOnceAndCheckedStrictly(): void
|
|
{
|
|
$key = $this->installation->installKey();
|
|
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
|
|
self::assertSame($key, $this->installation->installKey(), 'Einmal angelegt, bleibt er.');
|
|
self::assertTrue($this->installation->acceptsKey(' '.strtoupper($key).' '), 'Abgetippt mit Leerzeichen und Großbuchstaben zählt.');
|
|
self::assertFalse($this->installation->acceptsKey(substr($key, 0, 31)));
|
|
self::assertFalse($this->installation->acceptsKey(''));
|
|
self::assertTrue($this->installation->acceptsProof($this->installation->keyProof()));
|
|
self::assertFalse($this->installation->acceptsProof(''));
|
|
self::assertFalse($this->installation->acceptsProof($key));
|
|
}
|
|
|
|
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'));
|
|
}
|
|
|
|
public function testAnUnreachableDatabaseDoesNotReopenTheInstaller(): void
|
|
{
|
|
// Ein gedrehtes Passwort oder eine Wartung beim Hoster: die Datenbank antwortet nicht.
|
|
// Frueher hiess das "nicht installiert" - und der Installer nahm von jedem Besucher eine
|
|
// neue Datenbank samt Administratorkonto an.
|
|
$unreachable = ConnectionFactory::create('mysql://niemand:nichts@127.0.0.1:1/weg');
|
|
$settings = new SiteSettings($unreachable, new MediaLibrary($unreachable, $this->root.'/var/media'));
|
|
$broken = new Installation(
|
|
$unreachable,
|
|
new PageRepository($unreachable, new DocumentValidator(), $settings),
|
|
new PartialRepository($unreachable, new DocumentValidator()),
|
|
new MenuRepository($unreachable, $settings),
|
|
$settings,
|
|
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
|
|
$this->root,
|
|
$this->root.'/var/media',
|
|
'test-secret',
|
|
);
|
|
// Vor dem Datenbankschritt gibt es noch nichts zu schuetzen.
|
|
self::assertFalse($broken->isInstalled());
|
|
// Sobald eine Datenbank eingetragen ist, ist eine tote Verbindung eine Stoerung.
|
|
$broken->writeEnvironment(['DATABASE_URL' => 'mysql://niemand:nichts@127.0.0.1:1/weg']);
|
|
self::assertTrue($broken->isInstalled());
|
|
// Die Marke der Website zaehlt auch ohne jede Verbindung - selbst ohne .env.
|
|
unlink($this->root.'/.env');
|
|
self::assertFalse($broken->isInstalled());
|
|
file_put_contents($this->root.'/var/installed', gmdate('c')."\n");
|
|
self::assertTrue($broken->isInstalled());
|
|
// Eine leere SQLite-Datenbank ohne Marke bleibt dagegen der Installer-Fall.
|
|
unlink($this->root.'/var/installed');
|
|
self::assertFalse($this->installation->isInstalled());
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|