2026-01-26 21:08:59 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 00:31:43 +00:00
|
|
|
use Core\Core\Tenant\Models\User;
|
|
|
|
|
use Core\Core\Tenant\Models\UserTwoFactorAuth;
|
2026-01-26 21:08:59 +00:00
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
Cache::flush();
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('TwoFactorAuthenticatable Trait', function () {
|
|
|
|
|
describe('twoFactorAuth() relationship', function () {
|
|
|
|
|
it('returns HasOne relationship', function () {
|
|
|
|
|
expect($this->user->twoFactorAuth())->toBeInstanceOf(
|
|
|
|
|
\Illuminate\Database\Eloquent\Relations\HasOne::class
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null when no 2FA record exists', function () {
|
|
|
|
|
expect($this->user->twoFactorAuth)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 2FA record when it exists', function () {
|
|
|
|
|
$twoFactorAuth = UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => ['code1', 'code2'],
|
|
|
|
|
'confirmed_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorAuth)->toBeInstanceOf(UserTwoFactorAuth::class)
|
|
|
|
|
->and($this->user->twoFactorAuth->id)->toBe($twoFactorAuth->id);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('hasTwoFactorAuthEnabled()', function () {
|
|
|
|
|
it('returns false when no 2FA record exists', function () {
|
|
|
|
|
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false when 2FA record exists but secret_key is null', function () {
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => null,
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
'confirmed_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false when 2FA record exists but confirmed_at is null', function () {
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
'confirmed_at' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns true when 2FA is fully enabled', function () {
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => ['code1', 'code2'],
|
|
|
|
|
'confirmed_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->hasTwoFactorAuthEnabled())->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('twoFactorAuthSecretKey()', function () {
|
|
|
|
|
it('returns null when no 2FA record exists', function () {
|
|
|
|
|
expect($this->user->twoFactorAuthSecretKey())->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns the secret key when 2FA record exists', function () {
|
|
|
|
|
$secretKey = 'JBSWY3DPEHPK3PXP';
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => $secretKey,
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('twoFactorRecoveryCodes()', function () {
|
|
|
|
|
it('returns empty array when no 2FA record exists', function () {
|
|
|
|
|
expect($this->user->twoFactorRecoveryCodes())->toBe([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns empty array when recovery_codes is null', function () {
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorRecoveryCodes())->toBe([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns recovery codes as array', function () {
|
|
|
|
|
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorRecoveryCodes())->toBe($codes);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('twoFactorReplaceRecoveryCode()', function () {
|
|
|
|
|
it('does nothing when no 2FA record exists', function () {
|
|
|
|
|
// Should not throw
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('nonexistent');
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorAuth)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does nothing when code is not found in recovery codes', function () {
|
|
|
|
|
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('NONEXISTENT');
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
expect($this->user->twoFactorRecoveryCodes())->toBe($codes);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('replaces a used recovery code with a new one', function () {
|
|
|
|
|
$codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3'];
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('CODE2-CODE2');
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
$newCodes = $this->user->twoFactorRecoveryCodes();
|
|
|
|
|
|
|
|
|
|
// Should still have 3 codes
|
|
|
|
|
expect($newCodes)->toHaveCount(3)
|
|
|
|
|
// First and third codes should be unchanged
|
|
|
|
|
->and($newCodes[0])->toBe('CODE1-CODE1')
|
|
|
|
|
->and($newCodes[2])->toBe('CODE3-CODE3')
|
|
|
|
|
// Second code should be different and in the expected format
|
|
|
|
|
->and($newCodes[1])->not->toBe('CODE2-CODE2')
|
|
|
|
|
->and($newCodes[1])->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('twoFactorQrCodeUrl()', function () {
|
|
|
|
|
it('generates valid TOTP URL', function () {
|
|
|
|
|
$secretKey = 'JBSWY3DPEHPK3PXP';
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => $secretKey,
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$url = $this->user->twoFactorQrCodeUrl();
|
|
|
|
|
|
|
|
|
|
expect($url)->toStartWith('otpauth://totp/')
|
|
|
|
|
->and($url)->toContain($secretKey)
|
|
|
|
|
->and($url)->toContain(rawurlencode($this->user->email))
|
|
|
|
|
->and($url)->toContain('issuer=');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('includes app name in the URL', function () {
|
|
|
|
|
$appName = config('app.name');
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$url = $this->user->twoFactorQrCodeUrl();
|
|
|
|
|
|
|
|
|
|
expect($url)->toContain(rawurlencode($appName));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('twoFactorQrCodeSvg()', function () {
|
|
|
|
|
it('returns empty string when no secret exists', function () {
|
|
|
|
|
expect($this->user->twoFactorQrCodeSvg())->toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns SVG content when secret exists', function () {
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$svg = $this->user->twoFactorQrCodeSvg();
|
|
|
|
|
|
|
|
|
|
expect($svg)->toStartWith('<svg')
|
|
|
|
|
->and($svg)->toContain('</svg>');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('generateRecoveryCode() via twoFactorReplaceRecoveryCode()', function () {
|
|
|
|
|
it('generates codes in the expected format', function () {
|
|
|
|
|
$codes = ['TESTCODE1'];
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('TESTCODE1');
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
$newCode = $this->user->twoFactorRecoveryCodes()[0];
|
|
|
|
|
|
|
|
|
|
// Format: 10 uppercase hex chars - 10 uppercase hex chars
|
|
|
|
|
expect($newCode)->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('generates unique codes', function () {
|
|
|
|
|
$codes = ['CODE1', 'CODE2', 'CODE3'];
|
|
|
|
|
|
|
|
|
|
UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
// Replace all codes
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('CODE1');
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('CODE2');
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
$this->user->twoFactorReplaceRecoveryCode('CODE3');
|
|
|
|
|
$this->user->refresh();
|
|
|
|
|
|
|
|
|
|
$newCodes = $this->user->twoFactorRecoveryCodes();
|
|
|
|
|
|
|
|
|
|
// All codes should be unique
|
|
|
|
|
expect(array_unique($newCodes))->toHaveCount(3);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('UserTwoFactorAuth Model', function () {
|
|
|
|
|
it('belongs to a user', function () {
|
|
|
|
|
$twoFactorAuth = UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($twoFactorAuth->user)->toBeInstanceOf(User::class)
|
|
|
|
|
->and($twoFactorAuth->user->id)->toBe($this->user->id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('casts recovery_codes to collection', function () {
|
|
|
|
|
$codes = ['CODE1', 'CODE2'];
|
|
|
|
|
|
|
|
|
|
$twoFactorAuth = UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => $codes,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($twoFactorAuth->recovery_codes)->toBeInstanceOf(\Illuminate\Support\Collection::class)
|
|
|
|
|
->and($twoFactorAuth->recovery_codes->toArray())->toBe($codes);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('casts confirmed_at to datetime', function () {
|
|
|
|
|
$confirmedAt = now();
|
|
|
|
|
|
|
|
|
|
$twoFactorAuth = UserTwoFactorAuth::create([
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'secret_key' => 'JBSWY3DPEHPK3PXP',
|
|
|
|
|
'recovery_codes' => [],
|
|
|
|
|
'confirmed_at' => $confirmedAt,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class);
|
|
|
|
|
});
|
|
|
|
|
});
|