randomGenerator = new SecureRandomGenerator(); $this->secretFactory = new MfaSecretFactory($this->randomGenerator); $this->totpProvider = new TotpProvider(timeStep: 30, digits: 6, windowSize: 1); $this->mfaService = new MfaService($this->totpProvider); }); describe('MfaSecretFactory', function () { it('generates valid base32 secret', function () { $secret = $this->secretFactory->generate(); expect($secret)->toBeInstanceOf(MfaSecret::class); expect($secret->value)->toMatch('/^[A-Z2-7]+$/'); expect(strlen($secret->value))->toBeGreaterThanOrEqual(32); }); it('generates secret with custom length', function () { $secret = $this->secretFactory->generateWithLength(32); // 32 bytes = 51-52 base32 characters (due to padding) expect(strlen($secret->value))->toBeGreaterThanOrEqual(51); }); it('throws exception for insufficient byte length', function () { $this->secretFactory->generateWithLength(10); })->throws(InvalidArgumentException::class, 'at least 16 bytes'); }); describe('MfaSecret', function () { it('validates base32 format', function () { $validSecret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; // Valid base32 (32+ chars) $secret = MfaSecret::fromString($validSecret); expect($secret->value)->toBe($validSecret); }); it('throws exception for invalid base32 characters', function () { MfaSecret::fromString('INVALID189'); // 1, 8, 9 not valid in base32 })->throws(InvalidArgumentException::class, 'base32 encoded'); it('throws exception for too short secret', function () { MfaSecret::fromString('JBSWY3DP'); // Only 8 characters })->throws(InvalidArgumentException::class, 'at least 32 characters'); it('generates QR code URI', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $uri = $secret->toQrCodeUri('MyApp', 'user@example.com'); expect($uri)->toStartWith('otpauth://totp/'); expect($uri)->toContain('MyApp:user%40example.com'); // URL-encoded email expect($uri)->toContain('secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); expect($uri)->toContain('issuer=MyApp'); }); it('masks secret for safe logging', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $masked = $secret->getMasked(); expect($masked)->toBe('JBSW****3PXP'); expect($masked)->not->toContain('EHPK3PXPJBSWY3DPEHPK'); }); }); describe('MfaChallenge', function () { it('creates valid challenge', function () { $challenge = MfaChallenge::create( challengeId: 'test-123', method: MfaMethod::TOTP, validitySeconds: 300, maxAttempts: 3 ); expect($challenge->challengeId)->toBe('test-123'); expect($challenge->method)->toBe(MfaMethod::TOTP); expect($challenge->attempts)->toBe(0); expect($challenge->maxAttempts)->toBe(3); expect($challenge->isValid())->toBeTrue(); }); it('detects expiration', function () { $now = new DateTimeImmutable(); $created = $now->modify('-10 seconds'); $expired = $now->modify('-5 seconds'); // Expired 5 seconds ago $challenge = new MfaChallenge( challengeId: 'test-123', method: MfaMethod::TOTP, createdAt: $created, expiresAt: $expired, attempts: 0, maxAttempts: 3 ); expect($challenge->isExpired())->toBeTrue(); expect($challenge->isValid())->toBeFalse(); }); it('tracks attempts correctly', function () { $challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, maxAttempts: 3); expect($challenge->attempts)->toBe(0); expect($challenge->hasAttemptsRemaining())->toBeTrue(); expect($challenge->getRemainingAttempts())->toBe(3); $challenge = $challenge->withIncrementedAttempts(); expect($challenge->attempts)->toBe(1); expect($challenge->getRemainingAttempts())->toBe(2); $challenge = $challenge->withIncrementedAttempts(); $challenge = $challenge->withIncrementedAttempts(); expect($challenge->attempts)->toBe(3); expect($challenge->hasAttemptsRemaining())->toBeFalse(); expect($challenge->isValid())->toBeFalse(); }); it('exports to array correctly', function () { $challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, validitySeconds: 300); $array = $challenge->toArray(); expect($array)->toHaveKey('challenge_id'); expect($array)->toHaveKey('method'); expect($array)->toHaveKey('method_display'); expect($array)->toHaveKey('remaining_attempts'); expect($array)->toHaveKey('seconds_until_expiry'); expect($array)->toHaveKey('is_valid'); expect($array['challenge_id'])->toBe('test-123'); expect($array['method'])->toBe('totp'); expect($array['is_valid'])->toBeTrue(); }); }); describe('TotpProvider', function () { it('generates challenge for TOTP', function () { $challenge = $this->totpProvider->generateChallenge(); expect($challenge)->toBeInstanceOf(MfaChallenge::class); expect($challenge->method)->toBe(MfaMethod::TOTP); expect($challenge->isValid())->toBeTrue(); }); it('verifies valid TOTP code', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $challenge = $this->totpProvider->generateChallenge(); // Generate code for current time window $currentTime = time(); $timeStep = (int) floor($currentTime / 30); // We'll use the provider's internal logic to generate a valid code // In real usage, the authenticator app would generate this $reflector = new ReflectionClass($this->totpProvider); $method = $reflector->getMethod('generateCodeForTimeStep'); $method->setAccessible(true); $expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep); $code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT)); $result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]); expect($result)->toBeTrue(); }); it('rejects invalid TOTP code', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $challenge = $this->totpProvider->generateChallenge(); $wrongCode = MfaCode::fromString('000000'); $result = $this->totpProvider->verify($challenge, $wrongCode, ['secret' => $secret]); expect($result)->toBeFalse(); }); it('accepts code within time window', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $challenge = $this->totpProvider->generateChallenge(); $currentTime = time(); $previousTimeStep = (int) floor($currentTime / 30) - 1; // One window before $reflector = new ReflectionClass($this->totpProvider); $method = $reflector->getMethod('generateCodeForTimeStep'); $method->setAccessible(true); $expectedCode = $method->invoke($this->totpProvider, $secret, $previousTimeStep); $code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT)); // Should accept code from previous window (windowSize = 1) $result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]); expect($result)->toBeTrue(); }); it('throws exception when secret is missing', function () { $challenge = $this->totpProvider->generateChallenge(); $code = MfaCode::fromString('123456'); $this->totpProvider->verify($challenge, $code, []); })->throws(InvalidArgumentException::class, 'requires secret'); it('indicates TOTP does not require server-side code generation', function () { expect($this->totpProvider->requiresCodeGeneration())->toBeFalse(); }); it('returns correct code validity period', function () { expect($this->totpProvider->getCodeValiditySeconds())->toBe(30); }); }); describe('MfaService', function () { it('generates challenge via service', function () { $challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP); expect($challenge)->toBeInstanceOf(MfaChallenge::class); expect($challenge->method)->toBe(MfaMethod::TOTP); }); it('verifies code via service', function () { $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP); $currentTime = time(); $timeStep = (int) floor($currentTime / 30); $reflector = new ReflectionClass($this->totpProvider); $method = $reflector->getMethod('generateCodeForTimeStep'); $method->setAccessible(true); $expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep); $code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT)); $result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]); expect($result)->toBeTrue(); }); it('rejects verification for expired challenge', function () { $now = new DateTimeImmutable(); $created = $now->modify('-10 seconds'); $expired = $now->modify('-5 seconds'); // Expired 5 seconds ago $challenge = new MfaChallenge( challengeId: 'test-123', method: MfaMethod::TOTP, createdAt: $created, expiresAt: $expired, attempts: 0, maxAttempts: 3 ); $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $code = MfaCode::fromString('123456'); $result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]); expect($result)->toBeFalse(); }); it('checks if method is supported', function () { expect($this->mfaService->supportsMethod(MfaMethod::TOTP))->toBeTrue(); expect($this->mfaService->supportsMethod(MfaMethod::SMS))->toBeFalse(); }); it('returns supported methods', function () { $methods = $this->mfaService->getSupportedMethods(); expect($methods)->toHaveCount(1); expect($methods[0])->toBe(MfaMethod::TOTP); }); it('throws exception for unsupported method', function () { $this->mfaService->generateChallenge(MfaMethod::SMS); })->throws(App\Framework\Mfa\Exceptions\MfaException::class, 'No MFA provider registered'); }); describe('TOTP RFC 6238 Compliance', function () { it('generates 6-digit codes by default', function () { $provider = new TotpProvider(); $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $reflector = new ReflectionClass($provider); $method = $reflector->getMethod('generateCodeForTimeStep'); $method->setAccessible(true); $code = $method->invoke($provider, $secret, 12345); $codeStr = str_pad((string) $code, 6, '0', STR_PAD_LEFT); expect(strlen($codeStr))->toBe(6); expect($codeStr)->toMatch('/^\d{6}$/'); }); it('supports 8-digit codes', function () { $provider = new TotpProvider(digits: 8); $secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'); $reflector = new ReflectionClass($provider); $method = $reflector->getMethod('generateCodeForTimeStep'); $method->setAccessible(true); $code = $method->invoke($provider, $secret, 12345); $codeStr = str_pad((string) $code, 8, '0', STR_PAD_LEFT); expect(strlen($codeStr))->toBe(8); }); it('uses 30-second time step by default', function () { $provider = new TotpProvider(); expect($provider->getCodeValiditySeconds())->toBe(30); }); it('validates constructor parameters', function () { expect(fn() => new TotpProvider(timeStep: 0)) ->toThrow(InvalidArgumentException::class, 'at least 1 second'); expect(fn() => new TotpProvider(digits: 5)) ->toThrow(InvalidArgumentException::class, 'between 6 and 8'); expect(fn() => new TotpProvider(digits: 9)) ->toThrow(InvalidArgumentException::class, 'between 6 and 8'); expect(fn() => new TotpProvider(windowSize: -1)) ->toThrow(InvalidArgumentException::class, 'cannot be negative'); }); });