Skip to content

Commit 9d7196d

Browse files
committed
tests: add comprehensive MFA feature tests, fix test env app key, update fakes
1 parent bda2052 commit 9d7196d

File tree

5 files changed

+172
-13
lines changed

5 files changed

+172
-13
lines changed

tests/Feature/ExampleTest.php

Lines changed: 0 additions & 7 deletions
This file was deleted.

tests/Feature/MFATest.php

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
use CodingLibs\MFA\MFA;
4+
use CodingLibs\MFA\Contracts\MfaChannel;
5+
use CodingLibs\MFA\Models\MfaChallenge;
6+
use CodingLibs\MFA\Models\MfaMethod;
7+
use CodingLibs\MFA\Totp\GoogleTotp;
8+
use Illuminate\Contracts\Auth\Authenticatable;
9+
10+
class MFATestFakeUser implements Authenticatable
11+
{
12+
public function __construct(public int|string $id, public ?string $email = null) {}
13+
public function getAuthIdentifierName()
14+
{
15+
return 'id';
16+
}
17+
public function getAuthIdentifier()
18+
{
19+
return $this->id;
20+
}
21+
public function getAuthPassword()
22+
{
23+
return '';
24+
}
25+
public function getAuthPasswordName()
26+
{
27+
return 'password';
28+
}
29+
public function getRememberToken()
30+
{
31+
return null;
32+
}
33+
public function setRememberToken($value): void {}
34+
public function getRememberTokenName()
35+
{
36+
return 'remember_token';
37+
}
38+
}
39+
40+
class MFATestDummyChannel implements MfaChannel
41+
{
42+
public ?string $lastCode = null;
43+
public function __construct(public string $name = 'custom') {}
44+
public function getName(): string { return $this->name; }
45+
public function send(Authenticatable $user, string $code, array $options = []): void
46+
{
47+
$this->lastCode = $code;
48+
}
49+
}
50+
51+
it('sets up TOTP and enables the method', function () {
52+
$user = new MFATestFakeUser(1001, 'user@example.com');
53+
$mfa = app(MFA::class);
54+
55+
$result = $mfa->setupTotp($user, 'Acme', 'user@example.com');
56+
57+
expect($result)
58+
->toHaveKeys(['secret', 'otpauth_url']);
59+
60+
$method = $mfa->getMethod($user, 'totp');
61+
expect($method)->toBeInstanceOf(MfaMethod::class);
62+
expect($mfa->isEnabled($user, 'totp'))->toBeTrue();
63+
expect($method->secret)->toBeString()->not->toBe('');
64+
});
65+
66+
it('verifies a valid TOTP code and updates last_used_at', function () {
67+
$user = new MFATestFakeUser(1002, 'user2@example.com');
68+
$mfa = app(MFA::class);
69+
70+
$result = $mfa->setupTotp($user, 'Acme', 'user2@example.com');
71+
$secret = $result['secret'];
72+
73+
$ref = new ReflectionClass(GoogleTotp::class);
74+
$base32Decode = $ref->getMethod('base32Decode');
75+
$base32Decode->setAccessible(true);
76+
$hotp = $ref->getMethod('hotp');
77+
$hotp->setAccessible(true);
78+
$truncate = $ref->getMethod('truncateToDigits');
79+
$truncate->setAccessible(true);
80+
81+
$period = 30;
82+
$digits = 6;
83+
$timeSlice = (int) floor(time() / $period);
84+
$hash = $hotp->invoke(null, $base32Decode->invoke(null, $secret), $timeSlice);
85+
$expected = $truncate->invoke(null, $hash, $digits);
86+
87+
$ok = $mfa->verifyTotp($user, $expected);
88+
expect($ok)->toBeTrue();
89+
90+
$method = $mfa->getMethod($user, 'totp');
91+
expect($method->last_used_at)->not->toBeNull();
92+
});
93+
94+
it('issues a challenge through a custom channel and verifies it', function () {
95+
$user = new MFATestFakeUser(1003);
96+
$mfa = app(MFA::class);
97+
98+
$channel = new MFATestDummyChannel('custom');
99+
$mfa->registerChannel($channel);
100+
101+
$challenge = $mfa->issueChallenge($user, 'custom');
102+
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
103+
expect($challenge->code)->toBeString()->toHaveLength(6);
104+
expect($channel->lastCode)->toBe($challenge->code);
105+
106+
$verified = $mfa->verifyChallenge($user, 'custom', $challenge->code);
107+
expect($verified)->toBeTrue();
108+
109+
$challenge->refresh();
110+
expect($challenge->consumed_at)->not->toBeNull();
111+
expect($mfa->isEnabled($user, 'custom'))->toBeTrue();
112+
});
113+
114+
it('fails to verify expired challenges', function () {
115+
$user = new MFATestFakeUser(1004);
116+
$mfa = app(MFA::class);
117+
118+
$channel = new MFATestDummyChannel('custom2');
119+
$mfa->registerChannel($channel);
120+
121+
$challenge = $mfa->issueChallenge($user, 'custom2');
122+
$challenge->expires_at = now()->subMinute();
123+
$challenge->save();
124+
125+
$verified = $mfa->verifyChallenge($user, 'custom2', $challenge->code);
126+
expect($verified)->toBeFalse();
127+
});
128+
129+
it('enables and disables methods', function () {
130+
$user = new MFATestFakeUser(1005);
131+
$mfa = app(MFA::class);
132+
133+
$mfa->enableMethod($user, 'email');
134+
expect($mfa->isEnabled($user, 'email'))->toBeTrue();
135+
136+
$mfa->disableMethod($user, 'email');
137+
expect($mfa->isEnabled($user, 'email'))->toBeFalse();
138+
});
139+
140+
it('remembers device, skips verification with token, and can forget device', function () {
141+
config(['mfa.remember.enabled' => true, 'mfa.remember.lifetime_days' => 1]);
142+
143+
$user = new MFATestFakeUser(1006);
144+
$mfa = app(MFA::class);
145+
146+
$request = Illuminate\Http\Request::create('/', 'GET');
147+
app()->instance('request', $request);
148+
149+
$result = $mfa->rememberDevice($user, null, 'MyDevice');
150+
expect($result['token'])->toBeString()->not->toBe('');
151+
expect($result['cookie'])->not->toBeNull();
152+
expect($result['cookie']->getName())->toBe($mfa->getRememberCookieName());
153+
154+
$skip = $mfa->shouldSkipVerification($user, $result['token']);
155+
expect($skip)->toBeTrue();
156+
157+
$deleted = $mfa->forgetRememberedDevice($user, $result['token']);
158+
expect($deleted)->toBeGreaterThanOrEqual(1);
159+
160+
$skipAgain = $mfa->shouldSkipVerification($user, $result['token']);
161+
expect($skipAgain)->toBeFalse();
162+
});
163+
164+

tests/Feature/RecoveryCodesTest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use CodingLibs\MFA\Models\MfaRecoveryCode;
44
use Illuminate\Contracts\Auth\Authenticatable;
55

6-
uses(Tests\TestCase::class);
6+
// TestCase for Feature tests is applied via tests/Pest.php
77

88
class FakeUser implements Authenticatable
99
{
@@ -20,6 +20,10 @@ public function getAuthPassword()
2020
{
2121
return '';
2222
}
23+
public function getAuthPasswordName()
24+
{
25+
return 'password';
26+
}
2327
public function getRememberToken()
2428
{
2529
return null;

tests/TestCase.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ protected function getPackageProviders($app)
1414

1515
protected function getEnvironmentSetUp($app): void
1616
{
17+
// Ensure app key exists for encrypter-dependent features
18+
$app['config']->set('app.key', 'base64:' . base64_encode(random_bytes(32)));
19+
1720
config()->set('database.default', 'testing');
1821
config()->set('database.connections.testing', [
1922
'driver' => 'sqlite',

tests/Unit/ExampleTest.php

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)