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, nameless endpoints through on both hosts', function () { // Livewire's own endpoints and the health check are used by both // sides and never carry an admin.* route name. Sign-in used to be on // this list too — it is not any more: the console has its own named // routes (admin.login, admin.two-factor) and reaches them through // isConsoleRoute(), not through this nameless allowlist. If this list // is wrong the symptom is "the console is broken", so it is written // out rather than inferred. foreach (['/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { expect(guard('admin.example.test', $path, null))->toBe('through', $path); } }); it('does not serve the portal sign-in or registration on the console host', function () { // The reported fault, at its root: the console used to answer /login // with the portal's page, which offers "Registrieren" — and /register // was never in SHARED, so the link 404'd. expect(guard('admin.example.test', '/register', 'register'))->toBe('404') ->and(guard('admin.example.test', '/login', 'login'))->toBe('404'); }); it('still serves the console\'s own sign-in on its host', function () { expect(guard('admin.example.test', '/login', 'admin.login'))->toBe('through') ->and(guard('admin.example.test', '/two-factor', 'admin.two-factor'))->toBe('through'); }); it('keeps only the endpoints both sides genuinely share', function () { expect(guard('admin.example.test', '/livewire/update', null))->toBe('through') ->and(guard('admin.example.test', '/up', null))->toBe('through'); }); 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(); });