CluPilotCloud/tests/Feature/Admin/OperatorTwoFactorEnrollment...

173 lines
6.9 KiB
PHP

<?php
use App\Livewire\Admin\TwoFactorSetup;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
use PragmaRX\Google2FA\Google2FA;
/**
* Operators setting up their own two-factor, from the console.
*
* Mirrors tests/Feature/CustomerTwoFactorTest.php on purpose: Fortify's four
* actions only ever touch the model they are handed (no guard, no auth()
* call inside them — confirmed by reading the vendor source), so the same
* shape App\Livewire\Settings already used for the portal works here
* unchanged, just pointed at the `operator` guard instead of the default
* one via ConfirmsPassword::confirmationGuard().
*
* Its own component and route (App\Livewire\Admin\TwoFactorSetup,
* admin.two-factor-setup) rather than a card on Admin\Settings — see
* RequireOperatorTwoFactor for why: that component also carries staff
* management, site visibility, network restrictions, account changes, and
* the two-factor policy switch itself, none of which an unenrolled operator
* should get for free.
*
* What matters here is the gate: every action is re-checked against a
* recent password confirmation on the server, because a Livewire action is
* reachable by anyone who can post to /livewire/update, and "the button was
* not on screen" has never stopped anyone. Uses a non-Owner role throughout
* — this has to work for every operator, not only the one who can flip the
* compulsory switch.
*/
it('refuses every two-factor action without a recent password confirmation', function () {
$op = operator('Support');
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->call($action)
->assertForbidden();
}
expect($op->refresh()->two_factor_secret)->toBeNull();
});
it('takes a password, then sets two-factor up and confirms it with a real code', function () {
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->assertHasNoErrors();
$page->call('enableTwoFactor');
expect($op->refresh()->two_factor_secret)->not->toBeNull()
// Not on until a code has been accepted: someone who scans nothing and
// closes the tab must not be locked out of their own account.
->and($op->two_factor_confirmed_at)->toBeNull();
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
expect($op->refresh()->two_factor_confirmed_at)->not->toBeNull()
->and($page->get('recoveryCodes'))->toBeArray()->not->toBeEmpty();
});
it('rejects a wrong code instead of switching it on', function () {
$op = operator('Support');
Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor')
->set('twoFactorCode', '000000')
->call('confirmTwoFactor')
->assertHasErrors('twoFactorCode');
expect($op->refresh()->two_factor_confirmed_at)->toBeNull();
});
it('does not accept a wrong password, and says so without hinting', function () {
$op = operator('Support');
Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'falsch')
->call('confirmPassword')
->assertHasErrors('confirmablePassword')
->call('enableTwoFactor')
->assertForbidden();
});
it('never puts the secret where the browser can see it', function () {
// A Livewire property travels to the browser and back in the component
// snapshot. The QR image is derived from the secret; the secret is not.
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$secret = decrypt($op->refresh()->two_factor_secret);
expect(json_encode($page->snapshot))->not->toContain($secret);
});
it('actually renders the enrolment control on the page, not just in the component', function () {
// The original gap was exactly this: a component with the right methods
// but a blade that never called them. Assert on rendered HTML, not
// component state, so a forgotten wire:click regresses this test too.
$op = operator('Support');
// Nothing to enrol with yet — the password gate is what shows.
$page = Livewire::actingAs($op, 'operator')->test(TwoFactorSetup::class);
$page->assertSee(__('two_factor_setup.confirm_first'))
->assertSee(__('two_factor_setup.state_off'));
$page->set('confirmablePassword', 'password')->call('confirmPassword');
$page->assertSee(__('two_factor_setup.enable'));
$page->call('enableTwoFactor');
$page->assertSee(__('two_factor_setup.activate'))
->assertSee(__('two_factor_setup.code'));
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor');
$page->assertSee(__('two_factor_setup.state_on'))
->assertSee(__('two_factor_setup.codes_title'))
->assertSee(__('two_factor_setup.disable'));
});
it('lets an operator regenerate recovery codes and disable two-factor once confirmed', function () {
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
$firstCodes = $op->refresh()->two_factor_recovery_codes;
$page->call('regenerateRecoveryCodes');
expect($op->refresh()->two_factor_recovery_codes)->not->toBe($firstCodes);
$page->call('disableTwoFactor')->assertHasNoErrors();
expect($op->refresh()->two_factor_confirmed_at)->toBeNull()
->and($op->two_factor_secret)->toBeNull();
});
it('sends the operator to sign in instead of a 500 when the session has ended', function () {
// render() has no requireConfirmedPassword() gate in front of it — unlike
// every action above, Livewire calls it on every update including a bare
// property sync, so it is directly reachable with no other guard in the
// way once the session that owned the page has ended.
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')->test(TwoFactorSetup::class);
// The page sat open long enough for the operator's own session to end.
Auth::guard('operator')->logout();
$page->call('$refresh')->assertRedirect(route('admin.login'));
});