repository = $this->createMock(RefreshTokenRepository::class); $this->clock = new class implements Clock { public DateTimeImmutable $now; public function __construct() { $this->now = new DateTimeImmutable('2026-01-31 10:00:00'); } public function now(): DateTimeImmutable { return $this->now; } }; $this->manager = new RefreshTokenManager($this->repository, $this->clock); } #[Test] public function createGeneratesAndSavesNewToken(): void { $userId = UserId::generate(); $tenantId = TenantId::fromString(self::TENANT_ID); $fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'); $this->repository->expects(self::once()) ->method('save') ->with(self::isInstanceOf(RefreshToken::class)); $token = $this->manager->create($userId, $tenantId, $fingerprint); self::assertSame($userId, $token->userId); self::assertSame($tenantId, $token->tenantId); } #[Test] public function refreshThrowsForTokenNotFound(): void { $this->repository->method('find')->willReturn(null); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Token not found'); // Use a valid UUID format for the token ID $validUuid = '550e8400-e29b-41d4-a716-446655440099'; $this->manager->refresh( base64_encode($validUuid), DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'), ); } #[Test] public function refreshRotatesTokenAndReturnsNew(): void { $existingToken = $this->createExistingToken(isRotated: false); $tokenString = $existingToken->toTokenString(); $fingerprint = $existingToken->deviceFingerprint; $this->repository->method('find') ->willReturn($existingToken); $this->repository->expects(self::exactly(2)) ->method('save'); $newToken = $this->manager->refresh($tokenString, $fingerprint); self::assertNotEquals($existingToken->id, $newToken->id); self::assertEquals($existingToken->familyId, $newToken->familyId); } #[Test] public function refreshThrowsForExpiredToken(): void { $expiredToken = RefreshToken::create( UserId::generate(), TenantId::fromString(self::TENANT_ID), DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'), new DateTimeImmutable('2026-01-01 10:00:00'), // Issued long ago 3600, // 1 hour TTL - expired ); $this->repository->method('find')->willReturn($expiredToken); $this->repository->expects(self::once())->method('delete'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('expired'); $this->manager->refresh( $expiredToken->toTokenString(), $expiredToken->deviceFingerprint, ); } #[Test] public function refreshThrowsAndInvalidatesFamilyForWrongDevice(): void { $existingToken = $this->createExistingToken(); $differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1'); $this->repository->method('find')->willReturn($existingToken); $this->repository->expects(self::once()) ->method('invalidateFamily') ->with($existingToken->familyId); $this->expectException(TokenReplayDetectedException::class); $this->manager->refresh($existingToken->toTokenString(), $differentFingerprint); } #[Test] public function refreshThrowsAndInvalidatesFamilyForReplayAttack(): void { // Token rotaté il y a plus de 30 secondes (hors grace period) $rotatedToken = $this->createExistingToken( isRotated: true, issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'), rotatedAt: new DateTimeImmutable('2026-01-31 09:30:00'), // Rotaté 30 min avant "now" ); $this->repository->method('find')->willReturn($rotatedToken); $this->repository->expects(self::once()) ->method('invalidateFamily') ->with($rotatedToken->familyId); $this->expectException(TokenReplayDetectedException::class); $this->manager->refresh( $rotatedToken->toTokenString(), $rotatedToken->deviceFingerprint, ); } #[Test] public function refreshThrowsTokenAlreadyRotatedForGracePeriod(): void { // Token rotaté il y a 10 secondes (dans la grace period de 30s) $rotatedToken = $this->createExistingToken( isRotated: true, issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'), rotatedAt: new DateTimeImmutable('2026-01-31 09:59:50'), // Rotaté 10s avant "now" (10:00:00) ); $this->repository->method('find')->willReturn($rotatedToken); // Ne doit PAS invalider la famille $this->repository->expects(self::never())->method('invalidateFamily'); $this->expectException(TokenAlreadyRotatedException::class); $this->manager->refresh( $rotatedToken->toTokenString(), $rotatedToken->deviceFingerprint, ); } #[Test] public function revokeInvalidatesTokenFamily(): void { $existingToken = $this->createExistingToken(); $this->repository->method('find')->willReturn($existingToken); $this->repository->expects(self::once()) ->method('invalidateFamily') ->with($existingToken->familyId); $this->manager->revoke($existingToken->toTokenString()); } private function createExistingToken( bool $isRotated = false, ?DateTimeImmutable $issuedAt = null, ?DateTimeImmutable $rotatedAt = null, ): RefreshToken { $issuedAt ??= new DateTimeImmutable('2026-01-31 09:00:00'); return RefreshToken::reconstitute( id: RefreshTokenId::generate(), familyId: TokenFamilyId::generate(), userId: UserId::generate(), tenantId: TenantId::fromString(self::TENANT_ID), deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'), issuedAt: $issuedAt, expiresAt: $issuedAt->modify('+7 days'), rotatedFrom: null, isRotated: $isRotated, rotatedAt: $rotatedAt, ); } }