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 .env', $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 localhost', $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', $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 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; } }