From fef13a6d3d650812d4b18d8e6eaa3e8bf53efd10 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 17 Jan 2026 11:00:45 +0100 Subject: [PATCH] test(astrolabe): add comprehensive unit tests for token refresh and storage Add unit tests addressing reviewer feedback on test coverage gaps: IdpTokenRefresher::refreshAccessToken() tests: - Token refresh with internal Nextcloud OIDC - Token refresh with external IdP (Keycloak) - Error handling: missing client_secret, missing MCP URL - Error handling: invalid responses, HTTP exceptions - Token rotation validation (missing refresh_token in response) McpTokenStorage tests (multi-user basic auth): - OAuth token storage, retrieval, deletion - Token expiration checks with 60-second buffer - getAccessToken with automatic refresh callback - App password storage for background sync - hasBackgroundSyncAccess() for both OAuth and app passwords - Background sync type detection and timestamp tracking Test coverage: 41 tests, 76 assertions (up from 5 tests) Co-Authored-By: Claude Opus 4.5 --- .../unit/Service/IdpTokenRefresherTest.php | 358 +++++++++++- .../unit/Service/McpTokenStorageTest.php | 517 ++++++++++++++++++ 2 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php diff --git a/third_party/astrolabe/tests/unit/Service/IdpTokenRefresherTest.php b/third_party/astrolabe/tests/unit/Service/IdpTokenRefresherTest.php index 760586b..c39f8df 100644 --- a/third_party/astrolabe/tests/unit/Service/IdpTokenRefresherTest.php +++ b/third_party/astrolabe/tests/unit/Service/IdpTokenRefresherTest.php @@ -8,19 +8,21 @@ use OCA\Astrolabe\Service\IdpTokenRefresher; use OCA\Astrolabe\Service\McpServerClient; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; use OCP\IConfig; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; /** - * Unit tests for IdpTokenRefresher::getNextcloudBaseUrl(). + * Unit tests for IdpTokenRefresher. * - * Tests the internal URL resolution logic for OAuth token refresh requests. + * Tests the internal URL resolution logic and token refresh flows. */ final class IdpTokenRefresherTest extends TestCase { private IConfig&MockObject $config; private IClientService&MockObject $clientService; + private IClient&MockObject $httpClient; private LoggerInterface&MockObject $logger; private McpServerClient&MockObject $mcpServerClient; private IdpTokenRefresher $refresher; @@ -30,11 +32,11 @@ final class IdpTokenRefresherTest extends TestCase { $this->config = $this->createMock(IConfig::class); $this->clientService = $this->createMock(IClientService::class); + $this->httpClient = $this->createMock(IClient::class); $this->logger = $this->createMock(LoggerInterface::class); $this->mcpServerClient = $this->createMock(McpServerClient::class); - $mockClient = $this->createMock(IClient::class); - $this->clientService->method('newClient')->willReturn($mockClient); + $this->clientService->method('newClient')->willReturn($this->httpClient); $this->refresher = new IdpTokenRefresher( $this->config, @@ -44,6 +46,10 @@ final class IdpTokenRefresherTest extends TestCase { ); } + // ========================================================================= + // getNextcloudBaseUrl() tests + // ========================================================================= + /** * @dataProvider provideBaseUrlTestCases */ @@ -76,4 +82,348 @@ final class IdpTokenRefresherTest extends TestCase { 'https internal url' => ['https://internal.example.com', 'https://internal.example.com'], ]; } + + // ========================================================================= + // refreshAccessToken() tests + // ========================================================================= + + public function testRefreshAccessTokenFailsWithoutClientSecret(): void { + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', ''], + ]); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('no client secret configured')); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenFailsWithoutMcpServerUrl(): void { + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', ''], + ]); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Token refresh failed'), + $this->callback(fn ($ctx) => str_contains($ctx['error'], 'MCP server URL not configured')) + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenWithInternalNextcloudOidc(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ['astrolabe_internal_url', '', ''], + ]); + + $this->mcpServerClient->method('getClientId') + ->willReturn('test-client-id'); + + // Mock MCP server status response (no external IdP configured) + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn(json_encode([ + 'version' => '1.0.0', + 'auth_mode' => 'multi_user_oauth', + // No 'oidc.discovery_url' = use internal Nextcloud OIDC + ])); + + // Mock token endpoint response + $tokenResponse = $this->createMock(IResponse::class); + $tokenResponse->method('getBody') + ->willReturn(json_encode([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ])); + + // Setup HTTP client to return appropriate responses + $this->httpClient->method('get') + ->with('http://mcp-server:8000/api/v1/status') + ->willReturn($statusResponse); + + $this->httpClient->method('post') + ->with( + 'http://localhost/apps/oidc/token', + $this->callback(function ($options) { + // Verify the POST body contains expected parameters + $body = $options['body'] ?? ''; + return str_contains($body, 'grant_type=refresh_token') + && str_contains($body, 'client_id=test-client-id') + && str_contains($body, 'client_secret=test-secret') + && str_contains($body, 'refresh_token=test-refresh-token'); + }) + ) + ->willReturn($tokenResponse); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNotNull($result); + $this->assertEquals('new-access-token', $result['access_token']); + $this->assertEquals('new-refresh-token', $result['refresh_token']); + $this->assertEquals(3600, $result['expires_in']); + } + + public function testRefreshAccessTokenWithExternalIdp(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ]); + + $this->mcpServerClient->method('getClientId') + ->willReturn('test-client-id'); + + // Mock MCP server status response (external IdP configured) + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn(json_encode([ + 'version' => '1.0.0', + 'auth_mode' => 'multi_user_oauth', + 'oidc' => [ + 'discovery_url' => 'https://keycloak.example.com/realms/test/.well-known/openid-configuration', + ], + ])); + + // Mock OIDC discovery response + $discoveryResponse = $this->createMock(IResponse::class); + $discoveryResponse->method('getBody') + ->willReturn(json_encode([ + 'issuer' => 'https://keycloak.example.com/realms/test', + 'token_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/token', + 'authorization_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/auth', + ])); + + // Mock token endpoint response + $tokenResponse = $this->createMock(IResponse::class); + $tokenResponse->method('getBody') + ->willReturn(json_encode([ + 'access_token' => 'keycloak-access-token', + 'refresh_token' => 'keycloak-refresh-token', + 'expires_in' => 300, + 'token_type' => 'Bearer', + ])); + + // Setup HTTP client calls in order + $this->httpClient->method('get') + ->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) { + if (str_contains($url, 'status')) { + return $statusResponse; + } + if (str_contains($url, '.well-known/openid-configuration')) { + return $discoveryResponse; + } + throw new \Exception("Unexpected URL: $url"); + }); + + $this->httpClient->method('post') + ->with( + 'https://keycloak.example.com/realms/test/protocol/openid-connect/token', + $this->anything() + ) + ->willReturn($tokenResponse); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNotNull($result); + $this->assertEquals('keycloak-access-token', $result['access_token']); + $this->assertEquals('keycloak-refresh-token', $result['refresh_token']); + $this->assertEquals(300, $result['expires_in']); + } + + public function testRefreshAccessTokenFailsOnMissingRefreshTokenInResponse(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ['astrolabe_internal_url', '', ''], + ]); + + $this->mcpServerClient->method('getClientId') + ->willReturn('test-client-id'); + + // Mock MCP server status response + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn(json_encode(['version' => '1.0.0'])); + + // Mock token response WITHOUT refresh_token (token rotation failure) + $tokenResponse = $this->createMock(IResponse::class); + $tokenResponse->method('getBody') + ->willReturn(json_encode([ + 'access_token' => 'new-access-token', + // Missing refresh_token! + 'expires_in' => 3600, + ])); + + $this->httpClient->method('get')->willReturn($statusResponse); + $this->httpClient->method('post')->willReturn($tokenResponse); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('No refresh token in response'), + $this->anything() + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenHandlesHttpException(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ]); + + // HTTP client throws exception + $this->httpClient->method('get') + ->willThrowException(new \Exception('Connection refused')); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Token refresh failed'), + $this->callback(fn ($ctx) => str_contains($ctx['error'], 'Connection refused')) + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenHandlesInvalidStatusResponse(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ]); + + // Mock invalid JSON response + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn('not valid json'); + + $this->httpClient->method('get')->willReturn($statusResponse); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Token refresh failed'), + $this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid status response')) + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenHandlesInvalidDiscoveryResponse(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ]); + + $this->mcpServerClient->method('getClientId') + ->willReturn('test-client-id'); + + // Mock MCP server status response with external IdP + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn(json_encode([ + 'oidc' => [ + 'discovery_url' => 'https://keycloak.example.com/.well-known/openid-configuration', + ], + ])); + + // Mock invalid discovery response (missing token_endpoint) + $discoveryResponse = $this->createMock(IResponse::class); + $discoveryResponse->method('getBody') + ->willReturn(json_encode([ + 'issuer' => 'https://keycloak.example.com', + // Missing token_endpoint! + ])); + + $this->httpClient->method('get') + ->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) { + if (str_contains($url, 'status')) { + return $statusResponse; + } + return $discoveryResponse; + }); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Token refresh failed'), + $this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid OIDC discovery response')) + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } + + public function testRefreshAccessTokenHandlesInvalidTokenResponse(): void { + // Setup config + $this->config->method('getSystemValue') + ->willReturnMap([ + ['astrolabe_client_secret', '', 'test-secret'], + ['mcp_server_url', '', 'http://mcp-server:8000'], + ['astrolabe_internal_url', '', ''], + ]); + + $this->mcpServerClient->method('getClientId') + ->willReturn('test-client-id'); + + // Mock MCP server status response + $statusResponse = $this->createMock(IResponse::class); + $statusResponse->method('getBody') + ->willReturn(json_encode(['version' => '1.0.0'])); + + // Mock token response without access_token + $tokenResponse = $this->createMock(IResponse::class); + $tokenResponse->method('getBody') + ->willReturn(json_encode([ + 'error' => 'invalid_grant', + 'error_description' => 'Refresh token expired', + ])); + + $this->httpClient->method('get')->willReturn($statusResponse); + $this->httpClient->method('post')->willReturn($tokenResponse); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + $this->stringContains('Token refresh failed'), + $this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid token response')) + ); + + $result = $this->refresher->refreshAccessToken('test-refresh-token'); + + $this->assertNull($result); + } } diff --git a/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php b/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php new file mode 100644 index 0000000..7997131 --- /dev/null +++ b/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php @@ -0,0 +1,517 @@ +config = $this->createMock(IConfig::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->storage = new McpTokenStorage( + $this->config, + $this->crypto, + $this->logger + ); + } + + // ========================================================================= + // OAuth Token Storage Tests + // ========================================================================= + + public function testStoreUserToken(): void { + $userId = 'testuser'; + $accessToken = 'access-token-123'; + $refreshToken = 'refresh-token-456'; + $expiresAt = time() + 3600; + + $expectedTokenData = [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_at' => $expiresAt, + ]; + + $this->crypto->expects($this->once()) + ->method('encrypt') + ->with(json_encode($expectedTokenData)) + ->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 testGetAccessTokenReturnsNullWhenRefreshFails(): 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)); + + // Refresh callback returns null (failure) + $refreshCallback = fn (string $refreshToken) => null; + + $result = $this->storage->getAccessToken($userId, $refreshCallback); + + $this->assertNull($result); + } + + public function testGetAccessTokenReturnsNullWhenExpiredAndNoCallback(): 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)); + + // No refresh callback provided + $result = $this->storage->getAccessToken($userId, null); + + $this->assertNull($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); + } +}