Files
KantSites/tests/CanonicalHostTest.php
T
kantarciandClaude Fable 5.1 7eb462ac7b 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>
2026-09-15 20:15:20 +02:00

94 lines
5.9 KiB
PHP

<?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);
}
}