config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)))); it('confirms a console password against the operator record, not a portal user', function () { $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']); Livewire::actingAs($operator, 'operator')->test(Secrets::class) ->set('confirmablePassword', 'operator-passwort') ->call('confirmPassword') ->assertHasNoErrors(); }); it('rejects a password that belongs to a portal user with the same address', function () { // The trap: two tables, one address. Confirming against the wrong one would // let a customer's password unlock the console. $operator = Operator::factory()->role('Owner')->create([ 'email' => 'doppelt@clupilot.test', 'password' => 'operator-passwort', ]); User::factory()->create(['email' => 'doppelt@clupilot.test', 'password' => 'kunden-passwort']); Livewire::actingAs($operator, 'operator')->test(Secrets::class) ->set('confirmablePassword', 'kunden-passwort') ->call('confirmPassword') ->assertHasErrors('confirmablePassword'); }); it('still confirms a portal password on the web guard', function () { $user = User::factory()->create(['password' => 'kunden-passwort']); Livewire::actingAs($user)->test(App\Livewire\Settings::class) ->set('confirmablePassword', 'kunden-passwort') ->call('confirmPassword') ->assertHasNoErrors(); }); it('resolves the operator explicitly by guard, not by whichever guard happens to be the ambient default', function () { // actingAs()/Livewire::actingAs() both call Auth::shouldUse($guard) as a // side effect, which makes the named guard the DEFAULT guard — and under // that, auth()->user() and auth()->guard('operator')->user() resolve // identically, so every OTHER test in this file passes just the same // whether confirmPassword() asks for the right guard explicitly or just // reads the ambient default. That is not a hypothetical: reverting the // trait's guard(...)->user() call back to a bare auth()->user() passes // the entire 834-test suite, this file included — the gap this test // closes, by making that distinction observable. // // Signing in with Auth::guard('operator')->login() alone (no actingAs) // does NOT reach this test: it proves confirmationGuard is genuinely // consulted, but Secrets::mount()'s own $this->authorize('secrets.manage') // ALSO reads the ambient default guard (Laravel's Gate resolves the // current user the same bare way) — so mount() itself 403s before // confirmPassword() is ever reached, on the very account this test means // to prove works. Confirmed directly: Auth::guard('web')->check() is // false, Auth::guard('operator')->check() is true, and still // Gate::check('secrets.manage') is false in that state. // // So: mount through actingAs() as usual — authorize() runs exactly once, // while shouldUse('operator') is still in effect from actingAs, and // legitimately passes. mount() does not run again on a later ->call(). // Only THEN, before touching confirmPassword() specifically, undo the // masking by hand: Auth::shouldUse('web') switches the ambient default // back to 'web' — genuinely empty, not merely relabelled — while the // operator's own session on the 'operator' guard is untouched (setUser() // on a guard does not depend on which guard is "default"). That is // exactly the real shape of a console request: a genuine operator sign-in // (App\Livewire\Auth\OperatorLogin::completeLogin()) never touches the // web guard, default or not. $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']); $page = Livewire::actingAs($operator, 'operator')->test(Secrets::class); Auth::shouldUse('web'); expect(Auth::guard('web')->check())->toBeFalse() ->and(Auth::guard('operator')->check())->toBeTrue() ->and(auth()->check())->toBeFalse(); $page->set('confirmablePassword', 'operator-passwort') ->call('confirmPassword') ->assertHasNoErrors(); }); it("does not let a portal customer's password confirmation satisfy the console's own gate", function () { // Codex R15 P1a: confirmPassword() always checked the RIGHT account per // guard (the tests above), but until now it stamped a single // guard-agnostic session marker — auth.password_confirmed_at, no guard or // identity in the key — that ANY confirmationGuard() then accepted. In // shared-host mode (this suite's default, and this VM's own mode) the // portal and console guards keep their login key in the SAME session, so // signing into both and confirming the CUSTOMER's password on the portal // settings page left the marker in place for the OPERATOR-only Secrets // page too. This is the escalation itself, not a proxy for it. $operator = Operator::factory()->role('Owner')->create(['password' => 'operator-passwort']); $customer = User::factory()->create(['password' => 'kunden-passwort']); // Signed into both guards, one shared session — the login() calls write // real session data, unlike actingAs() (in-memory user only), so the // marker written by the first call below is genuinely there for the // second to find, exactly as it would be for a real browser carrying // both identities. Auth::guard('web')->login($customer); Auth::guard('operator')->login($operator); // Confirm the CUSTOMER's password on the portal settings page. // App\Livewire\Settings::confirmationGuard() defaults to 'web', resolved // explicitly by guard — no actingAs needed to reach the right account, // the login() call above already put it in the session. Livewire::test(App\Livewire\Settings::class) ->set('confirmablePassword', 'kunden-passwort') ->call('confirmPassword') ->assertHasNoErrors(); // Walk into the console as the OPERATOR with that confirmation still // sitting in the session. actingAs() only sets the AMBIENT default guard // Gate reads for Secrets::mount()'s authorize() call — it does not touch // the 'web' guard's own session entry set above, so this is the real // shape of the attack: one browser, two identities, one session. Livewire::actingAs($operator, 'operator')->test(Secrets::class) ->assertViewHas('unlocked', false); });