129 lines
6.6 KiB
PHP
129 lines
6.6 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\Integrations;
|
|
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(Integrations::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(Integrations::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 Integrations::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(Integrations::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
|
|
// Integrations 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 Integrations::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(Integrations::class)
|
|
->assertViewHas('unlocked', false);
|
|
});
|