Skip to content

Commit 05e4cbe

Browse files
committed
tests: add edge-case tests for TOTP, registry, and MFA negative flows
1 parent 9d7196d commit 05e4cbe

File tree

3 files changed

+126
-0
lines changed

3 files changed

+126
-0
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/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/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+

0 commit comments

Comments
 (0)