timeFactory = $this->createMock(ITimeFactory::class); $this->tokenStorage = $this->createMock(McpTokenStorage::class); $this->tokenRefresher = $this->createMock(IdpTokenRefresher::class); $this->logger = $this->createMock(LoggerInterface::class); $this->job = new RefreshUserTokens( $this->timeFactory, $this->tokenStorage, $this->tokenRefresher, $this->logger ); } /** * Set up default withTokenLock behavior that executes the callback. * Call this in tests that need the lock to succeed. */ private function setupDefaultLockBehavior(): void { $this->tokenStorage->method('withTokenLock') ->willReturnCallback(fn ($userId, $callback) => $callback()); } // ========================================================================= // Constructor Tests // ========================================================================= public function testConstructorSetsInterval(): void { // Use reflection to access the protected interval property $reflection = new \ReflectionClass($this->job); $property = $reflection->getProperty('interval'); $property->setAccessible(true); $this->assertEquals(900, $property->getValue($this->job)); } // ========================================================================= // run() Method Tests // ========================================================================= public function testRunWithNoUsers(): void { $this->tokenStorage->method('getAllUsersWithTokens') ->willReturn([]); $this->logger->expects($this->exactly(2)) ->method('info') ->willReturnCallback(function (string $message) { static $callCount = 0; $callCount++; if ($callCount === 1) { $this->assertStringContainsString('Starting', $message); } else { $this->assertStringContainsString('total=0', $message); $this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message); } }); // Call run() via reflection since it's protected $this->invokeRun(); } public function testRunWithMultipleUsersAndMixedResults(): void { $this->setupDefaultLockBehavior(); $this->tokenStorage->method('getAllUsersWithTokens') ->willReturn(['alice', 'bob', 'charlie']); // Alice: token with plenty of time (skipped) // Bob: token near expiry with refresh token (refreshed) // Charlie: token near expiry without refresh token (failed) $this->tokenStorage->method('getUserToken') ->willReturnCallback(function (string $userId) { $now = time(); return match ($userId) { 'alice' => [ 'access_token' => 'alice-token', 'refresh_token' => 'alice-refresh', 'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime) 'issued_at' => $now, ], 'bob' => [ 'access_token' => 'bob-token', 'refresh_token' => 'bob-refresh', 'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime) 'issued_at' => $now - 3500, ], 'charlie' => [ 'access_token' => 'charlie-token', // No refresh_token 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ], default => null, }; }); // Bob's refresh should succeed $this->tokenRefresher->method('refreshAccessToken') ->with('bob-refresh') ->willReturn([ 'access_token' => 'bob-new-token', 'refresh_token' => 'bob-new-refresh', 'expires_in' => 3600, ]); $this->tokenStorage->expects($this->once()) ->method('storeUserToken') ->with( 'bob', 'bob-new-token', 'bob-new-refresh', $this->anything(), $this->anything() ); $this->logger->expects($this->exactly(2)) ->method('info') ->willReturnCallback(function (string $message) { static $callCount = 0; $callCount++; if ($callCount === 2) { $this->assertStringContainsString('total=3', $message); $this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message); } }); $this->invokeRun(); } public function testRunProcessesUsersInBatches(): void { $this->setupDefaultLockBehavior(); // Simulate 150 users processed in 2 batches (100 + 50) $batch1 = array_map(fn ($i) => "user{$i}", range(1, 100)); $batch2 = array_map(fn ($i) => "user{$i}", range(101, 150)); $callCount = 0; $this->tokenStorage->method('getAllUsersWithTokens') ->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) { $callCount++; // First call: offset 0, return 100 users (full batch) if ($offset === 0) { $this->assertEquals(100, $limit); return $batch1; } // Second call: offset 100, return 50 users (partial batch = last) if ($offset === 100) { $this->assertEquals(100, $limit); return $batch2; } // Should not be called again $this->fail("Unexpected getAllUsersWithTokens call with offset $offset"); }); // All tokens have plenty of time (all skipped) $this->tokenStorage->method('getUserToken') ->willReturnCallback(function (string $userId) { $now = time(); return [ 'access_token' => "{$userId}-token", 'refresh_token' => "{$userId}-refresh", 'expires_at' => $now + 3600, 'issued_at' => $now, ]; }); $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $this->logger->expects($this->exactly(2)) ->method('info') ->willReturnCallback(function (string $message) { static $infoCallCount = 0; $infoCallCount++; if ($infoCallCount === 2) { $this->assertStringContainsString('total=150', $message); $this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message); } }); $this->invokeRun(); // Verify getAllUsersWithTokens was called exactly twice (2 batches) $this->assertEquals(2, $callCount); } // ========================================================================= // refreshUserTokenIfNeeded() Tests // ========================================================================= public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void { $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'valid-token', 'refresh_token' => 'refresh-token', 'expires_at' => $now + 3600, // 1 hour remaining 'issued_at' => $now, ]); $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('skipped', $result); } public function testRefreshTriggeredWhenTokenNearExpiry(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'expiring-token', 'refresh_token' => 'refresh-token', 'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s) 'issued_at' => $now - 3300, // Issued 55 min ago ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->with('refresh-token') ->willReturn([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 3600, ]); $this->tokenStorage->expects($this->once()) ->method('storeUserToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshFailsWhenNoRefreshToken(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'expiring-token', // No refresh_token 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); $this->logger->expects($this->once()) ->method('warning') ->with($this->stringContains('no refresh token')); $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('failed', $result); } public function testRefreshFailsWhenRefresherReturnsNull(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'expiring-token', 'refresh_token' => 'invalid-refresh', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->with('invalid-refresh') ->willReturn(null); $this->logger->expects($this->once()) ->method('warning') ->with($this->stringContains('Refresh returned null')); // Should NOT delete token - let on-demand refresh handle cleanup $this->tokenStorage->expects($this->never()) ->method('deleteUserToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('failed', $result); } public function testRefreshUsesIssuedAtForLifetimeCalculation(): void { $this->setupDefaultLockBehavior(); $now = time(); // Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min) // 10/60 = 16.7% remaining, which is < 50%, so should refresh $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'token', 'refresh_token' => 'refresh', 'expires_at' => $now + 600, // 10 min remaining 'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->willReturn([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600, ]); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void { $this->setupDefaultLockBehavior(); $now = time(); // Token without issued_at, uses default 3600s lifetime // 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'token', 'refresh_token' => 'refresh', 'expires_at' => $now + 300, // 5 min remaining // No issued_at ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->willReturn([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600, ]); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshStoresNewTokenWithIssuedAt(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'old-token', 'refresh_token' => 'old-refresh', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->willReturn([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600, ]); // Verify storeUserToken is called with issued_at parameter $this->tokenStorage->expects($this->once()) ->method('storeUserToken') ->with( 'testuser', 'new-token', 'new-refresh', $this->greaterThan($now), // expires_at = now + 3600 $this->greaterThanOrEqual($now) // issued_at = now ); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'old-token', 'refresh_token' => 'original-refresh', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); // IdP returns new access token but no new refresh token (no rotation) $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->willReturn([ 'access_token' => 'new-token', // No refresh_token in response 'expires_in' => 3600, ]); // Should use the original refresh token $this->tokenStorage->expects($this->once()) ->method('storeUserToken') ->with( 'testuser', 'new-token', 'original-refresh', // Original refresh token preserved $this->anything(), $this->anything() ); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshHandlesException(): void { $this->setupDefaultLockBehavior(); $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'token', 'refresh_token' => 'refresh', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->willThrowException(new \Exception('Network error')); $this->logger->expects($this->once()) ->method('error') ->with($this->stringContains('Failed to refresh')); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('failed', $result); } public function testRefreshSkippedWhenNoToken(): void { $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn(null); $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('skipped', $result); } // ========================================================================= // Locking Tests // ========================================================================= public function testRefreshSkippedWhenLockCannotBeAcquired(): void { $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'expiring-token', 'refresh_token' => 'refresh-token', 'expires_at' => $now + 100, // ~100s remaining (< 50% of default) 'issued_at' => $now - 3500, ]); // Lock acquisition fails (on-demand refresh is holding it) $this->tokenStorage->expects($this->once()) ->method('withTokenLock') ->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser')); // Token refresher should NOT be called when lock fails $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $this->logger->expects($this->once()) ->method('debug') ->with($this->stringContains('Lock held for user testuser')); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('skipped', $result); } public function testRefreshUsesLockForTokenRefresh(): void { $now = time(); $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturn([ 'access_token' => 'expiring-token', 'refresh_token' => 'refresh-token', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]); // withTokenLock is called and executes the callback $this->tokenStorage->expects($this->once()) ->method('withTokenLock') ->with('testuser', $this->isInstanceOf(\Closure::class)) ->willReturnCallback(function ($userId, $callback) { return $callback(); }); $this->tokenRefresher->expects($this->once()) ->method('refreshAccessToken') ->with('refresh-token') ->willReturn([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 3600, ]); $this->tokenStorage->expects($this->once()) ->method('storeUserToken'); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('refreshed', $result); } public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void { $now = time(); // First call (before lock): token is expiring // Calls inside lock callback: token is now fresh $callCount = 0; $this->tokenStorage->method('getUserToken') ->with('testuser') ->willReturnCallback(function () use (&$callCount, $now) { $callCount++; if ($callCount === 1) { // First check: token is expiring return [ 'access_token' => 'expiring-token', 'refresh_token' => 'refresh-token', 'expires_at' => $now + 100, 'issued_at' => $now - 3500, ]; } // Inside lock: token was already refreshed return [ 'access_token' => 'already-refreshed-token', 'refresh_token' => 'new-refresh-token', 'expires_at' => $now + 3600, // Fresh token 'issued_at' => $now, ]; }); // withTokenLock is called and executes the callback $this->tokenStorage->expects($this->once()) ->method('withTokenLock') ->willReturnCallback(function ($userId, $callback) { return $callback(); }); // Token refresher should NOT be called since token is already fresh $this->tokenRefresher->expects($this->never()) ->method('refreshAccessToken'); $this->logger->expects($this->once()) ->method('debug') ->with($this->stringContains('already refreshed')); $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); $this->assertEquals('skipped', $result); } // ========================================================================= // Helper Methods // ========================================================================= /** * Invoke the protected run() method. */ private function invokeRun(): void { $reflection = new \ReflectionClass($this->job); $method = $reflection->getMethod('run'); $method->setAccessible(true); $method->invoke($this->job, null); } /** * Invoke the private refreshUserTokenIfNeeded() method. */ private function invokeRefreshUserTokenIfNeeded(string $userId): string { $reflection = new \ReflectionClass($this->job); $method = $reflection->getMethod('refreshUserTokenIfNeeded'); $method->setAccessible(true); return $method->invoke($this->job, $userId); } }