Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16 Abfragen auf der Startseite des Nachbaus). Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen Fussmenue durch die Kontrastpruefung. Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und 180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert. Die Browserpruefung laeuft jetzt gegen jede Installation statt nur gegen die Demo: geprueft wird, was in der Sitemap steht, und das Ladebudget trennt Bilder vom Beiwerk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
12 KiB
PHP
251 lines
12 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.local'), '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.local');
|
|
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.local'), $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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|