test: add edge case tests for TotpService
Add 37 Pest tests covering TotpService edge cases that were previously untested: clock drift acceptance/rejection across time windows, malformed base32 secrets (lowercase, padding, invalid chars, empty), code format handling (spaces, dashes, too short/long, alphabetic, whitespace), replay behaviour documentation, cross-secret rejection, base32 encode/decode round-trips (binary, empty, boundary bytes), and RFC 6238 conformance (determinism, zero-padding, period variation). Fixes #17 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c51e4310b1
commit
5d8c68b2d8
1 changed files with 405 additions and 0 deletions
405
tests/Feature/TotpServiceTest.php
Normal file
405
tests/Feature/TotpServiceTest.php
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Tenant\Services\TotpService;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->service = new TotpService;
|
||||
});
|
||||
|
||||
describe('TotpService', function () {
|
||||
|
||||
describe('generateSecretKey()', function () {
|
||||
it('returns a valid base32 string', function () {
|
||||
$secret = $this->service->generateSecretKey();
|
||||
|
||||
expect($secret)->toMatch('/^[A-Z2-7]+$/');
|
||||
});
|
||||
|
||||
it('returns a secret of expected length for 160-bit key', function () {
|
||||
$secret = $this->service->generateSecretKey();
|
||||
|
||||
// 160 bits / 5 bits per base32 char = 32 chars
|
||||
expect(strlen($secret))->toBe(32);
|
||||
});
|
||||
|
||||
it('generates unique secrets on each call', function () {
|
||||
$secrets = [];
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$secrets[] = $this->service->generateSecretKey();
|
||||
}
|
||||
|
||||
expect(array_unique($secrets))->toHaveCount(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qrCodeUrl()', function () {
|
||||
it('produces a valid otpauth URI', function () {
|
||||
$url = $this->service->qrCodeUrl('MyApp', 'user@example.com', 'JBSWY3DPEHPK3PXP');
|
||||
|
||||
expect($url)->toStartWith('otpauth://totp/')
|
||||
->and($url)->toContain('secret=JBSWY3DPEHPK3PXP')
|
||||
->and($url)->toContain('issuer=MyApp')
|
||||
->and($url)->toContain('algorithm=SHA1')
|
||||
->and($url)->toContain('digits=6')
|
||||
->and($url)->toContain('period=30');
|
||||
});
|
||||
|
||||
it('URL-encodes the application name with special characters', function () {
|
||||
$url = $this->service->qrCodeUrl('My App & Co.', 'user@example.com', 'SECRET');
|
||||
|
||||
expect($url)->toContain(rawurlencode('My App & Co.'));
|
||||
});
|
||||
|
||||
it('URL-encodes the email address', function () {
|
||||
$url = $this->service->qrCodeUrl('App', 'user+tag@example.com', 'SECRET');
|
||||
|
||||
expect($url)->toContain(rawurlencode('user+tag@example.com'));
|
||||
});
|
||||
|
||||
it('handles empty application name', function () {
|
||||
$url = $this->service->qrCodeUrl('', 'user@example.com', 'SECRET');
|
||||
|
||||
expect($url)->toStartWith('otpauth://totp/');
|
||||
});
|
||||
|
||||
it('handles unicode characters in name and email', function () {
|
||||
$url = $this->service->qrCodeUrl('Ünïcödé', 'umlaut@über.de', 'SECRET');
|
||||
|
||||
expect($url)->toStartWith('otpauth://totp/')
|
||||
->and($url)->toContain(rawurlencode('Ünïcödé'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify() — code format edge cases', function () {
|
||||
it('rejects an empty string', function () {
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', ''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a code that is too short', function () {
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', '12345'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a code that is too long', function () {
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', '1234567'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects purely alphabetic input', function () {
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', 'abcdef'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('strips spaces from a formatted code before validating length', function () {
|
||||
// '123 456' after stripping non-digits becomes '123456' (6 digits)
|
||||
// It should not be rejected for length — only for incorrect OTP value
|
||||
$result = $this->service->verify('JBSWY3DPEHPK3PXP', '123 456');
|
||||
|
||||
// The code is the right length once cleaned, but almost certainly wrong
|
||||
// We just confirm it does not crash and returns a boolean
|
||||
expect($result)->toBeBool();
|
||||
});
|
||||
|
||||
it('strips dashes from a formatted code before validating length', function () {
|
||||
$result = $this->service->verify('JBSWY3DPEHPK3PXP', '123-456');
|
||||
|
||||
expect($result)->toBeBool();
|
||||
});
|
||||
|
||||
it('rejects a code that becomes too short after stripping non-digits', function () {
|
||||
// 'abc12' strips to '12' which is too short
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', 'abc12'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects a code consisting solely of whitespace', function () {
|
||||
expect($this->service->verify('JBSWY3DPEHPK3PXP', ' '))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify() — correct code validation with known secret', function () {
|
||||
/**
|
||||
* Helper: generate a valid TOTP code for the given secret and timestamp
|
||||
* by invoking the protected generateCode() method via reflection.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->generateCodeAt = function (string $secret, int $timestamp): string {
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
// base32Decode the secret first, same as verify() does
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, $secret);
|
||||
|
||||
return $reflection->invoke($this->service, $secretBytes, $timestamp);
|
||||
};
|
||||
});
|
||||
|
||||
it('accepts a code generated for the current time window', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
$code = ($this->generateCodeAt)($secret, $now);
|
||||
|
||||
expect($this->service->verify($secret, $code))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects a completely wrong code', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
$validCode = ($this->generateCodeAt)($secret, $now);
|
||||
|
||||
// Flip every digit so it's definitely wrong
|
||||
$wrongCode = '';
|
||||
for ($i = 0; $i < strlen($validCode); $i++) {
|
||||
$wrongCode .= ((int) $validCode[$i] + 5) % 10;
|
||||
}
|
||||
|
||||
expect($this->service->verify($secret, $wrongCode))->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts a code from the previous time window (clock drift -30s)', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
$pastCode = ($this->generateCodeAt)($secret, $now - 30);
|
||||
|
||||
expect($this->service->verify($secret, $pastCode))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts a code from the next time window (clock drift +30s)', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
$futureCode = ($this->generateCodeAt)($secret, $now + 30);
|
||||
|
||||
expect($this->service->verify($secret, $futureCode))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects a code from two time windows ago (beyond drift tolerance)', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
// Go back far enough that it falls outside the WINDOW=1 tolerance.
|
||||
// Align to the start of a period so the -2 window is definitively out.
|
||||
$now = time();
|
||||
$periodStart = $now - ($now % 30);
|
||||
// A code from 3 full periods ago is guaranteed out of WINDOW=1
|
||||
$oldCode = ($this->generateCodeAt)($secret, $periodStart - 90);
|
||||
|
||||
// Only valid if it happens to collide with the current window,
|
||||
// which is astronomically unlikely
|
||||
expect($this->service->verify($secret, $oldCode))->toBeFalse();
|
||||
});
|
||||
|
||||
it('handles a code with leading zeros', function () {
|
||||
// Use reflection to generate a code, which already zero-pads,
|
||||
// then verify it round-trips correctly
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$code = ($this->generateCodeAt)($secret, time());
|
||||
|
||||
expect(strlen($code))->toBe(6);
|
||||
expect($this->service->verify($secret, $code))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify() — malformed and invalid base32 secrets', function () {
|
||||
it('handles a lowercase secret by treating it case-insensitively', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
|
||||
// Generate code with uppercase secret
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, $secret);
|
||||
$code = $reflection->invoke($this->service, $secretBytes, $now);
|
||||
|
||||
// Verify using lowercase secret — base32Decode uppercases internally
|
||||
expect($this->service->verify(strtolower($secret), $code))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles a secret with trailing padding characters', function () {
|
||||
$secret = 'JBSWY3DPEHPK3PXP====';
|
||||
$now = time();
|
||||
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, $secret);
|
||||
$code = $reflection->invoke($this->service, $secretBytes, $now);
|
||||
|
||||
expect($this->service->verify($secret, $code))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not throw on an empty secret', function () {
|
||||
// An empty secret decodes to empty bytes; the HMAC will still compute,
|
||||
// but the code won't match a random guess
|
||||
expect($this->service->verify('', '000000'))->toBeBool();
|
||||
});
|
||||
|
||||
it('silently skips invalid base32 characters (digits 0, 1, 8, 9)', function () {
|
||||
// Characters 0, 1, 8, 9 are not in the base32 alphabet.
|
||||
// base32Decode skips them, so 'JBSWY3DP01890189' decodes
|
||||
// to fewer bytes than expected but should not throw.
|
||||
expect($this->service->verify('JBSWY3DP01890189', '123456'))->toBeBool();
|
||||
});
|
||||
|
||||
it('does not throw on a secret containing only invalid characters', function () {
|
||||
expect($this->service->verify('01890189', '123456'))->toBeBool();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify() — replay and reuse scenarios', function () {
|
||||
it('accepts the same valid code twice (no built-in replay protection)', function () {
|
||||
// TotpService itself has no state — replay prevention is the caller's job.
|
||||
// This test documents that behaviour explicitly.
|
||||
$secret = 'JBSWY3DPEHPK3PXP';
|
||||
$now = time();
|
||||
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, $secret);
|
||||
$code = $reflection->invoke($this->service, $secretBytes, $now);
|
||||
|
||||
expect($this->service->verify($secret, $code))->toBeTrue();
|
||||
expect($this->service->verify($secret, $code))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects a code generated for a different secret', function () {
|
||||
$secretA = 'JBSWY3DPEHPK3PXP';
|
||||
$secretB = 'KRSXG5CTMVRXEZLU';
|
||||
$now = time();
|
||||
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytesA = $decodeMethod->invoke($this->service, $secretA);
|
||||
$codeA = $reflection->invoke($this->service, $secretBytesA, $now);
|
||||
|
||||
expect($this->service->verify($secretB, $codeA))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('base32 encode/decode round-trip', function () {
|
||||
it('round-trips arbitrary binary data', function () {
|
||||
$encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode');
|
||||
$encodeMethod->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
// Test with the exact byte length used for secrets (20 bytes)
|
||||
$original = random_bytes(20);
|
||||
$encoded = $encodeMethod->invoke($this->service, $original);
|
||||
$decoded = $decodeMethod->invoke($this->service, $encoded);
|
||||
|
||||
expect($decoded)->toBe($original);
|
||||
});
|
||||
|
||||
it('round-trips a known test vector', function () {
|
||||
// RFC 4648 test vector: "Hello!" => "JBSWY3DPEE======"
|
||||
$encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode');
|
||||
$encodeMethod->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$encoded = $encodeMethod->invoke($this->service, 'Hello!');
|
||||
$decoded = $decodeMethod->invoke($this->service, $encoded);
|
||||
|
||||
expect($decoded)->toBe('Hello!');
|
||||
});
|
||||
|
||||
it('round-trips empty input', function () {
|
||||
$encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode');
|
||||
$encodeMethod->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$encoded = $encodeMethod->invoke($this->service, '');
|
||||
$decoded = $decodeMethod->invoke($this->service, $encoded);
|
||||
|
||||
expect($decoded)->toBe('');
|
||||
});
|
||||
|
||||
it('round-trips single byte', function () {
|
||||
$encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode');
|
||||
$encodeMethod->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$encoded = $encodeMethod->invoke($this->service, "\x00");
|
||||
$decoded = $decodeMethod->invoke($this->service, $encoded);
|
||||
|
||||
expect($decoded)->toBe("\x00");
|
||||
});
|
||||
|
||||
it('round-trips all-ones byte', function () {
|
||||
$encodeMethod = new ReflectionMethod(TotpService::class, 'base32Encode');
|
||||
$encodeMethod->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$encoded = $encodeMethod->invoke($this->service, "\xFF");
|
||||
$decoded = $decodeMethod->invoke($this->service, $encoded);
|
||||
|
||||
expect($decoded)->toBe("\xFF");
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCode() — RFC 6238 conformance', function () {
|
||||
it('produces a 6-digit zero-padded string', function () {
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP');
|
||||
|
||||
// Test across several timestamps
|
||||
foreach ([0, 1, 1000000000, 2000000000] as $ts) {
|
||||
$code = $reflection->invoke($this->service, $secretBytes, $ts);
|
||||
expect(strlen($code))->toBe(6)
|
||||
->and($code)->toMatch('/^[0-9]{6}$/');
|
||||
}
|
||||
});
|
||||
|
||||
it('generates deterministic output for the same secret and timestamp', function () {
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP');
|
||||
$timestamp = 1234567890;
|
||||
|
||||
$code1 = $reflection->invoke($this->service, $secretBytes, $timestamp);
|
||||
$code2 = $reflection->invoke($this->service, $secretBytes, $timestamp);
|
||||
|
||||
expect($code1)->toBe($code2);
|
||||
});
|
||||
|
||||
it('produces different codes for different time periods', function () {
|
||||
$reflection = new ReflectionMethod(TotpService::class, 'generateCode');
|
||||
$reflection->setAccessible(true);
|
||||
$decodeMethod = new ReflectionMethod(TotpService::class, 'base32Decode');
|
||||
$decodeMethod->setAccessible(true);
|
||||
|
||||
$secretBytes = $decodeMethod->invoke($this->service, 'JBSWY3DPEHPK3PXP');
|
||||
|
||||
// Codes for time periods that are far apart should differ
|
||||
// (there is a 1-in-1000000 chance of collision per pair, so we
|
||||
// test several to make a false pass astronomically unlikely)
|
||||
$codes = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$codes[] = $reflection->invoke($this->service, $secretBytes, $i * 30);
|
||||
}
|
||||
|
||||
// At least 9 out of 10 should be unique (allowing for one freak collision)
|
||||
expect(count(array_unique($codes)))->toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue