Skip to content

Commit b484ffe

Browse files
authored
Merge pull request #24 from coding-libs/tests/improvements-mfa
Tests/improvements mfa
2 parents bda2052 + 05e4cbe commit b484ffe

File tree

8 files changed

+298
-13
lines changed

8 files changed

+298
-13
lines changed

tests/ChannelRegistryEdgeTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use CodingLibs\MFA\ChannelRegistry;
4+
use CodingLibs\MFA\Contracts\MfaChannel;
5+
use Illuminate\Contracts\Auth\Authenticatable;
6+
7+
class RegistryDummy implements MfaChannel
8+
{
9+
public function __construct(public string $name) {}
10+
public function getName(): string { return $this->name; }
11+
public function send(Authenticatable $user, string $code, array $options = []): void {}
12+
}
13+
14+
it('returns null for missing channels', function () {
15+
$registry = new ChannelRegistry();
16+
expect($registry->get('unknown'))->toBeNull();
17+
});
18+
19+
it('all returns lowercase keys and latest wins on duplicates', function () {
20+
$registry = new ChannelRegistry();
21+
$first = new RegistryDummy('Email');
22+
$second = new RegistryDummy('EMAIL');
23+
24+
$registry->register($first);
25+
$registry->register($second);
26+
27+
$all = $registry->all();
28+
expect(array_keys($all))->toBe(['email']);
29+
expect($registry->get('email'))->toBe($second);
30+
});
31+
32+

tests/Feature/ExampleTest.php

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

tests/Feature/MFANegativeTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
use CodingLibs\MFA\MFA;
4+
use Illuminate\Contracts\Auth\Authenticatable;
5+
6+
class MFANegFakeUser implements Authenticatable
7+
{
8+
public function __construct(public int|string $id) {}
9+
public function getAuthIdentifierName() { return 'id'; }
10+
public function getAuthIdentifier() { return $this->id; }
11+
public function getAuthPassword() { return ''; }
12+
public function getAuthPasswordName() { return 'password'; }
13+
public function getRememberToken() { return null; }
14+
public function setRememberToken($value): void {}
15+
public function getRememberTokenName() { return 'remember_token'; }
16+
}
17+
18+
it('verifyTotp returns false when method not set', function () {
19+
$user = new MFANegFakeUser(9001);
20+
$mfa = app(MFA::class);
21+
expect($mfa->verifyTotp($user, '000000'))->toBeFalse();
22+
});
23+
24+
it('issueChallenge returns null for unknown channel', function () {
25+
$user = new MFANegFakeUser(9002);
26+
$mfa = app(MFA::class);
27+
expect($mfa->issueChallenge($user, 'unknown'))->toBeNull();
28+
});
29+
30+
it('verifyChallenge returns false when no active challenge', function () {
31+
$user = new MFANegFakeUser(9003);
32+
$mfa = app(MFA::class);
33+
expect($mfa->verifyChallenge($user, 'email', '123456'))->toBeFalse();
34+
});
35+
36+

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/GoogleTotpEdgeTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
use CodingLibs\MFA\Totp\GoogleTotp;
4+
5+
it('rejects invalid code formats', function () {
6+
$secret = GoogleTotp::generateSecret();
7+
// Non-numeric and wrong length
8+
expect(GoogleTotp::verifyCode($secret, 'abcdef', 6, 30, 1))->toBeFalse();
9+
expect(GoogleTotp::verifyCode($secret, '123', 6, 30, 1))->toBeFalse();
10+
// Very long string
11+
expect(GoogleTotp::verifyCode($secret, str_repeat('9', 20), 6, 30, 1))->toBeFalse();
12+
});
13+
14+
it('supports 4 and 8 digit OTPs using exact slice', function () {
15+
$secret = GoogleTotp::generateSecret();
16+
$ref = new ReflectionClass(GoogleTotp::class);
17+
$base32Decode = $ref->getMethod('base32Decode');
18+
$base32Decode->setAccessible(true);
19+
$hotp = $ref->getMethod('hotp');
20+
$hotp->setAccessible(true);
21+
$truncate = $ref->getMethod('truncateToDigits');
22+
$truncate->setAccessible(true);
23+
24+
$period = 30;
25+
$timeSlice = (int) floor(time() / $period);
26+
$key = $base32Decode->invoke(null, $secret);
27+
28+
$hash = $hotp->invoke(null, $key, $timeSlice);
29+
$code4 = $truncate->invoke(null, $hash, 4);
30+
$code8 = $truncate->invoke(null, $hash, 8);
31+
32+
expect(GoogleTotp::verifyCode($secret, $code4, 4, $period, 0))->toBeTrue();
33+
expect(GoogleTotp::verifyCode($secret, $code8, 8, $period, 0))->toBeTrue();
34+
});
35+
36+
it('respects zero window (adjacent slice fails)', function () {
37+
$secret = GoogleTotp::generateSecret();
38+
$ref = new ReflectionClass(GoogleTotp::class);
39+
$base32Decode = $ref->getMethod('base32Decode');
40+
$base32Decode->setAccessible(true);
41+
$hotp = $ref->getMethod('hotp');
42+
$hotp->setAccessible(true);
43+
$truncate = $ref->getMethod('truncateToDigits');
44+
$truncate->setAccessible(true);
45+
46+
$period = 30;
47+
$digits = 6;
48+
$timeSlice = (int) floor(time() / $period);
49+
$key = $base32Decode->invoke(null, $secret);
50+
51+
$prev = $truncate->invoke(null, $hotp->invoke(null, $key, $timeSlice - 1), $digits);
52+
$curr = $truncate->invoke(null, $hotp->invoke(null, $key, $timeSlice), $digits);
53+
54+
expect(GoogleTotp::verifyCode($secret, $prev, $digits, $period, 0))->toBeFalse();
55+
expect(GoogleTotp::verifyCode($secret, $curr, $digits, $period, 0))->toBeTrue();
56+
});
57+
58+

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)