173 lines
6.9 KiB
PHP
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'));
|
|
});
|