repository = new InMemoryOAuthTokenRepository(); $this->provider = Mockery::mock(OAuthProvider::class); $this->provider->allows('getName')->andReturn('test-provider'); $this->service = new OAuthService($this->repository, ['test-provider' => $this->provider]); }); afterEach(function () { Mockery::close(); }); describe('OAuthService - Token Retrieval', function () { it('retrieves valid non-expired token', function () { $validAccessToken = AccessToken::create('valid_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $oauthToken = new OAuthToken($validAccessToken, null, TokenType::BEARER, null); // Save token to repository $this->repository->saveForUser('user-123', 'test-provider', $oauthToken); $result = $this->service->getTokenForUser('user-123', 'test-provider'); expect($result->userId)->toBe('user-123'); expect($result->provider)->toBe('test-provider'); expect($result->isExpired())->toBeFalse(); }); it('automatically refreshes expired token when refresh token exists', function () { // Expired token with refresh token $expiredAccessToken = AccessToken::create('expired_access_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100))); $refreshToken = RefreshToken::create('refresh_1234567890'); $expiredOAuthToken = new OAuthToken($expiredAccessToken, $refreshToken, TokenType::BEARER, null); // Save expired token $this->repository->saveForUser('user-123', 'test-provider', $expiredOAuthToken); // New refreshed token from provider $newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $newRefreshToken = RefreshToken::create('new_refresh_1234567890'); $newOAuthToken = new OAuthToken($newAccessToken, $newRefreshToken, TokenType::BEARER, null); $this->provider->expects('refreshToken') ->once() ->andReturn($newOAuthToken); $result = $this->service->getTokenForUser('user-123', 'test-provider'); expect($result->token->accessToken->toString())->toBe('new_access_1234567890'); expect($result->isExpired())->toBeFalse(); }); it('throws exception for expired token without refresh token', function () { $expiredAccessToken = AccessToken::create('expired_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100))); $expiredOAuthToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null); $this->repository->saveForUser('user-123', 'test-provider', $expiredOAuthToken); $this->service->getTokenForUser('user-123', 'test-provider'); })->throws(FrameworkException::class); it('throws exception when token not found', function () { $this->service->getTokenForUser('user-123', 'test-provider'); })->throws(FrameworkException::class); }); describe('OAuthService - Explicit Token Refresh', function () { it('refreshes token successfully', function () { $oldAccessToken = AccessToken::create('old_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(100))); $refreshToken = RefreshToken::create('refresh_1234567890'); $oldOAuthToken = new OAuthToken($oldAccessToken, $refreshToken, TokenType::BEARER, null); $storedToken = $this->repository->saveForUser('user-123', 'test-provider', $oldOAuthToken); $newAccessToken = AccessToken::create('new_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))); $newRefreshToken = RefreshToken::create('new_refresh_1234567890'); $newOAuthToken = new OAuthToken($newAccessToken, $newRefreshToken, TokenType::BEARER, null); $this->provider->expects('refreshToken') ->once() ->andReturn($newOAuthToken); $result = $this->service->refreshToken($storedToken); expect($result->token->accessToken->toString())->toBe('new_access_1234567890'); }); it('handles provider refresh failures', function () { $oldAccessToken = AccessToken::create('old_access_1234567890', Timestamp::now()->add(Duration::fromSeconds(100))); $refreshToken = RefreshToken::create('refresh_1234567890'); $oldOAuthToken = new OAuthToken($oldAccessToken, $refreshToken, TokenType::BEARER, null); $storedToken = $this->repository->saveForUser('user-123', 'test-provider', $oldOAuthToken); $this->provider->expects('refreshToken') ->once() ->andThrow(new \RuntimeException('Provider refresh failed')); $this->service->refreshToken($storedToken); })->throws(\RuntimeException::class, 'Provider refresh failed'); }); describe('OAuthService - Batch Token Refresh', function () { it('refreshes expiring tokens in batch', function () { // Token 1: Expiring soon (200 seconds) with refresh token $accessToken1 = AccessToken::create('access1_1234567890', Timestamp::now()->add(Duration::fromSeconds(200))); $refreshToken1 = RefreshToken::create('refresh1_1234567890'); $oauthToken1 = new OAuthToken($accessToken1, $refreshToken1, TokenType::BEARER, null); $this->repository->saveForUser('user-1', 'test-provider', $oauthToken1); // Token 2: Expiring soon (150 seconds) with refresh token $accessToken2 = AccessToken::create('access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(150))); $refreshToken2 = RefreshToken::create('refresh2_1234567890'); $oauthToken2 = new OAuthToken($accessToken2, $refreshToken2, TokenType::BEARER, null); $this->repository->saveForUser('user-2', 'test-provider', $oauthToken2); // Mock refresh for both tokens $this->provider->allows('refreshToken') ->andReturnUsing(function ($token) { return new OAuthToken( AccessToken::create('new_' . $token->accessToken->toString(), Timestamp::now()->add(Duration::fromSeconds(3600))), RefreshToken::create('new_refresh_1234567890'), TokenType::BEARER, null ); }); $refreshedCount = $this->service->refreshExpiringTokens(300); expect($refreshedCount)->toBe(2); }); it('continues batch refresh even if some tokens fail', function () { // Token 1: Will fail to refresh $accessToken1 = AccessToken::create('access1_1234567890', Timestamp::now()->add(Duration::fromSeconds(200))); $refreshToken1 = RefreshToken::create('refresh1_1234567890'); $oauthToken1 = new OAuthToken($accessToken1, $refreshToken1, TokenType::BEARER, null); $this->repository->saveForUser('user-1', 'test-provider', $oauthToken1); // Token 2: Will succeed $accessToken2 = AccessToken::create('access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(150))); $refreshToken2 = RefreshToken::create('refresh2_1234567890'); $oauthToken2 = new OAuthToken($accessToken2, $refreshToken2, TokenType::BEARER, null); $this->repository->saveForUser('user-2', 'test-provider', $oauthToken2); // Mock to fail first, succeed second $callCount = 0; $this->provider->allows('refreshToken') ->andReturnUsing(function () use (&$callCount) { $callCount++; if ($callCount === 1) { throw new \RuntimeException('Provider error'); } return new OAuthToken( AccessToken::create('new_access2_1234567890', Timestamp::now()->add(Duration::fromSeconds(3600))), RefreshToken::create('new_refresh2_1234567890'), TokenType::BEARER, null ); }); $refreshedCount = $this->service->refreshExpiringTokens(300); expect($refreshedCount)->toBe(1); // Only 1 succeeded }); it('returns zero when no tokens are expiring', function () { $refreshedCount = $this->service->refreshExpiringTokens(300); expect($refreshedCount)->toBe(0); }); }); describe('OAuthService - Token Cleanup', function () { it('cleans up expired tokens without refresh capability', function () { // Create 5 expired tokens without refresh token for ($i = 1; $i <= 5; $i++) { $expiredAccessToken = AccessToken::create("expired_{$i}_1234567890", Timestamp::now()->subtract(Duration::fromSeconds(100))); $expiredToken = new OAuthToken($expiredAccessToken, null, TokenType::BEARER, null); $this->repository->saveForUser("user-{$i}", 'test-provider', $expiredToken); } // Create 1 expired token WITH refresh token (should not be deleted) $expiredWithRefresh = AccessToken::create('expired_with_refresh_1234567890', Timestamp::now()->subtract(Duration::fromSeconds(100))); $refreshToken = RefreshToken::create('refresh_1234567890'); $tokenWithRefresh = new OAuthToken($expiredWithRefresh, $refreshToken, TokenType::BEARER, null); $this->repository->saveForUser('user-6', 'test-provider', $tokenWithRefresh); $deletedCount = $this->service->cleanupExpiredTokens(); expect($deletedCount)->toBe(5); expect($this->repository->all())->toHaveCount(1); // Only token with refresh remains }); it('returns zero when no tokens to cleanup', function () { $deletedCount = $this->service->cleanupExpiredTokens(); expect($deletedCount)->toBe(0); }); }); describe('OAuthService - Provider Configuration', function () { it('finds configured provider', function () { $provider = $this->service->getProvider('test-provider'); expect($provider)->toBe($this->provider); }); it('throws exception for unconfigured provider', function () { $this->service->getProvider('unknown-provider'); })->throws(FrameworkException::class); });