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

196 lines
9.4 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Der Bildschirm „System“ - die einzige Stelle, an der eine Installation ohne Konsole erfährt,
* dass etwas mit ihr nicht stimmt.
*
* Geprüft wird hier nicht das Aussehen, sondern zwei Eigenschaften, die schweigend verschwinden
* können: dass ausstehende Migrationen dort **auffallen**, und dass niemand sie auslösen kann,
* der nicht angemeldeter Administrator ist. Beides fiel beim Veröffentlichen von 1.4.3 auf -
* siehe ADR 070.
*/
final class SystemScreenTest extends WebTestCase
{
private const FIXTURE_PASSWORD = 'Fixture-only-password';
private Connection $db;
private string $path;
private array $previous;
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
// Die Tabellen entstehen hier über das Testschema und nicht über die Migrationen - genau
// die Lage einer Installation, deren Dateien von Hand hochgeladen wurden: Der Code ist
// neu, die Migrationsliste der Datenbank kennt ihn nicht.
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', [
'id' => bin2hex(random_bytes(8)),
'username' => $username,
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
'created_at' => gmdate('Y-m-d H:i:s'),
'password_changed_at' => gmdate('Y-m-d H:i:s'),
]);
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
foreach (glob($this->path.'-media/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->path.'-media')) {
rmdir($this->path.'-media');
}
[$_SERVER, $_ENV] = $this->previous;
}
public function testOutstandingMigrationsAreImpossibleToOverlookAndCanBeRunFromThere(): void
{
$client = $this->login();
$client->request('GET', '/admin/system');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
// Vorher gab es dafür nur die Konsole - also genau das, was auf geteiltem Hosting fehlt.
self::assertStringContainsString('Migrationen stehen aus', $html);
self::assertStringContainsString('Version20260912000100', $html, 'Die Namen gehören dazu: sie sagen, was passieren wird.');
self::assertStringContainsString('/admin/system/migrationen', $html);
// Eine Sicherung vorher ist keine Höflichkeit: Migrationen sind nicht umkehrbar.
self::assertStringContainsString('Sicherung', $html);
}
public function testTheScreenNamesTheDatabaseItActuallyUses(): void
{
// Umut hat `var/cms.db` gelöscht, weil in `.env` eine andere Datei stand - und die
// Website war weg. Der Grund: `.env.local` übersteuert `.env`, und der Installer schreibt
// nur `.env.local`. Wer nicht weiß, dass es diese Datei gibt, liest die falsche und
// handelt danach. Eine Verwaltung, die das verschweigt, lädt genau dazu ein.
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('SQLite', $html);
self::assertStringContainsString(
htmlspecialchars(str_replace('\\', '/', $this->path), ENT_QUOTES),
$html,
'Die Datei, die wirklich benutzt wird, muss dastehen - nicht die, die irgendwo konfiguriert ist.',
);
self::assertStringContainsString('Server-Umgebung', $html, 'Der Test übergibt die Verbindung ausdrücklich als Umgebungsvariable.');
self::assertStringNotContainsString('übersteuert <code>.env</code>', $html);
}
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
{
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
// Schritte ab – und die Seite, die sie anzeigen soll, starb daran. Der Updater über Git
// nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, und die Vorlage
// liest `step`. Ergebnis: 500 statt Ergebnis, und der zweite Knopf unerreichbar. Ein
// Test, der nur das Ergebnis-Array prüft, findet so etwas nie – dieser rendert die Seite.
$archiv = new ArchiveUpdate(
dirname(__DIR__),
new Updater(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], new CommandRunner(), $this->db),
new Backup(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], $this->path.'-media', $this->db, new CommandRunner()),
new Download(connectTimeout: 1, timeout: 2),
// Eine Adresse, die es nicht gibt: Der Lauf endet nach dem ersten Schritt, ohne
// irgendetwas anzufassen – und genau dieser eine Schritt muss auf der Seite stehen.
'https://kein-server.invalid/download',
'https://kein-server.invalid/api',
);
$ergebnis = $archiv->run();
self::assertFalse($ergebnis['ok']);
self::assertSame(
['step', 'ok', 'detail'],
array_keys($ergebnis['steps'][0]),
'Beide Updater beschreiben ihre Schritte gleich - die Vorlage kennt nur eine Form.',
);
$client = $this->login();
$client->request('GET', '/admin/system');
$sitzung = $client->getRequest()->getSession();
$sitzung->set('update_steps', $ergebnis['steps']);
$sitzung->save();
$client->request('GET', '/admin/system');
self::assertSame(200, $client->getResponse()->getStatusCode(), 'Die Ergebnisseite darf an den eigenen Schritten nicht sterben.');
self::assertStringContainsString('Fassung ermitteln', (string) $client->getResponse()->getContent());
}
public function testNobodyButAnAdministratorCanStartAMigration(): void
{
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
self::ensureKernelShutdown();
$anonym = self::createClient();
$anonym->followRedirects(false);
$anonym->request('POST', '/admin/system/migrationen');
self::assertSame('http://localhost/admin/login', $anonym->getResponse()->headers->get('Location'));
$redaktion = $this->login('editor');
$redaktion->request('POST', '/admin/system/migrationen');
self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Eine Redakteurin darf das nicht.');
// Und selbst als Administrator nicht ohne gültiges Formular aus dieser Sitzung.
$admin = $this->login();
$admin->request('POST', '/admin/system/migrationen', ['_csrf_token' => 'erfunden']);
$admin->followRedirect();
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
}
private function login(string $username = 'admin'): KernelBrowser
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->followRedirects(false);
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
$client->followRedirect();
return $client;
}
}