diff --git a/workers/main/src/common/errors/OAuth2Error.test.ts b/workers/main/src/common/errors/OAuth2Error.test.ts new file mode 100644 index 0000000..cb13210 --- /dev/null +++ b/workers/main/src/common/errors/OAuth2Error.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2Error } from './OAuth2Error'; + +describe('OAuth2Error', () => { + it('should set the message, name, and default error code', () => { + const err = new OAuth2Error('test message'); + + expect(err.message).toBe('test message'); + expect(err.name).toBe('OAuth2Error'); + expect(err.code).toBe('UNKNOWN_OAUTH2_ERROR'); + }); + + it('should set the message, name, and custom error code', () => { + const err = new OAuth2Error('test message', 'CUSTOM_ERROR'); + + expect(err.message).toBe('test message'); + expect(err.name).toBe('OAuth2Error'); + expect(err.code).toBe('CUSTOM_ERROR'); + }); +}); diff --git a/workers/main/src/common/errors/OAuth2Error.ts b/workers/main/src/common/errors/OAuth2Error.ts new file mode 100644 index 0000000..bd9a6f4 --- /dev/null +++ b/workers/main/src/common/errors/OAuth2Error.ts @@ -0,0 +1,10 @@ +import { AppError } from './AppError'; + +export class OAuth2Error extends AppError { + public readonly code: string; + + constructor(message: string, code: string = 'UNKNOWN_OAUTH2_ERROR') { + super(message, 'OAuth2Error'); + this.code = code; + } +} diff --git a/workers/main/src/common/errors/index.ts b/workers/main/src/common/errors/index.ts index 8e8f9d8..1841927 100644 --- a/workers/main/src/common/errors/index.ts +++ b/workers/main/src/common/errors/index.ts @@ -1,6 +1,7 @@ export * from './AppError'; export * from './FileUtilsError'; export * from './FinAppRepositoryError'; +export * from './OAuth2Error'; export * from './QuickBooksRepositoryError'; export * from './SlackRepositoryError'; export * from './TargetUnitRepositoryError'; diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts new file mode 100644 index 0000000..96de53d --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +describe('FileTokenStorage - Basic', () => { + let fileTokenStorage: FileTokenStorage; + let mockMkdir: ReturnType; + let mockWriteFile: ReturnType; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + beforeEach(() => { + mockMkdir = vi.mocked(fs.mkdir); + mockWriteFile = vi.mocked(fs.writeFile); + + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with default service name', () => { + const storage = new FileTokenStorage(); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom service name', () => { + const storage = new FileTokenStorage('custom-service'); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom token file path', () => { + const customPath = '/custom/path/token.json'; + const storage = new FileTokenStorage('test-service', customPath); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + }); + + describe('save', () => { + it('should save token data successfully', async () => { + await fileTokenStorage.save(mockTokenData); + + expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { + recursive: true, + }); + expect(mockWriteFile).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify(mockTokenData, null, 2), + ); + }); + + it('should throw OAuth2Error when save fails', async () => { + mockWriteFile.mockRejectedValue(new Error('Write failed')); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + OAuth2Error, + ); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + 'Failed to save token data to file', + ); + }); + + it('should throw OAuth2Error when mkdir fails', async () => { + mockMkdir.mockRejectedValue(new Error('Mkdir failed')); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + OAuth2Error, + ); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + 'Failed to save token data to file', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts new file mode 100644 index 0000000..3960230 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts @@ -0,0 +1,65 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('path', () => ({ + join: vi.fn(), +})); + +describe('FileTokenStorage - Clear', () => { + let fileTokenStorage: FileTokenStorage; + let mockJoin: ReturnType; + let mockUnlink: ReturnType; + + beforeEach(() => { + mockJoin = vi.mocked(join); + mockUnlink = vi.mocked(fs.unlink); + + mockJoin.mockReturnValue('/test/path/token.json'); + mockUnlink.mockResolvedValue(undefined); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('clear', () => { + it('should clear token data successfully', async () => { + await fileTokenStorage.clear(); + + expect(mockUnlink).toHaveBeenCalledWith('/test/path/token.json'); + }); + + it('should not throw error when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + + error.code = 'ENOENT'; + mockUnlink.mockRejectedValue(error); + + await expect(fileTokenStorage.clear()).resolves.toBeUndefined(); + }); + + it('should throw OAuth2Error when clear fails with other error', async () => { + mockUnlink.mockRejectedValue(new Error('Delete failed')); + + await expect(fileTokenStorage.clear()).rejects.toThrow(OAuth2Error); + await expect(fileTokenStorage.clear()).rejects.toThrow( + 'Failed to clear token data from file', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts new file mode 100644 index 0000000..e9312b1 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts @@ -0,0 +1,117 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('path', () => ({ + join: vi.fn(), +})); + +describe('FileTokenStorage - Load', () => { + let fileTokenStorage: FileTokenStorage; + let mockJoin: ReturnType; + let mockReadFile: ReturnType; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + beforeEach(() => { + mockJoin = vi.mocked(join); + mockReadFile = vi.mocked(fs.readFile); + + mockJoin.mockReturnValue('/test/path/token.json'); + mockReadFile.mockResolvedValue(JSON.stringify(mockTokenData)); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('load', () => { + it('should load valid token data successfully', async () => { + const result = await fileTokenStorage.load(); + + expect(mockReadFile).toHaveBeenCalledWith( + '/test/path/token.json', + 'utf8', + ); + expect(result).toEqual(mockTokenData); + }); + + it('should return null when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + + error.code = 'ENOENT'; + mockReadFile.mockRejectedValue(error); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when file read fails with other error', async () => { + mockReadFile.mockRejectedValue(new Error('Read failed')); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is invalid', async () => { + const invalidData = { invalid: 'data' }; + + mockReadFile.mockResolvedValue(JSON.stringify(invalidData)); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data has missing fields', async () => { + const invalidData = { + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + mockReadFile.mockResolvedValue(JSON.stringify(invalidData)); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is null', async () => { + mockReadFile.mockResolvedValue('null'); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is not an object', async () => { + mockReadFile.mockResolvedValue('"string"'); + + 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..138785f --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.ts @@ -0,0 +1,72 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; + +import { OAuth2Error } from '../../common/errors'; +import { TokenStorageProvider } from './types'; +import { TokenData } from './types'; + +export class FileTokenStorage implements TokenStorageProvider { + private readonly tokenFilePath: string; + private readonly serviceName: string; + + constructor(serviceName: string = 'qbo', tokenFilePath?: string) { + this.serviceName = serviceName; + this.tokenFilePath = + tokenFilePath || + join(process.cwd(), 'data', 'oauth2_tokens', `${serviceName}.json`); + } + + async save(tokenData: TokenData): Promise { + try { + const dir = join(this.tokenFilePath, '..'); + + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + this.tokenFilePath, + JSON.stringify(tokenData, null, 2), + ); + } catch { + throw new OAuth2Error('Failed to save token data to file'); + } + } + + async load(): Promise { + try { + const data = await fs.readFile(this.tokenFilePath, 'utf8'); + const tokenData = JSON.parse(data) as TokenData; + + if (!this.isValidTokenData(tokenData)) { + return null; + } + + return tokenData; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + return null; + } + } + + async clear(): Promise { + try { + await fs.unlink(this.tokenFilePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new OAuth2Error('Failed to clear token data from file'); + } + } + } + + private isValidTokenData(data: unknown): data is TokenData { + return ( + typeof data === 'object' && + data !== null && + typeof (data as TokenData).access_token === 'string' && + typeof (data as TokenData).refresh_token === 'string' && + typeof (data as TokenData).expires_at === 'number' && + typeof (data as TokenData).token_type === 'string' + ); + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts new file mode 100644 index 0000000..ec40b61 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Basic', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create OAuth2TokenManager instance', () => { + expect(tokenManager).toBeInstanceOf(OAuth2TokenManager); + }); + + it('should return false when no token is set', () => { + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false when token is expired', () => { + const expiredTokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return true when token is valid', () => { + const validTokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(validTokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + + it('should return refresh token from config when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should return cached refresh token when available', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'cached-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.getCurrentRefreshToken()).toBe('cached-refresh-token'); + }); + + it('should return cached access token when valid', async () => { + const tokenData: TokenData = { + access_token: 'valid-access-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-access-token'); + }); + + it('should refresh token when expired and return new access token', async () => { + const expiredTokenData: TokenData = { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('new-access-token'); + }); + + it('should handle malformed token data gracefully', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty refresh token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: '', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle invalid expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: NaN, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle token refresh when token is within buffer time', async () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-token'); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts new file mode 100644 index 0000000..06ee4e4 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Error Handling', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('token validation', () => { + it('should return false for empty access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false for null access token', () => { + const tokenData: TokenData = { + access_token: null as unknown as string, + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false for expired token', () => { + const tokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return true for valid token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + }); + + describe('refresh token handling', () => { + it('should return default refresh token when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should return cached refresh token when available', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'cached-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.getCurrentRefreshToken()).toBe( + 'cached-refresh-token', + ); + }); + }); + + describe('edge cases', () => { + it('should handle negative expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: -1000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle zero expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: 0, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle very small expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Number.MIN_SAFE_INTEGER, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts new file mode 100644 index 0000000..b617c4f --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Storage & Edge Cases', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load tokens from storage on initialization', () => { + expect(tokenManager).toBeInstanceOf(OAuth2TokenManager); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty string access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle null access token', () => { + const tokenData: TokenData = { + access_token: null as unknown as string, + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle invalid expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: NaN, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle expired token', () => { + const tokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle valid token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + + it('should return default refresh token when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should handle token refresh when token is within buffer time', async () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-token'); + }); + + it('should handle concurrent token refresh requests', async () => { + const expiredTokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + + const promises = [ + tokenManager.getAccessToken(), + tokenManager.getAccessToken(), + tokenManager.getAccessToken(), + ]; + + const results = await Promise.all(promises); + + results.forEach((result) => { + expect(result).toBe('new-access-token'); + }); + }); + + it('should handle setTokenDataForTesting with null data', () => { + tokenManager.setTokenDataForTesting(null as unknown as TokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle setTokenDataForTesting with undefined data', () => { + tokenManager.setTokenDataForTesting(undefined as unknown as TokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.ts new file mode 100644 index 0000000..d92d9d7 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.ts @@ -0,0 +1,221 @@ +import { OAuth2Error } from '../../common/errors'; +import { ERROR_CODES, ERROR_MESSAGES, TOKEN_CONFIG } from './constants'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2TokenRefreshProvider } from './OAuth2TokenRefreshProvider'; +import { + OAuth2TokenManagerInterface, + TokenRefreshProvider, + TokenStorageProvider, +} from './types'; +import { TokenData } from './types'; + +export class OAuth2TokenManager implements OAuth2TokenManagerInterface { + private accessToken: string | null = null; + private tokenExpiry: Date | null = null; + private refreshToken: string | null = null; + private refreshPromise: Promise | null = null; + + private readonly storage: TokenStorageProvider; + private readonly refreshProvider: TokenRefreshProvider; + private readonly defaultRefreshToken: string; + + constructor(serviceName: string, defaultRefreshToken: string) { + this.storage = new FileTokenStorage(serviceName); + this.refreshProvider = new OAuth2TokenRefreshProvider(); + this.defaultRefreshToken = defaultRefreshToken; + } + + private async initializeTokens(): Promise { + await this.loadTokens(); + } + + async getAccessToken(): Promise { + await this.initializeTokens(); + + if (!this.accessToken) { + throw new OAuth2Error(ERROR_MESSAGES.NO_ACCESS_TOKEN); + } + + await this.refreshAccessToken(); + + if (!this.accessToken) { + throw new OAuth2Error(ERROR_MESSAGES.NO_ACCESS_TOKEN); + } + + return this.accessToken; + } + + getCurrentRefreshToken(): string { + return this.refreshToken ?? this.defaultRefreshToken; + } + + isTokenValid(): boolean { + if (!this.accessToken || !this.refreshToken || !this.tokenExpiry) { + return false; + } + + if (this.accessToken.length === 0 || this.refreshToken.length === 0) { + return false; + } + + return Date.now() < this.tokenExpiry.getTime(); + } + + private async loadTokens(): Promise { + try { + const tokenData = await this.storage.load(); + + if (tokenData) { + this.setTokenData(tokenData); + } + } catch { + throw new OAuth2Error(ERROR_MESSAGES.LOAD_TOKENS_FAILED); + } + + if (!this.refreshToken) { + this.refreshToken = this.defaultRefreshToken; + } + } + + private isValidTokenData(tokenData: TokenData): boolean { + if (!tokenData) { + return false; + } + + if ( + typeof tokenData.access_token !== 'string' || + tokenData.access_token.length === 0 + ) { + return false; + } + + if ( + typeof tokenData.refresh_token !== 'string' || + tokenData.refresh_token.length === 0 + ) { + return false; + } + + if ( + typeof tokenData.expires_at !== 'number' || + !Number.isFinite(tokenData.expires_at) || + tokenData.expires_at <= 0 + ) { + return false; + } + + const expiryDate = new Date(tokenData.expires_at); + + if (isNaN(expiryDate.getTime())) { + return false; + } + + return true; + } + + private async refreshAccessToken(): Promise { + if (this.isWithinRefreshBuffer()) { + return; + } + + if (this.refreshPromise) { + await this.refreshPromise; + + return; + } + + this.refreshPromise = this.performTokenRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + private async performTokenRefresh(): Promise { + const currentRefreshToken = this.getCurrentRefreshToken(); + + try { + const newTokenData = + await this.refreshProvider.refreshToken(currentRefreshToken); + + this.setTokenData(newTokenData); + await this.saveTokens(); + } catch (error) { + if ( + error instanceof OAuth2Error && + error.code === ERROR_CODES.INVALID_GRANT + ) { + await this.clearStoredTokens(); + } + throw error; + } + } + + private async clearStoredTokens(): Promise { + this.accessToken = null; + this.tokenExpiry = null; + this.refreshToken = null; + this.refreshPromise = null; + + try { + await this.storage.clear(); + } catch { + throw new OAuth2Error(ERROR_MESSAGES.CLEAR_TOKENS_FAILED); + } + } + + private isWithinRefreshBuffer(): boolean { + if (!this.tokenExpiry) { + return false; + } + + const bufferTime = TOKEN_CONFIG.TOKEN_BUFFER_MINUTES * 60 * 1000; + const now = new Date(); + + return now.getTime() < this.tokenExpiry.getTime() - bufferTime; + } + + private setTokenData(tokenData: TokenData): void { + if (!this.isValidTokenData(tokenData)) { + throw new OAuth2Error(ERROR_MESSAGES.INVALID_TOKEN_DATA); + } + + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token; + this.tokenExpiry = new Date(tokenData.expires_at); + } + + private async saveTokens(): Promise { + if (!this.accessToken || !this.refreshToken || !this.tokenExpiry) { + return; + } + + const tokenData: TokenData = { + access_token: this.accessToken, + refresh_token: this.refreshToken, + expires_at: this.tokenExpiry.getTime(), + token_type: TOKEN_CONFIG.DEFAULT_TOKEN_TYPE, + }; + + await this.storage.save(tokenData); + } + + setTokenDataForTesting(tokenData: TokenData): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + 'setTokenDataForTesting can only be used in test environments', + ); + } + + if (tokenData) { + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token; + this.tokenExpiry = new Date(tokenData.expires_at); + } else { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = null; + } + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts new file mode 100644 index 0000000..6de5db8 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2TokenRefreshProvider } from './OAuth2TokenRefreshProvider'; + +describe('OAuth2TokenRefreshProvider', () => { + it('should create instance successfully', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + + expect(refreshProvider).toBeInstanceOf(OAuth2TokenRefreshProvider); + }); + + it('should have refreshToken method', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + + expect(typeof refreshProvider.refreshToken).toBe('function'); + }); + + it('should have refreshToken method that returns a promise', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + const result = refreshProvider.refreshToken('test-token'); + + expect(result).toBeInstanceOf(Promise); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts new file mode 100644 index 0000000..7d95e81 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; + +import { OAuth2Error } from '../../common/errors'; +import { qboConfig } from '../../configs/qbo'; +import { ERROR_CODES, TOKEN_CONFIG } from './constants'; +import { TokenRefreshProvider } from './types'; +import { TokenData, TokenResponse } from './types'; + +export class OAuth2TokenRefreshProvider implements TokenRefreshProvider { + async refreshToken(refreshToken: string): Promise { + const tokenData = { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }; + + const authString = Buffer.from( + `${qboConfig.clientId}:${qboConfig.clientSecret}`, + ).toString('base64'); + + try { + const response = await axios.post( + qboConfig.tokenEndpoint, + new URLSearchParams(tokenData).toString(), + { + headers: { + 'Content-Type': TOKEN_CONFIG.CONTENT_TYPE, + 'Authorization': `Basic ${authString}`, + 'Accept': TOKEN_CONFIG.ACCEPT_TYPE, + }, + }, + ); + + return this.mapResponseToTokenData(response.data); + } catch (error) { + throw this.handleRefreshError(error); + } + } + + private handleRefreshError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data as { error?: string } | undefined; + + if (status === 400 && data?.error === 'invalid_grant') { + return new OAuth2Error( + 'Refresh token is invalid or expired. Please obtain a new refresh token from QuickBooks.', + ERROR_CODES.INVALID_GRANT, + ); + } + + if (status === 401) { + return new OAuth2Error( + 'Invalid client credentials. Please check QBO_CLIENT_ID and QBO_CLIENT_SECRET.', + ERROR_CODES.INVALID_CLIENT, + ); + } + + if (status === 403) { + return new OAuth2Error( + 'Access denied. Please check your QuickBooks app permissions.', + ERROR_CODES.ACCESS_DENIED, + ); + } + + return new OAuth2Error( + `QBO API error (${status}): ${data?.error || error.message}`, + ERROR_CODES.API_ERROR, + ); + } + + return new OAuth2Error( + `Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`, + ERROR_CODES.NETWORK_ERROR, + ); + } + + private mapResponseToTokenData(response: TokenResponse): TokenData { + const expiryTime = new Date(); + + expiryTime.setSeconds( + expiryTime.getSeconds() + + response.expires_in - + TOKEN_CONFIG.EXPIRY_BUFFER_SECONDS, + ); + + return { + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_at: expiryTime.getTime(), + token_type: response.token_type, + }; + } +} diff --git a/workers/main/src/services/OAuth2/constants.ts b/workers/main/src/services/OAuth2/constants.ts new file mode 100644 index 0000000..ff5de86 --- /dev/null +++ b/workers/main/src/services/OAuth2/constants.ts @@ -0,0 +1,27 @@ +export const TOKEN_CONFIG = { + TOKEN_BUFFER_MINUTES: 5, + EXPIRY_BUFFER_SECONDS: 60, + DEFAULT_TOKEN_TYPE: 'Bearer', + CONTENT_TYPE: 'application/x-www-form-urlencoded', + ACCEPT_TYPE: 'application/json', +} as const; + +export const ERROR_MESSAGES = { + NO_ACCESS_TOKEN: 'Failed to obtain access token', + NO_REFRESH_TOKEN: 'No refresh token available in configuration or file', + REFRESH_FAILED: 'Failed to refresh access token', + INVALID_TOKEN_DATA: 'Invalid token data structure', + LOAD_TOKENS_FAILED: 'Failed to load OAuth2 tokens', + CLEAR_TOKENS_FAILED: 'Failed to clear OAuth2 tokens', +} as const; + +export const ERROR_CODES = { + INVALID_GRANT: 'INVALID_GRANT', + INVALID_CLIENT: 'INVALID_CLIENT', + ACCESS_DENIED: 'ACCESS_DENIED', + API_ERROR: 'API_ERROR', + NETWORK_ERROR: 'NETWORK_ERROR', + INVALID_TOKEN_DATA: 'INVALID_TOKEN_DATA', + CLEAR_TOKENS_FAILED: 'CLEAR_TOKENS_FAILED', + LOAD_TOKENS_FAILED: 'LOAD_TOKENS_FAILED', +} as const; diff --git a/workers/main/src/services/OAuth2/types.ts b/workers/main/src/services/OAuth2/types.ts new file mode 100644 index 0000000..7839132 --- /dev/null +++ b/workers/main/src/services/OAuth2/types.ts @@ -0,0 +1,33 @@ +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +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 TokenRefreshProvider { + refreshToken(refreshToken: string): Promise; +} + +export interface OAuth2TokenManagerInterface { + getAccessToken(): Promise; + + getCurrentRefreshToken(): string; + + isTokenValid(): boolean; +}