Jede Route an einen Hostnamen, und storage/{path} zugemacht
parent
78d6c9c320
commit
4f57435c4d
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
Loading…
Reference in New Issue