CluPilotCloud/tests/Feature/Admin/PasswordConfirmationGuardTe...

129 lines
6.6 KiB
PHP

<?php
use App\Livewire\Admin\Secrets;
use App\Livewire\Settings;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
beforeEach(fn () => 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(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(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);
});