From a505ece8607145c65333dd02443e325b72acb891 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Wed, 6 Aug 2025 14:48:34 +0200 Subject: [PATCH 1/2] Add OAuth2 FileTokenStorage and OAuth2Manager with tests - Implemented `FileTokenStorage` for managing OAuth2 token data using file storage, including methods for saving, loading, and clearing tokens. - Developed `OAuth2Manager` to handle OAuth2 authentication flow, including token retrieval and refresh logic. - Added comprehensive unit tests for both `FileTokenStorage` and `OAuth2Manager` to ensure functionality and error handling. - Introduced type definitions for `TokenData`, `TokenStorageProvider`, and `OAuth2Config` to enhance type safety and clarity. These changes establish a robust foundation for OAuth2 integration, improving token management and authentication processes in the application. --- .../services/OAuth2/FileTokenStorage.test.ts | 222 ++++++++++++ .../src/services/OAuth2/FileTokenStorage.ts | 81 +++++ .../src/services/OAuth2/OAuth2Manager.test.ts | 336 ++++++++++++++++++ .../main/src/services/OAuth2/OAuth2Manager.ts | 138 +++++++ workers/main/src/services/OAuth2/index.ts | 3 + .../main/src/services/OAuth2/types.test.ts | 157 ++++++++ workers/main/src/services/OAuth2/types.ts | 23 ++ 7 files changed, 960 insertions(+) create mode 100644 workers/main/src/services/OAuth2/FileTokenStorage.test.ts create mode 100644 workers/main/src/services/OAuth2/FileTokenStorage.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.test.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.ts create mode 100644 workers/main/src/services/OAuth2/index.ts create mode 100644 workers/main/src/services/OAuth2/types.test.ts create mode 100644 workers/main/src/services/OAuth2/types.ts diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts new file mode 100644 index 0000000..406fa58 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts @@ -0,0 +1,222 @@ +import { AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileUtilsError } from '../../common/errors'; +import * as fileUtils from '../../common/fileUtils'; +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('../../common/fileUtils'); +vi.mock('simple-oauth2'); + +describe('FileTokenStorage', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, + }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + describe('constructor', () => { + it('should create instance with default service name', () => { + const storage = new FileTokenStorage(undefined, mockOAuth2Client); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom service name', () => { + const storage = new FileTokenStorage('custom-service', mockOAuth2Client); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + }); + + describe('save', () => { + it('should save token data successfully', async () => { + const writeJsonFileSpy = vi + .spyOn(fileUtils, 'writeJsonFile') + .mockResolvedValue(); + + await fileTokenStorage.save(mockTokenData); + + expect(writeJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + mockTokenData, + ); + }); + + it('should throw FileUtilsError when save fails', async () => { + const error = new Error('Write failed'); + + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue(error); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); + }); + + it('should handle non-Error exceptions', async () => { + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue('String error'); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); + }); + }); + + describe('load', () => { + it('should load valid token data successfully', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); + + const result = await fileTokenStorage.load(); + + expect(result).toEqual(mockTokenData); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should return null when file does not exist', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockRejectedValue( + new Error('File not found'), + ); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is invalid', async () => { + const invalidTokenData = { invalid: 'data' }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is null', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(null); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is not an object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue('string data'); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is empty object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue({}); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + }); + + describe('clear', () => { + it('should clear token data successfully', async () => { + const deleteJsonFileSpy = vi + .spyOn(fileUtils, 'deleteJsonFile') + .mockResolvedValue(); + + await fileTokenStorage.clear(); + + expect(deleteJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + ); + }); + + it('should throw FileUtilsError when clear fails', async () => { + const error = new Error('Delete failed'); + + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue(error); + + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); + + it('should handle non-Error exceptions in clear', async () => { + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue('String error'); + + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); + }); + + describe('isValidTokenData', () => { + it('should validate correct token data structure', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); + + const result = await fileTokenStorage.load(); + + expect(result).toEqual(mockTokenData); + }); + + it('should reject token data with missing access_token', async () => { + const invalidTokenData = { + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should reject token data with missing refresh_token', async () => { + const invalidTokenData = { + access_token: 'test-access-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.ts b/workers/main/src/services/OAuth2/FileTokenStorage.ts new file mode 100644 index 0000000..1f315b0 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.ts @@ -0,0 +1,81 @@ +import { join } from 'path'; +import { AuthorizationCode } from 'simple-oauth2'; + +import { FileUtilsError } from '../../common/errors'; +import { + deleteJsonFile, + readJsonFile, + writeJsonFile, +} from '../../common/fileUtils'; +import { TokenData, TokenStorageProvider } from './types'; + +export class FileTokenStorage implements TokenStorageProvider { + private readonly tokenFilePath: string; + + constructor( + serviceName: string = 'qbo', + private oauth2Client: AuthorizationCode, + ) { + this.tokenFilePath = join( + process.cwd(), + 'data', + 'oauth2_tokens', + `${serviceName}.json`, + ); + } + + async save(tokenData: TokenData): Promise { + try { + await writeJsonFile(this.tokenFilePath, tokenData); + } catch (error) { + throw new FileUtilsError( + `Failed to save token data to file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async load(): Promise { + try { + const tokenData = await readJsonFile(this.tokenFilePath); + + if (!this.isValidTokenData(tokenData)) { + return null; + } + + return tokenData; + } catch { + return null; + } + } + + async clear(): Promise { + try { + await deleteJsonFile(this.tokenFilePath); + } catch (error) { + throw new FileUtilsError( + `Failed to clear token data from file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private isValidTokenData(data: unknown): data is TokenData { + if (!data || typeof data !== 'object') { + return false; + } + + const tokenData = data as Record; + + try { + this.oauth2Client.createToken({ + access_token: tokenData.access_token as string, + refresh_token: tokenData.refresh_token as string, + expires_at: new Date(tokenData.expires_at as number), + token_type: tokenData.token_type as string, + }); + + return true; + } catch { + return false; + } + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.test.ts new file mode 100644 index 0000000..722edd2 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.test.ts @@ -0,0 +1,336 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +describe('OAuth2Manager', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRefreshToken: ReturnType; + let mockRevokeAll: ReturnType; + let mockExpired: ReturnType; + + const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshToken = vi.fn(); + mockRevokeAll = vi.fn(); + mockExpired = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + revokeAll: mockRevokeAll, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: vi.fn(), + load: vi.fn(), + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: vi.fn().mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + describe('constructor', () => { + it('should create OAuth2Manager with correct configuration', () => { + expect(oauth2Manager).toBeInstanceOf(OAuth2Manager); + expect(AuthorizationCode).toHaveBeenCalledWith({ + client: { + id: mockOAuth2Config.clientId, + secret: mockOAuth2Config.clientSecret, + }, + auth: { + tokenHost: mockOAuth2Config.tokenHost, + tokenPath: mockOAuth2Config.tokenPath, + }, + http: { + json: 'strict', + headers: { + 'User-Agent': 'TemporalWorker/1.0', + }, + }, + }); + }); + + it('should create FileTokenStorage with service name', () => { + expect(FileTokenStorage).toHaveBeenCalledWith( + 'test-service', + mockOAuth2Client, + ); + }); + }); + + describe('getAccessToken', () => { + it('should return access token when token is valid and not expired', async () => { + mockExpired.mockReturnValue(false); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockStorage.load).toHaveBeenCalled(); + expect(mockOAuth2Client.createToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should refresh token when token is expired', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockStorage.save).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error when no token is available', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(null); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'No access token available', + ); + }); + + it('should handle concurrent refresh requests', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + const promises = [ + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + ]; + + const results = await Promise.all(promises); + + expect(results).toEqual([ + mockTokenData.access_token, + mockTokenData.access_token, + mockTokenData.access_token, + ]); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('refreshToken', () => { + it('should refresh token successfully', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + // First load the token + await oauth2Manager.getAccessToken(); + + await oauth2Manager.refreshToken(); + + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockStorage.save).toHaveBeenCalled(); + }); + + it('should handle concurrent refresh requests', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + // First load the token + await oauth2Manager.getAccessToken(); + + const promises = [ + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + ]; + + await Promise.all(promises); + + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('should throw OAuth2Error when no token to refresh', async () => { + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'No access token to refresh', + ); + }); + + it('should handle invalid_grant error by clearing tokens', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('invalid_grant')); + mockExpired.mockReturnValue(false); // Ensure token is not expired initially + + // Load token without triggering refresh + await oauth2Manager.getAccessToken(); + + // Now call refreshToken directly + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Invalid refresh token - tokens cleared', + ); + + expect(mockStorage.clear).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error for other refresh failures', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('Network error')); + + // First load the token + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Failed to refresh token: Network error', + ); + }); + }); + + describe('clearTokens', () => { + it('should clear tokens successfully', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + // First load the token + await oauth2Manager.getAccessToken(); + + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockStorage.clear).toHaveBeenCalled(); + }); + + it('should handle revoke failure gracefully', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockRevokeAll.mockRejectedValue(new Error('Revoke failed')); + + // First load the token + await oauth2Manager.getAccessToken(); + + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockStorage.clear).toHaveBeenCalled(); + }); + + it('should clear tokens even when no access token exists', async () => { + await oauth2Manager.clearTokens(); + + expect(mockStorage.clear).toHaveBeenCalled(); + }); + }); + + describe('ensureTokenLoaded', () => { + it('should load token from storage when not already loaded', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + + expect(mockStorage.load).toHaveBeenCalled(); + expect(mockOAuth2Client.createToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should not reload token when already loaded', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(false); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.getAccessToken(); + + expect(mockStorage.load).toHaveBeenCalledTimes(1); + }); + }); + + describe('saveToken', () => { + it('should save token with correct data structure', async () => { + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + + await oauth2Manager.getAccessToken(); + + expect(mockStorage.save).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: mockTokenData.expires_at, + token_type: mockTokenData.token_type, + }); + }); + + it('should use fallback values when token properties are missing', async () => { + const incompleteTokenData = { + access_token: 'test-access-token', + refresh_token: undefined, + expires_at: undefined, + token_type: undefined, + }; + + const incompleteAccessToken = { + token: incompleteTokenData, + refresh: mockRefreshToken, + revokeAll: mockRevokeAll, + expired: mockExpired, + } as unknown as AccessToken; + + mockRefreshToken.mockResolvedValue(incompleteAccessToken); + vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + + await oauth2Manager.getAccessToken(); + + expect(mockStorage.save).toHaveBeenCalledWith({ + access_token: 'test-access-token', + refresh_token: mockOAuth2Config.refreshToken, + expires_at: expect.any(Number), + token_type: 'Bearer', + }); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.ts b/workers/main/src/services/OAuth2/OAuth2Manager.ts new file mode 100644 index 0000000..b46a037 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.ts @@ -0,0 +1,138 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Config, TokenData } from './types'; + +export class OAuth2Manager { + private accessToken: AccessToken | null = null; + private refreshPromise: Promise | null = null; + private readonly storage: FileTokenStorage; + private readonly oauth2Client: AuthorizationCode; + + constructor( + serviceName: string, + private oauth2Config: OAuth2Config, + ) { + this.oauth2Client = new AuthorizationCode({ + client: { + id: oauth2Config.clientId, + secret: oauth2Config.clientSecret, + }, + auth: { + tokenHost: oauth2Config.tokenHost, + tokenPath: oauth2Config.tokenPath, + }, + http: { + json: 'strict', + headers: { + 'User-Agent': 'TemporalWorker/1.0', + }, + }, + }); + this.storage = new FileTokenStorage(serviceName, this.oauth2Client); + } + + async getAccessToken(): Promise { + await this.ensureTokenLoaded(); + + if (!this.accessToken) { + throw new OAuth2Error('No access token available'); + } + + const isExpired = this.accessToken.expired( + this.oauth2Config.tokenExpirationWindowSeconds, + ); + + if (isExpired) { + await this.refreshToken(); + } + + return this.accessToken.token.access_token as string; + } + + async refreshToken(): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + + return; + } + + this.refreshPromise = this.performRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + async clearTokens(): Promise { + if (this.accessToken) { + try { + await this.accessToken.revokeAll(); + } catch (error) { + // Log error but don't throw - token revocation failure shouldn't prevent clearing + console.error( + 'Failed to revoke tokens:', + error instanceof Error ? error.message : String(error), + ); + } + } + + this.accessToken = null; + this.refreshPromise = null; + await this.storage.clear(); + } + + private async ensureTokenLoaded(): Promise { + if (this.accessToken) return; + + const tokenData = await this.storage.load(); + + if (tokenData) { + this.accessToken = this.oauth2Client.createToken({ + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: new Date(tokenData.expires_at), + token_type: tokenData.token_type, + }); + } + } + + private async performRefresh(): Promise { + if (!this.accessToken) { + throw new OAuth2Error('No access token to refresh'); + } + + try { + this.accessToken = await this.accessToken.refresh(); + await this.saveToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.includes('invalid_grant')) { + await this.clearTokens(); + throw new OAuth2Error('Invalid refresh token - tokens cleared'); + } + + throw new OAuth2Error(`Failed to refresh token: ${message}`); + } + } + + private async saveToken(): Promise { + if (!this.accessToken) return; + + const tokenData: TokenData = { + access_token: this.accessToken.token.access_token as string, + refresh_token: + (this.accessToken.token.refresh_token as string) || + this.oauth2Config.refreshToken, + expires_at: + (this.accessToken.token.expires_at as Date)?.getTime() || + Date.now() + 3600000, + token_type: (this.accessToken.token.token_type as string) || 'Bearer', + }; + + await this.storage.save(tokenData); + } +} diff --git a/workers/main/src/services/OAuth2/index.ts b/workers/main/src/services/OAuth2/index.ts new file mode 100644 index 0000000..99b130e --- /dev/null +++ b/workers/main/src/services/OAuth2/index.ts @@ -0,0 +1,3 @@ +export { FileTokenStorage } from './FileTokenStorage'; +export { OAuth2Manager } from './OAuth2Manager'; +export * from './types'; diff --git a/workers/main/src/services/OAuth2/types.test.ts b/workers/main/src/services/OAuth2/types.test.ts new file mode 100644 index 0000000..ed3236c --- /dev/null +++ b/workers/main/src/services/OAuth2/types.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2Config, TokenData, TokenStorageProvider } from './types'; + +describe('OAuth2 Types', () => { + describe('TokenData', () => { + it('should have required properties', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + expect(tokenData.access_token).toBe('test-access-token'); + expect(tokenData.refresh_token).toBe('test-refresh-token'); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); + expect(tokenData.token_type).toBe('Bearer'); + }); + + it('should allow different token types', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Basic', + }; + + expect(tokenData.token_type).toBe('Basic'); + }); + + it('should handle future expiration times', () => { + const futureTime = Date.now() + 86400000; // 24 hours from now + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: futureTime, + token_type: 'Bearer', + }; + + expect(tokenData.expires_at).toBe(futureTime); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); + }); + }); + + describe('TokenStorageProvider', () => { + it('should define required methods', () => { + const mockStorage: TokenStorageProvider = { + save: async () => {}, + load: async () => null, + clear: async () => {}, + }; + + expect(typeof mockStorage.save).toBe('function'); + expect(typeof mockStorage.load).toBe('function'); + expect(typeof mockStorage.clear).toBe('function'); + }); + + it('should allow async operations', async () => { + const mockStorage: TokenStorageProvider = { + save: async (tokenData: TokenData) => { + expect(tokenData.access_token).toBe('test-token'); + }, + load: async () => ({ + access_token: 'test-token', + refresh_token: 'test-refresh', + expires_at: Date.now(), + token_type: 'Bearer', + }), + clear: async () => {}, + }; + + await mockStorage.save({ + access_token: 'test-token', + refresh_token: 'test-refresh', + expires_at: Date.now(), + token_type: 'Bearer', + }); + + const result = await mockStorage.load(); + + expect(result).not.toBeNull(); + expect(result?.access_token).toBe('test-token'); + }); + }); + + describe('OAuth2Config', () => { + it('should have required properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.clientSecret).toBe('test-client-secret'); + expect(config.refreshToken).toBe('test-refresh-token'); + expect(config.tokenHost).toBe('https://oauth.example.com'); + expect(config.tokenPath).toBe('/oauth/token'); + expect(config.tokenExpirationWindowSeconds).toBe(300); + }); + + it('should have optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + authorizeHost: 'https://auth.example.com', + authorizePath: '/oauth/authorize', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.authorizeHost).toBe('https://auth.example.com'); + expect(config.authorizePath).toBe('/oauth/authorize'); + }); + + it('should work without optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.authorizeHost).toBeUndefined(); + expect(config.authorizePath).toBeUndefined(); + }); + + it('should handle different expiration window values', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 0, + }; + + expect(config.tokenExpirationWindowSeconds).toBe(0); + + const configWithWindow: OAuth2Config = { + ...config, + tokenExpirationWindowSeconds: 600, + }; + + expect(configWithWindow.tokenExpirationWindowSeconds).toBe(600); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/types.ts b/workers/main/src/services/OAuth2/types.ts new file mode 100644 index 0000000..d45fd46 --- /dev/null +++ b/workers/main/src/services/OAuth2/types.ts @@ -0,0 +1,23 @@ +export interface TokenData { + access_token: string; + refresh_token: string; + expires_at: number; + token_type: string; +} + +export interface TokenStorageProvider { + save(tokenData: TokenData): Promise; + load(): Promise; + clear(): Promise; +} + +export interface OAuth2Config { + clientId: string; + clientSecret: string; + refreshToken: string; + tokenHost: string; + tokenPath: string; + authorizeHost?: string; + authorizePath?: string; + tokenExpirationWindowSeconds: number; +} From bff32da12057dd142b28d454f7c57561927ce118 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Thu, 7 Aug 2025 18:39:28 +0200 Subject: [PATCH 2/2] Add OAuth2Manager and related tests - Introduced `OAuth2Manager` to manage OAuth2 authentication flow, including token retrieval, refresh, and error handling. - Implemented `FileTokenStorage` for managing OAuth2 token data using file storage, with methods for saving, loading, and clearing tokens. - Added comprehensive unit tests for `OAuth2Manager`, covering scenarios for token retrieval, refresh, and error handling. - Enhanced type definitions for `TokenData`, `TokenStorageProvider`, and `OAuth2Config` to improve type safety and clarity. These changes establish a robust foundation for OAuth2 integration, enhancing token management and authentication processes in the application. --- workers/main/package-lock.json | 114 ++++++ workers/main/package.json | 2 + .../services/OAuth2/FileTokenStorage.test.ts | 329 ++++++++++------- .../OAuth2/OAuth2Manager.accessToken.test.ts | 155 ++++++++ .../OAuth2/OAuth2Manager.clear.test.ts | 113 ++++++ .../OAuth2/OAuth2Manager.constructor.test.ts | 71 ++++ .../OAuth2/OAuth2Manager.refresh.test.ts | 150 ++++++++ .../src/services/OAuth2/OAuth2Manager.test.ts | 336 ------------------ .../main/src/services/OAuth2/OAuth2Manager.ts | 31 +- .../OAuth2/OAuth2Manager.utils.test.ts | 173 +++++++++ .../main/src/services/OAuth2/types.test.ts | 260 +++++++------- 11 files changed, 1129 insertions(+), 605 deletions(-) create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts delete mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.test.ts create mode 100644 workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts diff --git a/workers/main/package-lock.json b/workers/main/package-lock.json index 035b520..fb3574b 100644 --- a/workers/main/package-lock.json +++ b/workers/main/package-lock.json @@ -18,12 +18,14 @@ "axios-retry": "4.5.0", "mongoose": "8.15.1", "mysql2": "3.14.1", + "simple-oauth2": "5.1.0", "zod": "3.25.17" }, "devDependencies": { "@eslint/js": "9.27.0", "@temporalio/testing": "1.11.8", "@types/node": "22.15.21", + "@types/simple-oauth2": "5.0.7", "@vitest/coverage-v8": "3.1.3", "c8": "10.1.3", "dotenv": "16.5.0", @@ -814,6 +816,53 @@ "node": ">=6" } }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@hapi/topo/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/wreck": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.0.tgz", + "integrity": "sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1445,6 +1494,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/@slack/logger": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", @@ -1951,6 +2027,13 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "node_modules/@types/simple-oauth2": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-5.0.7.tgz", + "integrity": "sha512-8JbWVJbiTSBQP/7eiyGKyXWAqp3dKQZpaA+pdW16FCi32ujkzRMG8JfjoAzdWt6W8U591ZNdHcPtP2D7ILTKuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -5501,6 +5584,25 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/joi/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -7001,6 +7103,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-oauth2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-5.1.0.tgz", + "integrity": "sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw==", + "license": "Apache-2.0", + "dependencies": { + "@hapi/hoek": "^11.0.4", + "@hapi/wreck": "^18.0.0", + "debug": "^4.3.4", + "joi": "^17.6.4" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", diff --git a/workers/main/package.json b/workers/main/package.json index e5afba2..e7df20b 100644 --- a/workers/main/package.json +++ b/workers/main/package.json @@ -11,6 +11,7 @@ "@eslint/js": "9.27.0", "@temporalio/testing": "1.11.8", "@types/node": "22.15.21", + "@types/simple-oauth2": "5.0.7", "@vitest/coverage-v8": "3.1.3", "c8": "10.1.3", "dotenv": "16.5.0", @@ -40,6 +41,7 @@ "axios-retry": "4.5.0", "mongoose": "8.15.1", "mysql2": "3.14.1", + "simple-oauth2": "5.1.0", "zod": "3.25.17" } } diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts index 406fa58..12f50ff 100644 --- a/workers/main/src/services/OAuth2/FileTokenStorage.test.ts +++ b/workers/main/src/services/OAuth2/FileTokenStorage.test.ts @@ -9,18 +9,17 @@ import { TokenData } from './types'; vi.mock('../../common/fileUtils'); vi.mock('simple-oauth2'); -describe('FileTokenStorage', () => { - let fileTokenStorage: FileTokenStorage; +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +describe('FileTokenStorage Constructor', () => { let mockOAuth2Client: AuthorizationCode; let mockCreateToken: ReturnType; - const mockTokenData: TokenData = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: Date.now() + 3600000, - token_type: 'Bearer', - }; - beforeEach(() => { vi.clearAllMocks(); @@ -31,192 +30,260 @@ describe('FileTokenStorage', () => { mockOAuth2Client = { createToken: mockCreateToken, } as unknown as AuthorizationCode; + }); - fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + it('should create instance with default service name', () => { + const storage = new FileTokenStorage(undefined, mockOAuth2Client); + + expect(storage).toBeInstanceOf(FileTokenStorage); }); - describe('constructor', () => { - it('should create instance with default service name', () => { - const storage = new FileTokenStorage(undefined, mockOAuth2Client); + it('should create instance with custom service name', () => { + const storage = new FileTokenStorage('custom-service', mockOAuth2Client); - expect(storage).toBeInstanceOf(FileTokenStorage); - }); + expect(storage).toBeInstanceOf(FileTokenStorage); + }); +}); + +describe('FileTokenStorage Save', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; - it('should create instance with custom service name', () => { - const storage = new FileTokenStorage('custom-service', mockOAuth2Client); + beforeEach(() => { + vi.clearAllMocks(); - expect(storage).toBeInstanceOf(FileTokenStorage); + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, }); + + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); }); - describe('save', () => { - it('should save token data successfully', async () => { - const writeJsonFileSpy = vi - .spyOn(fileUtils, 'writeJsonFile') - .mockResolvedValue(); + it('should save token data successfully', async () => { + const writeJsonFileSpy = vi + .spyOn(fileUtils, 'writeJsonFile') + .mockResolvedValue(); - await fileTokenStorage.save(mockTokenData); + await fileTokenStorage.save(mockTokenData); - expect(writeJsonFileSpy).toHaveBeenCalledWith( - expect.stringContaining('test-service.json'), - mockTokenData, - ); - }); + expect(writeJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + mockTokenData, + ); + }); - it('should throw FileUtilsError when save fails', async () => { - const error = new Error('Write failed'); + it('should throw FileUtilsError when save fails', async () => { + const error = new Error('Write failed'); - vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue(error); + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue(error); - await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( - FileUtilsError, - ); - }); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); + }); - it('should handle non-Error exceptions', async () => { - vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue('String error'); + it('should handle non-Error exceptions', async () => { + vi.spyOn(fileUtils, 'writeJsonFile').mockRejectedValue('String error'); - await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( - FileUtilsError, - ); - }); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + FileUtilsError, + ); }); +}); - describe('load', () => { - it('should load valid token data successfully', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); +describe('FileTokenStorage Load', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; - const result = await fileTokenStorage.load(); + beforeEach(() => { + vi.clearAllMocks(); - expect(result).toEqual(mockTokenData); - expect(mockCreateToken).toHaveBeenCalledWith({ - access_token: mockTokenData.access_token, - refresh_token: mockTokenData.refresh_token, - expires_at: new Date(mockTokenData.expires_at), - token_type: mockTokenData.token_type, - }); + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, }); - it('should return null when file does not exist', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockRejectedValue( - new Error('File not found'), - ); + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); + + it('should load valid token data successfully', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); - const result = await fileTokenStorage.load(); + const result = await fileTokenStorage.load(); - expect(result).toBeNull(); + expect(result).toEqual(mockTokenData); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, }); + }); + + it('should return null when file does not exist', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockRejectedValue( + new Error('File not found'), + ); - it('should return null when token data is invalid', async () => { - const invalidTokenData = { invalid: 'data' }; + const result = await fileTokenStorage.load(); - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); - mockCreateToken.mockImplementation(() => { - throw new Error('Invalid token'); - }); + expect(result).toBeNull(); + }); - const result = await fileTokenStorage.load(); + it('should return null when token data is invalid', async () => { + const invalidTokenData = { invalid: 'data' }; - expect(result).toBeNull(); + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); }); - it('should return null when token data is null', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(null); + const result = await fileTokenStorage.load(); - const result = await fileTokenStorage.load(); + expect(result).toBeNull(); + }); - expect(result).toBeNull(); - }); + it('should return null when token data is null', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(null); - it('should return null when token data is not an object', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue('string data'); + const result = await fileTokenStorage.load(); - const result = await fileTokenStorage.load(); + expect(result).toBeNull(); + }); - expect(result).toBeNull(); - }); + it('should return null when token data is not an object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue('string data'); - it('should return null when token data is empty object', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue({}); - mockCreateToken.mockImplementation(() => { - throw new Error('Invalid token'); - }); + const result = await fileTokenStorage.load(); - const result = await fileTokenStorage.load(); + expect(result).toBeNull(); + }); - expect(result).toBeNull(); + it('should return null when token data is empty object', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue({}); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); }); +}); - describe('clear', () => { - it('should clear token data successfully', async () => { - const deleteJsonFileSpy = vi - .spyOn(fileUtils, 'deleteJsonFile') - .mockResolvedValue(); +describe('FileTokenStorage Clear', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; - await fileTokenStorage.clear(); + beforeEach(() => { + vi.clearAllMocks(); - expect(deleteJsonFileSpy).toHaveBeenCalledWith( - expect.stringContaining('test-service.json'), - ); + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, }); - it('should throw FileUtilsError when clear fails', async () => { - const error = new Error('Delete failed'); + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; - vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue(error); + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); - await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); - }); + it('should clear token data successfully', async () => { + const deleteJsonFileSpy = vi + .spyOn(fileUtils, 'deleteJsonFile') + .mockResolvedValue(); - it('should handle non-Error exceptions in clear', async () => { - vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue('String error'); + await fileTokenStorage.clear(); - await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); - }); + expect(deleteJsonFileSpy).toHaveBeenCalledWith( + expect.stringContaining('test-service.json'), + ); }); - describe('isValidTokenData', () => { - it('should validate correct token data structure', async () => { - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); + it('should throw FileUtilsError when clear fails', async () => { + const error = new Error('Delete failed'); + + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue(error); - const result = await fileTokenStorage.load(); + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); + + it('should handle non-Error exceptions in clear', async () => { + vi.spyOn(fileUtils, 'deleteJsonFile').mockRejectedValue('String error'); - expect(result).toEqual(mockTokenData); + await expect(fileTokenStorage.clear()).rejects.toThrow(FileUtilsError); + }); +}); + +describe('FileTokenStorage Validation', () => { + let fileTokenStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateToken = vi.fn().mockReturnValue({ + token: mockTokenData, }); - it('should reject token data with missing access_token', async () => { - const invalidTokenData = { - refresh_token: 'test-refresh-token', - expires_at: Date.now() + 3600000, - token_type: 'Bearer', - }; + mockOAuth2Client = { + createToken: mockCreateToken, + } as unknown as AuthorizationCode; + + fileTokenStorage = new FileTokenStorage('test-service', mockOAuth2Client); + }); - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); - mockCreateToken.mockImplementation(() => { - throw new Error('Invalid token'); - }); + it('should validate correct token data structure', async () => { + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(mockTokenData); - const result = await fileTokenStorage.load(); + const result = await fileTokenStorage.load(); - expect(result).toBeNull(); + expect(result).toEqual(mockTokenData); + }); + + it('should reject token data with missing access_token', async () => { + const invalidTokenData = { + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); }); - it('should reject token data with missing refresh_token', async () => { - const invalidTokenData = { - access_token: 'test-access-token', - expires_at: Date.now() + 3600000, - token_type: 'Bearer', - }; + const result = await fileTokenStorage.load(); - vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); - mockCreateToken.mockImplementation(() => { - throw new Error('Invalid token'); - }); + expect(result).toBeNull(); + }); - const result = await fileTokenStorage.load(); + it('should reject token data with missing refresh_token', async () => { + const invalidTokenData = { + access_token: 'test-access-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; - expect(result).toBeNull(); + vi.spyOn(fileUtils, 'readJsonFile').mockResolvedValue(invalidTokenData); + mockCreateToken.mockImplementation(() => { + throw new Error('Invalid token'); }); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); }); }); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts new file mode 100644 index 0000000..5c60504 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.accessToken.test.ts @@ -0,0 +1,155 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager GetAccessToken', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRefreshToken: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshToken = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should return access token when token is valid and not expired', async () => { + mockExpired.mockReturnValue(false); + mockLoad.mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockLoad).toHaveBeenCalled(); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should refresh token when token is expired', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + const result = await oauth2Manager.getAccessToken(); + + expect(result).toBe(mockTokenData.access_token); + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error when no token is available', async () => { + mockLoad.mockResolvedValue(null); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'No access token available', + ); + }); + + it('should throw OAuth2Error when token has invalid format', async () => { + const invalidTokenData = { + ...mockTokenDataWithDate, + access_token: null, + }; + + const invalidAccessToken = { + token: invalidTokenData, + expired: mockExpired, + } as unknown as AccessToken; + + mockExpired.mockReturnValue(false); + mockLoad.mockResolvedValue(mockTokenData); + mockCreateToken.mockReturnValue(invalidAccessToken); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'Invalid access token format', + ); + }); + + it('should handle concurrent refresh requests', async () => { + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + const promises = [ + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + oauth2Manager.getAccessToken(), + ]; + + const results = await Promise.all(promises); + + expect(results).toEqual([ + mockTokenData.access_token, + mockTokenData.access_token, + mockTokenData.access_token, + ]); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts new file mode 100644 index 0000000..5885b7c --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.clear.test.ts @@ -0,0 +1,113 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager ClearTokens', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRevokeAll: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockClear: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRevokeAll = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockClear = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + revokeAll: mockRevokeAll, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: vi.fn(), + load: mockLoad, + clear: mockClear, + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should clear tokens successfully', async () => { + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); + + it('should handle revoke failure gracefully', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRevokeAll.mockRejectedValue(new Error('Revoke failed')); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); + + it('should clear tokens even when no access token exists', async () => { + await oauth2Manager.clearTokens(); + + expect(mockClear).toHaveBeenCalled(); + }); + + it('should handle revoke error with non-Error object', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRevokeAll.mockRejectedValue('String error'); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.clearTokens(); + + expect(mockRevokeAll).toHaveBeenCalled(); + expect(mockClear).toHaveBeenCalled(); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts new file mode 100644 index 0000000..6b345be --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.constructor.test.ts @@ -0,0 +1,71 @@ +import { AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +describe('OAuth2Manager Constructor', () => { + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + + beforeEach(() => { + vi.clearAllMocks(); + + mockStorage = { + save: vi.fn(), + load: vi.fn(), + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: vi.fn(), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + }); + + it('should create OAuth2Manager with correct configuration', () => { + const oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + + expect(oauth2Manager).toBeInstanceOf(OAuth2Manager); + expect(AuthorizationCode).toHaveBeenCalledWith({ + client: { + id: mockOAuth2Config.clientId, + secret: mockOAuth2Config.clientSecret, + }, + auth: { + tokenHost: mockOAuth2Config.tokenHost, + tokenPath: mockOAuth2Config.tokenPath, + }, + http: { + json: 'strict', + headers: { + 'User-Agent': 'TemporalWorker/1.0', + }, + }, + }); + }); + + it('should create FileTokenStorage with service name', () => { + new OAuth2Manager('test-service', mockOAuth2Config); + + expect(FileTokenStorage).toHaveBeenCalledWith( + 'test-service', + mockOAuth2Client, + ); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts new file mode 100644 index 0000000..46c595b --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.refresh.test.ts @@ -0,0 +1,150 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager RefreshToken', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockRefreshToken: ReturnType; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockClear: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRefreshToken = vi.fn(); + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockClear = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: mockClear, + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + it('should refresh token successfully', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.refreshToken(); + + expect(mockRefreshToken).toHaveBeenCalled(); + expect(mockSave).toHaveBeenCalled(); + }); + + it('should handle concurrent refresh requests', async () => { + mockRefreshToken.mockResolvedValue(mockAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + + const promises = [ + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + oauth2Manager.refreshToken(), + ]; + + await Promise.all(promises); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('should throw OAuth2Error when no token to refresh', async () => { + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'No access token to refresh', + ); + }); + + it('should handle invalid_grant error by clearing tokens', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('invalid_grant')); + mockExpired.mockReturnValue(false); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Invalid refresh token - tokens cleared', + ); + + expect(mockClear).toHaveBeenCalled(); + }); + + it('should throw OAuth2Error for other refresh failures', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue(new Error('Network error')); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Failed to refresh token: Network error', + ); + }); + + it('should handle refresh error with non-Error object', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockRefreshToken.mockRejectedValue('String error'); + + await oauth2Manager.getAccessToken(); + + await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.refreshToken()).rejects.toThrow( + 'Failed to refresh token: String error', + ); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.test.ts deleted file mode 100644 index 722edd2..0000000 --- a/workers/main/src/services/OAuth2/OAuth2Manager.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { AccessToken, AuthorizationCode } from 'simple-oauth2'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { OAuth2Error } from '../../common/errors'; -import { FileTokenStorage } from './FileTokenStorage'; -import { OAuth2Manager } from './OAuth2Manager'; -import { OAuth2Config, TokenData } from './types'; - -vi.mock('./FileTokenStorage'); -vi.mock('simple-oauth2'); - -describe('OAuth2Manager', () => { - let oauth2Manager: OAuth2Manager; - let mockStorage: FileTokenStorage; - let mockOAuth2Client: AuthorizationCode; - let mockAccessToken: AccessToken; - let mockRefreshToken: ReturnType; - let mockRevokeAll: ReturnType; - let mockExpired: ReturnType; - - const mockOAuth2Config: OAuth2Config = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - refreshToken: 'test-refresh-token', - tokenHost: 'https://oauth.example.com', - tokenPath: '/oauth/token', - tokenExpirationWindowSeconds: 300, - }; - - const mockTokenData: TokenData = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: Date.now() + 3600000, - token_type: 'Bearer', - }; - - const mockTokenDataWithDate = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: new Date(Date.now() + 3600000), - token_type: 'Bearer', - }; - - beforeEach(() => { - vi.clearAllMocks(); - - mockRefreshToken = vi.fn(); - mockRevokeAll = vi.fn(); - mockExpired = vi.fn(); - - mockAccessToken = { - token: mockTokenDataWithDate, - refresh: mockRefreshToken, - revokeAll: mockRevokeAll, - expired: mockExpired, - } as unknown as AccessToken; - - mockStorage = { - save: vi.fn(), - load: vi.fn(), - clear: vi.fn(), - } as unknown as FileTokenStorage; - - mockOAuth2Client = { - createToken: vi.fn().mockReturnValue(mockAccessToken), - } as unknown as AuthorizationCode; - - vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); - vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); - - oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); - }); - - describe('constructor', () => { - it('should create OAuth2Manager with correct configuration', () => { - expect(oauth2Manager).toBeInstanceOf(OAuth2Manager); - expect(AuthorizationCode).toHaveBeenCalledWith({ - client: { - id: mockOAuth2Config.clientId, - secret: mockOAuth2Config.clientSecret, - }, - auth: { - tokenHost: mockOAuth2Config.tokenHost, - tokenPath: mockOAuth2Config.tokenPath, - }, - http: { - json: 'strict', - headers: { - 'User-Agent': 'TemporalWorker/1.0', - }, - }, - }); - }); - - it('should create FileTokenStorage with service name', () => { - expect(FileTokenStorage).toHaveBeenCalledWith( - 'test-service', - mockOAuth2Client, - ); - }); - }); - - describe('getAccessToken', () => { - it('should return access token when token is valid and not expired', async () => { - mockExpired.mockReturnValue(false); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - const result = await oauth2Manager.getAccessToken(); - - expect(result).toBe(mockTokenData.access_token); - expect(mockStorage.load).toHaveBeenCalled(); - expect(mockOAuth2Client.createToken).toHaveBeenCalledWith({ - access_token: mockTokenData.access_token, - refresh_token: mockTokenData.refresh_token, - expires_at: new Date(mockTokenData.expires_at), - token_type: mockTokenData.token_type, - }); - }); - - it('should refresh token when token is expired', async () => { - mockExpired.mockReturnValue(true); - mockRefreshToken.mockResolvedValue(mockAccessToken); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - const result = await oauth2Manager.getAccessToken(); - - expect(result).toBe(mockTokenData.access_token); - expect(mockRefreshToken).toHaveBeenCalled(); - expect(mockStorage.save).toHaveBeenCalled(); - }); - - it('should throw OAuth2Error when no token is available', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(null); - - await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); - await expect(oauth2Manager.getAccessToken()).rejects.toThrow( - 'No access token available', - ); - }); - - it('should handle concurrent refresh requests', async () => { - mockExpired.mockReturnValue(true); - mockRefreshToken.mockResolvedValue(mockAccessToken); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - const promises = [ - oauth2Manager.getAccessToken(), - oauth2Manager.getAccessToken(), - oauth2Manager.getAccessToken(), - ]; - - const results = await Promise.all(promises); - - expect(results).toEqual([ - mockTokenData.access_token, - mockTokenData.access_token, - mockTokenData.access_token, - ]); - expect(mockRefreshToken).toHaveBeenCalledTimes(1); - }); - }); - - describe('refreshToken', () => { - it('should refresh token successfully', async () => { - mockRefreshToken.mockResolvedValue(mockAccessToken); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - // First load the token - await oauth2Manager.getAccessToken(); - - await oauth2Manager.refreshToken(); - - expect(mockRefreshToken).toHaveBeenCalled(); - expect(mockStorage.save).toHaveBeenCalled(); - }); - - it('should handle concurrent refresh requests', async () => { - mockRefreshToken.mockResolvedValue(mockAccessToken); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - // First load the token - await oauth2Manager.getAccessToken(); - - const promises = [ - oauth2Manager.refreshToken(), - oauth2Manager.refreshToken(), - oauth2Manager.refreshToken(), - ]; - - await Promise.all(promises); - - expect(mockRefreshToken).toHaveBeenCalledTimes(1); - }); - - it('should throw OAuth2Error when no token to refresh', async () => { - await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); - await expect(oauth2Manager.refreshToken()).rejects.toThrow( - 'No access token to refresh', - ); - }); - - it('should handle invalid_grant error by clearing tokens', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockRefreshToken.mockRejectedValue(new Error('invalid_grant')); - mockExpired.mockReturnValue(false); // Ensure token is not expired initially - - // Load token without triggering refresh - await oauth2Manager.getAccessToken(); - - // Now call refreshToken directly - await expect(oauth2Manager.refreshToken()).rejects.toThrow( - 'Invalid refresh token - tokens cleared', - ); - - expect(mockStorage.clear).toHaveBeenCalled(); - }); - - it('should throw OAuth2Error for other refresh failures', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockRefreshToken.mockRejectedValue(new Error('Network error')); - - // First load the token - await oauth2Manager.getAccessToken(); - - await expect(oauth2Manager.refreshToken()).rejects.toThrow(OAuth2Error); - await expect(oauth2Manager.refreshToken()).rejects.toThrow( - 'Failed to refresh token: Network error', - ); - }); - }); - - describe('clearTokens', () => { - it('should clear tokens successfully', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - // First load the token - await oauth2Manager.getAccessToken(); - - await oauth2Manager.clearTokens(); - - expect(mockRevokeAll).toHaveBeenCalled(); - expect(mockStorage.clear).toHaveBeenCalled(); - }); - - it('should handle revoke failure gracefully', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockRevokeAll.mockRejectedValue(new Error('Revoke failed')); - - // First load the token - await oauth2Manager.getAccessToken(); - - await oauth2Manager.clearTokens(); - - expect(mockRevokeAll).toHaveBeenCalled(); - expect(mockStorage.clear).toHaveBeenCalled(); - }); - - it('should clear tokens even when no access token exists', async () => { - await oauth2Manager.clearTokens(); - - expect(mockStorage.clear).toHaveBeenCalled(); - }); - }); - - describe('ensureTokenLoaded', () => { - it('should load token from storage when not already loaded', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - - await oauth2Manager.getAccessToken(); - - expect(mockStorage.load).toHaveBeenCalled(); - expect(mockOAuth2Client.createToken).toHaveBeenCalledWith({ - access_token: mockTokenData.access_token, - refresh_token: mockTokenData.refresh_token, - expires_at: new Date(mockTokenData.expires_at), - token_type: mockTokenData.token_type, - }); - }); - - it('should not reload token when already loaded', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockExpired.mockReturnValue(false); - - await oauth2Manager.getAccessToken(); - await oauth2Manager.getAccessToken(); - - expect(mockStorage.load).toHaveBeenCalledTimes(1); - }); - }); - - describe('saveToken', () => { - it('should save token with correct data structure', async () => { - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockExpired.mockReturnValue(true); - mockRefreshToken.mockResolvedValue(mockAccessToken); - - await oauth2Manager.getAccessToken(); - - expect(mockStorage.save).toHaveBeenCalledWith({ - access_token: mockTokenData.access_token, - refresh_token: mockTokenData.refresh_token, - expires_at: mockTokenData.expires_at, - token_type: mockTokenData.token_type, - }); - }); - - it('should use fallback values when token properties are missing', async () => { - const incompleteTokenData = { - access_token: 'test-access-token', - refresh_token: undefined, - expires_at: undefined, - token_type: undefined, - }; - - const incompleteAccessToken = { - token: incompleteTokenData, - refresh: mockRefreshToken, - revokeAll: mockRevokeAll, - expired: mockExpired, - } as unknown as AccessToken; - - mockRefreshToken.mockResolvedValue(incompleteAccessToken); - vi.mocked(mockStorage.load).mockResolvedValue(mockTokenData); - mockExpired.mockReturnValue(true); - - await oauth2Manager.getAccessToken(); - - expect(mockStorage.save).toHaveBeenCalledWith({ - access_token: 'test-access-token', - refresh_token: mockOAuth2Config.refreshToken, - expires_at: expect.any(Number), - token_type: 'Bearer', - }); - }); - }); -}); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.ts b/workers/main/src/services/OAuth2/OAuth2Manager.ts index b46a037..937e9c7 100644 --- a/workers/main/src/services/OAuth2/OAuth2Manager.ts +++ b/workers/main/src/services/OAuth2/OAuth2Manager.ts @@ -48,7 +48,13 @@ export class OAuth2Manager { await this.refreshToken(); } - return this.accessToken.token.access_token as string; + const token = this.accessToken.token; + + if (!token || typeof token.access_token !== 'string') { + throw new OAuth2Error('Invalid access token format'); + } + + return token.access_token; } async refreshToken(): Promise { @@ -122,15 +128,26 @@ export class OAuth2Manager { private async saveToken(): Promise { if (!this.accessToken) return; + const token = this.accessToken.token; + + if (!token) { + throw new OAuth2Error('Invalid token data'); + } + const tokenData: TokenData = { - access_token: this.accessToken.token.access_token as string, + access_token: + typeof token.access_token === 'string' ? token.access_token : '', refresh_token: - (this.accessToken.token.refresh_token as string) || - this.oauth2Config.refreshToken, + (typeof token.refresh_token === 'string' + ? token.refresh_token + : null) || this.oauth2Config.refreshToken, expires_at: - (this.accessToken.token.expires_at as Date)?.getTime() || - Date.now() + 3600000, - token_type: (this.accessToken.token.token_type as string) || 'Bearer', + (token.expires_at instanceof Date + ? token.expires_at.getTime() + : null) || Date.now() + 3600000, + token_type: + (typeof token.token_type === 'string' ? token.token_type : null) || + 'Bearer', }; await this.storage.save(tokenData); diff --git a/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts b/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts new file mode 100644 index 0000000..e2f37b1 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2Manager.utils.test.ts @@ -0,0 +1,173 @@ +import { AccessToken, AuthorizationCode } from 'simple-oauth2'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2Manager } from './OAuth2Manager'; +import { OAuth2Config, TokenData } from './types'; + +vi.mock('./FileTokenStorage'); +vi.mock('simple-oauth2'); + +const mockOAuth2Config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, +}; + +const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', +}; + +const mockTokenDataWithDate = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: new Date(Date.now() + 3600000), + token_type: 'Bearer', +}; + +describe('OAuth2Manager Utils', () => { + let oauth2Manager: OAuth2Manager; + let mockStorage: FileTokenStorage; + let mockOAuth2Client: AuthorizationCode; + let mockAccessToken: AccessToken; + let mockExpired: ReturnType; + let mockLoad: ReturnType; + let mockSave: ReturnType; + let mockCreateToken: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockExpired = vi.fn(); + mockLoad = vi.fn(); + mockSave = vi.fn(); + mockCreateToken = vi.fn(); + + mockAccessToken = { + token: mockTokenDataWithDate, + expired: mockExpired, + } as unknown as AccessToken; + + mockStorage = { + save: mockSave, + load: mockLoad, + clear: vi.fn(), + } as unknown as FileTokenStorage; + + mockOAuth2Client = { + createToken: mockCreateToken.mockReturnValue(mockAccessToken), + } as unknown as AuthorizationCode; + + vi.mocked(FileTokenStorage).mockImplementation(() => mockStorage); + vi.mocked(AuthorizationCode).mockImplementation(() => mockOAuth2Client); + + oauth2Manager = new OAuth2Manager('test-service', mockOAuth2Config); + }); + + describe('ensureTokenLoaded', () => { + it('should load token from storage when not already loaded', async () => { + mockLoad.mockResolvedValue(mockTokenData); + + await oauth2Manager.getAccessToken(); + + expect(mockLoad).toHaveBeenCalled(); + expect(mockCreateToken).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: new Date(mockTokenData.expires_at), + token_type: mockTokenData.token_type, + }); + }); + + it('should not reload token when already loaded', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(false); + + await oauth2Manager.getAccessToken(); + await oauth2Manager.getAccessToken(); + + expect(mockLoad).toHaveBeenCalledTimes(1); + }); + }); + + describe('saveToken', () => { + let mockRefreshToken: ReturnType; + + beforeEach(() => { + mockRefreshToken = vi.fn(); + mockAccessToken = { + token: mockTokenDataWithDate, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + mockCreateToken.mockReturnValue(mockAccessToken); + }); + + it('should save token with correct data structure', async () => { + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + mockRefreshToken.mockResolvedValue(mockAccessToken); + + await oauth2Manager.getAccessToken(); + + expect(mockSave).toHaveBeenCalledWith({ + access_token: mockTokenData.access_token, + refresh_token: mockTokenData.refresh_token, + expires_at: mockTokenData.expires_at, + token_type: mockTokenData.token_type, + }); + }); + + it('should use fallback values when token properties are missing', async () => { + const incompleteTokenData = { + access_token: 'test-access-token', + refresh_token: undefined, + expires_at: undefined, + token_type: undefined, + }; + + const incompleteAccessToken = { + token: incompleteTokenData, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockRefreshToken.mockResolvedValue(incompleteAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + + await oauth2Manager.getAccessToken(); + + expect(mockSave).toHaveBeenCalledWith({ + access_token: 'test-access-token', + refresh_token: mockOAuth2Config.refreshToken, + expires_at: expect.any(Number) as number, + token_type: 'Bearer', + }); + }); + + it('should handle null token data', async () => { + const nullAccessToken = { + token: null, + refresh: mockRefreshToken, + expired: mockExpired, + } as unknown as AccessToken; + + mockRefreshToken.mockResolvedValue(nullAccessToken); + mockLoad.mockResolvedValue(mockTokenData); + mockExpired.mockReturnValue(true); + + await expect(oauth2Manager.getAccessToken()).rejects.toThrow(OAuth2Error); + await expect(oauth2Manager.getAccessToken()).rejects.toThrow( + 'Invalid token data', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/types.test.ts b/workers/main/src/services/OAuth2/types.test.ts index ed3236c..b29ce77 100644 --- a/workers/main/src/services/OAuth2/types.test.ts +++ b/workers/main/src/services/OAuth2/types.test.ts @@ -2,156 +2,154 @@ import { describe, expect, it } from 'vitest'; import { OAuth2Config, TokenData, TokenStorageProvider } from './types'; -describe('OAuth2 Types', () => { - describe('TokenData', () => { - it('should have required properties', () => { - const tokenData: TokenData = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: Date.now() + 3600000, - token_type: 'Bearer', - }; - - expect(tokenData.access_token).toBe('test-access-token'); - expect(tokenData.refresh_token).toBe('test-refresh-token'); - expect(tokenData.expires_at).toBeGreaterThan(Date.now()); - expect(tokenData.token_type).toBe('Bearer'); - }); - - it('should allow different token types', () => { - const tokenData: TokenData = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: Date.now() + 3600000, - token_type: 'Basic', - }; +describe('TokenData', () => { + it('should have required properties', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + expect(tokenData.access_token).toBe('test-access-token'); + expect(tokenData.refresh_token).toBe('test-refresh-token'); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); + expect(tokenData.token_type).toBe('Bearer'); + }); - expect(tokenData.token_type).toBe('Basic'); - }); + it('should allow different token types', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Basic', + }; - it('should handle future expiration times', () => { - const futureTime = Date.now() + 86400000; // 24 hours from now - const tokenData: TokenData = { - access_token: 'test-access-token', - refresh_token: 'test-refresh-token', - expires_at: futureTime, - token_type: 'Bearer', - }; + expect(tokenData.token_type).toBe('Basic'); + }); - expect(tokenData.expires_at).toBe(futureTime); - expect(tokenData.expires_at).toBeGreaterThan(Date.now()); - }); + it('should handle future expiration times', () => { + const futureTime = Date.now() + 86400000; // 24 hours from now + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: futureTime, + token_type: 'Bearer', + }; + + expect(tokenData.expires_at).toBe(futureTime); + expect(tokenData.expires_at).toBeGreaterThan(Date.now()); }); +}); - describe('TokenStorageProvider', () => { - it('should define required methods', () => { - const mockStorage: TokenStorageProvider = { - save: async () => {}, - load: async () => null, - clear: async () => {}, - }; - - expect(typeof mockStorage.save).toBe('function'); - expect(typeof mockStorage.load).toBe('function'); - expect(typeof mockStorage.clear).toBe('function'); - }); +describe('TokenStorageProvider', () => { + it('should define required methods', () => { + const mockStorage: TokenStorageProvider = { + save: async () => {}, + load: async () => null, + clear: async () => {}, + }; + + expect(typeof mockStorage.save).toBe('function'); + expect(typeof mockStorage.load).toBe('function'); + expect(typeof mockStorage.clear).toBe('function'); + }); - it('should allow async operations', async () => { - const mockStorage: TokenStorageProvider = { - save: async (tokenData: TokenData) => { - expect(tokenData.access_token).toBe('test-token'); - }, - load: async () => ({ - access_token: 'test-token', - refresh_token: 'test-refresh', - expires_at: Date.now(), - token_type: 'Bearer', - }), - clear: async () => {}, - }; - - await mockStorage.save({ + it('should allow async operations', async () => { + const mockStorage: TokenStorageProvider = { + save: async (tokenData: TokenData) => { + expect(tokenData.access_token).toBe('test-token'); + }, + load: async () => ({ access_token: 'test-token', refresh_token: 'test-refresh', expires_at: Date.now(), token_type: 'Bearer', - }); + }), + clear: async () => {}, + }; + + await mockStorage.save({ + access_token: 'test-token', + refresh_token: 'test-refresh', + expires_at: Date.now(), + token_type: 'Bearer', + }); - const result = await mockStorage.load(); + const result = await mockStorage.load(); - expect(result).not.toBeNull(); - expect(result?.access_token).toBe('test-token'); - }); + expect(result).not.toBeNull(); + expect(result?.access_token).toBe('test-token'); }); +}); - describe('OAuth2Config', () => { - it('should have required properties', () => { - const config: OAuth2Config = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - refreshToken: 'test-refresh-token', - tokenHost: 'https://oauth.example.com', - tokenPath: '/oauth/token', - tokenExpirationWindowSeconds: 300, - }; - - expect(config.clientId).toBe('test-client-id'); - expect(config.clientSecret).toBe('test-client-secret'); - expect(config.refreshToken).toBe('test-refresh-token'); - expect(config.tokenHost).toBe('https://oauth.example.com'); - expect(config.tokenPath).toBe('/oauth/token'); - expect(config.tokenExpirationWindowSeconds).toBe(300); - }); +describe('OAuth2Config', () => { + it('should have required properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.clientSecret).toBe('test-client-secret'); + expect(config.refreshToken).toBe('test-refresh-token'); + expect(config.tokenHost).toBe('https://oauth.example.com'); + expect(config.tokenPath).toBe('/oauth/token'); + expect(config.tokenExpirationWindowSeconds).toBe(300); + }); - it('should have optional authorize properties', () => { - const config: OAuth2Config = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - refreshToken: 'test-refresh-token', - tokenHost: 'https://oauth.example.com', - tokenPath: '/oauth/token', - authorizeHost: 'https://auth.example.com', - authorizePath: '/oauth/authorize', - tokenExpirationWindowSeconds: 300, - }; - - expect(config.authorizeHost).toBe('https://auth.example.com'); - expect(config.authorizePath).toBe('/oauth/authorize'); - }); + it('should have optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + authorizeHost: 'https://auth.example.com', + authorizePath: '/oauth/authorize', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.authorizeHost).toBe('https://auth.example.com'); + expect(config.authorizePath).toBe('/oauth/authorize'); + }); - it('should work without optional authorize properties', () => { - const config: OAuth2Config = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - refreshToken: 'test-refresh-token', - tokenHost: 'https://oauth.example.com', - tokenPath: '/oauth/token', - tokenExpirationWindowSeconds: 300, - }; - - expect(config.clientId).toBe('test-client-id'); - expect(config.authorizeHost).toBeUndefined(); - expect(config.authorizePath).toBeUndefined(); - }); + it('should work without optional authorize properties', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 300, + }; + + expect(config.clientId).toBe('test-client-id'); + expect(config.authorizeHost).toBeUndefined(); + expect(config.authorizePath).toBeUndefined(); + }); - it('should handle different expiration window values', () => { - const config: OAuth2Config = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - refreshToken: 'test-refresh-token', - tokenHost: 'https://oauth.example.com', - tokenPath: '/oauth/token', - tokenExpirationWindowSeconds: 0, - }; + it('should handle different expiration window values', () => { + const config: OAuth2Config = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.example.com', + tokenPath: '/oauth/token', + tokenExpirationWindowSeconds: 0, + }; - expect(config.tokenExpirationWindowSeconds).toBe(0); + expect(config.tokenExpirationWindowSeconds).toBe(0); - const configWithWindow: OAuth2Config = { - ...config, - tokenExpirationWindowSeconds: 600, - }; + const configWithWindow: OAuth2Config = { + ...config, + tokenExpirationWindowSeconds: 600, + }; - expect(configWithWindow.tokenExpirationWindowSeconds).toBe(600); - }); + expect(configWithWindow.tokenExpirationWindowSeconds).toBe(600); }); });