Files
KantSites/tests/SystemScreenTest.php
T
kantarciandClaude Opus 5.5 47292c7266 Finish an archive update in one go and translate Kantarci Reisen into Turkish
The request that swaps the files now ends with a plain page instead of running on through the
framework with old code in memory and new code on disk - that is what answered with a 500 after
a successful update. The page calls the second step itself with a one-time key: migrations,
clean-up, maintenance off, back to the system screen (ADR 112).

Turkish is complete in the fixed template texts and form messages. Two language switches on one
page get distinct landmark names. Kantarci Reisen gets a Turkish version of every page, the
header, footer, menu and enquiry form, with a DE · TR switch; the reset script applies it.
The DM Sans 600 files are needed after all and are committed with fonts.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00

342 lines
18 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\OriginCheck;
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 testTheScreenNamesThePublicAddressAndNoticesWhenItIsNotTheOneInUse(): void
{
// Die Verwaltung laeuft im Test unter localhost, eingetragen ist https://cms.test: der
// Bildschirm nennt beides, statt so zu tun, als passe es.
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('https://cms.test', $html);
self::assertStringContainsString('läuft gerade unter <code>localhost</code>', $html);
self::assertStringContainsString('Stimmt die nicht mehr, unten ändern.', $html);
// Unter der eingetragenen Adresse selbst: kein Hinweis, dafuer die Richtung der Umleitung.
$client->request('GET', 'https://cms.test/admin/login');
$client->submit($client->getCrawler()->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => self::FIXTURE_PASSWORD]));
$client->request('GET', 'https://cms.test/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('die Schreibweise mit www leitet hierher um', $html);
self::assertStringNotContainsString('läuft gerade unter', $html);
// Und die andere Schreibweise kommt gar nicht erst bis zur Verwaltung.
$client->request('GET', 'https://www.cms.test/admin/system');
self::assertSame(301, $client->getResponse()->getStatusCode());
self::assertSame('https://cms.test/admin/system', $client->getResponse()->headers->get('Location'));
}
public function testTheAddressIsSetInTheAdministrationButOnlyWhereItReallyArrives(): void
{
// Umut: „bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird“ -
// wie die WordPress-Adresse. Im Test kommt SITE_ORIGIN aus der Server-Umgebung; dort hat
// sie Vorrang, also gibt es kein Feld, und auch ein POST ändert nichts.
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('lässt sich nur beim Hoster ändern', $html);
self::assertStringNotContainsString('name="origin"', $html);
$token = (string) $client->getCrawler()->filter('form[action$="/system/wartung"] input[name=_csrf_token]')->attr('value');
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://www.cms.test']);
$client->followRedirect();
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
// Steht sie in .env, gibt es das Feld samt Umschalter auf die andere Schreibweise.
$geladen = $_SERVER['SYMFONY_DOTENV_VARS'] ?? null;
$_SERVER['SYMFONY_DOTENV_VARS'] = 'SITE_ORIGIN';
try {
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('name="origin" type="url"', $html);
self::assertStringContainsString('value="https://www.cms.test" formnovalidate>www.cms.test</button>', $html);
// Eine Adresse, unter der diese Website nicht antwortet, wird nicht gespeichert - sonst
// leitete die andere Schreibweise ins Leere. Das Feld behält sie, und erst jetzt
// erscheint „Trotzdem übernehmen“.
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://kein-server.invalid/']);
$client->followRedirect();
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Unter https://kein-server.invalid antwortet diese Website nicht', $html);
self::assertStringContainsString('Nichts geändert.', $html);
self::assertStringContainsString('value="https://kein-server.invalid"', $html);
self::assertStringContainsString('name="force"', $html);
foreach (['https://cms.test/start' => 'ohne Pfad', 'ftp://cms.test' => 'beginnt mit https://'] as $falsch => $meldung) {
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => $falsch]);
$client->followRedirect();
self::assertStringContainsString($meldung, (string) $client->getResponse()->getContent());
}
} finally {
if (null === $geladen) {
unset($_SERVER['SYMFONY_DOTENV_VARS']);
} else {
$_SERVER['SYMFONY_DOTENV_VARS'] = $geladen;
}
}
self::assertStringContainsString('SITE_ORIGIN', (string) file_get_contents(dirname(__DIR__).'/.env'));
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
$client->request('GET', '/.well-known/kantsites-origin');
self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
}
public function testTheAddressCheckWantsThisInstallationsOwnAnswer(): void
{
$antwort = null;
$download = new class($antwort) extends Download {
public function __construct(private ?string &$antwort)
{
parent::__construct();
}
public function get(string $url): ?string
{
return $this->antwort;
}
};
$check = new OriginCheck('geheim', $download);
self::assertStringContainsString('antwortet diese Website nicht', (string) $check->problem('https://www.example.de'));
$antwort = hash_hmac('sha256', 'site-origin', 'anderes-geheimnis');
self::assertStringContainsString('antwortet eine andere Website', (string) $check->problem('https://www.example.de'));
$antwort = $check->token().'
';
self::assertNull($check->problem('https://www.example.de'));
self::assertStringContainsString('nur HTTPS', (string) $check->problem('http://www.example.de'));
}
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 testTheSecondUpdateStepRunsByItselfWithItsOneTimeKey(): void
{
// Umut am 09.10.2026: Update eingespielt, „direkt danach kam 500“, zurück, dann migriert.
// Die Anfrage, die die Dateien tauscht, stirbt am gemischten Code. Seit ADR 112 endet sie
// mit einer schlichten Seite, die den Abschluss über diesen Weg selbst aufruft.
$marke = dirname(__DIR__).'/'.ArchiveUpdate::MARKE;
@mkdir(dirname($marke), 0o775, true);
file_put_contents($marke, json_encode(['fassung' => '9.9.9', 'seit' => gmdate('c'), 'alt' => 'x', 'token' => str_repeat('a', 32)]));
// Die Testdatenbank entsteht aus dem Schema, nicht aus Migrationen. Eine echte
// Installation kennt ihre ausgeführten Migrationen - dasselbe Protokoll hier.
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS migration_versions (version VARCHAR(191) NOT NULL PRIMARY KEY, executed_at DATETIME DEFAULT NULL, execution_time INTEGER DEFAULT NULL)');
foreach (glob(dirname(__DIR__).'/migrations/Version*.php') ?: [] as $datei) {
$this->db->executeStatement('INSERT OR IGNORE INTO migration_versions (version, executed_at, execution_time) VALUES (?, ?, 0)', ['DoctrineMigrations\\'.basename($datei, '.php'), gmdate('Y-m-d H:i:s')]);
}
try {
$redaktion = $this->login('editor');
$redaktion->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Nur ein Administrator schließt ein Update ab.');
self::assertFileExists($marke);
$admin = $this->login();
$admin->request('GET', '/admin/system/abschliessen?token=falsch');
$admin->followRedirect();
self::assertStringContainsString('gehört zu keinem offenen Update', (string) $admin->getResponse()->getContent());
self::assertFileExists($marke, 'Ein falscher Schlüssel schließt nichts ab.');
$admin->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
self::assertSame('/admin/system', parse_url((string) $admin->getResponse()->headers->get('Location'), PHP_URL_PATH));
$admin->followRedirect();
self::assertStringContainsString('Update auf 9.9.9 abgeschlossen', (string) $admin->getResponse()->getContent());
self::assertFileDoesNotExist($marke, 'Nach dem Abschluss ist nichts mehr offen.');
// Neu laden ist harmlos.
$admin->request('GET', '/admin/system/abschliessen?token='.str_repeat('a', 32));
$admin->followRedirect();
self::assertStringContainsString('bereits abgeschlossen', (string) $admin->getResponse()->getContent());
} finally {
@unlink($marke);
}
}
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;
}
}