diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..d17a158 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -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, ], diff --git a/tests/Feature/HostSeparationTest.php b/tests/Feature/HostSeparationTest.php new file mode 100644 index 0000000..a4ed64b --- /dev/null +++ b/tests/Feature/HostSeparationTest.php @@ -0,0 +1,180 @@ +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'"); +});