Send a guest bounced off the console to the console's own sign-in

ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.

Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
feat/operator-identity
nexxo 2026-07-28 13:32:05 +02:00
parent 47812cfca9
commit 5e22e16291
5 changed files with 57 additions and 6 deletions

View File

@ -1,5 +1,6 @@
<?php
use App\Support\AdminArea;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -52,6 +53,22 @@ return Application::configure(basePath: dirname(__DIR__))
// Stripe posts server-to-server with its own signature (no CSRF token).
$middleware->validateCsrfTokens(except: ['webhooks/stripe']);
// ApplicationBuilder::withMiddleware() defaults this to
// fn () => route('login') BEFORE this callback ever runs — always the
// PORTAL's Fortify page, regardless of which side of the site the
// guest was actually turned away from. In non-exclusive (shared)
// mode that sends a guest bounced off /admin to a sign-in form whose
// table holds no operators; exclusive mode happened to self-correct
// only because RestrictAdminHost 404s the wrong host before this
// ever ran. Fixed for both modes at once, rather than relying on
// which one a given deployment happens to run: ask AdminArea, which
// both routes/web.php's own registration and RestrictAdminHost
// already treat as the one source of truth for "is this the
// console".
$middleware->redirectGuestsTo(
fn (Request $request) => AdminArea::isConsole($request) ? route('admin.login') : route('login'),
);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View File

@ -5,10 +5,44 @@ use App\Models\User;
$adminRoutes = ['admin.overview', 'admin.customers', 'admin.instances', 'admin.hosts', 'admin.provisioning', 'admin.revenue'];
it('redirects guests from the admin console to login', function (string $route) {
$this->get(route($route))->assertRedirect('/login');
it('redirects guests from the admin console to the CONSOLE login, not the portal one', function (string $route) {
// Not '/login': that is Fortify's portal page, whose table holds no
// operators — a sign-in a guest bounced off the console could never
// complete. bootstrap/app.php's redirectGuestsTo() asks AdminArea which
// side this request was for. This suite's default (ADMIN_HOSTS="",
// phpunit.xml) is non-exclusive/shared mode; the exclusive-mode side of
// the same fix is covered separately below.
$this->get(route($route))->assertRedirect(route('admin.login'));
})->with($adminRoutes);
it('sends a guest to the right login for whichever side they hit, in exclusive mode too', function () {
// The other of the two modes redirectGuestsTo() has to get right — see
// the shared-mode version of this test above. AdminArea::isConsole()
// reads admin_access.* LIVE, per request (unlike route domain-binding,
// which this suite's routes do not carry: they are compiled once at
// boot under the default non-exclusive ADMIN_HOSTS="", so both
// route('admin.login') and route('login') below resolve against
// whichever host the current request came in on, exactly as production
// would resolve them for a request truly received on that host).
config()->set('admin_access.hosts', ['admin.example.test']);
config()->set('admin_access.exclusive', true);
$consoleLocation = $this->get('http://admin.example.test/admin')
->assertRedirect()->headers->get('Location');
expect(parse_url($consoleLocation, PHP_URL_HOST))->toBe('admin.example.test')
->and(parse_url($consoleLocation, PHP_URL_PATH))->toBe('/admin/login');
// A different, non-console host in the same exclusive setup still gets
// the PORTAL's sign-in page — this fix is not a blanket change to every
// guest redirect, only to the ones landing on the console side.
$portalLocation = $this->get('http://app.example.test/dashboard')
->assertRedirect()->headers->get('Location');
expect(parse_url($portalLocation, PHP_URL_HOST))->toBe('app.example.test')
->and(parse_url($portalLocation, PHP_URL_PATH))->toBe('/login');
});
it('forbids non-admin users from the admin console', function (string $route) {
$user = User::factory()->create();

View File

@ -11,7 +11,7 @@ use Livewire\Livewire;
// admin() helper is defined in HostManagementTest (global Pest function).
it('gates the datacenters page', function () {
$this->get(route('admin.datacenters'))->assertRedirect('/login');
$this->get(route('admin.datacenters'))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.datacenters'))->assertForbidden();
$this->actingAs(admin(), 'operator')->get(route('admin.datacenters'))->assertOk()->assertSee(__('datacenters.title'));
});

View File

@ -15,14 +15,14 @@ use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('gates the add-host page', function () {
$this->get(route('admin.hosts.create'))->assertRedirect('/login');
$this->get(route('admin.hosts.create'))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden();
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title'));
});
it('gates the host detail page', function () {
$host = Host::factory()->create();
$this->get(route('admin.hosts.show', $host))->assertRedirect('/login');
$this->get(route('admin.hosts.show', $host))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden();
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name);
});

View File

@ -22,7 +22,7 @@ it('shows real provisioning runs to admins', function () {
});
it('gates the provisioning console to admins', function () {
$this->get(route('admin.provisioning'))->assertRedirect('/login');
$this->get(route('admin.provisioning'))->assertRedirect(route('admin.login'));
$this->actingAs(User::factory()->create())
->get(route('admin.provisioning'))->assertForbidden();
});