CluPilotCloud/tests/Feature/Admin/ConsoleHostSeparationTest.php

165 lines
7.3 KiB
PHP

<?php
use App\Http\Middleware\RestrictAdminHost;
use App\Support\AdminArea;
use Illuminate\Http\Request;
use Illuminate\Routing\Route as RoutingRoute;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Which host serves the console, and what else that host will answer.
*
* Two modes, and the difference between them is the whole point:
*
* - Shared. The console lives under /admin and the same host also serves the
* portal and the public site. This is a development machine, which lists its
* own IP in ADMIN_HOSTS so the console is reachable without DNS.
* - Exclusive. The console has a hostname to itself, sits at the root of it,
* and nothing else answers there.
*
* Shipping the exclusive rule without the switch took the public site off a
* development machine, because that machine's own IP was in ADMIN_HOSTS. The
* modes are tested apart for that reason.
*/
/** Drive the guard directly: route registration is fixed at boot. */
function guard(string $host, string $path, ?string $routeName): Response|string
{
$request = Request::create("http://{$host}{$path}", 'GET');
$request->setRouteResolver(function () use ($routeName, $path) {
$route = new RoutingRoute(['GET'], $path, fn () => null);
return $routeName !== null ? $route->name($routeName) : $route;
});
try {
(new RestrictAdminHost)->handle($request, fn () => response('through'));
return 'through';
} catch (NotFoundHttpException) {
return '404';
}
}
describe('shared host', function () {
beforeEach(function () {
config()->set('admin_access.hosts', ['10.10.90.185', 'localhost']);
config()->set('admin_access.exclusive', false);
});
it('keeps the console under /admin', function () {
expect(AdminArea::prefix())->toBe('admin')
->and(AdminArea::isConsole(Request::create('http://localhost/admin/customers')))->toBeTrue()
->and(AdminArea::isConsole(Request::create('http://localhost/cloud')))->toBeFalse();
});
it('still serves the portal and the public site from that host', function () {
// The regression: a machine reached by IP has that IP in ADMIN_HOSTS,
// and 404ing everything else there removes the product from it.
expect(guard('localhost', '/cloud', 'cloud'))->toBe('through')
->and(guard('localhost', '/', 'home'))->toBe('through')
->and(guard('localhost', '/status', 'status'))->toBe('through');
});
it('keeps the console off a host that is not listed', function () {
expect(guard('www.example.test', '/admin', 'admin.overview'))->toBe('404');
});
});
describe('exclusive host', function () {
beforeEach(function () {
config()->set('admin_access.hosts', ['admin.example.test']);
config()->set('admin_access.exclusive', true);
});
it('puts the console at the root of its own host', function () {
expect(AdminArea::prefix())->toBe('')
->and(AdminArea::home())->toBe('/')
->and(AdminArea::isConsole(Request::create('http://admin.example.test/customers')))->toBeTrue()
->and(AdminArea::isConsole(Request::create('http://app.example.test/customers')))->toBeFalse();
});
it('answers nothing but the console on that host', function () {
expect(guard('admin.example.test', '/', 'admin.overview'))->toBe('through')
->and(guard('admin.example.test', '/customers', 'admin.customers'))->toBe('through')
// The portal and the public site are gone from this hostname.
->and(guard('admin.example.test', '/cloud', 'cloud'))->toBe('404')
->and(guard('admin.example.test', '/status', 'status'))->toBe('404')
->and(guard('admin.example.test', '/dashboard', 'dashboard'))->toBe('404');
});
it('lets the shared authentication endpoints through on both hosts', function () {
// Sign-in posts and Livewire's own endpoints are used by both sides. If
// this list is wrong the symptom is "the console is broken", so it is
// written out rather than inferred.
foreach (['/login', '/logout', '/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) {
expect(guard('admin.example.test', $path, null))->toBe('through', $path);
}
});
it('keeps the console off the public hosts', function () {
expect(guard('app.example.test', '/', 'admin.overview'))->toBe('404')
->and(guard('www.example.test', '/customers', 'admin.customers'))->toBe('404');
});
it('still serves the public site on its own host', function () {
expect(guard('www.example.test', '/', 'home'))->toBe('through')
->and(guard('app.example.test', '/dashboard', 'dashboard'))->toBe('through');
});
});
it('never treats a request as the console just because a hostname is configured', function () {
// isConsole drives the public-site switch and the network allowlist. Getting
// it wrong in this direction hands the console's exemptions to the portal.
config()->set('admin_access.hosts', ['admin.example.test']);
config()->set('admin_access.exclusive', false);
expect(AdminArea::isHostBound())->toBeTrue()
->and(AdminArea::isExclusive())->toBeFalse()
->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse()
->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue();
});
it('can cache its routes with several console hostnames configured', function () {
// Registering the console once per accepted hostname reused one set of
// names, and route caching refuses to serialise two routes under one name —
// which took `artisan optimize` down on the live server, mid-deploy.
config()->set('admin_access.hosts', ['admin.example.test', '127.0.0.1', 'localhost']);
config()->set('admin_access.exclusive', true);
$router = new Illuminate\Routing\Router(app('events'), app());
Illuminate\Support\Facades\Route::swap($router);
require base_path('routes/web.php');
$router->getRoutes()->refreshNameLookups();
$names = [];
foreach ($router->getRoutes() as $route) {
if (($name = $route->getName()) !== null) {
expect($names)->not->toContain($name, "duplicate route name: {$name}");
$names[] = $name;
}
}
// The canonical host still owns the plain names, so route() builds URLs
// for it and not for a recovery address.
expect($names)->toContain('admin.overview')
->and($router->getRoutes()->getByName('admin.overview')->getDomain())->toBe('admin.example.test');
});
it('keeps the navigation marked on a recovery hostname', function () {
// The alternates are registered as admin.viaN.* so route caching can
// serialise them — which silently breaks every exact name check, and the
// navigation loses its active state exactly when something is already wrong.
$named = fn (string $name) => tap(request(), fn ($r) => $r->setRouteResolver(
fn () => (new Illuminate\Routing\Route(['GET'], '/', fn () => null))->name($name),
));
$named('admin.overview');
expect(AdminArea::routeIs('admin.overview'))->toBeTrue();
$named('admin.via0.overview');
expect(AdminArea::routeIs('admin.overview'))->toBeTrue()
->and(AdminArea::routeIs('admin.settings'))->toBeFalse();
});