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. * * @param bool|null $exclusive Out-Parameter: nach dem Aufruf der Wert von * AdminArea::isExclusive() der ZWEITEN * Anwendung. Muss hier gelesen werden, nicht * nach dem Rückgabewert dieser Funktion — siehe * Kommentar im try-Block unten. */ function hostSeparationTable(?bool &$exclusive = null): 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', ]; // Alle drei Kanäle einzeln, und das ist kein Übereifer: phpunit.xml legt // seine -Werte in $_ENV und putenv() ab, NIE in $_SERVER. Wer nur // $_SERVER sichert, bekommt null zurück, löscht beim Zurückstellen // ADMIN_HOSTS und FILES_HOST — und jeder Test NACH diesem liest sie dann // aus der echten .env des Containers. Genau so sind ImpersonationTest und // PublicSiteGateTest gefallen, mit einem Fehlerbild, das nach einer // Reihenfolge-Abhängigkeit der Suite aussieht und keine ist. $previous = []; foreach ($env as $key => $value) { $previous[$key] = [ 'env' => array_key_exists($key, $_ENV) ? $_ENV[$key] : null, 'server' => array_key_exists($key, $_SERVER) ? $_SERVER[$key] : null, 'putenv' => getenv($key), ]; 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(); // AdminArea::isExclusive() liest von config(), und config() beantwortet // JEDE Anwendung, die gerade Container::getInstance() ist — hier also // die zweite. Nach diesem try-Block ist sie im finally schon wieder // abgebaut und Container::getInstance() zeigt zurück auf die erste // Testanwendung; ein Aufruf nach return hätte also nicht den hier // gesetzten ADMIN_HOST_EXCLUSIVE gelesen, sondern den der Suite, und // die Prüfung unten hätte sich still auf das Falsche verlassen. $exclusive = AdminArea::isExclusive(); $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']); // Und die Fehlerbehandlung, die sonst auf die weggeworfene zweite // Anwendung zeigt. HandleExceptions hält seine Anwendung STATISCH // (static::$app) und hängt sich über set_error_handler(), // set_exception_handler() und register_shutdown_function() in globalen // PHP-Zustand. Ohne dieses Zurückstellen liefe jede Warnung im Rest // dieses Tests durch einen Container, den wir gerade abgeräumt haben — // dieselbe Klasse Fehler wie beim Eloquent-Resolver darüber, nur eine // Ebene tiefer und entsprechend schwerer zuzuordnen. (new HandleExceptions)->bootstrap($original); foreach ($previous as $key => $was) { if ($was['putenv'] === false) { putenv($key); } else { putenv("{$key}={$was['putenv']}"); } if ($was['env'] === null) { unset($_ENV[$key]); } else { $_ENV[$key] = $was['env']; } if ($was['server'] === null) { unset($_SERVER[$key]); } else { $_SERVER[$key] = $was['server']; } } } 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', ]; $table = hostSeparationTable($exclusive); // Diese Zusicherung ist der ganze Sinn der Prüfung unten, und ohne sie war // sie nicht wahr: deploy/install.sh:311 schreibt ADMIN_HOST_EXCLUSIVE=false // als Vorgabe für jede neue Installation. Im nicht-exklusiven Fallback // registriert routes/web.php:127-139 JEDE /admin/*-Route ohne Domain — die // Konsole lebt dort absichtlich unter /admin auf jedem Hostnamen, damit ein // Upgrade niemanden aussperrt (siehe AdminArea-Klassenkommentar). "Jede // Route hat einen Hostnamen" ist in diesem Modus also schlicht falsch und // nicht gemeint — die Prüfung hat nur im exklusiven Modus etwas zu sagen, // und $env oben setzt ADMIN_HOST_EXCLUSIVE=true genau deshalb explizit. expect($exclusive)->toBeTrue( 'ADMIN_HOST_EXCLUSIVE aus $env wurde nicht wirksam — ohne exklusiven ' .'Modus bindet routes/web.php admin/* an keine Domain, und die ' .'Prüfung unten würde das nicht bemerken.' ); $unbound = []; foreach ($table 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'"); });