config = $this->createMock(IConfig::class); $this->crypto = $this->createMock(ICrypto::class); $this->db = $this->createMock(IDBConnection::class); $this->logger = $this->createMock(LoggerInterface::class); $this->lockingProvider = $this->createMock(ILockingProvider::class); $this->storage = new McpTokenStorage( $this->config, $this->crypto, $this->db, $this->logger, $this->lockingProvider ); } // ========================================================================= // OAuth Token Storage Tests // ========================================================================= public function testStoreUserToken(): void { $userId = 'testuser'; $accessToken = 'access-token-123'; $refreshToken = 'refresh-token-456'; $expiresAt = time() + 3600; $this->crypto->expects($this->once()) ->method('encrypt') ->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) { $data = json_decode($json, true); return $data['access_token'] === $accessToken && $data['refresh_token'] === $refreshToken && $data['expires_at'] === $expiresAt && isset($data['issued_at']); // issued_at should be set (defaults to time()) })) ->willReturn('encrypted-data'); $this->config->expects($this->once()) ->method('setUserValue') ->with($userId, 'astrolabe', 'oauth_tokens', 'encrypted-data'); $this->storage->storeUserToken($userId, $accessToken, $refreshToken, $expiresAt); } public function testGetUserTokenReturnsTokenData(): void { $userId = 'testuser'; $tokenData = [ 'access_token' => 'access-token-123', 'refresh_token' => 'refresh-token-456', 'expires_at' => time() + 3600, ]; $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'oauth_tokens', '') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->with('encrypted-data') ->willReturn(json_encode($tokenData)); $result = $this->storage->getUserToken($userId); $this->assertEquals($tokenData, $result); } public function testGetUserTokenReturnsNullWhenNoTokenStored(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'oauth_tokens', '') ->willReturn(''); $result = $this->storage->getUserToken($userId); $this->assertNull($result); } public function testGetUserTokenReturnsNullOnDecryptionFailure(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willThrowException(new \Exception('Decryption failed')); $result = $this->storage->getUserToken($userId); $this->assertNull($result); } public function testDeleteUserToken(): void { $userId = 'testuser'; $this->config->expects($this->once()) ->method('deleteUserValue') ->with($userId, 'astrolabe', 'oauth_tokens'); $this->storage->deleteUserToken($userId); } // ========================================================================= // Token Expiration Tests // ========================================================================= public function testIsExpiredReturnsTrueWhenNoExpiresAt(): void { $token = ['access_token' => 'test']; $this->assertTrue($this->storage->isExpired($token)); } public function testIsExpiredReturnsTrueWhenExpired(): void { $token = [ 'access_token' => 'test', 'expires_at' => time() - 100, // Expired 100 seconds ago ]; $this->assertTrue($this->storage->isExpired($token)); } public function testIsExpiredReturnsTrueWhenAboutToExpire(): void { $token = [ 'access_token' => 'test', 'expires_at' => time() + 30, // Expires in 30 seconds (within 60s buffer) ]; $this->assertTrue($this->storage->isExpired($token)); } public function testIsExpiredReturnsFalseWhenValid(): void { $token = [ 'access_token' => 'test', 'expires_at' => time() + 3600, // Expires in 1 hour ]; $this->assertFalse($this->storage->isExpired($token)); } // ========================================================================= // getAccessToken with Refresh Callback Tests // ========================================================================= public function testGetAccessTokenReturnsNullWhenNoToken(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturn(''); $result = $this->storage->getAccessToken($userId); $this->assertNull($result); } public function testGetAccessTokenReturnsTokenWhenValid(): void { $userId = 'testuser'; $tokenData = [ 'access_token' => 'valid-access-token', 'refresh_token' => 'refresh-token', 'expires_at' => time() + 3600, // Valid for 1 hour ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($tokenData)); $result = $this->storage->getAccessToken($userId); $this->assertEquals('valid-access-token', $result); } public function testGetAccessTokenRefreshesExpiredToken(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; $newTokenData = [ 'access_token' => 'new-access-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 3600, ]; // First call returns expired token, subsequent calls for storing new token $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($expiredTokenData)); // Encrypt is called when storing the new token $this->crypto->method('encrypt') ->willReturn('new-encrypted-data'); $this->config->expects($this->once()) ->method('setUserValue') ->with($userId, 'astrolabe', 'oauth_tokens', 'new-encrypted-data'); // Refresh callback $refreshCallback = function (string $refreshToken) use ($newTokenData) { $this->assertEquals('old-refresh-token', $refreshToken); return $newTokenData; }; $result = $this->storage->getAccessToken($userId, $refreshCallback); $this->assertEquals('new-access-token', $result); } public function testGetAccessTokenReturnsNullWhenRefreshFailsAndDeletesToken(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($expiredTokenData)); // Expect stale token to be deleted when refresh fails $this->config->expects($this->once()) ->method('deleteUserValue') ->with($userId, 'astrolabe', 'oauth_tokens'); // Refresh callback returns null (failure) $refreshCallback = fn (string $refreshToken) => null; $result = $this->storage->getAccessToken($userId, $refreshCallback); $this->assertNull($result); } public function testGetAccessTokenReturnsNullWhenExpiredAndNoCallbackAndDeletesToken(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($expiredTokenData)); // Expect stale token to be deleted when expired with no callback $this->config->expects($this->once()) ->method('deleteUserValue') ->with($userId, 'astrolabe', 'oauth_tokens'); // No refresh callback provided $result = $this->storage->getAccessToken($userId, null); $this->assertNull($result); } // ========================================================================= // Token Refresh Locking Tests // ========================================================================= public function testGetAccessTokenAcquiresLockWhenRefreshing(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; $newTokenData = [ 'access_token' => 'new-access-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 3600, ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($expiredTokenData)); $this->crypto->method('encrypt') ->willReturn('new-encrypted-data'); // Verify lock is acquired and released $this->lockingProvider->expects($this->once()) ->method('acquireLock') ->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE); $this->lockingProvider->expects($this->once()) ->method('releaseLock') ->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE); $refreshCallback = fn (string $refreshToken) => $newTokenData; $result = $this->storage->getAccessToken($userId, $refreshCallback); $this->assertEquals('new-access-token', $result); } public function testGetAccessTokenReturnsStaleTokenOnLockedException(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($expiredTokenData)); // Lock acquisition fails $this->lockingProvider->expects($this->once()) ->method('acquireLock') ->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser')); // Refresh callback should NOT be called when lock fails $refreshCallbackCalled = false; $refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) { $refreshCallbackCalled = true; return ['access_token' => 'new-token', 'expires_in' => 3600]; }; $result = $this->storage->getAccessToken($userId, $refreshCallback); // Should return stale token instead of failing $this->assertEquals('expired-access-token', $result); $this->assertFalse($refreshCallbackCalled); } public function testGetAccessTokenSkipsRefreshWhenTokenAlreadyRefreshedWhileWaitingForLock(): void { $userId = 'testuser'; $expiredTokenData = [ 'access_token' => 'expired-access-token', 'refresh_token' => 'old-refresh-token', 'expires_at' => time() - 100, // Expired ]; // After lock is acquired, token appears fresh (another process refreshed it) $freshTokenData = [ 'access_token' => 'fresh-access-token', 'refresh_token' => 'fresh-refresh-token', 'expires_at' => time() + 3600, // Valid for 1 hour ]; $callCount = 0; $this->config->method('getUserValue') ->willReturn('encrypted-data'); // First call returns expired, subsequent calls return fresh $this->crypto->method('decrypt') ->willReturnCallback(function () use (&$callCount, $expiredTokenData, $freshTokenData) { $callCount++; return $callCount === 1 ? json_encode($expiredTokenData) : json_encode($freshTokenData); }); $this->lockingProvider->expects($this->once()) ->method('acquireLock'); $this->lockingProvider->expects($this->once()) ->method('releaseLock'); // Refresh callback should NOT be called since token is already fresh $refreshCallbackCalled = false; $refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) { $refreshCallbackCalled = true; return ['access_token' => 'new-token', 'expires_in' => 3600]; }; $result = $this->storage->getAccessToken($userId, $refreshCallback); $this->assertEquals('fresh-access-token', $result); $this->assertFalse($refreshCallbackCalled); } public function testGetAccessTokenNoLockRequiredWhenNotExpired(): void { $userId = 'testuser'; $validTokenData = [ 'access_token' => 'valid-access-token', 'refresh_token' => 'refresh-token', 'expires_at' => time() + 3600, // Valid for 1 hour ]; $this->config->method('getUserValue') ->willReturn('encrypted-data'); $this->crypto->method('decrypt') ->willReturn(json_encode($validTokenData)); // Lock should NOT be acquired for valid tokens $this->lockingProvider->expects($this->never()) ->method('acquireLock'); $this->lockingProvider->expects($this->never()) ->method('releaseLock'); $result = $this->storage->getAccessToken($userId); $this->assertEquals('valid-access-token', $result); } // ========================================================================= // App Password Storage Tests (Multi-User Basic Auth) // ========================================================================= public function testStoreBackgroundSyncPassword(): void { $userId = 'testuser'; $appPassword = 'app-password-secret'; $this->crypto->expects($this->once()) ->method('encrypt') ->with($appPassword) ->willReturn('encrypted-password'); // Expect three setUserValue calls: password, type, timestamp $this->config->expects($this->exactly(3)) ->method('setUserValue') ->willReturnCallback(function ($uid, $app, $key, $value) use ($userId) { $this->assertEquals($userId, $uid); $this->assertEquals('astrolabe', $app); $this->assertContains($key, [ 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at' ]); return null; }); $this->storage->storeBackgroundSyncPassword($userId, $appPassword); } public function testGetBackgroundSyncPasswordReturnsPassword(): void { $userId = 'testuser'; $appPassword = 'app-password-secret'; $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'background_sync_password', '') ->willReturn('encrypted-password'); $this->crypto->method('decrypt') ->with('encrypted-password') ->willReturn($appPassword); $result = $this->storage->getBackgroundSyncPassword($userId); $this->assertEquals($appPassword, $result); } public function testGetBackgroundSyncPasswordReturnsNullWhenNotSet(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'background_sync_password', '') ->willReturn(''); $result = $this->storage->getBackgroundSyncPassword($userId); $this->assertNull($result); } public function testGetBackgroundSyncPasswordReturnsNullOnDecryptionFailure(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturn('encrypted-password'); $this->crypto->method('decrypt') ->willThrowException(new \Exception('Decryption failed')); $result = $this->storage->getBackgroundSyncPassword($userId); $this->assertNull($result); } public function testDeleteBackgroundSyncPassword(): void { $userId = 'testuser'; // Expect three deleteUserValue calls $this->config->expects($this->exactly(3)) ->method('deleteUserValue') ->willReturnCallback(function ($uid, $app, $key) use ($userId) { $this->assertEquals($userId, $uid); $this->assertEquals('astrolabe', $app); $this->assertContains($key, [ 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at' ]); return null; }); $this->storage->deleteBackgroundSyncPassword($userId); } // ========================================================================= // Background Sync Access Check Tests // ========================================================================= public function testHasBackgroundSyncAccessReturnsTrueWithOAuthToken(): void { $userId = 'testuser'; $tokenData = [ 'access_token' => 'access-token', 'refresh_token' => 'refresh-token', 'expires_at' => time() + 3600, ]; $this->config->method('getUserValue') ->willReturnCallback(function ($uid, $app, $key, $default) use ($tokenData) { if ($key === 'oauth_tokens') { return 'encrypted-oauth-data'; } return $default; }); $this->crypto->method('decrypt') ->willReturn(json_encode($tokenData)); $result = $this->storage->hasBackgroundSyncAccess($userId); $this->assertTrue($result); } public function testHasBackgroundSyncAccessReturnsTrueWithAppPassword(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturnCallback(function ($uid, $app, $key, $default) { if ($key === 'oauth_tokens') { return ''; // No OAuth tokens } if ($key === 'background_sync_password') { return 'encrypted-password'; } return $default; }); $this->crypto->method('decrypt') ->willReturn('decrypted-app-password'); $result = $this->storage->hasBackgroundSyncAccess($userId); $this->assertTrue($result); } public function testHasBackgroundSyncAccessReturnsFalseWithNeither(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturn(''); // No tokens or passwords $result = $this->storage->hasBackgroundSyncAccess($userId); $this->assertFalse($result); } // ========================================================================= // Background Sync Type Tests // ========================================================================= public function testGetBackgroundSyncTypeReturnsAppPassword(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturnCallback(function ($uid, $app, $key, $default) { if ($key === 'background_sync_type') { return 'app_password'; } return $default; }); $result = $this->storage->getBackgroundSyncType($userId); $this->assertEquals('app_password', $result); } public function testGetBackgroundSyncTypeFallsBackToOAuth(): void { $userId = 'testuser'; $tokenData = [ 'access_token' => 'access-token', 'refresh_token' => 'refresh-token', 'expires_at' => time() + 3600, ]; $this->config->method('getUserValue') ->willReturnCallback(function ($uid, $app, $key, $default) { if ($key === 'background_sync_type') { return ''; // Type not explicitly set } if ($key === 'oauth_tokens') { return 'encrypted-oauth-data'; } return $default; }); $this->crypto->method('decrypt') ->willReturn(json_encode($tokenData)); $result = $this->storage->getBackgroundSyncType($userId); $this->assertEquals('oauth', $result); } public function testGetBackgroundSyncTypeReturnsNullWhenNotProvisioned(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->willReturn(''); $result = $this->storage->getBackgroundSyncType($userId); $this->assertNull($result); } // ========================================================================= // Background Sync Provisioned Timestamp Tests // ========================================================================= public function testGetBackgroundSyncProvisionedAtReturnsTimestamp(): void { $userId = 'testuser'; $timestamp = time(); $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'background_sync_provisioned_at', '') ->willReturn((string)$timestamp); $result = $this->storage->getBackgroundSyncProvisionedAt($userId); $this->assertEquals($timestamp, $result); } public function testGetBackgroundSyncProvisionedAtReturnsNullWhenNotSet(): void { $userId = 'testuser'; $this->config->method('getUserValue') ->with($userId, 'astrolabe', 'background_sync_provisioned_at', '') ->willReturn(''); $result = $this->storage->getBackgroundSyncProvisionedAt($userId); $this->assertNull($result); } // ========================================================================= // getAllUsersWithTokens Tests // ========================================================================= public function testGetAllUsersWithTokensReturnsUserIds(): void { $qb = $this->createMock(IQueryBuilder::class); $expr = $this->createMock(IExpressionBuilder::class); $result = $this->createMock(IResult::class); // Chain builder methods $qb->method('select')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('andWhere')->willReturnSelf(); $qb->method('expr')->willReturn($expr); $qb->method('createNamedParameter')->willReturnArgument(0); $qb->method('executeQuery')->willReturn($result); // Mock expression builder $expr->method('eq')->willReturn('mocked_condition'); // Mock result set with multiple users $result->method('fetch')->willReturnOnConsecutiveCalls( ['userid' => 'admin'], ['userid' => 'alice'], ['userid' => 'bob'], false // End of results ); $result->expects($this->once())->method('closeCursor'); $this->db->method('getQueryBuilder')->willReturn($qb); $userIds = $this->storage->getAllUsersWithTokens(); $this->assertEquals(['admin', 'alice', 'bob'], $userIds); } public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void { $qb = $this->createMock(IQueryBuilder::class); $expr = $this->createMock(IExpressionBuilder::class); $result = $this->createMock(IResult::class); // Chain builder methods $qb->method('select')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('andWhere')->willReturnSelf(); $qb->method('expr')->willReturn($expr); $qb->method('createNamedParameter')->willReturnArgument(0); $qb->method('executeQuery')->willReturn($result); // Mock expression builder $expr->method('eq')->willReturn('mocked_condition'); // Mock empty result set $result->method('fetch')->willReturn(false); $result->expects($this->once())->method('closeCursor'); $this->db->method('getQueryBuilder')->willReturn($qb); $userIds = $this->storage->getAllUsersWithTokens(); $this->assertEquals([], $userIds); } public function testGetAllUsersWithTokensWithLimitAndOffset(): void { $qb = $this->createMock(IQueryBuilder::class); $expr = $this->createMock(IExpressionBuilder::class); $result = $this->createMock(IResult::class); // Chain builder methods $qb->method('select')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('andWhere')->willReturnSelf(); $qb->method('expr')->willReturn($expr); $qb->method('createNamedParameter')->willReturnArgument(0); $qb->method('executeQuery')->willReturn($result); // Verify setMaxResults and setFirstResult are called with correct values $qb->expects($this->once()) ->method('setMaxResults') ->with(50) ->willReturnSelf(); $qb->expects($this->once()) ->method('setFirstResult') ->with(100) ->willReturnSelf(); // Mock expression builder $expr->method('eq')->willReturn('mocked_condition'); // Mock result set $result->method('fetch')->willReturnOnConsecutiveCalls( ['userid' => 'user1'], ['userid' => 'user2'], false ); $result->expects($this->once())->method('closeCursor'); $this->db->method('getQueryBuilder')->willReturn($qb); $userIds = $this->storage->getAllUsersWithTokens(50, 100); $this->assertEquals(['user1', 'user2'], $userIds); } public function testGetAllUsersWithTokensWithZeroLimitDoesNotSetMaxResults(): void { $qb = $this->createMock(IQueryBuilder::class); $expr = $this->createMock(IExpressionBuilder::class); $result = $this->createMock(IResult::class); // Chain builder methods $qb->method('select')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('andWhere')->willReturnSelf(); $qb->method('expr')->willReturn($expr); $qb->method('createNamedParameter')->willReturnArgument(0); $qb->method('executeQuery')->willReturn($result); // setMaxResults should NOT be called when limit is 0 $qb->expects($this->never()) ->method('setMaxResults'); // setFirstResult should NOT be called when offset is 0 $qb->expects($this->never()) ->method('setFirstResult'); // Mock expression builder $expr->method('eq')->willReturn('mocked_condition'); // Mock result set $result->method('fetch')->willReturn(false); $result->expects($this->once())->method('closeCursor'); $this->db->method('getQueryBuilder')->willReturn($qb); $this->storage->getAllUsersWithTokens(0, 0); } }