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', '/broadcasting/auth'] 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 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 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(); }); it('lets an unenrolled operator sign out on a recovery hostname, not just the canonical one', function () { // Whole-branch review, P2: RequireOperatorTwoFactor's exemption used // AdminArea::routeIs() for the setup route but bare // $request->routeIs('admin.logout') for logout — which matches only the // canonical name. On a recovery host the logout route is named // admin.via0.logout (same reason the navigation test above exists), so // the bare check failed there and the middleware bounced an unenrolled // operator's logout POST back to enrolment — trapping them on exactly // the host that exists because the canonical one is not working. A // canonical-host test would pass whichever form the check takes, so this // one is deliberately on the alternate. config()->set('admin_access.hosts', ['admin.example.test', 'admin-recovery.example.test']); config()->set('admin_access.exclusive', true); Settings::set('console.require_2fa', true); // Register routes for real from the config just set, rather than // asserting the via0 name by fiat — same technique as "it can cache its // routes…" above. Proves the whole path: this host, this URL, genuinely // resolves to admin.via0.logout before the middleware is ever involved. $router = new Router(app('events'), app()); Illuminate\Support\Facades\Route::swap($router); require base_path('routes/web.php'); $router->getRoutes()->refreshNameLookups(); $matched = $router->getRoutes()->match( Request::create('http://admin-recovery.example.test/logout', 'POST'), ); expect($matched->getName())->toBe('admin.via0.logout'); $operator = Operator::factory()->role('Owner')->create(); Auth::guard('operator')->login($operator); // Drive the middleware directly, on a request resolved to that genuinely // registered route — RequireOperatorTwoFactor reads the route's name, not // which router produced it, so this is the real check without needing a // full dispatch (session/CSRF middleware) around it. $request = Request::create('http://admin-recovery.example.test/logout', 'POST'); $request->setRouteResolver(fn () => $matched); app()->instance('request', $request); $response = (new RequireOperatorTwoFactor)->handle($request, fn ($req) => response('through')); expect($response->getContent())->toBe('through'); });