import { describe, expect, mock, test } from 'bun:test'; import type { OAuthTokens } from '../client/config.js'; import { areTokensExpired, calculateTokenExpiry, refreshTokensWithRetry, } from '../client/oauth-flow.js'; describe('oauth-flow utilities', () => { describe('areTokensExpired', () => { test('should return true for undefined tokens', () => { expect(areTokensExpired(undefined)).toBe(true); }); test('should return true when tokenExpiryTime is in the past', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 3600, }; const pastExpiryTime = Date.now() - 1000; expect(areTokensExpired(tokens, pastExpiryTime)).toBe(true); }); test('should return true when tokenExpiryTime is within buffer period', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 3600, }; // Expiry time 2 minutes in future (less than 5 minute buffer) const soonExpiryTime = Date.now() + 120 * 1000; expect(areTokensExpired(tokens, soonExpiryTime, 300)).toBe(true); }); test('should return false when tokenExpiryTime is beyond buffer period', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 3600, }; // Expiry time 10 minutes in future (beyond 5 minute buffer) const futureExpiryTime = Date.now() + 600 * 1000; expect(areTokensExpired(tokens, futureExpiryTime, 300)).toBe(false); }); test('should return false when no expiry info and no tokenExpiryTime', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', }; expect(areTokensExpired(tokens)).toBe(false); }); test('should return true when expires_in is less than buffer', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 100, // 100 seconds }; expect(areTokensExpired(tokens, undefined, 300)).toBe(true); }); test('should return false when expires_in is greater than buffer', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 600, // 600 seconds }; expect(areTokensExpired(tokens, undefined, 300)).toBe(false); }); test('should use custom buffer period', () => { const tokens: OAuthTokens = { access_token: 'test-token', token_type: 'Bearer', expires_in: 150, }; expect(areTokensExpired(tokens, undefined, 100)).toBe(false); expect(areTokensExpired(tokens, undefined, 200)).toBe(true); }); }); describe('calculateTokenExpiry', () => { test('should calculate correct expiry timestamp', () => { const expiresIn = 3600; // 1 hour const before = Date.now(); const expiry = calculateTokenExpiry(expiresIn); const after = Date.now(); const expectedMin = before + expiresIn * 1000; const expectedMax = after + expiresIn * 1000; expect(expiry).toBeGreaterThanOrEqual(expectedMin); expect(expiry).toBeLessThanOrEqual(expectedMax); }); test('should handle different expiry values', () => { const testCases = [60, 300, 3600, 7200]; for (const expiresIn of testCases) { const expiry = calculateTokenExpiry(expiresIn); const expectedApprox = Date.now() + expiresIn * 1000; // Allow 100ms tolerance expect(Math.abs(expiry - expectedApprox)).toBeLessThan(100); } }); }); describe('refreshTokensWithRetry', () => { test('should successfully refresh tokens on first attempt', async () => { const mockTokens: OAuthTokens = { access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600, refresh_token: 'new-refresh-token', }; const mockFetch = mock(() => Promise.resolve( new Response(JSON.stringify(mockTokens), { status: 200, headers: { 'Content-Type': 'application/json' }, }) ) ); const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret', }; const result = await refreshTokensWithRetry( 'https://auth.example.com', clientInfo, 'old-refresh-token', undefined, 3, 100, mockFetch as unknown as typeof fetch ); expect(result).toEqual(mockTokens); expect(mockFetch).toHaveBeenCalledTimes(1); }); test('should retry on failure and eventually succeed', async () => { const mockTokens: OAuthTokens = { access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600, refresh_token: 'refresh-token', // Include the refresh token that was sent }; let callCount = 0; const mockFetch = mock(() => { callCount++; if (callCount < 2) { return Promise.reject(new Error('Network error')); } return Promise.resolve( new Response(JSON.stringify(mockTokens), { status: 200, headers: { 'Content-Type': 'application/json' }, }) ); }); const clientInfo = { client_id: 'test-client-id' }; const result = await refreshTokensWithRetry( 'https://auth.example.com', clientInfo, 'refresh-token', undefined, 3, 10, // Short delay for testing mockFetch as unknown as typeof fetch ); expect(result).toEqual(mockTokens); expect(callCount).toBe(2); }); test('should throw error after max retries', async () => { const mockFetch = mock(() => Promise.reject(new Error('Network error'))); const clientInfo = { client_id: 'test-client-id' }; await expect( refreshTokensWithRetry( 'https://auth.example.com', clientInfo, 'refresh-token', undefined, 3, 10, mockFetch as unknown as typeof fetch ) ).rejects.toThrow('Token refresh failed after 3 attempts'); expect(mockFetch).toHaveBeenCalledTimes(3); }); test('should use exponential backoff for retries', async () => { const callTimes: number[] = []; const mockFetch = mock(() => { callTimes.push(Date.now()); return Promise.reject(new Error('Network error')); }); const clientInfo = { client_id: 'test-client-id' }; const baseDelay = 50; try { await refreshTokensWithRetry( 'https://auth.example.com', clientInfo, 'refresh-token', undefined, 3, baseDelay, mockFetch as unknown as typeof fetch ); } catch { // Expected to fail } // Should have made 3 attempts expect(callTimes.length).toBe(3); // Calculate delays between attempts const delay1 = callTimes[1]! - callTimes[0]!; const delay2 = callTimes[2]! - callTimes[1]!; // Verify exponential backoff (delays should increase) // First retry should be approximately baseDelay * 1 expect(delay1).toBeGreaterThanOrEqual(baseDelay); // Second retry should be approximately baseDelay * 2 and greater than first expect(delay2).toBeGreaterThan(delay1); expect(delay2).toBeGreaterThanOrEqual(baseDelay * 2); }); test('should pass custom addClientAuth function', async () => { const mockTokens: OAuthTokens = { access_token: 'new-token', token_type: 'Bearer', }; const mockFetch = mock(() => Promise.resolve( new Response(JSON.stringify(mockTokens), { status: 200 }) ) ); const mockAddClientAuth = mock(() => { // Mock implementation }); const clientInfo = { client_id: 'test-client-id' }; await refreshTokensWithRetry( 'https://auth.example.com', clientInfo, 'refresh-token', mockAddClientAuth, 3, 10, mockFetch as unknown as typeof fetch ); // The SDK's refreshAuthorization should have been called with addClientAuth expect(mockFetch).toHaveBeenCalled(); }); }); });