Jede Route an einen Hostnamen, und storage/{path} zugemacht

feat/versandtakt
nexxo 2026-08-04 14:28:13 +02:00
parent 78d6c9c320
commit 4f57435c4d
2 changed files with 189 additions and 1 deletions

View File

@ -33,7 +33,15 @@ return [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
// Aus, und das ist der Unterschied zwischen zwei Routen und keiner:
// `true` meldet `storage/{path}` als GET und PUT an — ohne
// Hostnamen, also auf JEDEM Namen dieser Installation. Benutzt hat
// sie nie jemand. Jede Datei, die dieses Produkt ausliefert, geht
// durch einen eigenen Controller (PublicFileController,
// BootstrapArchiveController, invoices.pdf, dpa.file), und die
// beiden `temporaryUrl()`-Aufrufe im Repo sind Livewire-Uploads,
// die `livewire/preview-file/…` erzeugen und nicht diese Route.
'serve' => false,
'throw' => false,
'report' => false,
],

View File

@ -0,0 +1,180 @@
<?php
use Illuminate\Container\Container;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Facade;
/**
* Jede Route gehört zu genau einem Hostnamen.
*
* Gemessen wird die VOLLSTÄNDIGE Routentabelle, nicht nur routes/web.php. Der
* Unterschied ist der ganze Punkt: `storage/{path}`, `livewire/*`, `up` und
* `broadcasting/auth` meldet nicht diese Datei an, sondern ein Service-Provider
* und `storage/{path}` war genau die Route, die niemandem aufgefallen ist.
* Ein Test, der die halbe Tabelle misst, prüft die Trennung nicht.
*
* Routen entstehen beim Booten, also ändert `config()->set()` daran nichts.
* PortalHostTest baut dafür einen frischen Router und lädt routes/web.php neu;
* das sieht die Provider-Routen nicht. Hier wird deshalb eine zweite
* Anwendungsinstanz gebootet dasselbe, was `artisan route:list` tut.
*
* Was danach wieder zurückgestellt werden MUSS, und warum: Eloquent hält seinen
* Connection-Resolver STATISCH. Die zweite Anwendung zeigt ihn auf ihre eigene,
* leere :memory:-Datenbank, und der restliche Testlauf fände danach keine
* einzige Tabelle mehr. Das ist kein hypothetisches Risiko genau so ist es
* beim Bau dieses Tests passiert.
*/
function hostSeparationTable(): array
{
$env = [
'APP_HOST' => 'app.clupilot.test',
'SITE_HOST' => 'www.clupilot.test,clupilot.test',
'STATUS_HOST' => 'status.clupilot.test',
'FILES_HOST' => 'files.clupilot.test',
'ADMIN_HOSTS' => 'admin.clupilot.test',
'ADMIN_HOST_EXCLUSIVE' => 'true',
];
// Gemerkt und zurückgestellt, nicht bloß gelöscht: phpunit.xml setzt
// FILES_HOST und ADMIN_HOSTS für den ganzen Lauf, und ein Test, der sie
// hinterher entfernt, kippt die Tests nach ihm.
$previous = [];
foreach ($env as $key => $value) {
$previous[$key] = $_SERVER[$key] ?? null;
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
$original = Container::getInstance();
try {
$app = require base_path('bootstrap/app.php');
$app->make(Kernel::class)->bootstrap();
$table = [];
foreach ($app['router']->getRoutes()->getRoutes() as $route) {
$table[] = [
'uri' => $route->uri(),
'domain' => $route->getDomain(),
'name' => $route->getName(),
];
}
} finally {
Container::setInstance($original);
Facade::clearResolvedInstances();
Facade::setFacadeApplication($original);
Model::setConnectionResolver($original['db']);
Model::setEventDispatcher($original['events']);
foreach ($previous as $key => $value) {
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
return $table;
}
/** Die Tabelle nach Routennamen, für die gezielten Prüfungen unten. */
function hostSeparationByName(): array
{
$byName = [];
foreach (hostSeparationTable() as $route) {
if ($route['name'] !== null && $route['name'] !== '') {
$byName[$route['name']] = $route;
}
}
return $byName;
}
it('bindet jede Route an einen Hostnamen, außer den hier benannten', function () {
// Jeder Eintrag mit seinem Grund. Wer hier etwas hinzufügt, schreibt den
// Grund dazu — sonst ist die Liste in einem Jahr eine Liste von Ausnahmen,
// die niemand mehr prüfen kann.
$shared = [
// Konsole und Portal posten Komponenten-Aktionen an denselben
// Endpunkt. RestrictAdminHost ist die Schutzschicht dafür; eine
// Host-Bindung hier zerlegt die Konsole.
'livewire/update',
'livewire/upload-file',
'livewire/preview-file/{filename}',
'livewire/livewire.js',
'livewire/livewire.min.js.map',
// Gesundheitsprüfung, ebenfalls RestrictAdminHost::SHARED.
'up',
// Der Live-Feed der Konsole meldet sich hier an (routes/channels.php,
// admin.runs) — ebenfalls RestrictAdminHost::SHARED.
'broadcasting/auth',
// Stripe postet an die URL, die es einmal bekommen hat. Ein
// Hostname-Fehler an dieser Stelle verliert Zahlungen.
'webhooks/stripe',
// "/" auf einem Namen, der weder Website noch Portal noch Status ist:
// eine Weiterleitung ins Portal, kein 404. Wer "/" aus dem Gedächtnis
// tippt, soll nicht auf einer Fehlerseite über Hostnamen belehrt werden.
'/',
// Weiterleitung auf den Statushost. Eine Statusseite ist die Adresse,
// die in einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist.
'status',
];
$unbound = [];
foreach (hostSeparationTable() as $route) {
if ($route['domain'] === null) {
$unbound[] = $route['uri'];
}
}
$unbound = array_values(array_unique($unbound));
sort($unbound);
sort($shared);
expect($unbound)->toBe($shared);
});
it('legt jeden Weg in ein Konto hinein und wieder heraus auf den Portal-Hostnamen', function () {
// Kriterium 4, und die teuerste Falle des ganzen Umbaus: Fortify meldet
// diese Routen SELBST an, nicht routes/web.php. Gebunden werden sie über
// config/fortify.php ('domain' => env('APP_HOST')). Verschwindet diese eine
// Zeile beim Aufräumen, kann sich auf app. niemand mehr anmelden — und
// routes/web.php sieht dabei völlig unverdächtig aus.
$byName = hostSeparationByName();
$mustBeOnThePortal = [
'login', 'login.store', 'logout',
'password.request', 'password.email',
'password.reset', 'password.update',
'two-factor.login', 'two-factor.login.store',
'password.confirm.store',
'verification.notice', 'verification.verify',
];
foreach ($mustBeOnThePortal as $name) {
expect($byName[$name]['domain'] ?? null)
->toBe('app.clupilot.test', "Route [{$name}] liegt nicht auf dem Portal-Hostnamen.");
}
});
it('behält die Namen, an denen PublicSiteGate die Downloads erkennt', function () {
// PublicSiteGate::isDownload() prüft auf ROUTENNAMEN. Zieht eine Route um
// und ändert dabei ihren Namen, fällt die Ausnahme still weg — und ein
// Server im Rettungssystem schiebt die 503-Platzhalterseite in `tar`.
$byName = hostSeparationByName();
expect($byName['bootstrap.archive']['domain'] ?? null)->toBe('files.clupilot.test')
->and($byName['files.public']['domain'] ?? null)->toBe('files.clupilot.test');
$gate = file_get_contents(base_path('app/Http/Middleware/PublicSiteGate.php'));
expect($gate)->toContain("'bootstrap.archive'")
->and($gate)->toContain("'files.public'");
});