210 lines
8.2 KiB
PHP
210 lines
8.2 KiB
PHP
<?php
|
|
|
|
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.
|
|
*/
|
|
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',
|
|
];
|
|
|
|
// 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();
|
|
|
|
$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',
|
|
];
|
|
|
|
$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'");
|
|
});
|