From 815a09be3418d256af718fe0a5ea82492913583e Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 27 Jan 2026 12:23:06 +0100 Subject: [PATCH] test(astrolabe): add unit tests for background token refresh - Fix McpTokenStorageTest: add IDBConnection mock for new constructor parameter - Add doctrine/dbal dev dependency for IQueryBuilder mock support - Add tests for getAllUsersWithTokens() database query method - Create RefreshUserTokensTest with comprehensive coverage: - Job interval configuration (15 min) - Token refresh threshold logic (50% lifetime) - issued_at tracking for accurate lifetime calculation - Fallback to default lifetime when issued_at missing - Token rotation handling - Error handling and logging Co-Authored-By: Claude Opus 4.5 --- third_party/astrolabe/composer.json | 1 + third_party/astrolabe/composer.lock | 304 +++++++++++- .../BackgroundJob/RefreshUserTokensTest.php | 435 ++++++++++++++++++ .../unit/Service/McpTokenStorageTest.php | 86 +++- 4 files changed, 818 insertions(+), 8 deletions(-) create mode 100644 third_party/astrolabe/tests/unit/BackgroundJob/RefreshUserTokensTest.php diff --git a/third_party/astrolabe/composer.json b/third_party/astrolabe/composer.json index d153cc0..91969c1 100644 --- a/third_party/astrolabe/composer.json +++ b/third_party/astrolabe/composer.json @@ -39,6 +39,7 @@ "php": "^8.1" }, "require-dev": { + "doctrine/dbal": "^3.8", "nextcloud/ocp": "dev-stable30", "phpunit/phpunit": "^10.0", "roave/security-advisories": "dev-latest" diff --git a/third_party/astrolabe/composer.lock b/third_party/astrolabe/composer.lock index 59c26e1..d45cb51 100644 --- a/third_party/astrolabe/composer.lock +++ b/third_party/astrolabe/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "94a9d7f7619235ef2a310deec2ce14f0", + "content-hash": "e6ea5a770c578a5d7694602bb2618cef", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -65,6 +65,259 @@ } ], "packages-dev": [ + { + "name": "doctrine/dbal", + "version": "3.10.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" + }, + "require-dev": { + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2025-11-29T10:46:08+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + }, + "time": "2025-04-07T20:06:18+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312", + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-17T22:40:21+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -775,6 +1028,55 @@ ], "time": "2025-12-06T07:50:42+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/third_party/astrolabe/tests/unit/BackgroundJob/RefreshUserTokensTest.php b/third_party/astrolabe/tests/unit/BackgroundJob/RefreshUserTokensTest.php new file mode 100644 index 0000000..62bd32a --- /dev/null +++ b/third_party/astrolabe/tests/unit/BackgroundJob/RefreshUserTokensTest.php @@ -0,0 +1,435 @@ +timeFactory = $this->createMock(ITimeFactory::class); + $this->tokenStorage = $this->createMock(McpTokenStorage::class); + $this->tokenRefresher = $this->createMock(IdpTokenRefresher::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->job = new RefreshUserTokens( + $this->timeFactory, + $this->tokenStorage, + $this->tokenRefresher, + $this->logger + ); + } + + // ========================================================================= + // Constructor Tests + // ========================================================================= + + public function testConstructorSetsInterval(): void { + // Use reflection to access the protected interval property + $reflection = new \ReflectionClass($this->job); + $property = $reflection->getProperty('interval'); + $property->setAccessible(true); + + $this->assertEquals(900, $property->getValue($this->job)); + } + + // ========================================================================= + // run() Method Tests + // ========================================================================= + + public function testRunWithNoUsers(): void { + $this->tokenStorage->method('getAllUsersWithTokens') + ->willReturn([]); + + $this->logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message) { + static $callCount = 0; + $callCount++; + if ($callCount === 1) { + $this->assertStringContainsString('Starting', $message); + } else { + $this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message); + } + }); + + $this->logger->expects($this->once()) + ->method('debug') + ->with($this->stringContains('Found 0 users')); + + // Call run() via reflection since it's protected + $this->invokeRun(); + } + + public function testRunWithMultipleUsersAndMixedResults(): void { + $this->tokenStorage->method('getAllUsersWithTokens') + ->willReturn(['alice', 'bob', 'charlie']); + + // Alice: token with plenty of time (skipped) + // Bob: token near expiry with refresh token (refreshed) + // Charlie: token near expiry without refresh token (failed) + $this->tokenStorage->method('getUserToken') + ->willReturnCallback(function (string $userId) { + $now = time(); + return match ($userId) { + 'alice' => [ + 'access_token' => 'alice-token', + 'refresh_token' => 'alice-refresh', + 'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime) + 'issued_at' => $now, + ], + 'bob' => [ + 'access_token' => 'bob-token', + 'refresh_token' => 'bob-refresh', + 'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime) + 'issued_at' => $now - 3500, + ], + 'charlie' => [ + 'access_token' => 'charlie-token', + // No refresh_token + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ], + default => null, + }; + }); + + // Bob's refresh should succeed + $this->tokenRefresher->method('refreshAccessToken') + ->with('bob-refresh') + ->willReturn([ + 'access_token' => 'bob-new-token', + 'refresh_token' => 'bob-new-refresh', + 'expires_in' => 3600, + ]); + + $this->tokenStorage->expects($this->once()) + ->method('storeUserToken') + ->with( + 'bob', + 'bob-new-token', + 'bob-new-refresh', + $this->anything(), + $this->anything() + ); + + $this->logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function (string $message) { + static $callCount = 0; + $callCount++; + if ($callCount === 2) { + $this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message); + } + }); + + $this->invokeRun(); + } + + // ========================================================================= + // refreshUserTokenIfNeeded() Tests + // ========================================================================= + + public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'valid-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => $now + 3600, // 1 hour remaining + 'issued_at' => $now, + ]); + + $this->tokenRefresher->expects($this->never()) + ->method('refreshAccessToken'); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('skipped', $result); + } + + public function testRefreshTriggeredWhenTokenNearExpiry(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'expiring-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s) + 'issued_at' => $now - 3300, // Issued 55 min ago + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->with('refresh-token') + ->willReturn([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + ]); + + $this->tokenStorage->expects($this->once()) + ->method('storeUserToken'); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('refreshed', $result); + } + + public function testRefreshFailsWhenNoRefreshToken(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'expiring-token', + // No refresh_token + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ]); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('no refresh token')); + + $this->tokenRefresher->expects($this->never()) + ->method('refreshAccessToken'); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('failed', $result); + } + + public function testRefreshFailsWhenRefresherReturnsNull(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'expiring-token', + 'refresh_token' => 'invalid-refresh', + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->with('invalid-refresh') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Refresh returned null')); + + // Should NOT delete token - let on-demand refresh handle cleanup + $this->tokenStorage->expects($this->never()) + ->method('deleteUserToken'); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('failed', $result); + } + + public function testRefreshUsesIssuedAtForLifetimeCalculation(): void { + $now = time(); + // Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min) + // 10/60 = 16.7% remaining, which is < 50%, so should refresh + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => $now + 600, // 10 min remaining + 'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 3600, + ]); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('refreshed', $result); + } + + public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void { + $now = time(); + // Token without issued_at, uses default 3600s lifetime + // 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => $now + 300, // 5 min remaining + // No issued_at + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 3600, + ]); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('refreshed', $result); + } + + public function testRefreshStoresNewTokenWithIssuedAt(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'old-token', + 'refresh_token' => 'old-refresh', + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-refresh', + 'expires_in' => 3600, + ]); + + // Verify storeUserToken is called with issued_at parameter + $this->tokenStorage->expects($this->once()) + ->method('storeUserToken') + ->with( + 'testuser', + 'new-token', + 'new-refresh', + $this->greaterThan($now), // expires_at = now + 3600 + $this->greaterThanOrEqual($now) // issued_at = now + ); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('refreshed', $result); + } + + public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'old-token', + 'refresh_token' => 'original-refresh', + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ]); + + // IdP returns new access token but no new refresh token (no rotation) + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->willReturn([ + 'access_token' => 'new-token', + // No refresh_token in response + 'expires_in' => 3600, + ]); + + // Should use the original refresh token + $this->tokenStorage->expects($this->once()) + ->method('storeUserToken') + ->with( + 'testuser', + 'new-token', + 'original-refresh', // Original refresh token preserved + $this->anything(), + $this->anything() + ); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('refreshed', $result); + } + + public function testRefreshHandlesException(): void { + $now = time(); + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn([ + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => $now + 100, + 'issued_at' => $now - 3500, + ]); + + $this->tokenRefresher->expects($this->once()) + ->method('refreshAccessToken') + ->willThrowException(new \Exception('Network error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Failed to refresh')); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('failed', $result); + } + + public function testRefreshSkippedWhenNoToken(): void { + $this->tokenStorage->method('getUserToken') + ->with('testuser') + ->willReturn(null); + + $this->tokenRefresher->expects($this->never()) + ->method('refreshAccessToken'); + + $result = $this->invokeRefreshUserTokenIfNeeded('testuser'); + + $this->assertEquals('skipped', $result); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Invoke the protected run() method. + */ + private function invokeRun(): void { + $reflection = new \ReflectionClass($this->job); + $method = $reflection->getMethod('run'); + $method->setAccessible(true); + $method->invoke($this->job, null); + } + + /** + * Invoke the private refreshUserTokenIfNeeded() method. + */ + private function invokeRefreshUserTokenIfNeeded(string $userId): string { + $reflection = new \ReflectionClass($this->job); + $method = $reflection->getMethod('refreshUserTokenIfNeeded'); + $method->setAccessible(true); + return $method->invoke($this->job, $userId); + } +} diff --git a/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php b/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php index 6137241..9688516 100644 --- a/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php +++ b/third_party/astrolabe/tests/unit/Service/McpTokenStorageTest.php @@ -5,7 +5,11 @@ declare(strict_types=1); namespace OCA\Astrolabe\Tests\Unit\Service; use OCA\Astrolabe\Service\McpTokenStorage; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IExpressionBuilder; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IConfig; +use OCP\IDBConnection; use OCP\Security\ICrypto; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,6 +23,7 @@ use Psr\Log\LoggerInterface; final class McpTokenStorageTest extends TestCase { private IConfig&MockObject $config; private ICrypto&MockObject $crypto; + private IDBConnection&MockObject $db; private LoggerInterface&MockObject $logger; private McpTokenStorage $storage; @@ -27,11 +32,13 @@ final class McpTokenStorageTest extends TestCase { $this->config = $this->createMock(IConfig::class); $this->crypto = $this->createMock(ICrypto::class); + $this->db = $this->createMock(IDBConnection::class); $this->logger = $this->createMock(LoggerInterface::class); $this->storage = new McpTokenStorage( $this->config, $this->crypto, + $this->db, $this->logger ); } @@ -46,15 +53,15 @@ final class McpTokenStorageTest extends TestCase { $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)) + ->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()) @@ -524,4 +531,69 @@ final class McpTokenStorageTest extends TestCase { $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); + } }