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'"); });