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:
Claude 2026-03-24 13:36:25 +00:00
parent c51e4310b1
commit 5d8c68b2d8
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View 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);
});
});
});