CluPilotCloud/tests/Feature/HostSeparationTest.php

243 lines
10 KiB
PHP

<?php
use App\Support\AdminArea;
use Illuminate\Container\Container;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bootstrap\HandleExceptions;
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.
*
* @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 <env>-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'");
});