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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Service;
|
||||
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\IConfig;
|
||||
use OCP\Security\ICrypto;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for McpTokenStorage.
|
||||
*
|
||||
* Tests OAuth token storage and app password functionality for multi-user basic auth.
|
||||
*/
|
||||
final class McpTokenStorageTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private ICrypto&MockObject $crypto;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private McpTokenStorage $storage;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user