137 lines
4.7 KiB
PHP
137 lines
4.7 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Auth\OperatorTwoFactorChallenge;
|
|
use App\Models\Operator;
|
|
use App\Support\AdminArea;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Livewire\Livewire;
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
|
|
/**
|
|
* The operator's two-factor step, exercised directly.
|
|
*
|
|
* OperatorLoginTest proves the password step hands off to this challenge
|
|
* without signing anyone in; these tests prove the challenge itself only
|
|
* completes the sign-in once the code actually checks out — the other half of
|
|
* "not signed in before the code is verified".
|
|
*/
|
|
function pendingOperator(array $attributes = []): Operator
|
|
{
|
|
$operator = Operator::factory()->role('Owner')->create(array_merge([
|
|
'password' => 'richtig-langes-pw',
|
|
'two_factor_secret' => encrypt('JBSWY3DPEHPK3PXP'),
|
|
'two_factor_recovery_codes' => encrypt(json_encode(['aaaa-1111', 'bbbb-2222'])),
|
|
'two_factor_confirmed_at' => now(),
|
|
], $attributes));
|
|
|
|
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => false]);
|
|
|
|
return $operator;
|
|
}
|
|
|
|
it('is a dead end without a password step already taken', function () {
|
|
Livewire::test(OperatorTwoFactorChallenge::class)->assertForbidden();
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
|
});
|
|
|
|
it('signs the operator in once the code actually checks out', function () {
|
|
$operator = pendingOperator();
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($operator->two_factor_secret));
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('code', $code)
|
|
->call('verify')
|
|
->assertRedirect(AdminArea::home());
|
|
|
|
expect(Auth::guard('operator')->check())->toBeTrue()
|
|
->and(session()->has('operator.2fa.id'))->toBeFalse();
|
|
});
|
|
|
|
it('refuses a wrong code and leaves the operator signed out', function () {
|
|
$operator = pendingOperator();
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('code', '000000')
|
|
->call('verify')
|
|
->assertHasErrors('code');
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse()
|
|
->and($operator->fresh()->last_login_at)->toBeNull();
|
|
});
|
|
|
|
it('accepts a recovery code exactly once', function () {
|
|
$operator = pendingOperator();
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('usingRecovery', true)
|
|
->set('recoveryCode', 'aaaa-1111')
|
|
->call('verify')
|
|
->assertRedirect(AdminArea::home());
|
|
|
|
expect(Auth::guard('operator')->check())->toBeTrue();
|
|
|
|
$codes = json_decode(decrypt($operator->fresh()->two_factor_recovery_codes), true);
|
|
expect($codes)->not->toContain('aaaa-1111')
|
|
->and($codes)->toContain('bbbb-2222');
|
|
|
|
// Spent — signing back out and trying the same code again must fail.
|
|
Auth::guard('operator')->logout();
|
|
session(['operator.2fa.id' => $operator->id, 'operator.2fa.remember' => false]);
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('usingRecovery', true)
|
|
->set('recoveryCode', 'aaaa-1111')
|
|
->call('verify')
|
|
->assertHasErrors('code');
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
|
});
|
|
|
|
it('refuses to complete the login when the operator was disabled after the password step', function () {
|
|
// The realistic case: an owner revokes access WHILE someone is mid-login,
|
|
// not before. The code entered here is genuinely correct — the account
|
|
// it belongs to no longer qualifies.
|
|
$operator = pendingOperator();
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($operator->two_factor_secret));
|
|
|
|
$operator->forceFill(['disabled_at' => now()])->save();
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('code', $code)
|
|
->call('verify')
|
|
->assertHasErrors('code');
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse()
|
|
->and($operator->fresh()->last_login_at)->toBeNull();
|
|
});
|
|
|
|
it('refuses to complete the login when every console role was revoked after the password step', function () {
|
|
$operator = pendingOperator();
|
|
$code = app(Google2FA::class)->getCurrentOtp(decrypt($operator->two_factor_secret));
|
|
|
|
$operator->syncRoles([]);
|
|
|
|
Livewire::test(OperatorTwoFactorChallenge::class)
|
|
->set('code', $code)
|
|
->call('verify')
|
|
->assertHasErrors('code');
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse()
|
|
->and($operator->fresh()->last_login_at)->toBeNull();
|
|
});
|
|
|
|
it('throttles repeated wrong codes', function () {
|
|
pendingOperator();
|
|
|
|
$component = Livewire::test(OperatorTwoFactorChallenge::class)->set('code', '000000');
|
|
|
|
foreach (range(1, 5) as $ignored) {
|
|
$component->call('verify');
|
|
}
|
|
|
|
$component->call('verify')->assertHasErrors('code');
|
|
|
|
expect(Auth::guard('operator')->check())->toBeFalse();
|
|
});
|