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 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); } // ========================================================================= // 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); } }