Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings

SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-15 20:15:20 +02:00
1 parent 45040b2ca6
commit 7eb462ac7b
53 files changed
+1023 -122

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Tests;
use App\EventSubscriber\CanonicalHostSubscriber;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Mit oder ohne www entscheidet SITE_ORIGIN - und nur dieses Paar wird umgeleitet.
*
* Die Gegenprobe ist die wichtigere Hälfte: eine Vorschauadresse des Hosters, eine IP-Adresse oder
* ein anderer Host dürfen nicht umgeleitet werden, sonst sperrt eine falsch eingetragene Adresse
* jeden aus - samt der Verwaltung.
*/
final class CanonicalHostTest extends TestCase
{
/** @return iterable<string, array{string, string, ?string}> */
public static function addresses(): iterable
{
yield 'www geht auf die nackte Domain, mit Pfad und Abfrage' => ['https://example.de', 'https://www.example.de/seite/unter?x=1&y=2', 'https://example.de/seite/unter?x=1&y=2'];
yield 'die nackte Domain geht auf www' => ['https://www.example.de', 'https://example.de/', 'https://www.example.de/'];
yield 'das Schema kommt aus SITE_ORIGIN, nicht aus der Anfrage' => ['https://example.de', 'http://www.example.de/kontakt', 'https://example.de/kontakt'];
yield 'Gross- und Kleinschreibung des Hosts spielt keine Rolle' => ['https://example.de', 'https://WWW.Example.DE/', 'https://example.de/'];
yield 'ein Port in SITE_ORIGIN bleibt erhalten' => ['https://example.de:8443', 'https://www.example.de/x', 'https://example.de:8443/x'];
yield 'auch die Verwaltung wechselt den Host - ein Host, ein Sitzungscookie' => ['https://example.de', 'https://www.example.de/admin/login', 'https://example.de/admin/login'];
yield 'ein abschliessender Schraegstrich in SITE_ORIGIN stoert nicht' => ['https://example.de/', 'https://www.example.de/a', 'https://example.de/a'];
yield 'auf der richtigen Adresse passiert nichts' => ['https://example.de', 'https://example.de/seite', null];
yield 'auf der richtigen Adresse mit www passiert nichts' => ['https://www.example.de', 'https://www.example.de/', null];
yield 'nur das Schema weicht ab: keine Umleitung, das macht der Webserver' => ['https://example.de', 'http://example.de/', null];
yield 'eine Subdomain ist kein www-Zwilling' => ['https://example.de', 'https://staging.example.de/', null];
yield 'eine fremde Domain bleibt erreichbar' => ['https://example.de', 'https://kunde123.hoster-vorschau.de/', null];
yield 'eine IP-Adresse bleibt erreichbar' => ['https://example.de', 'http://127.0.0.1/', null];
yield 'localhost bleibt erreichbar' => ['https://example.de', 'http://localhost:8080/', null];
yield 'die Entwicklungsadresse mit Port ist ihre eigene' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080/', null];
yield 'ein doppeltes www ist die www-Fassung der eingetragenen Adresse und kommt heim' => ['https://www.example.de', 'https://www.www.example.de/', 'https://www.example.de/'];
yield 'ein doppeltes www ist kein Zwilling der nackten Domain' => ['https://example.de', 'https://www.www.example.de/', null];
yield 'ohne SITE_ORIGIN keine Umleitung' => ['', 'https://www.example.de/', null];
yield 'SITE_ORIGIN ohne Schema ist keine Adresse' => ['example.de', 'https://www.example.de/', null];
yield 'die Vorlage aus .env.example leitet niemanden um' => ['https://example.de', 'https://neue-website.de/install', null];
}
#[DataProvider('addresses')]
public function testTheOtherSpellingIsSentToSiteOrigin(string $origin, string $url, ?string $expected): void
{
$event = $this->event(Request::create($url));
(new CanonicalHostSubscriber($origin))->onRequest($event);
$response = $event->getResponse();
if (null === $expected) {
self::assertNull($response, 'Diese Anfrage darf nicht umgeleitet werden.');
return;
}
self::assertNotNull($response);
self::assertSame(301, $response->getStatusCode(), 'Dauerhaft: Suchmaschinen sollen die Adresse übernehmen.');
self::assertSame($expected, $response->headers->get('Location'));
self::assertStringContainsString('max-age=3600', (string) $response->headers->get('Cache-Control'));
}
public function testOnlyReadingRequestsAreRedirected(): void
{
foreach (['POST', 'PUT', 'DELETE'] as $method) {
$event = $this->event(Request::create('https://www.example.de/formular/kontakt', $method));
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
self::assertNull($event->getResponse(), $method.': ein 301 würde die Eingabe verwerfen.');
}
$event = $this->event(Request::create('https://www.example.de/', 'HEAD'));
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
self::assertSame(301, $event->getResponse()?->getStatusCode());
}
public function testSubRequestsAreLeftAlone(): void
{
$event = new RequestEvent($this->createMock(HttpKernelInterface::class), Request::create('https://www.example.de/'), HttpKernelInterface::SUB_REQUEST);
(new CanonicalHostSubscriber('https://example.de'))->onRequest($event);
self::assertNull($event->getResponse());
}
public function testItAnswersBeforeMaintenanceAndInstaller(): void
{
$priority = CanonicalHostSubscriber::getSubscribedEvents()['kernel.request'][1];
self::assertGreaterThan(40, $priority, 'Vor dem Wartungszustand (40) und dem Installer (36): die Umleitung braucht keine Datenbank.');
self::assertLessThan(300, $priority, 'Nach den Kopfzeilen der Verwaltung (300), die keine Antwort setzen.');
}
private function event(Request $request): RequestEvent
{
return new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST);
}
}
+27 -2
View File
@@ -413,6 +413,17 @@ final class CmsTest extends TestCase
self::assertSame('/', $response->headers->get('Location'));
}
public function testTheOtherSpellingOfTheHostIsSentToSiteOrigin(): void
{
// SITE_ORIGIN der Tests ist https://cms.test - die www-Fassung fuehrt dorthin, mit Pfad
// und Abfrage, bevor irgendjemand die Datenbank fragt.
$response = $this->request('http://www.cms.test/home?a=1');
self::assertSame(301, $response->getStatusCode());
self::assertSame('https://cms.test/home?a=1', $response->headers->get('Location'));
// Ein fremder Host bleibt erreichbar: eine falsch eingetragene Adresse sperrt niemanden aus.
self::assertSame(200, $this->request('http://vorschau.hoster.test/robots.txt')->getStatusCode());
}
public function testCreatedPageStaysPrivateUntilPublishedAndKeepsItsAddress(): void
{
$id = $this->pages->createPage('ueber-uns', null, 'page', DemoContent::starter('Über uns'));
@@ -1431,7 +1442,7 @@ final class CmsTest extends TestCase
$ohne = $this->request('/');
self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent());
self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent());
self::assertStringNotContainsString('rel="preload"'." href=\"/assets/fonts", $ohne->getContent());
self::assertStringNotContainsString('rel="preload" href="/assets/fonts', $ohne->getContent());
// Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge
// er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der
@@ -2036,6 +2047,15 @@ final class CmsTest extends TestCase
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
self::assertSame(404, $this->request('/gibt-es-nicht')->getStatusCode());
self::assertStringContainsString("script-src 'none'", (string) $this->request('/gibt-es-nicht')->headers->get('Content-Security-Policy'));
// Tabs im Fussbereich: die Seite selbst hat keine, braucht das Skript aber trotzdem - und
// eine Richtlinie, die es zulaesst. Vorher blieb der Fuss eine Ankerliste mit blockiertem
// Skript.
$footer = $this->partials->findByKind('footer');
$this->partials->save($footer['id'], [...$footer['document'], 'schemaVersion' => max((int) $footer['document']['schemaVersion'], 7), 'blocks' => [...$footer['document']['blocks'], ...array_slice($document['blocks'], -1)]], (int) $footer['version']);
$withFooterTabs = $this->request('/ohne-tabs');
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $withFooterTabs->getContent());
self::assertStringContainsString("script-src 'self'", (string) $withFooterTabs->headers->get('Content-Security-Policy'));
}
public function testTabsSurviveSavingAndRejectMisplacedEntries(): void
@@ -2075,7 +2095,11 @@ final class CmsTest extends TestCase
$this->posts->saveMetadata($hidden, ['robots' => 'noindex'], [], [], $this->pages->findForEditor($hidden)['version']);
$this->publishedPage('offen', 'Offen');
$this->pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
$post = $this->publishedPost('ein-beitrag', 'Ein Beitrag', '2026-02-01', ['Projekte'], []);
// Neu veroeffentlicht nach einer Aenderung: lastmod ist das Datum der Fassung, nicht das
// der ersten Veroeffentlichung.
$edited = $this->pages->findForEditor($post);
$this->pages->publish($post, $this->pages->saveDraft($post, $edited['document'], $edited['version']), $edited['version'] + 1);
$sitemap = $this->request('/sitemap.xml');
self::assertSame(200, $sitemap->getStatusCode());
@@ -2086,6 +2110,7 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<loc>https://cms.test/offen</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/beitraege</loc>', $xml);
self::assertStringContainsString('<loc>https://cms.test/kategorie/projekte</loc>', $xml);
self::assertStringContainsString("<loc>https://cms.test/ein-beitrag</loc>\n <lastmod>".gmdate('Y-m-d').'</lastmod>', $xml);
// A noindex page and an unpublished page have no business in a sitemap.
self::assertStringNotContainsString('/intern', $xml);
self::assertStringNotContainsString('/entwurf', $xml);
+90 -11
View File
@@ -52,7 +52,7 @@ final class EditorSecurityTest extends WebTestCase
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
foreach (['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
}
}
@@ -72,9 +72,11 @@ final class EditorSecurityTest extends WebTestCase
[$_SERVER, $_ENV] = $this->previous;
}
private function login(string $username = 'admin'): KernelBrowser
/** Ein Client je Test (der Kernel startet nur einmal); ein zweiter Benutzer meldet sich im selben an. */
private function login(string $username = 'admin', ?KernelBrowser $client = null): KernelBrowser
{
$client = self::createClient();
$client ??= self::createClient();
$client->getCookieJar()->clear();
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password']));
self::assertResponseRedirects('/admin');
@@ -84,6 +86,25 @@ final class EditorSecurityTest extends WebTestCase
return $client;
}
public function testTheSessionCookieFollowsARenamedAdministration(): void
{
// Heisst die Verwaltung nicht mehr /admin, muss das Sitzungscookie auf ihren Pfad lauten -
// sonst schickt der Browser es nie mit, und jede Anmeldung landet wieder vor der Tuer.
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
$client = self::createClient();
$crawler = $client->request('GET', '/stiller-eingang/login');
self::assertResponseIsSuccessful();
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'Fixture-only-password']));
self::assertResponseRedirects('/stiller-eingang');
// Im Test heisst das Sitzungscookie anders als im Betrieb; der Pfad ist, was zaehlt.
$paths = array_map(static fn ($cookie): string => $cookie->getPath(), $client->getCookieJar()->all());
self::assertContains('/stiller-eingang', $paths, 'Das Sitzungscookie muss fuer den neuen Pfad gelten.');
self::assertNotContains('/admin', $paths);
$client->followRedirect();
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/stiller-eingang/logout"], form[action="/stiller-eingang/logout"]');
}
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
{
$client = $this->login();
@@ -109,12 +130,70 @@ final class EditorSecurityTest extends WebTestCase
public function test404AssignmentRequiresPublicationRights(): void
{
// Ein Redakteur sieht die Einstellungen gar nicht erst: alles dort wirkt sofort.
$client = $this->login('editor');
$client->request('GET', '/admin/settings');
self::assertSelectorNotExists('#not-found-form');
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
self::assertResponseStatusCodeSame(403);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
// Ein Veroeffentlicher darf die 404-Seite zuordnen, aber die Unternehmensdaten nicht aendern.
$this->login('publisher', $client);
$client->request('GET', '/admin/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('#not-found-form');
self::assertSelectorNotExists('form[action="/admin/settings/company"]');
}
/**
* Wer was aendern darf, steht in der Rollenhilfe - und hier, damit es so bleibt.
*
* Jede Zeile: eine Route, die Rolle, die zu wenig ist, und die Antwort 403. Ohne Anmeldung
* fuehrt jede dieser Adressen zur Anmeldung. Die Rollen, die duerfen, pruefen die uebrigen Tests.
*/
public function testEveryLiveChangeNeedsTheRoleTheRoleHelpNames(): void
{
$tooLittle = [
// Design, Unternehmensdaten, System, Benutzer, endgueltiges Loeschen: Administrator.
['publisher', 'GET', '/admin/theme'], ['publisher', 'POST', '/admin/theme/save'], ['publisher', 'POST', '/admin/theme/fonts'],
['publisher', 'POST', '/admin/settings/company'],
['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'],
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
['publisher', 'POST', '/admin/pages/p1/delete'],
// Alles, was sofort auf der Website steht: Veroeffentlicher.
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
['editor', 'GET', '/admin/partials'], ['editor', 'POST', '/admin/partials/new'],
['editor', 'GET', '/admin/menus'], ['editor', 'POST', '/admin/menus/header'],
['editor', 'GET', '/admin/forms'], ['editor', 'POST', '/admin/forms/neu'],
['editor', 'POST', '/admin/pages/p1/schedule'], ['editor', 'POST', '/admin/api/pages/p1/publish'],
];
$client = null;
foreach (['publisher', 'editor'] as $role) {
$client = $this->login($role, $client);
foreach (array_filter($tooLittle, static fn (array $row): bool => $row[0] === $role) as [, $method, $path]) {
$client->request($method, $path);
self::assertSame(403, $client->getResponse()->getStatusCode(), sprintf('%s %s %s', $role, $method, $path));
}
}
// Ein Redakteur sieht in der Navigation nur, was er oeffnen darf.
$crawler = $client->request('GET', '/admin');
self::assertResponseIsSuccessful();
$links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href'));
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/benutzer'] as $hidden) {
self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.');
}
self::assertContains('/admin/media', $links);
// Ohne Anmeldung: keine 403-Auskunft, sondern die Anmeldung.
$anonymous = $client;
$anonymous->getCookieJar()->clear();
foreach ([['GET', '/admin'], ['POST', '/admin/api/pages/p1/draft'], ['POST', '/admin/theme/save'], ['POST', '/admin/menus/header']] as [$method, $path]) {
$anonymous->request($method, $path);
self::assertResponseRedirects('http://localhost/admin/login', null, $method.' '.$path);
}
// Und ohne Anmeldung ist nichts geschrieben worden.
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
}
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
@@ -263,7 +342,7 @@ final class EditorSecurityTest extends WebTestCase
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
{
$client = $this->login('editor');
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
self::assertResponseIsSuccessful();
@@ -294,12 +373,12 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
}
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
{
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
$client = $this->login('editor');
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
@@ -519,7 +598,7 @@ final class EditorSecurityTest extends WebTestCase
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
$this->pageWithForm();
$client = $this->login('editor');
$client = $this->login('publisher');
$crawler = $client->request('GET', '/');
self::assertResponseIsSuccessful();
$html = (string) $client->getResponse()->getContent();
@@ -550,9 +629,9 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
}
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void
{
$client = $this->login('editor');
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/forms');
self::assertResponseIsSuccessful();
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
@@ -797,7 +876,7 @@ final class EditorSecurityTest extends WebTestCase
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
{
$client = $this->login('editor');
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/settings');
// As long as they are missing, the settings screen says so instead of hiding it.
self::assertCount(1, $crawler->filter('form[action="/admin/settings/rechtliches"]'));
+33
View File
@@ -200,6 +200,39 @@ final class InstallationTest extends TestCase
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'));
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Tests;
use App\Infrastructure\ProjectDirEnvProcessor;
use PHPUnit\Framework\TestCase;
final class ProjectDirEnvProcessorTest extends TestCase
{
public function testOnlyTheProjectDirectoryPlaceholderIsReplaced(): void
{
$processor = new ProjectDirEnvProcessor('/srv/site');
$read = static fn (string $name): string => match ($name) {
'DATABASE_URL' => 'sqlite:///%kernel.project_dir%/var/cms.db',
'MYSQL' => 'mysql://kunde:p%40ss%21wort@127.0.0.1:3306/kunde_db?charset=utf8mb4',
'MEDIA' => '%kernel.project_dir%/var/media',
};
self::assertSame('sqlite:////srv/site/var/cms.db', $processor->getEnv('projectdir', 'DATABASE_URL', $read));
// Prozentkodierte Zugangsdaten bleiben, wie sie sind - `resolve:` laese `%40ss%` als
// Parameter und liesse den Container bei jeder Anfrage abbrechen.
self::assertSame('mysql://kunde:p%40ss%21wort@127.0.0.1:3306/kunde_db?charset=utf8mb4', $processor->getEnv('projectdir', 'MYSQL', $read));
self::assertSame('/srv/site/var/media', $processor->getEnv('projectdir', 'MEDIA', $read));
self::assertSame(['projectdir' => 'string'], ProjectDirEnvProcessor::getProvidedTypes());
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Tests;
use App\Command\SiteOriginCommand;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Dotenv\Dotenv;
final class SiteOriginCommandTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-origin-'.bin2hex(random_bytes(6));
mkdir($this->root);
file_put_contents($this->root.'/.env', "APP_ENV=\"prod\"\nSITE_ORIGIN=\"https://alt.example\"\nAPP_SECRET=\"bleibt\"\n");
}
protected function tearDown(): void
{
foreach (glob($this->root.'/.env*') ?: [] as $file) {
unlink($file);
}
rmdir($this->root);
}
/** @return iterable<string, array{string, ?string}> */
public static function addresses(): iterable
{
yield 'mit www' => ['https://www.meine-firma.de', 'https://www.meine-firma.de'];
yield 'ohne www' => ['https://meine-firma.de', 'https://meine-firma.de'];
yield 'Schraegstrich und Grossschreibung werden bereinigt' => [' HTTPS://WWW.Meine-Firma.DE/ ', 'https://www.meine-firma.de'];
yield 'ein Port bleibt' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080'];
yield 'ohne Schema' => ['meine-firma.de', null];
yield 'mit Pfad' => ['https://meine-firma.de/start', null];
yield 'mit Abfrage' => ['https://meine-firma.de/?x=1', null];
yield 'mit Zugangsdaten' => ['https://user:pass@meine-firma.de', null];
yield 'falsches Schema' => ['ftp://meine-firma.de', null];
}
#[DataProvider('addresses')]
public function testOnlySchemeAndHostAreAccepted(string $input, ?string $expected): void
{
if (null === $expected) {
$this->expectException(\InvalidArgumentException::class);
}
self::assertSame($expected, SiteOriginCommand::normalize($input));
}
public function testTheCommandShowsAndWritesTheAddress(): void
{
$tester = new CommandTester(new SiteOriginCommand($this->root, 'https://alt.example'));
self::assertSame(0, $tester->execute([]));
self::assertStringContainsString('https://alt.example', $tester->getDisplay());
self::assertSame('https://alt.example', $this->values()['SITE_ORIGIN'], 'Anzeigen schreibt nichts.');
self::assertSame(0, $tester->execute(['adresse' => 'https://www.neu.example/']));
self::assertStringContainsString('ohne www leitet dorthin um', $tester->getDisplay());
self::assertSame('https://www.neu.example', $this->values()['SITE_ORIGIN']);
self::assertSame('bleibt', $this->values()['APP_SECRET'], 'Die uebrigen Einstellungen bleiben erhalten.');
self::assertSame(1, $tester->execute(['adresse' => 'neu.example']));
self::assertStringContainsString('beginnt mit https://', $tester->getDisplay());
self::assertSame('https://www.neu.example', $this->values()['SITE_ORIGIN'], 'Eine abgelehnte Adresse aendert nichts.');
}
private function values(): array
{
return (new Dotenv())->parse((string) file_get_contents($this->root.'/.env'));
}
}
+25
View File
@@ -124,6 +124,31 @@ final class SystemScreenTest extends WebTestCase
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('cms:site:origin', $html, 'Der Weg, es zu aendern, steht dabei.');
// 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 testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
{
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
+7 -3
View File
@@ -192,7 +192,7 @@ test('plugin blocks stay movable and intact while the editor refuses to fill or
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
});
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
test('the post list block carries only its filter, lifts the schema to v18 and undoes atomically', () => {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
const block = locate(history.document.blocks, id).block;
@@ -200,7 +200,8 @@ test('the post list block carries only its filter, lifts the schema to v5 and un
// Content is the filter and the count; the posts themselves are added when the page is served.
assert.deepEqual(block.props, { count: 3, category: '', tag: '', layout: 'karten' });
assert.equal(block.children, undefined);
assert.equal(history.document.schemaVersion, 5);
// v18, weil der Server die Darstellung "karten" erst ab dieser Fassung annimmt.
assert.equal(history.document.schemaVersion, 18);
// Nothing can be nested inside it, and it does not swallow a following block.
assert.equal(canContain(block, { type: 'text' }), false);
@@ -431,7 +432,10 @@ test('a current document still accepts new blocks, and every block type can be i
}
// Jeder Typ, der eine Fassung anhebt, landet tatsächlich im Dokument.
for (const [type, needed] of [['gallery', 3], ['postlist', 5], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 15]]) {
// Die Zahlen sind die des Servers fuer die Eigenschaften, die createBlock setzt (Beitragsliste
// mit Darstellung: v18, Menue mit Art: v16, Slider mit Punkten: v17) - nicht die erste Fassung,
// die den Typ kannte.
for (const [type, needed] of [['gallery', 3], ['postlist', 18], ['quote', 6], ['tabs', 7], ['form', 8], ['breadcrumb', 9], ['menu', 16], ['partialref', 11], ['embed', 14], ['icon', 15], ['progress', 15], ['slider', 17], ['social', 23]]) {
const history = new EditorHistory(fixture());
const id = history.change(doc => insertBlock(doc, type, 's2'));
assert.ok(locate(history.document.blocks, id), `${type} wird eingefügt`);