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>
94 lines
5.9 KiB
PHP
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);
|
|
}
|
|
}
|