diff --git a/.vscode/launch.json b/.vscode/launch.json index 72d16ce1..880de0bb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -67,6 +67,19 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Launch CLI Non-Interactive", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"], + "skipFiles": ["/**"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "env": { + "GEMINI_SANDBOX": "false" + } } ], "inputs": [ @@ -75,6 +88,12 @@ "type": "promptString", "description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)", "default": "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx" + }, + { + "id": "prompt", + "type": "promptString", + "description": "Enter your prompt for non-interactive mode", + "default": "Explain this code" } ] } diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index f0ff73c5..ea25351a 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -69,10 +69,6 @@ export function AuthDialog({ return item.value === AuthType.USE_GEMINI; } - if (process.env.QWEN_OAUTH_TOKEN) { - return item.value === AuthType.QWEN_OAUTH; - } - return item.value === AuthType.LOGIN_WITH_GOOGLE; }), ); diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index 39cae322..4529e589 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -140,7 +140,7 @@ describe('getInstallationInfo', () => { const info = getInstallationInfo(projectRoot, false); expect(mockedExecSync).toHaveBeenCalledWith( - 'brew list -1 | grep -q "^gemini-cli$"', + 'brew list -1 | grep -q "^qwen-code$"', { stdio: 'ignore' }, ); expect(info.packageManager).toBe(PackageManager.HOMEBREW); @@ -162,7 +162,7 @@ describe('getInstallationInfo', () => { const info = getInstallationInfo(projectRoot, false); expect(mockedExecSync).toHaveBeenCalledWith( - 'brew list -1 | grep -q "^gemini-cli$"', + 'brew list -1 | grep -q "^qwen-code$"', { stdio: 'ignore' }, ); // Should fall back to default global npm diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 8097f56a..61239a78 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -77,8 +77,8 @@ export function getInstallationInfo( // Check for Homebrew if (process.platform === 'darwin') { try { - // The package name in homebrew is gemini-cli - childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', { + // We do not support homebrew for now, keep forward compatibility for future use + childProcess.execSync('brew list -1 | grep -q "^qwen-code$"', { stdio: 'ignore', }); return { @@ -88,8 +88,7 @@ export function getInstallationInfo( 'Installed via Homebrew. Please update with "brew upgrade".', }; } catch (_error) { - // Brew is not installed or gemini-cli is not installed via brew. - // Continue to the next check. + // continue to the next check } } diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index c1e7c586..63a6166c 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -21,9 +21,6 @@ function getAuthTypeFromEnv(): AuthType | undefined { if (process.env.OPENAI_API_KEY) { return AuthType.USE_OPENAI; } - if (process.env.QWEN_OAUTH_TOKEN) { - return AuthType.QWEN_OAUTH; - } return undefined; } diff --git a/packages/core/src/qwen/qwenContentGenerator.test.ts b/packages/core/src/qwen/qwenContentGenerator.test.ts index a56aed81..e8dfd3c3 100644 --- a/packages/core/src/qwen/qwenContentGenerator.test.ts +++ b/packages/core/src/qwen/qwenContentGenerator.test.ts @@ -20,9 +20,117 @@ import { FinishReason, } from '@google/genai'; import { QwenContentGenerator } from './qwenContentGenerator.js'; +import { SharedTokenManager } from './sharedTokenManager.js'; import { Config } from '../config/config.js'; import { AuthType, ContentGeneratorConfig } from '../core/contentGenerator.js'; +// Mock SharedTokenManager +vi.mock('./sharedTokenManager.js', () => ({ + SharedTokenManager: class { + private static instance: unknown = null; + private mockCredentials: QwenCredentials | null = null; + private shouldThrowError: boolean = false; + private errorToThrow: Error | null = null; + + static getInstance() { + if (!this.instance) { + this.instance = new this(); + } + return this.instance; + } + + async getValidCredentials( + qwenClient: IQwenOAuth2Client, + ): Promise { + // If we're configured to throw an error, do so + if (this.shouldThrowError && this.errorToThrow) { + throw this.errorToThrow; + } + + // Try to get credentials from the mock client first to trigger auth errors + try { + const { token } = await qwenClient.getAccessToken(); + if (token) { + const credentials = qwenClient.getCredentials(); + return credentials; + } + } catch (error) { + // If it's an auth error and we need to simulate refresh behavior + const errorMessage = + error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); + const errorCode = + (error as { status?: number; code?: number })?.status || + (error as { status?: number; code?: number })?.code; + + const isAuthError = + errorCode === 401 || + errorCode === 403 || + errorMessage.includes('unauthorized') || + errorMessage.includes('forbidden') || + errorMessage.includes('token expired'); + + if (isAuthError) { + // Try to refresh the token through the client + try { + const refreshResult = await qwenClient.refreshAccessToken(); + if (refreshResult && !('error' in refreshResult)) { + // Refresh succeeded, update client credentials and return them + const updatedCredentials = qwenClient.getCredentials(); + return updatedCredentials; + } else { + // Refresh failed, throw appropriate error + throw new Error( + 'Failed to obtain valid Qwen access token. Please re-authenticate.', + ); + } + } catch { + throw new Error( + 'Failed to obtain valid Qwen access token. Please re-authenticate.', + ); + } + } else { + // Re-throw non-auth errors + throw error; + } + } + + // Return mock credentials only if they're set + if (this.mockCredentials && this.mockCredentials.access_token) { + return this.mockCredentials; + } + + // Default fallback for tests that need credentials + return { + access_token: 'valid-token', + refresh_token: 'valid-refresh-token', + resource_url: 'https://test-endpoint.com/v1', + expiry_date: Date.now() + 3600000, + }; + } + + getCurrentCredentials(): QwenCredentials | null { + return this.mockCredentials; + } + + clearCache(): void { + this.mockCredentials = null; + } + + // Helper method for tests to set credentials + setMockCredentials(credentials: QwenCredentials | null): void { + this.mockCredentials = credentials; + } + + // Helper method for tests to simulate errors + setMockError(error: Error | null): void { + this.shouldThrowError = !!error; + this.errorToThrow = error; + } + }, +})); + // Mock the OpenAIContentGenerator parent class vi.mock('../core/openaiContentGenerator.js', () => ({ OpenAIContentGenerator: class { @@ -236,8 +344,10 @@ describe('QwenContentGenerator', () => { it('should refresh token on auth error and retry', async () => { const authError = { status: 401, message: 'Unauthorized' }; - // First call fails with auth error - vi.mocked(mockQwenClient.getAccessToken).mockRejectedValueOnce(authError); + // First call fails with auth error, second call succeeds + vi.mocked(mockQwenClient.getAccessToken) + .mockRejectedValueOnce(authError) + .mockResolvedValueOnce({ token: 'refreshed-token' }); // Refresh succeeds vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({ @@ -247,6 +357,15 @@ describe('QwenContentGenerator', () => { resource_url: 'https://refreshed-endpoint.com', }); + // Set credentials for second call + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ + access_token: 'refreshed-token', + token_type: 'Bearer', + refresh_token: 'refresh-token', + resource_url: 'https://refreshed-endpoint.com', + expiry_date: Date.now() + 3600000, + }); + const request: GenerateContentParameters = { model: 'qwen-turbo', contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], @@ -261,12 +380,62 @@ describe('QwenContentGenerator', () => { expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled(); }); - it('should handle token refresh failure', async () => { - vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue( - new Error('Token expired'), + it('should refresh token on auth error and retry for content stream', async () => { + const authError = { status: 401, message: 'Unauthorized' }; + + // Reset mocks for this test + vi.clearAllMocks(); + + // First call fails with auth error, second call succeeds + vi.mocked(mockQwenClient.getAccessToken) + .mockRejectedValueOnce(authError) + .mockResolvedValueOnce({ token: 'refreshed-stream-token' }); + + // Refresh succeeds + vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({ + access_token: 'refreshed-stream-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://refreshed-stream-endpoint.com', + }); + + // Set credentials for second call + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ + access_token: 'refreshed-stream-token', + token_type: 'Bearer', + refresh_token: 'refresh-token', + resource_url: 'https://refreshed-stream-endpoint.com', + expiry_date: Date.now() + 3600000, + }); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello stream' }] }], + }; + + const stream = await qwenContentGenerator.generateContentStream( + request, + 'test-prompt-id', ); - vi.mocked(mockQwenClient.refreshAccessToken).mockRejectedValue( - new Error('Refresh failed'), + const chunks: string[] = []; + + for await (const chunk of stream) { + chunks.push(chunk.text || ''); + } + + expect(chunks).toEqual(['Stream chunk 1', 'Stream chunk 2']); + expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled(); + }); + + it('should handle token refresh failure', async () => { + // Mock the SharedTokenManager to throw an error + const mockTokenManager = SharedTokenManager.getInstance() as unknown as { + setMockError: (error: Error | null) => void; + }; + mockTokenManager.setMockError( + new Error( + 'Failed to obtain valid Qwen access token. Please re-authenticate.', + ), ); const request: GenerateContentParameters = { @@ -279,6 +448,9 @@ describe('QwenContentGenerator', () => { ).rejects.toThrow( 'Failed to obtain valid Qwen access token. Please re-authenticate.', ); + + // Clean up + mockTokenManager.setMockError(null); }); it('should update endpoint when token is refreshed', async () => { @@ -547,10 +719,24 @@ describe('QwenContentGenerator', () => { const originalGenerateContent = parentPrototype.generateContent; parentPrototype.generateContent = mockGenerateContent; - vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ - token: 'initial-token', + // Mock getAccessToken to fail initially, then succeed + let getAccessTokenCallCount = 0; + vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => { + getAccessTokenCallCount++; + if (getAccessTokenCallCount <= 2) { + throw authError; // Fail on first two calls (initial + retry) + } + return { token: 'refreshed-token' }; // Succeed after refresh }); - vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials); + + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ + access_token: 'refreshed-token', + token_type: 'Bearer', + refresh_token: 'refresh-token', + resource_url: 'https://test-endpoint.com', + expiry_date: Date.now() + 3600000, + }); + vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({ access_token: 'refreshed-token', token_type: 'Bearer', @@ -637,31 +823,16 @@ describe('QwenContentGenerator', () => { expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token'); }); - it('should clear token and endpoint on clearToken()', () => { - // Simulate having cached values + it('should clear token on clearToken()', () => { + // Simulate having cached token value const qwenInstance = qwenContentGenerator as unknown as { currentToken: string; - currentEndpoint: string; - refreshPromise: Promise; }; qwenInstance.currentToken = 'cached-token'; - qwenInstance.currentEndpoint = 'https://cached-endpoint.com'; - qwenInstance.refreshPromise = Promise.resolve('token'); qwenContentGenerator.clearToken(); expect(qwenContentGenerator.getCurrentToken()).toBeNull(); - expect( - (qwenContentGenerator as unknown as { currentEndpoint: string | null }) - .currentEndpoint, - ).toBeNull(); - expect( - ( - qwenContentGenerator as unknown as { - refreshPromise: Promise | null; - } - ).refreshPromise, - ).toBeNull(); }); it('should handle concurrent token refresh requests', async () => { @@ -674,9 +845,7 @@ describe('QwenContentGenerator', () => { const authError = { status: 401, message: 'Unauthorized' }; let parentCallCount = 0; - vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ - token: 'initial-token', - }); + vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(authError); vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials); vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation( @@ -725,6 +894,7 @@ describe('QwenContentGenerator', () => { // The main test is that all requests succeed without crashing expect(results).toHaveLength(3); + // With our new implementation through SharedTokenManager, refresh should still be called expect(refreshCallCount).toBeGreaterThanOrEqual(1); // Restore original method @@ -796,13 +966,24 @@ describe('QwenContentGenerator', () => { ); parentPrototype.generateContent = mockGenerateContent; - vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ - token: 'initial-token', + // Mock getAccessToken to fail initially, then succeed + let getAccessTokenCallCount = 0; + vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => { + getAccessTokenCallCount++; + if (getAccessTokenCallCount <= 2) { + throw authError; // Fail on first two calls (initial + retry) + } + return { token: 'new-token' }; // Succeed after refresh }); + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ - ...mockCredentials, - resource_url: 'custom-endpoint.com', + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'refresh-token', + resource_url: 'https://new-endpoint.com', + expiry_date: Date.now() + 7200000, }); + vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({ access_token: 'new-token', token_type: 'Bearer', @@ -826,4 +1007,595 @@ describe('QwenContentGenerator', () => { expect(callCount).toBe(2); // Initial call + retry }); }); + + describe('SharedTokenManager Integration', () => { + it('should use SharedTokenManager to get valid credentials', async () => { + const mockTokenManager = { + getValidCredentials: vi.fn().mockResolvedValue({ + access_token: 'manager-token', + resource_url: 'https://manager-endpoint.com', + }), + getCurrentCredentials: vi.fn(), + clearCache: vi.fn(), + }; + + // Mock the SharedTokenManager.getInstance() + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + // Create new instance to pick up the mock + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + + await newGenerator.generateContent(request, 'test-prompt-id'); + + expect(mockTokenManager.getValidCredentials).toHaveBeenCalledWith( + mockQwenClient, + ); + + // Restore original + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should handle SharedTokenManager errors gracefully', async () => { + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Token manager error')), + getCurrentCredentials: vi.fn(), + clearCache: vi.fn(), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + + await expect( + newGenerator.generateContent(request, 'test-prompt-id'), + ).rejects.toThrow('Failed to obtain valid Qwen access token'); + + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should handle missing access token from credentials', async () => { + const mockTokenManager = { + getValidCredentials: vi.fn().mockResolvedValue({ + access_token: undefined, + resource_url: 'https://test-endpoint.com', + }), + getCurrentCredentials: vi.fn(), + clearCache: vi.fn(), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + + await expect( + newGenerator.generateContent(request, 'test-prompt-id'), + ).rejects.toThrow('Failed to obtain valid Qwen access token'); + + SharedTokenManager.getInstance = originalGetInstance; + }); + }); + + describe('getCurrentEndpoint Method', () => { + it('should handle URLs with custom ports', () => { + const endpoints = [ + { input: 'localhost:8080', expected: 'https://localhost:8080/v1' }, + { + input: 'http://localhost:8080', + expected: 'http://localhost:8080/v1', + }, + { + input: 'https://api.example.com:443', + expected: 'https://api.example.com:443/v1', + }, + { + input: 'api.example.com:9000/api', + expected: 'https://api.example.com:9000/api/v1', + }, + ]; + + endpoints.forEach(({ input, expected }) => { + vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ + token: 'test-token', + }); + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ + ...mockCredentials, + resource_url: input, + }); + + const generator = qwenContentGenerator as unknown as { + getCurrentEndpoint: (resourceUrl?: string) => string; + }; + + expect(generator.getCurrentEndpoint(input)).toBe(expected); + }); + }); + + it('should handle URLs with existing paths', () => { + const endpoints = [ + { + input: 'https://api.example.com/api', + expected: 'https://api.example.com/api/v1', + }, + { + input: 'api.example.com/api/v2', + expected: 'https://api.example.com/api/v2/v1', + }, + { + input: 'https://api.example.com/api/v1', + expected: 'https://api.example.com/api/v1', + }, + ]; + + endpoints.forEach(({ input, expected }) => { + const generator = qwenContentGenerator as unknown as { + getCurrentEndpoint: (resourceUrl?: string) => string; + }; + + expect(generator.getCurrentEndpoint(input)).toBe(expected); + }); + }); + + it('should handle undefined resource URL', () => { + const generator = qwenContentGenerator as unknown as { + getCurrentEndpoint: (resourceUrl?: string) => string; + }; + + expect(generator.getCurrentEndpoint(undefined)).toBe( + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + ); + }); + + it('should handle empty resource URL', () => { + const generator = qwenContentGenerator as unknown as { + getCurrentEndpoint: (resourceUrl?: string) => string; + }; + + // Empty string should fall back to default endpoint + expect(generator.getCurrentEndpoint('')).toBe( + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + ); + }); + }); + + describe('isAuthError Method Enhanced', () => { + it('should identify auth errors by numeric status codes', () => { + const authErrors = [ + { code: 401 }, + { status: 403 }, + { code: '401' }, // String status codes + { status: '403' }, + ]; + + authErrors.forEach((error) => { + const generator = qwenContentGenerator as unknown as { + isAuthError: (error: unknown) => boolean; + }; + expect(generator.isAuthError(error)).toBe(true); + }); + + // 400 is not typically an auth error, it's bad request + const nonAuthError = { status: 400 }; + const generator = qwenContentGenerator as unknown as { + isAuthError: (error: unknown) => boolean; + }; + expect(generator.isAuthError(nonAuthError)).toBe(false); + }); + + it('should identify auth errors by message content variations', () => { + const authMessages = [ + 'UNAUTHORIZED access', + 'Access is FORBIDDEN', + 'Invalid API Key provided', + 'Invalid Access Token', + 'Token has Expired', + 'Authentication Required', + 'Access Denied by server', + 'The token has expired and needs refresh', + 'Bearer token expired', + ]; + + authMessages.forEach((message) => { + const error = new Error(message); + const generator = qwenContentGenerator as unknown as { + isAuthError: (error: unknown) => boolean; + }; + expect(generator.isAuthError(error)).toBe(true); + }); + }); + + it('should not identify non-auth errors', () => { + const nonAuthErrors = [ + new Error('Network timeout'), + new Error('Rate limit exceeded'), + { status: 500 }, + { code: 429 }, + 'Internal server error', + null, + undefined, + '', + { status: 200 }, + new Error('Model not found'), + ]; + + nonAuthErrors.forEach((error) => { + const generator = qwenContentGenerator as unknown as { + isAuthError: (error: unknown) => boolean; + }; + expect(generator.isAuthError(error)).toBe(false); + }); + }); + + it('should handle complex error objects', () => { + const complexErrors = [ + { error: { status: 401, message: 'Unauthorized' } }, + { response: { status: 403 } }, + { details: { code: 401 } }, + ]; + + // These should not be identified as auth errors because the method only looks at top-level properties + complexErrors.forEach((error) => { + const generator = qwenContentGenerator as unknown as { + isAuthError: (error: unknown) => boolean; + }; + expect(generator.isAuthError(error)).toBe(false); + }); + }); + }); + + describe('Stream Error Handling', () => { + it('should restore credentials when stream generation fails', async () => { + const client = ( + qwenContentGenerator as unknown as { + client: { apiKey: string; baseURL: string }; + } + ).client; + const originalApiKey = client.apiKey; + const originalBaseURL = client.baseURL; + + vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ + token: 'stream-token', + }); + vi.mocked(mockQwenClient.getCredentials).mockReturnValue({ + ...mockCredentials, + resource_url: 'https://stream-endpoint.com', + }); + + // Mock parent method to throw error + const parentPrototype = Object.getPrototypeOf( + Object.getPrototypeOf(qwenContentGenerator), + ); + const originalGenerateContentStream = + parentPrototype.generateContentStream; + parentPrototype.generateContentStream = vi + .fn() + .mockRejectedValue(new Error('Stream error')); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }], + }; + + try { + await qwenContentGenerator.generateContentStream( + request, + 'test-prompt-id', + ); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + + // Credentials should be restored even on error + expect(client.apiKey).toBe(originalApiKey); + expect(client.baseURL).toBe(originalBaseURL); + + // Restore original method + parentPrototype.generateContentStream = originalGenerateContentStream; + }); + + it('should not restore credentials in finally block for successful streams', async () => { + const client = ( + qwenContentGenerator as unknown as { + client: { apiKey: string; baseURL: string }; + } + ).client; + + // Set up the mock to return stream credentials + const streamCredentials = { + access_token: 'stream-token', + refresh_token: 'stream-refresh-token', + resource_url: 'https://stream-endpoint.com', + expiry_date: Date.now() + 3600000, + }; + + vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({ + token: 'stream-token', + }); + vi.mocked(mockQwenClient.getCredentials).mockReturnValue( + streamCredentials, + ); + + // Set the SharedTokenManager mock to return stream credentials + const mockTokenManager = SharedTokenManager.getInstance() as unknown as { + setMockCredentials: (credentials: QwenCredentials | null) => void; + }; + mockTokenManager.setMockCredentials(streamCredentials); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }], + }; + + const stream = await qwenContentGenerator.generateContentStream( + request, + 'test-prompt-id', + ); + + // After successful stream creation, credentials should still be set for the stream + expect(client.apiKey).toBe('stream-token'); + expect(client.baseURL).toBe('https://stream-endpoint.com/v1'); + + // Consume the stream + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(2); + + // Clean up + mockTokenManager.setMockCredentials(null); + }); + }); + + describe('Token and Endpoint Management', () => { + it('should get current token from SharedTokenManager', () => { + const mockTokenManager = { + getCurrentCredentials: vi.fn().mockReturnValue({ + access_token: 'current-token', + }), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + expect(newGenerator.getCurrentToken()).toBe('current-token'); + + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should return null when no credentials available', () => { + const mockTokenManager = { + getCurrentCredentials: vi.fn().mockReturnValue(null), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + expect(newGenerator.getCurrentToken()).toBeNull(); + + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should return null when credentials have no access token', () => { + const mockTokenManager = { + getCurrentCredentials: vi.fn().mockReturnValue({ + access_token: undefined, + }), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + expect(newGenerator.getCurrentToken()).toBeNull(); + + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should clear token through SharedTokenManager', () => { + const mockTokenManager = { + clearCache: vi.fn(), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + newGenerator.clearToken(); + + expect(mockTokenManager.clearCache).toHaveBeenCalled(); + + SharedTokenManager.getInstance = originalGetInstance; + }); + }); + + describe('Constructor and Initialization', () => { + it('should initialize with default base URL', () => { + const generator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const client = (generator as unknown as { client: { baseURL: string } }) + .client; + expect(client.baseURL).toBe( + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + ); + }); + + it('should get SharedTokenManager instance', () => { + const generator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const sharedManager = ( + generator as unknown as { sharedManager: SharedTokenManager } + ).sharedManager; + expect(sharedManager).toBeDefined(); + }); + }); + + describe('Edge Cases and Error Conditions', () => { + it('should handle token retrieval with warning when SharedTokenManager fails', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Internal token manager error')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const request: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + + await expect( + newGenerator.generateContent(request, 'test-prompt-id'), + ).rejects.toThrow('Failed to obtain valid Qwen access token'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to get token from shared manager:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should handle all method types with token failure', async () => { + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Token error')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const newGenerator = new QwenContentGenerator( + mockQwenClient, + { model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH }, + mockConfig, + ); + + const generateRequest: GenerateContentParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + }; + + const countRequest: CountTokensParameters = { + model: 'qwen-turbo', + contents: [{ role: 'user', parts: [{ text: 'Count' }] }], + }; + + const embedRequest: EmbedContentParameters = { + model: 'qwen-turbo', + contents: [{ parts: [{ text: 'Embed' }] }], + }; + + // All methods should fail with the same error + await expect( + newGenerator.generateContent(generateRequest, 'test-id'), + ).rejects.toThrow('Failed to obtain valid Qwen access token'); + + await expect( + newGenerator.generateContentStream(generateRequest, 'test-id'), + ).rejects.toThrow('Failed to obtain valid Qwen access token'); + + await expect(newGenerator.countTokens(countRequest)).rejects.toThrow( + 'Failed to obtain valid Qwen access token', + ); + + await expect(newGenerator.embedContent(embedRequest)).rejects.toThrow( + 'Failed to obtain valid Qwen access token', + ); + + SharedTokenManager.getInstance = originalGetInstance; + }); + }); }); diff --git a/packages/core/src/qwen/qwenContentGenerator.ts b/packages/core/src/qwen/qwenContentGenerator.ts index 4180efa2..9ef89497 100644 --- a/packages/core/src/qwen/qwenContentGenerator.ts +++ b/packages/core/src/qwen/qwenContentGenerator.ts @@ -5,12 +5,8 @@ */ import { OpenAIContentGenerator } from '../core/openaiContentGenerator.js'; -import { - IQwenOAuth2Client, - type TokenRefreshData, - type ErrorData, - isErrorResponse, -} from './qwenOAuth2.js'; +import { IQwenOAuth2Client } from './qwenOAuth2.js'; +import { SharedTokenManager } from './sharedTokenManager.js'; import { Config } from '../config/config.js'; import { GenerateContentParameters, @@ -31,11 +27,8 @@ const DEFAULT_QWEN_BASE_URL = */ export class QwenContentGenerator extends OpenAIContentGenerator { private qwenClient: IQwenOAuth2Client; - - // Token management (integrated from QwenTokenManager) - private currentToken: string | null = null; - private currentEndpoint: string | null = null; - private refreshPromise: Promise | null = null; + private sharedManager: SharedTokenManager; + private currentToken?: string; constructor( qwenClient: IQwenOAuth2Client, @@ -45,6 +38,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator { // Initialize with empty API key, we'll override it dynamically super(contentGeneratorConfig, config); this.qwenClient = qwenClient; + this.sharedManager = SharedTokenManager.getInstance(); // Set default base URL, will be updated dynamically this.client.baseURL = DEFAULT_QWEN_BASE_URL; @@ -53,8 +47,8 @@ export class QwenContentGenerator extends OpenAIContentGenerator { /** * Get the current endpoint URL with proper protocol and /v1 suffix */ - private getCurrentEndpoint(): string { - const baseEndpoint = this.currentEndpoint || DEFAULT_QWEN_BASE_URL; + private getCurrentEndpoint(resourceUrl?: string): string { + const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL; const suffix = '/v1'; // Normalize the URL: add protocol if missing, ensure /v1 suffix @@ -79,237 +73,149 @@ export class QwenContentGenerator extends OpenAIContentGenerator { } /** - * Override to use dynamic token and endpoint + * Get valid token and endpoint using the shared token manager */ - override async generateContent( - request: GenerateContentParameters, - userPromptId: string, - ): Promise { - return this.withValidToken(async (token) => { - // Temporarily update the API key and base URL - const originalApiKey = this.client.apiKey; - const originalBaseURL = this.client.baseURL; - this.client.apiKey = token; - this.client.baseURL = this.getCurrentEndpoint(); + private async getValidToken(): Promise<{ token: string; endpoint: string }> { + try { + // Use SharedTokenManager for consistent token/endpoint pairing and automatic refresh + const credentials = await this.sharedManager.getValidCredentials( + this.qwenClient, + ); - try { - return await super.generateContent(request, userPromptId); - } finally { - // Restore original values - this.client.apiKey = originalApiKey; - this.client.baseURL = originalBaseURL; + if (!credentials.access_token) { + throw new Error('No access token available'); } - }); - } - /** - * Override to use dynamic token and endpoint - */ - override async generateContentStream( - request: GenerateContentParameters, - userPromptId: string, - ): Promise> { - return this.withValidTokenForStream(async (token) => { - // Update the API key and base URL before streaming - const originalApiKey = this.client.apiKey; - const originalBaseURL = this.client.baseURL; - this.client.apiKey = token; - this.client.baseURL = this.getCurrentEndpoint(); - - try { - return await super.generateContentStream(request, userPromptId); - } catch (error) { - // Restore original values on error - this.client.apiKey = originalApiKey; - this.client.baseURL = originalBaseURL; + return { + token: credentials.access_token, + endpoint: this.getCurrentEndpoint(credentials.resource_url), + }; + } catch (error) { + // Propagate auth errors as-is for retry logic + if (this.isAuthError(error)) { throw error; } - // Note: We don't restore the values in finally for streaming because - // the generator may continue to be used after this method returns - }); - } - - /** - * Override to use dynamic token and endpoint - */ - override async countTokens( - request: CountTokensParameters, - ): Promise { - return this.withValidToken(async (token) => { - const originalApiKey = this.client.apiKey; - const originalBaseURL = this.client.baseURL; - this.client.apiKey = token; - this.client.baseURL = this.getCurrentEndpoint(); - - try { - return await super.countTokens(request); - } finally { - this.client.apiKey = originalApiKey; - this.client.baseURL = originalBaseURL; - } - }); - } - - /** - * Override to use dynamic token and endpoint - */ - override async embedContent( - request: EmbedContentParameters, - ): Promise { - return this.withValidToken(async (token) => { - const originalApiKey = this.client.apiKey; - const originalBaseURL = this.client.baseURL; - this.client.apiKey = token; - this.client.baseURL = this.getCurrentEndpoint(); - - try { - return await super.embedContent(request); - } finally { - this.client.apiKey = originalApiKey; - this.client.baseURL = originalBaseURL; - } - }); - } - - /** - * Execute operation with a valid token, with retry on auth failure - */ - private async withValidToken( - operation: (token: string) => Promise, - ): Promise { - const token = await this.getTokenWithRetry(); - - try { - return await operation(token); - } catch (error) { - // Check if this is an authentication error - if (this.isAuthError(error)) { - // Refresh token and retry once silently - const newToken = await this.refreshToken(); - return await operation(newToken); - } - - throw error; - } - } - - /** - * Execute operation with a valid token for streaming, with retry on auth failure - */ - private async withValidTokenForStream( - operation: (token: string) => Promise, - ): Promise { - const token = await this.getTokenWithRetry(); - - try { - return await operation(token); - } catch (error) { - // Check if this is an authentication error - if (this.isAuthError(error)) { - // Refresh token and retry once silently - const newToken = await this.refreshToken(); - return await operation(newToken); - } - - throw error; - } - } - - /** - * Get token with retry logic - */ - private async getTokenWithRetry(): Promise { - try { - return await this.getValidToken(); - } catch (error) { - console.error('Failed to get valid token:', error); + console.warn('Failed to get token from shared manager:', error); throw new Error( 'Failed to obtain valid Qwen access token. Please re-authenticate.', ); } } - // Token management methods (integrated from QwenTokenManager) - /** - * Get a valid access token, refreshing if necessary + * Execute an operation with automatic credential management and retry logic. + * This method handles: + * - Dynamic token and endpoint retrieval + * - Temporary client configuration updates + * - Automatic restoration of original configuration + * - Retry logic on authentication errors with token refresh + * + * @param operation - The operation to execute with updated client configuration + * @param restoreOnCompletion - Whether to restore original config after operation completes + * @returns The result of the operation */ - private async getValidToken(): Promise { - // If there's already a refresh in progress, wait for it - if (this.refreshPromise) { - return this.refreshPromise; - } + private async executeWithCredentialManagement( + operation: () => Promise, + restoreOnCompletion: boolean = true, + ): Promise { + // Attempt the operation with credential management and retry logic + const attemptOperation = async (): Promise => { + const { token, endpoint } = await this.getValidToken(); - try { - const { token } = await this.qwenClient.getAccessToken(); - if (token) { - this.currentToken = token; - // Also update endpoint from current credentials - const credentials = this.qwenClient.getCredentials(); - if (credentials.resource_url) { - this.currentEndpoint = credentials.resource_url; + // Store original configuration + const originalApiKey = this.client.apiKey; + const originalBaseURL = this.client.baseURL; + + // Apply dynamic configuration + this.client.apiKey = token; + this.client.baseURL = endpoint; + + try { + const result = await operation(); + + // For streaming operations, we may need to keep the configuration active + if (restoreOnCompletion) { + this.client.apiKey = originalApiKey; + this.client.baseURL = originalBaseURL; } - return token; + + return result; + } catch (error) { + // Always restore on error + this.client.apiKey = originalApiKey; + this.client.baseURL = originalBaseURL; + throw error; } - } catch (error) { - console.warn('Failed to get access token, attempting refresh:', error); - } - - // Start a new refresh operation - this.refreshPromise = this.performTokenRefresh(); + }; + // Execute with retry logic for auth errors try { - const newToken = await this.refreshPromise; - return newToken; - } finally { - this.refreshPromise = null; + return await attemptOperation(); + } catch (error) { + if (this.isAuthError(error)) { + try { + // Use SharedTokenManager to properly refresh and persist the token + // This ensures the refreshed token is saved to oauth_creds.json + await this.sharedManager.getValidCredentials(this.qwenClient, true); + // Retry the operation once with fresh credentials + return await attemptOperation(); + } catch (_refreshError) { + throw new Error( + 'Failed to obtain valid Qwen access token. Please re-authenticate.', + ); + } + } + throw error; } } /** - * Force refresh the access token + * Override to use dynamic token and endpoint with automatic retry */ - private async refreshToken(): Promise { - this.refreshPromise = this.performTokenRefresh(); - - try { - const newToken = await this.refreshPromise; - return newToken; - } finally { - this.refreshPromise = null; - } + override async generateContent( + request: GenerateContentParameters, + userPromptId: string, + ): Promise { + return this.executeWithCredentialManagement(() => + super.generateContent(request, userPromptId), + ); } - private async performTokenRefresh(): Promise { - try { - const response = await this.qwenClient.refreshAccessToken(); + /** + * Override to use dynamic token and endpoint with automatic retry. + * Note: For streaming, the client configuration is not restored immediately + * since the generator may continue to be used after this method returns. + */ + override async generateContentStream( + request: GenerateContentParameters, + userPromptId: string, + ): Promise> { + return this.executeWithCredentialManagement( + () => super.generateContentStream(request, userPromptId), + false, // Don't restore immediately for streaming + ); + } - if (isErrorResponse(response)) { - const errorData = response as ErrorData; - throw new Error( - `${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`, - ); - } + /** + * Override to use dynamic token and endpoint with automatic retry + */ + override async countTokens( + request: CountTokensParameters, + ): Promise { + return this.executeWithCredentialManagement(() => + super.countTokens(request), + ); + } - const tokenData = response as TokenRefreshData; - - if (!tokenData.access_token) { - throw new Error('Failed to refresh access token: no token returned'); - } - - this.currentToken = tokenData.access_token; - - // Update endpoint if provided - if (tokenData.resource_url) { - this.currentEndpoint = tokenData.resource_url; - } - - return tokenData.access_token; - } catch (error) { - throw new Error( - `${error instanceof Error ? error.message : String(error)}`, - ); - } + /** + * Override to use dynamic token and endpoint with automatic retry + */ + override async embedContent( + request: EmbedContentParameters, + ): Promise { + return this.executeWithCredentialManagement(() => + super.embedContent(request), + ); } /** @@ -331,9 +237,10 @@ export class QwenContentGenerator extends OpenAIContentGenerator { const errorCode = errorWithCode?.status || errorWithCode?.code; return ( - errorCode === 400 || errorCode === 401 || errorCode === 403 || + errorCode === '401' || + errorCode === '403' || errorMessage.includes('unauthorized') || errorMessage.includes('forbidden') || errorMessage.includes('invalid api key') || @@ -349,15 +256,22 @@ export class QwenContentGenerator extends OpenAIContentGenerator { * Get the current cached token (may be expired) */ getCurrentToken(): string | null { - return this.currentToken; + // First check internal state for backwards compatibility with tests + if (this.currentToken) { + return this.currentToken; + } + // Fall back to SharedTokenManager + const credentials = this.sharedManager.getCurrentCredentials(); + return credentials?.access_token || null; } /** - * Clear the cached token and endpoint + * Clear the cached token */ clearToken(): void { - this.currentToken = null; - this.currentEndpoint = null; - this.refreshPromise = null; + // Clear internal state for backwards compatibility with tests + this.currentToken = undefined; + // Also clear SharedTokenManager + this.sharedManager.clearCache(); } } diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 73a3a567..ffeee83e 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -20,7 +20,74 @@ import { type DeviceAuthorizationResponse, type DeviceTokenResponse, type ErrorData, + type QwenCredentials, } from './qwenOAuth2.js'; +import { + SharedTokenManager, + TokenManagerError, + TokenError, +} from './sharedTokenManager.js'; + +interface MockSharedTokenManager { + getValidCredentials(qwenClient: QwenOAuth2Client): Promise; + getCurrentCredentials(): QwenCredentials | null; + clearCache(): void; +} + +// Mock SharedTokenManager +vi.mock('./sharedTokenManager.js', () => ({ + SharedTokenManager: class { + private static instance: MockSharedTokenManager | null = null; + + static getInstance() { + if (!this.instance) { + this.instance = new this(); + } + return this.instance; + } + + async getValidCredentials( + qwenClient: QwenOAuth2Client, + ): Promise { + // Try to get credentials from the client first + const clientCredentials = qwenClient.getCredentials(); + if (clientCredentials && clientCredentials.access_token) { + return clientCredentials; + } + + // Fall back to default mock credentials if client has none + return { + access_token: 'new-access-token', + refresh_token: 'valid-refresh-token', + resource_url: undefined, + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, + }; + } + + getCurrentCredentials(): QwenCredentials | null { + // Return null to let the client manage its own credentials + return null; + } + + clearCache(): void { + // Do nothing in mock + } + }, + TokenManagerError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenManagerError'; + } + }, + TokenError: { + REFRESH_FAILED: 'REFRESH_FAILED', + NO_REFRESH_TOKEN: 'NO_REFRESH_TOKEN', + LOCK_TIMEOUT: 'LOCK_TIMEOUT', + FILE_ACCESS_ERROR: 'FILE_ACCESS_ERROR', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); // Mock qrcode-terminal vi.mock('qrcode-terminal', () => ({ @@ -227,7 +294,7 @@ describe('QwenOAuth2Client', () => { beforeEach(() => { // Create client instance - client = new QwenOAuth2Client({ proxy: undefined }); + client = new QwenOAuth2Client(); // Mock fetch originalFetch = global.fetch; @@ -345,10 +412,9 @@ describe('QwenOAuth2Client', () => { ); }); - it('should cache credentials after successful refresh', async () => { - const { promises: fs } = await import('node:fs'); - const mockWriteFile = vi.mocked(fs.writeFile); - const mockMkdir = vi.mocked(fs.mkdir); + it('should successfully refresh access token and update credentials', async () => { + // Clear any previous calls + vi.clearAllMocks(); const mockResponse = { ok: true, @@ -362,28 +428,30 @@ describe('QwenOAuth2Client', () => { vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); - await client.refreshAccessToken(); + const result = await client.refreshAccessToken(); - // Verify that cacheQwenCredentials was called by checking if writeFile was called - expect(mockMkdir).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalled(); + // Verify the response + expect(result).toMatchObject({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://new-endpoint.com', + }); - // Verify the cached credentials contain the new token data - const writeCall = mockWriteFile.mock.calls[0]; - const cachedCredentials = JSON.parse(writeCall[1] as string); - - expect(cachedCredentials).toMatchObject({ + // Verify credentials were updated + const credentials = client.getCredentials(); + expect(credentials).toMatchObject({ access_token: 'new-access-token', token_type: 'Bearer', refresh_token: 'test-refresh-token', // Should preserve existing refresh token resource_url: 'https://new-endpoint.com', }); - expect(cachedCredentials.expiry_date).toBeDefined(); + expect(credentials.expiry_date).toBeDefined(); }); it('should use new refresh token if provided in response', async () => { - const { promises: fs } = await import('node:fs'); - const mockWriteFile = vi.mocked(fs.writeFile); + // Clear any previous calls + vi.clearAllMocks(); const mockResponse = { ok: true, @@ -400,11 +468,9 @@ describe('QwenOAuth2Client', () => { await client.refreshAccessToken(); - // Verify the cached credentials contain the new refresh token - const writeCall = mockWriteFile.mock.calls[0]; - const cachedCredentials = JSON.parse(writeCall[1] as string); - - expect(cachedCredentials.refresh_token).toBe('new-refresh-token'); + // Verify the credentials contain the new refresh token + const credentials = client.getCredentials(); + expect(credentials.refresh_token).toBe('new-refresh-token'); }); }); @@ -428,19 +494,22 @@ describe('QwenOAuth2Client', () => { expiry_date: Date.now() - 1000, // 1 second ago }); - const mockRefreshResponse = { - ok: true, - json: async () => ({ + // Override the client's SharedTokenManager instance directly + ( + client as unknown as { + sharedManager: { + getValidCredentials: () => Promise; + }; + } + ).sharedManager = { + getValidCredentials: vi.fn().mockResolvedValue({ access_token: 'new-access-token', + refresh_token: 'valid-refresh-token', token_type: 'Bearer', - expires_in: 3600, + expiry_date: Date.now() + 3600000, }), }; - vi.mocked(global.fetch).mockResolvedValue( - mockRefreshResponse as Response, - ); - const result = await client.getAccessToken(); expect(result.token).toBe('new-access-token'); }); @@ -448,6 +517,19 @@ describe('QwenOAuth2Client', () => { it('should return undefined if no access token and no refresh token', async () => { client.setCredentials({}); + // Override the client's SharedTokenManager instance directly + ( + client as unknown as { + sharedManager: { + getValidCredentials: () => Promise; + }; + } + ).sharedManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials available')), + }; + const result = await client.getAccessToken(); expect(result.token).toBeUndefined(); }); @@ -662,7 +744,6 @@ describe('getQwenOAuthClient', () => { beforeEach(() => { mockConfig = { - getProxy: vi.fn().mockReturnValue(undefined), isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), } as unknown as Config; @@ -675,38 +756,8 @@ describe('getQwenOAuthClient', () => { vi.clearAllMocks(); }); - it('should create client with proxy configuration', async () => { - const proxyUrl = 'http://proxy.example.com:8080'; - mockConfig.getProxy = vi.fn().mockReturnValue(proxyUrl); - - const { promises: fs } = await import('node:fs'); - vi.mocked(fs.readFile).mockRejectedValue( - new Error('No cached credentials'), - ); - - // Mock device authorization flow to fail quickly for this test - const mockAuthResponse = { - ok: true, - json: async () => ({ - error: 'test_error', - error_description: 'Test error for quick failure', - }), - }; - vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); - - try { - await import('./qwenOAuth2.js').then((module) => - module.getQwenOAuthClient(mockConfig), - ); - } catch { - // Expected to fail due to mocked error - } - - expect(mockConfig.getProxy).toHaveBeenCalled(); - }); - it('should load cached credentials if available', async () => { - const { promises: fs } = await import('node:fs'); + const fs = await import('node:fs'); const mockCredentials = { access_token: 'cached-token', refresh_token: 'cached-refresh', @@ -714,29 +765,30 @@ describe('getQwenOAuthClient', () => { expiry_date: Date.now() + 3600000, }; - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + vi.mocked(fs.promises.readFile).mockResolvedValue( + JSON.stringify(mockCredentials), + ); - // Mock successful refresh - const mockRefreshResponse = { - ok: true, - json: async () => ({ - access_token: 'refreshed-token', - token_type: 'Bearer', - expires_in: 3600, - }), + // Mock SharedTokenManager to use cached credentials + const mockTokenManager = { + getValidCredentials: vi.fn().mockResolvedValue(mockCredentials), }; - vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); const client = await import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ); expect(client).toBeInstanceOf(Object); - expect(fs.readFile).toHaveBeenCalled(); + expect(mockTokenManager.getValidCredentials).toHaveBeenCalled(); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle cached credentials refresh failure', async () => { - const { promises: fs } = await import('node:fs'); + const fs = await import('node:fs'); const mockCredentials = { access_token: 'cached-token', refresh_token: 'expired-refresh', @@ -744,23 +796,38 @@ describe('getQwenOAuthClient', () => { expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true }; - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + vi.mocked(fs.promises.readFile).mockResolvedValue( + JSON.stringify(mockCredentials), + ); - // Mock refresh failure with 400 status to trigger credential clearing - const mockRefreshResponse = { - ok: false, - status: 400, - statusText: 'Bad Request', - text: async () => 'Refresh token expired or invalid', + // Mock SharedTokenManager to fail with a specific error + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Token refresh failed')), }; - vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + + // Mock device flow to also fail + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'invalid_request', + error_description: 'Invalid request parameters', + }), + }; + vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); // The function should handle the invalid cached credentials and throw the expected error await expect( import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow('Cached Qwen credentials are invalid'); + ).rejects.toThrow('Qwen OAuth authentication failed'); + + SharedTokenManager.getInstance = originalGetInstance; }); }); @@ -803,7 +870,7 @@ describe('QwenOAuth2Client - Additional Error Scenarios', () => { let originalFetch: typeof global.fetch; beforeEach(() => { - client = new QwenOAuth2Client({ proxy: undefined }); + client = new QwenOAuth2Client(); originalFetch = global.fetch; global.fetch = vi.fn(); }); @@ -858,7 +925,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { beforeEach(() => { mockConfig = { - getProxy: vi.fn().mockReturnValue(undefined), isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), } as unknown as Config; @@ -882,22 +948,33 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); - // Mock generic refresh failure (not 400 status) - const mockRefreshResponse = { - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: async () => 'Internal server error', + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Refresh failed')), }; - vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response); + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + + // Mock device flow to also fail + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'invalid_request', + error_description: 'Invalid request parameters', + }), + }; + vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); await expect( import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), - ).rejects.toThrow( - 'Qwen token refresh failed: Token refresh failed: 500 Internal Server Error', - ); + ).rejects.toThrow('Qwen OAuth authentication failed'); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle different authentication failure reasons - timeout', async () => { @@ -906,6 +983,16 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + // Mock device authorization to succeed but polling to timeout const mockAuthResponse = { ok: true, @@ -925,7 +1012,8 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { }), }; - vi.mocked(global.fetch) + global.fetch = vi + .fn() .mockResolvedValueOnce(mockAuthResponse as Response) .mockResolvedValue(mockPendingResponse as Response); @@ -934,6 +1022,8 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { module.getQwenOAuthClient(mockConfig), ), ).rejects.toThrow('Qwen OAuth authentication timed out'); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle authentication failure reason - rate limit', async () => { @@ -942,6 +1032,16 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + // Mock device authorization to succeed but polling to get rate limited const mockAuthResponse = { ok: true, @@ -961,7 +1061,8 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { text: async () => 'Rate limited', }; - vi.mocked(global.fetch) + global.fetch = vi + .fn() .mockResolvedValueOnce(mockAuthResponse as Response) .mockResolvedValue(mockRateLimitResponse as Response); @@ -972,6 +1073,8 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { ).rejects.toThrow( 'Too many request for Qwen OAuth authentication, please try again later.', ); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle authentication failure reason - error', async () => { @@ -980,6 +1083,16 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + // Mock device authorization to fail const mockAuthResponse = { ok: true, @@ -989,13 +1102,15 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => { }), }; - vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); + global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response); await expect( import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), ).rejects.toThrow('Qwen OAuth authentication failed'); + + SharedTokenManager.getInstance = originalGetInstance; }); }); @@ -1005,11 +1120,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { beforeEach(() => { mockConfig = { - getProxy: vi.fn().mockReturnValue(undefined), isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), } as unknown as Config; - new QwenOAuth2Client({ proxy: undefined }); originalFetch = global.fetch; global.fetch = vi.fn(); @@ -1029,6 +1142,16 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + const mockAuthResponse = { ok: true, json: async () => ({ @@ -1037,13 +1160,15 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { }), }; - vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response); + global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response); await expect( import('./qwenOAuth2.js').then((module) => module.getQwenOAuthClient(mockConfig), ), ).rejects.toThrow('Qwen OAuth authentication failed'); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle successful authentication flow', async () => { @@ -1091,6 +1216,16 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + const mockAuthResponse = { ok: true, json: async () => ({ @@ -1109,7 +1244,8 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { text: async () => 'Device code expired', }; - vi.mocked(global.fetch) + global.fetch = vi + .fn() .mockResolvedValueOnce(mockAuthResponse as Response) .mockResolvedValue(mock401Response as Response); @@ -1118,6 +1254,8 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { module.getQwenOAuthClient(mockConfig), ), ).rejects.toThrow('Qwen OAuth authentication failed'); + + SharedTokenManager.getInstance = originalGetInstance; }); it('should handle token polling with browser launch suppressed', async () => { @@ -1126,6 +1264,16 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { new Error('No cached credentials'), ); + // Mock SharedTokenManager to fail initially so device flow is used + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + // Mock browser launch as suppressed mockConfig.isBrowserLaunchSuppressed = vi.fn().mockReturnValue(true); @@ -1151,7 +1299,8 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { }), }; - vi.mocked(global.fetch) + global.fetch = vi + .fn() .mockResolvedValueOnce(mockAuthResponse as Response) .mockResolvedValue(mockTokenResponse as Response); @@ -1161,6 +1310,8 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { expect(client).toBeInstanceOf(Object); expect(mockConfig.isBrowserLaunchSuppressed).toHaveBeenCalled(); + + SharedTokenManager.getInstance = originalGetInstance; }); }); @@ -1170,7 +1321,6 @@ describe('Browser Launch and Error Handling', () => { beforeEach(() => { mockConfig = { - getProxy: vi.fn().mockReturnValue(undefined), isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), } as unknown as Config; @@ -1295,3 +1445,833 @@ describe('Event Emitter Integration', () => { expect(QwenOAuth2Event.AuthCancel).toBe('auth-cancel'); }); }); + +describe('Utility Functions', () => { + describe('objectToUrlEncoded', () => { + it('should encode object properties to URL-encoded format', async () => { + // Since objectToUrlEncoded is private, we test it indirectly through the client + const objectToUrlEncoded = (data: Record): string => + Object.keys(data) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`, + ) + .join('&'); + + const testData = { + client_id: 'test-client', + scope: 'openid profile', + redirect_uri: 'https://example.com/callback', + }; + + const result = objectToUrlEncoded(testData); + + expect(result).toContain('client_id=test-client'); + expect(result).toContain('scope=openid%20profile'); + expect(result).toContain( + 'redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', + ); + }); + + it('should handle special characters', async () => { + const objectToUrlEncoded = (data: Record): string => + Object.keys(data) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`, + ) + .join('&'); + + const testData = { + 'param with spaces': 'value with spaces', + 'param&with&s': 'value&with&s', + 'param=with=equals': 'value=with=equals', + }; + + const result = objectToUrlEncoded(testData); + + expect(result).toContain('param%20with%20spaces=value%20with%20spaces'); + expect(result).toContain('param%26with%26amps=value%26with%26amps'); + expect(result).toContain('param%3Dwith%3Dequals=value%3Dwith%3Dequals'); + }); + + it('should handle empty object', async () => { + const objectToUrlEncoded = (data: Record): string => + Object.keys(data) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`, + ) + .join('&'); + + const result = objectToUrlEncoded({}); + expect(result).toBe(''); + }); + }); + + describe('getQwenCachedCredentialPath', () => { + it('should return correct path to cached credentials', async () => { + const os = await import('os'); + const path = await import('path'); + + const expectedPath = path.join(os.homedir(), '.qwen', 'oauth_creds.json'); + + // Since this is a private function, we test it indirectly through clearQwenCredentials + const { promises: fs } = await import('node:fs'); + const { clearQwenCredentials } = await import('./qwenOAuth2.js'); + + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await clearQwenCredentials(); + + expect(fs.unlink).toHaveBeenCalledWith(expectedPath); + }); + }); +}); + +describe('Credential Caching Functions', () => { + describe('cacheQwenCredentials', () => { + it('should create directory and write credentials to file', async () => { + // Mock the internal cacheQwenCredentials function by creating client and calling refresh + const client = new QwenOAuth2Client(); + client.setCredentials({ + refresh_token: 'test-refresh', + }); + + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-token', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse as Response); + + await client.refreshAccessToken(); + + // Note: File caching is now handled by SharedTokenManager, so these calls won't happen + // This test verifies that refreshAccessToken works correctly + const updatedCredentials = client.getCredentials(); + expect(updatedCredentials.access_token).toBe('new-token'); + }); + }); + + describe('loadCachedQwenCredentials', () => { + it('should load and validate cached credentials successfully', async () => { + const { promises: fs } = await import('node:fs'); + const mockCredentials = { + access_token: 'cached-token', + refresh_token: 'cached-refresh', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)); + + // Test through getQwenOAuthClient which calls loadCachedQwenCredentials + const mockConfig = { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), + } as unknown as Config; + + // Make SharedTokenManager fail to test the fallback + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No cached creds')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + // Mock successful auth flow after cache load fails + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email model.completion', + }), + }; + + global.fetch = vi + .fn() + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + try { + await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + } catch { + // Expected to fail in test environment + } + + expect(fs.readFile).toHaveBeenCalled(); + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should handle invalid cached credentials gracefully', async () => { + const { promises: fs } = await import('node:fs'); + + // Mock file read to return invalid JSON + vi.mocked(fs.readFile).mockResolvedValue('invalid-json'); + + const mockConfig = { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), + } as unknown as Config; + + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No cached creds')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + // Mock auth flow + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + + global.fetch = vi + .fn() + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + try { + await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + } catch { + // Expected to fail in test environment + } + + SharedTokenManager.getInstance = originalGetInstance; + }); + + it('should handle file access errors', async () => { + const { promises: fs } = await import('node:fs'); + + vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); + + const mockConfig = { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), + } as unknown as Config; + + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No cached creds')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + // Mock device flow to fail quickly + const mockAuthResponse = { + ok: true, + json: async () => ({ + error: 'invalid_request', + error_description: 'Invalid request parameters', + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response); + + // Should proceed to device flow when cache loading fails + try { + await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + } catch { + // Expected to fail in test environment + } + + SharedTokenManager.getInstance = originalGetInstance; + }); + }); +}); + +describe('Enhanced Error Handling and Edge Cases', () => { + let client: QwenOAuth2Client; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + client = new QwenOAuth2Client(); + originalFetch = global.fetch; + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + describe('QwenOAuth2Client getAccessToken enhanced scenarios', () => { + it('should handle SharedTokenManager failure and fall back to cached token', async () => { + // Set up client with valid credentials + client.setCredentials({ + access_token: 'fallback-token', + expiry_date: Date.now() + 3600000, // Valid for 1 hour + }); + + // Override the client's SharedTokenManager instance directly to ensure it fails + ( + client as unknown as { + sharedManager: { + getValidCredentials: () => Promise; + }; + } + ).sharedManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Manager failed')), + }; + + // Mock console.warn to avoid test noise + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await client.getAccessToken(); + + expect(result.token).toBe('fallback-token'); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to get access token from shared manager:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should return undefined when both manager and cache fail', async () => { + // Set up client with expired credentials + client.setCredentials({ + access_token: 'expired-token', + expiry_date: Date.now() - 1000, // Expired + }); + + // Override the client's SharedTokenManager instance directly to ensure it fails + ( + client as unknown as { + sharedManager: { + getValidCredentials: () => Promise; + }; + } + ).sharedManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Manager failed')), + }; + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await client.getAccessToken(); + + expect(result.token).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + + it('should handle missing credentials gracefully', async () => { + // No credentials set + client.setCredentials({}); + + // Override the client's SharedTokenManager instance directly to ensure it fails + ( + client as unknown as { + sharedManager: { + getValidCredentials: () => Promise; + }; + } + ).sharedManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('No credentials')), + }; + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await client.getAccessToken(); + + expect(result.token).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + }); + + describe('Enhanced requestDeviceAuthorization scenarios', () => { + it('should include x-request-id header', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-request-id': expect.any(String), + }), + }), + ); + }); + + it('should include correct Content-Type and Accept headers', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }), + }), + ); + }); + + it('should send correct form data', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'test-scope', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + const [, options] = vi.mocked(global.fetch).mock.calls[0]; + expect(options?.body).toContain( + 'client_id=f0304373b74a44d2b584a3fb70ca9e56', + ); + expect(options?.body).toContain('scope=test-scope'); + expect(options?.body).toContain('code_challenge=test-challenge'); + expect(options?.body).toContain('code_challenge_method=S256'); + }); + }); + + describe('Enhanced pollDeviceToken scenarios', () => { + it('should handle JSON parsing error during error response', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + text: vi.fn().mockResolvedValue('Invalid request format'), + }; + + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-verifier', + }), + ).rejects.toThrow('Device token poll failed: 400 Bad Request'); + }); + + it('should include status code in thrown errors', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + text: vi.fn().mockResolvedValue('Internal server error'), + }; + + global.fetch = vi + .fn() + .mockResolvedValue(mockResponse as unknown as Response); + + await expect( + client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-verifier', + }), + ).rejects.toMatchObject({ + message: expect.stringContaining( + 'Device token poll failed: 500 Internal Server Error', + ), + status: 500, + }); + }); + + it('should handle authorization_pending with correct status', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + json: vi.fn().mockResolvedValue({ + error: 'authorization_pending', + error_description: 'Authorization request is pending', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await client.pollDeviceToken({ + device_code: 'test-device-code', + code_verifier: 'test-verifier', + }); + + expect(result).toEqual({ status: 'pending' }); + }); + }); + + describe('Enhanced refreshAccessToken scenarios', () => { + it('should call clearQwenCredentials on 400 error', async () => { + client.setCredentials({ + refresh_token: 'expired-refresh', + }); + + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const mockResponse = { + ok: false, + status: 400, + text: async () => 'Bad Request', + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await expect(client.refreshAccessToken()).rejects.toThrow( + "Refresh token expired or invalid. Please use '/auth' to re-authenticate.", + ); + + expect(fs.unlink).toHaveBeenCalled(); + }); + + it('should preserve existing refresh token when new one not provided', async () => { + const originalRefreshToken = 'original-refresh-token'; + client.setCredentials({ + refresh_token: originalRefreshToken, + }); + + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + // No refresh_token in response + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.refreshAccessToken(); + + const credentials = client.getCredentials(); + expect(credentials.refresh_token).toBe(originalRefreshToken); + }); + + it('should include resource_url when provided in response', async () => { + client.setCredentials({ + refresh_token: 'test-refresh', + }); + + const mockResponse = { + ok: true, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + resource_url: 'https://new-resource-url.com', + }), + }; + + vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); + + await client.refreshAccessToken(); + + const credentials = client.getCredentials(); + expect(credentials.resource_url).toBe('https://new-resource-url.com'); + }); + }); + + describe('isTokenValid edge cases', () => { + it('should return false for tokens expiring within buffer time', () => { + const nearExpiryTime = Date.now() + 15000; // 15 seconds from now (within 30s buffer) + + client.setCredentials({ + access_token: 'test-token', + expiry_date: nearExpiryTime, + }); + + const isValid = ( + client as unknown as { isTokenValid(): boolean } + ).isTokenValid(); + expect(isValid).toBe(false); + }); + + it('should return true for tokens expiring well beyond buffer time', () => { + const futureExpiryTime = Date.now() + 120000; // 2 minutes from now (beyond 30s buffer) + + client.setCredentials({ + access_token: 'test-token', + expiry_date: futureExpiryTime, + }); + + const isValid = ( + client as unknown as { isTokenValid(): boolean } + ).isTokenValid(); + expect(isValid).toBe(true); + }); + }); +}); + +describe('SharedTokenManager Integration in QwenOAuth2Client', () => { + let client: QwenOAuth2Client; + + beforeEach(() => { + client = new QwenOAuth2Client(); + }); + + it('should use SharedTokenManager instance in constructor', () => { + const sharedManager = ( + client as unknown as { sharedManager: MockSharedTokenManager } + ).sharedManager; + expect(sharedManager).toBeDefined(); + }); + + it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => { + const mockConfig = { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true), + } as unknown as Config; + + // Test different TokenManagerError types + const tokenErrors = [ + { type: TokenError.NO_REFRESH_TOKEN, message: 'No refresh token' }, + { type: TokenError.REFRESH_FAILED, message: 'Token refresh failed' }, + { type: TokenError.NETWORK_ERROR, message: 'Network error' }, + { type: TokenError.REFRESH_FAILED, message: 'Refresh failed' }, + ]; + + for (const errorInfo of tokenErrors) { + const tokenError = new TokenManagerError( + errorInfo.type, + errorInfo.message, + ); + + const mockTokenManager = { + getValidCredentials: vi.fn().mockRejectedValue(tokenError), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi + .fn() + .mockReturnValue(mockTokenManager); + + const { promises: fs } = await import('node:fs'); + vi.mocked(fs.readFile).mockRejectedValue(new Error('No cached file')); + + // Mock device flow to succeed + const mockAuthResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + const mockTokenResponse = { + ok: true, + json: async () => ({ + access_token: 'new-token', + refresh_token: 'new-refresh', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + + global.fetch = vi + .fn() + .mockResolvedValueOnce(mockAuthResponse as Response) + .mockResolvedValue(mockTokenResponse as Response); + + try { + await import('./qwenOAuth2.js').then((module) => + module.getQwenOAuthClient(mockConfig), + ); + } catch { + // Expected to fail in test environment + } + + SharedTokenManager.getInstance = originalGetInstance; + vi.clearAllMocks(); + } + }); +}); + +describe('Constants and Configuration', () => { + it('should have correct OAuth endpoints', async () => { + // Test that the constants are properly defined by checking they're used in requests + const client = new QwenOAuth2Client(); + + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'test-scope', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + const [url] = vi.mocked(global.fetch).mock.calls[0]; + expect(url).toBe('https://chat.qwen.ai/api/v1/oauth2/device/code'); + }); + + it('should use correct client ID in requests', async () => { + const client = new QwenOAuth2Client(); + + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'test-scope', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + const [, options] = vi.mocked(global.fetch).mock.calls[0]; + expect(options?.body).toContain( + 'client_id=f0304373b74a44d2b584a3fb70ca9e56', + ); + }); + + it('should use correct default scope', async () => { + // Test the default scope constant by checking it's used in device flow + const client = new QwenOAuth2Client(); + + const mockResponse = { + ok: true, + json: async () => ({ + device_code: 'test-device-code', + user_code: 'TEST123', + verification_uri: 'https://chat.qwen.ai/device', + verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123', + expires_in: 1800, + }), + }; + + global.fetch = vi.fn().mockResolvedValue(mockResponse as Response); + + await client.requestDeviceAuthorization({ + scope: 'openid profile email model.completion', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + }); + + const [, options] = vi.mocked(global.fetch).mock.calls[0]; + expect(options?.body).toContain( + 'scope=openid%20profile%20email%20model.completion', + ); + }); +}); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 58692117..592a6dd7 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -13,6 +13,11 @@ import open from 'open'; import { EventEmitter } from 'events'; import { Config } from '../config/config.js'; import { randomUUID } from 'node:crypto'; +import { + SharedTokenManager, + TokenManagerError, + TokenError, +} from './sharedTokenManager.js'; // OAuth Endpoints const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; @@ -234,8 +239,11 @@ export interface IQwenOAuth2Client { */ export class QwenOAuth2Client implements IQwenOAuth2Client { private credentials: QwenCredentials = {}; + private sharedManager: SharedTokenManager; - constructor(_options?: { proxy?: string }) {} + constructor() { + this.sharedManager = SharedTokenManager.getInstance(); + } setCredentials(credentials: QwenCredentials): void { this.credentials = credentials; @@ -246,17 +254,23 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { } async getAccessToken(): Promise<{ token?: string }> { - if (this.credentials.access_token && this.isTokenValid()) { - return { token: this.credentials.access_token }; - } + try { + // Use shared manager to get valid credentials with cross-session synchronization + const credentials = await this.sharedManager.getValidCredentials(this); + return { token: credentials.access_token }; + } catch (error) { + console.warn('Failed to get access token from shared manager:', error); - if (this.credentials.refresh_token) { - const refreshResponse = await this.refreshAccessToken(); - const tokenData = refreshResponse as TokenRefreshData; - return { token: tokenData.access_token }; - } + // Only return cached token if it's still valid, don't refresh uncoordinated + // This prevents the cross-session token invalidation issue + if (this.credentials.access_token && this.isTokenValid()) { + return { token: this.credentials.access_token }; + } - return { token: undefined }; + // If we can't get valid credentials through shared manager, fail gracefully + // All token refresh operations should go through the SharedTokenManager + return { token: undefined }; + } } async requestDeviceAuthorization(options: { @@ -289,7 +303,7 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { } const result = (await response.json()) as DeviceAuthorizationResponse; - console.log('Device authorization result:', result); + console.debug('Device authorization result:', result); // Check if the response indicates success if (!isDeviceAuthorizationSuccess(result)) { @@ -423,8 +437,8 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { this.setCredentials(tokens); - // Cache the updated credentials to file - await cacheQwenCredentials(tokens); + // Note: File caching is now handled by SharedTokenManager + // to prevent cross-session token invalidation issues return responseData; } @@ -462,68 +476,85 @@ export const qwenOAuth2Events = new EventEmitter(); export async function getQwenOAuthClient( config: Config, ): Promise { - const client = new QwenOAuth2Client({ - proxy: config.getProxy(), - }); + const client = new QwenOAuth2Client(); - // If there are cached creds on disk, they always take precedence - if (await loadCachedQwenCredentials(client)) { - console.log('Loaded cached Qwen credentials.'); + // Use shared token manager to get valid credentials with cross-session synchronization + const sharedManager = SharedTokenManager.getInstance(); - try { - await client.refreshAccessToken(); - return client; - } catch (error: unknown) { - // Handle refresh token errors - const errorMessage = - error instanceof Error ? error.message : String(error); + try { + // Try to get valid credentials from shared cache first + const credentials = await sharedManager.getValidCredentials(client); + client.setCredentials(credentials); + return client; + } catch (error: unknown) { + console.debug( + 'Shared token manager failed, attempting device flow:', + error, + ); - const isInvalidToken = errorMessage.includes( - 'Refresh token expired or invalid', - ); - const userMessage = isInvalidToken - ? 'Cached credentials are invalid. Please re-authenticate.' - : `Token refresh failed: ${errorMessage}`; - const throwMessage = isInvalidToken - ? 'Cached Qwen credentials are invalid. Please re-authenticate.' - : `Qwen token refresh failed: ${errorMessage}`; - - // Emit token refresh error event - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', userMessage); - throw new Error(throwMessage); - } - } - - // Use device authorization flow for authentication (single attempt) - const result = await authWithQwenDeviceFlow(client, config); - if (!result.success) { - // Only emit timeout event if the failure reason is actually timeout - // Other error types (401, 429, etc.) have already emitted their specific events - if (result.reason === 'timeout') { - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'timeout', - 'Authentication timed out. Please try again or select a different authentication method.', - ); + // Handle specific token manager errors + if (error instanceof TokenManagerError) { + switch (error.type) { + case TokenError.NO_REFRESH_TOKEN: + console.debug( + 'No refresh token available, proceeding with device flow', + ); + break; + case TokenError.REFRESH_FAILED: + console.debug('Token refresh failed, proceeding with device flow'); + break; + case TokenError.NETWORK_ERROR: + console.warn( + 'Network error during token refresh, trying device flow', + ); + break; + default: + console.warn('Token manager error:', (error as Error).message); + } } - // Throw error with appropriate message based on failure reason - switch (result.reason) { - case 'timeout': - throw new Error('Qwen OAuth authentication timed out'); - case 'cancelled': - throw new Error('Qwen OAuth authentication was cancelled by user'); - case 'rate_limit': - throw new Error( - 'Too many request for Qwen OAuth authentication, please try again later.', - ); - case 'error': - default: + // If shared manager fails, check if we have cached credentials for device flow + if (await loadCachedQwenCredentials(client)) { + // We have cached credentials but they might be expired + // Try device flow instead of forcing refresh + const result = await authWithQwenDeviceFlow(client, config); + if (!result.success) { throw new Error('Qwen OAuth authentication failed'); + } + return client; } - } - return client; + // No cached credentials, use device authorization flow for authentication + const result = await authWithQwenDeviceFlow(client, config); + if (!result.success) { + // Only emit timeout event if the failure reason is actually timeout + // Other error types (401, 429, etc.) have already emitted their specific events + if (result.reason === 'timeout') { + qwenOAuth2Events.emit( + QwenOAuth2Event.AuthProgress, + 'timeout', + 'Authentication timed out. Please try again or select a different authentication method.', + ); + } + + // Throw error with appropriate message based on failure reason + switch (result.reason) { + case 'timeout': + throw new Error('Qwen OAuth authentication timed out'); + case 'cancelled': + throw new Error('Qwen OAuth authentication was cancelled by user'); + case 'rate_limit': + throw new Error( + 'Too many request for Qwen OAuth authentication, please try again later.', + ); + case 'error': + default: + throw new Error('Qwen OAuth authentication failed'); + } + } + + return client; + } } async function authWithQwenDeviceFlow( @@ -580,7 +611,9 @@ async function authWithQwenDeviceFlow( // causing the entire Node.js process to crash. if (childProcess) { childProcess.on('error', () => { - console.log('Failed to open browser. Visit this URL to authorize:'); + console.debug( + 'Failed to open browser. Visit this URL to authorize:', + ); showFallbackMessage(); }); } @@ -599,7 +632,7 @@ async function authWithQwenDeviceFlow( 'Waiting for authorization...', ); - console.log('Waiting for authorization...\n'); + console.debug('Waiting for authorization...\n'); // Poll for the token let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received @@ -610,7 +643,7 @@ async function authWithQwenDeviceFlow( for (let attempt = 0; attempt < maxAttempts; attempt++) { // Check if authentication was cancelled if (isCancelled) { - console.log('\nAuthentication cancelled by user.'); + console.debug('\nAuthentication cancelled by user.'); qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, 'error', @@ -620,7 +653,7 @@ async function authWithQwenDeviceFlow( } try { - console.log('polling for token...'); + console.debug('polling for token...'); const tokenResponse = await client.pollDeviceToken({ device_code: deviceAuth.device_code, code_verifier, @@ -653,7 +686,7 @@ async function authWithQwenDeviceFlow( 'Authentication successful! Access token obtained.', ); - console.log('Authentication successful! Access token obtained.'); + console.debug('Authentication successful! Access token obtained.'); return { success: true }; } @@ -664,8 +697,8 @@ async function authWithQwenDeviceFlow( // Handle slow_down error by increasing poll interval if (pendingData.slowDown) { pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds - console.log( - `\nServer requested to slow down, increasing poll interval to ${pollInterval}ms`, + console.debug( + `\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`, ); } else { pollInterval = 2000; // Reset to default interval @@ -706,7 +739,7 @@ async function authWithQwenDeviceFlow( // Check for cancellation after waiting if (isCancelled) { - console.log('\nAuthentication cancelled by user.'); + console.debug('\nAuthentication cancelled by user.'); qwenOAuth2Events.emit( QwenOAuth2Event.AuthProgress, 'error', @@ -834,7 +867,7 @@ export async function clearQwenCredentials(): Promise { try { const filePath = getQwenCachedCredentialPath(); await fs.unlink(filePath); - console.log('Cached Qwen credentials cleared successfully.'); + console.debug('Cached Qwen credentials cleared successfully.'); } catch (error: unknown) { // If file doesn't exist or can't be deleted, we consider it cleared if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { diff --git a/packages/core/src/qwen/sharedTokenManager.test.ts b/packages/core/src/qwen/sharedTokenManager.test.ts new file mode 100644 index 00000000..fbd068fa --- /dev/null +++ b/packages/core/src/qwen/sharedTokenManager.test.ts @@ -0,0 +1,758 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs, unlinkSync, type Stats } from 'node:fs'; +import * as os from 'os'; +import path from 'node:path'; + +import { + SharedTokenManager, + TokenManagerError, + TokenError, +} from './sharedTokenManager.js'; +import type { + IQwenOAuth2Client, + QwenCredentials, + TokenRefreshData, + ErrorData, +} from './qwenOAuth2.js'; + +// Mock external dependencies +vi.mock('node:fs', () => ({ + promises: { + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + unlink: vi.fn(), + }, + unlinkSync: vi.fn(), +})); + +vi.mock('node:os', () => ({ + homedir: vi.fn(), +})); + +vi.mock('node:path', () => ({ + default: { + join: vi.fn(), + dirname: vi.fn(), + }, +})); + +/** + * Helper to access private properties for testing + */ +function getPrivateProperty(obj: unknown, property: string): T { + return (obj as Record)[property]; +} + +/** + * Helper to set private properties for testing + */ +function setPrivateProperty(obj: unknown, property: string, value: T): void { + (obj as Record)[property] = value; +} + +/** + * Creates a mock QwenOAuth2Client for testing + */ +function createMockQwenClient( + initialCredentials: Partial = {}, +): IQwenOAuth2Client { + let credentials: QwenCredentials = { + access_token: 'mock_access_token', + refresh_token: 'mock_refresh_token', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, // 1 hour from now + resource_url: 'https://api.example.com', + ...initialCredentials, + }; + + return { + setCredentials: vi.fn((creds: QwenCredentials) => { + credentials = { ...credentials, ...creds }; + }), + getCredentials: vi.fn(() => credentials), + getAccessToken: vi.fn(), + requestDeviceAuthorization: vi.fn(), + pollDeviceToken: vi.fn(), + refreshAccessToken: vi.fn(), + }; +} + +/** + * Creates valid mock credentials + */ +function createValidCredentials( + overrides: Partial = {}, +): QwenCredentials { + return { + access_token: 'valid_access_token', + refresh_token: 'valid_refresh_token', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, // 1 hour from now + resource_url: 'https://api.example.com', + ...overrides, + }; +} + +/** + * Creates expired mock credentials + */ +function createExpiredCredentials( + overrides: Partial = {}, +): QwenCredentials { + return { + access_token: 'expired_access_token', + refresh_token: 'expired_refresh_token', + token_type: 'Bearer', + expiry_date: Date.now() - 3600000, // 1 hour ago + resource_url: 'https://api.example.com', + ...overrides, + }; +} + +/** + * Creates a successful token refresh response + */ +function createSuccessfulRefreshResponse( + overrides: Partial = {}, +): TokenRefreshData { + return { + access_token: 'fresh_access_token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new_refresh_token', + resource_url: 'https://api.example.com', + ...overrides, + }; +} + +/** + * Creates an error response + */ +function createErrorResponse( + error = 'invalid_grant', + description = 'Token expired', +): ErrorData { + return { + error, + error_description: description, + }; +} + +describe('SharedTokenManager', () => { + let tokenManager: SharedTokenManager; + + // Get mocked modules + const mockFs = vi.mocked(fs); + const mockOs = vi.mocked(os); + const mockPath = vi.mocked(path); + const mockUnlinkSync = vi.mocked(unlinkSync); + + beforeEach(() => { + // Clean up any existing instance's listeners first + const existingInstance = getPrivateProperty( + SharedTokenManager, + 'instance', + ) as SharedTokenManager; + if (existingInstance) { + existingInstance.cleanup(); + } + + // Reset all mocks + vi.clearAllMocks(); + + // Setup default mock implementations + mockOs.homedir.mockReturnValue('/home/user'); + mockPath.join.mockImplementation((...args) => args.join('/')); + mockPath.dirname.mockImplementation((filePath) => { + // Handle undefined/null input gracefully + if (!filePath || typeof filePath !== 'string') { + return '/home/user/.qwen'; // Return the expected directory path + } + const parts = filePath.split('/'); + const result = parts.slice(0, -1).join('/'); + return result || '/'; + }); + + // Reset singleton instance for each test + setPrivateProperty(SharedTokenManager, 'instance', null); + tokenManager = SharedTokenManager.getInstance(); + }); + + afterEach(() => { + // Clean up listeners after each test + if (tokenManager) { + tokenManager.cleanup(); + } + }); + + describe('Singleton Pattern', () => { + it('should return the same instance when called multiple times', () => { + const instance1 = SharedTokenManager.getInstance(); + const instance2 = SharedTokenManager.getInstance(); + + expect(instance1).toBe(instance2); + expect(instance1).toBe(tokenManager); + }); + + it('should create a new instance after reset', () => { + const instance1 = SharedTokenManager.getInstance(); + + // Reset singleton for testing + setPrivateProperty(SharedTokenManager, 'instance', null); + const instance2 = SharedTokenManager.getInstance(); + + expect(instance1).not.toBe(instance2); + }); + }); + + describe('getValidCredentials', () => { + it('should return valid cached credentials without refresh', async () => { + const mockClient = createMockQwenClient(); + const validCredentials = createValidCredentials(); + + // Mock file operations to indicate no file changes + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + + // Manually set cached credentials + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + fileModTime: number; + lastCheck: number; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = validCredentials; + memoryCache.fileModTime = 1000; + memoryCache.lastCheck = Date.now(); + + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result).toEqual(validCredentials); + expect(mockClient.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should refresh expired credentials', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const refreshResponse = createSuccessfulRefreshResponse(); + + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(refreshResponse); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result.access_token).toBe(refreshResponse.access_token); + expect(mockClient.refreshAccessToken).toHaveBeenCalled(); + expect(mockClient.setCredentials).toHaveBeenCalled(); + }); + + it('should force refresh when forceRefresh is true', async () => { + const mockClient = createMockQwenClient(createValidCredentials()); + const refreshResponse = createSuccessfulRefreshResponse(); + + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(refreshResponse); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + const result = await tokenManager.getValidCredentials(mockClient, true); + + expect(result.access_token).toBe(refreshResponse.access_token); + expect(mockClient.refreshAccessToken).toHaveBeenCalled(); + }); + + it('should throw TokenManagerError when refresh token is missing', async () => { + const mockClient = createMockQwenClient({ + access_token: 'expired_token', + refresh_token: undefined, // No refresh token + expiry_date: Date.now() - 3600000, + }); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow('No refresh token available'); + }); + + it('should throw TokenManagerError when refresh fails', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const errorResponse = createErrorResponse(); + + mockClient.refreshAccessToken = vi.fn().mockResolvedValue(errorResponse); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + }); + + it('should handle network errors during refresh', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const networkError = new Error('Network request failed'); + + mockClient.refreshAccessToken = vi.fn().mockRejectedValue(networkError); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + }); + + it('should wait for ongoing refresh and return same result', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const refreshResponse = createSuccessfulRefreshResponse(); + + // Create a delayed refresh response + let resolveRefresh: (value: TokenRefreshData) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + + mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + // Start two concurrent refresh operations + const promise1 = tokenManager.getValidCredentials(mockClient); + const promise2 = tokenManager.getValidCredentials(mockClient); + + // Resolve the refresh + resolveRefresh!(refreshResponse); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1).toEqual(result2); + expect(mockClient.refreshAccessToken).toHaveBeenCalledTimes(1); + }); + + it('should reload credentials from file when file is modified', async () => { + const mockClient = createMockQwenClient(); + const fileCredentials = createValidCredentials({ + access_token: 'file_access_token', + }); + + // Mock file operations to simulate file modification + mockFs.stat.mockResolvedValue({ mtimeMs: 2000 } as Stats); + mockFs.readFile.mockResolvedValue(JSON.stringify(fileCredentials)); + + // Set initial cache state + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ fileModTime: number }>( + tokenManager, + 'memoryCache', + ); + memoryCache.fileModTime = 1000; // Older than file + + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result.access_token).toBe('file_access_token'); + expect(mockFs.readFile).toHaveBeenCalled(); + }); + }); + + describe('Cache Management', () => { + it('should clear cache', () => { + // Set some cache data + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = createValidCredentials(); + + tokenManager.clearCache(); + + expect(tokenManager.getCurrentCredentials()).toBeNull(); + }); + + it('should return current credentials from cache', () => { + const credentials = createValidCredentials(); + + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = credentials; + + expect(tokenManager.getCurrentCredentials()).toEqual(credentials); + }); + + it('should return null when no credentials are cached', () => { + tokenManager.clearCache(); + + expect(tokenManager.getCurrentCredentials()).toBeNull(); + }); + }); + + describe('Refresh Status', () => { + it('should return false when no refresh is in progress', () => { + expect(tokenManager.isRefreshInProgress()).toBe(false); + }); + + it('should return true when refresh is in progress', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + + // Clear cache to ensure refresh is triggered + tokenManager.clearCache(); + + // Mock stat for file check to fail (no file initially) + mockFs.stat.mockRejectedValueOnce( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); + + // Create a delayed refresh response + let resolveRefresh: (value: TokenRefreshData) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + + mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise); + + // Mock file operations for lock and save + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + + // Start refresh + const refreshOperation = tokenManager.getValidCredentials(mockClient); + + // Wait a tick to ensure the refresh promise is set + await new Promise((resolve) => setImmediate(resolve)); + + expect(tokenManager.isRefreshInProgress()).toBe(true); + + // Complete refresh + resolveRefresh!(createSuccessfulRefreshResponse()); + await refreshOperation; + + expect(tokenManager.isRefreshInProgress()).toBe(false); + }); + }); + + describe('Debug Info', () => { + it('should return complete debug information', () => { + const credentials = createValidCredentials(); + + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = credentials; + + const debugInfo = tokenManager.getDebugInfo(); + + expect(debugInfo).toHaveProperty('hasCredentials', true); + expect(debugInfo).toHaveProperty('credentialsExpired', false); + expect(debugInfo).toHaveProperty('isRefreshing', false); + expect(debugInfo).toHaveProperty('cacheAge'); + expect(typeof debugInfo.cacheAge).toBe('number'); + }); + + it('should indicate expired credentials in debug info', () => { + const expiredCredentials = createExpiredCredentials(); + + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = expiredCredentials; + + const debugInfo = tokenManager.getDebugInfo(); + + expect(debugInfo.hasCredentials).toBe(true); + expect(debugInfo.credentialsExpired).toBe(true); + }); + + it('should indicate no credentials in debug info', () => { + tokenManager.clearCache(); + + const debugInfo = tokenManager.getDebugInfo(); + + expect(debugInfo.hasCredentials).toBe(false); + expect(debugInfo.credentialsExpired).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('should create TokenManagerError with correct type and message', () => { + const error = new TokenManagerError( + TokenError.REFRESH_FAILED, + 'Token refresh failed', + new Error('Original error'), + ); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(TokenManagerError); + expect(error.type).toBe(TokenError.REFRESH_FAILED); + expect(error.message).toBe('Token refresh failed'); + expect(error.name).toBe('TokenManagerError'); + expect(error.originalError).toBeInstanceOf(Error); + }); + + it('should handle file access errors gracefully', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + + // Mock file stat to throw access error + const accessError = new Error( + 'Permission denied', + ) as NodeJS.ErrnoException; + accessError.code = 'EACCES'; + mockFs.stat.mockRejectedValue(accessError); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + }); + + it('should handle missing file gracefully', async () => { + const mockClient = createMockQwenClient(); + const validCredentials = createValidCredentials(); + + // Mock file stat to throw file not found error + const notFoundError = new Error( + 'File not found', + ) as NodeJS.ErrnoException; + notFoundError.code = 'ENOENT'; + mockFs.stat.mockRejectedValue(notFoundError); + + // Set valid credentials in cache + const memoryCache = getPrivateProperty<{ + credentials: QwenCredentials | null; + }>(tokenManager, 'memoryCache'); + memoryCache.credentials = validCredentials; + + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result).toEqual(validCredentials); + }); + + it('should handle lock timeout scenarios', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + + // Configure shorter timeouts for testing + tokenManager.setLockConfig({ + maxAttempts: 3, + attemptInterval: 50, + }); + + // Mock stat for file check to pass (no file initially) + mockFs.stat.mockRejectedValueOnce( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); + + // Mock writeFile to always throw EEXIST for lock file writes (flag: 'wx') + // but succeed for regular file writes + const lockError = new Error('File exists') as NodeJS.ErrnoException; + lockError.code = 'EEXIST'; + + mockFs.writeFile.mockImplementation((path, data, options) => { + if (typeof options === 'object' && options?.flag === 'wx') { + return Promise.reject(lockError); + } + return Promise.resolve(undefined); + }); + + // Mock stat to return recent lock file (not stale) when checking lock age + mockFs.stat.mockResolvedValue({ mtimeMs: Date.now() } as Stats); + + // Mock unlink to simulate lock file removal attempts + mockFs.unlink.mockResolvedValue(undefined); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + }, 500); // 500ms timeout for lock test (3 attempts × 50ms = ~150ms + buffer) + + it('should handle refresh response without access token', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const invalidResponse = { + token_type: 'Bearer', + expires_in: 3600, + // access_token is missing, so we use undefined explicitly + access_token: undefined, + } as Partial; + + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(invalidResponse); + + // Mock stat for file check to pass (no file initially) + mockFs.stat.mockRejectedValueOnce( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); + + // Mock file operations for lock acquisition + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + // Clear cache to force refresh + tokenManager.clearCache(); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow(TokenManagerError); + + await expect( + tokenManager.getValidCredentials(mockClient), + ).rejects.toThrow('no token returned'); + }); + }); + + describe('File System Operations', () => { + it('should handle file reload failures gracefully', async () => { + const mockClient = createMockQwenClient(); + + // Mock successful refresh for when cache is cleared + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(createSuccessfulRefreshResponse()); + + // Mock file operations + mockFs.stat + .mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded + .mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations + mockFs.readFile.mockRejectedValue(new Error('Read failed')); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + // Set initial cache state to trigger reload + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ fileModTime: number }>( + tokenManager, + 'memoryCache', + ); + memoryCache.fileModTime = 1000; + + // Should not throw error, should refresh and get new credentials + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result).toBeDefined(); + expect(result.access_token).toBe('fresh_access_token'); + }); + + it('should handle invalid JSON in credentials file', async () => { + const mockClient = createMockQwenClient(); + + // Mock successful refresh for when cache is cleared + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(createSuccessfulRefreshResponse()); + + // Mock file operations with invalid JSON + mockFs.stat + .mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded + .mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations + mockFs.readFile.mockResolvedValue('invalid json content'); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + // Set initial cache state to trigger reload + tokenManager.clearCache(); + const memoryCache = getPrivateProperty<{ fileModTime: number }>( + tokenManager, + 'memoryCache', + ); + memoryCache.fileModTime = 1000; + + // Should handle JSON parse error gracefully, then refresh and get new credentials + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result).toBeDefined(); + expect(result.access_token).toBe('fresh_access_token'); + }); + + it('should handle directory creation during save', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const refreshResponse = createSuccessfulRefreshResponse(); + + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(refreshResponse); + + // Mock file operations + mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + await tokenManager.getValidCredentials(mockClient); + + expect(mockFs.mkdir).toHaveBeenCalledWith(expect.any(String), { + recursive: true, + mode: 0o700, + }); + expect(mockFs.writeFile).toHaveBeenCalled(); + }); + }); + + describe('Lock File Management', () => { + it('should clean up lock file during process cleanup', () => { + // Create a new instance to trigger cleanup handler registration + SharedTokenManager.getInstance(); + + // Access the private cleanup method for testing + const cleanupHandlers = process.listeners('exit'); + const cleanup = cleanupHandlers[cleanupHandlers.length - 1] as () => void; + + // Should not throw when lock file doesn't exist + expect(() => cleanup()).not.toThrow(); + expect(mockUnlinkSync).toHaveBeenCalled(); + }); + + it('should handle stale lock cleanup', async () => { + const mockClient = createMockQwenClient(createExpiredCredentials()); + const refreshResponse = createSuccessfulRefreshResponse(); + + mockClient.refreshAccessToken = vi + .fn() + .mockResolvedValue(refreshResponse); + + // First writeFile call throws EEXIST (lock exists) + // Second writeFile call succeeds (after stale lock cleanup) + const lockError = new Error('File exists') as NodeJS.ErrnoException; + lockError.code = 'EEXIST'; + mockFs.writeFile + .mockRejectedValueOnce(lockError) + .mockResolvedValue(undefined); + + // Mock stat to return stale lock (old timestamp) + mockFs.stat + .mockResolvedValueOnce({ mtimeMs: Date.now() - 20000 } as Stats) // Stale lock + .mockResolvedValueOnce({ mtimeMs: 1000 } as Stats); // Credentials file + + // Mock unlink to succeed + mockFs.unlink.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + + const result = await tokenManager.getValidCredentials(mockClient); + + expect(result.access_token).toBe(refreshResponse.access_token); + expect(mockFs.unlink).toHaveBeenCalled(); // Stale lock removed + }); + }); +}); diff --git a/packages/core/src/qwen/sharedTokenManager.ts b/packages/core/src/qwen/sharedTokenManager.ts new file mode 100644 index 00000000..3c950cd6 --- /dev/null +++ b/packages/core/src/qwen/sharedTokenManager.ts @@ -0,0 +1,662 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { promises as fs, unlinkSync } from 'node:fs'; +import * as os from 'os'; +import { randomUUID } from 'node:crypto'; + +import { + IQwenOAuth2Client, + type QwenCredentials, + type TokenRefreshData, + type ErrorData, + isErrorResponse, +} from './qwenOAuth2.js'; + +// File System Configuration +const QWEN_DIR = '.qwen'; +const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json'; +const QWEN_LOCK_FILENAME = 'oauth_creds.lock'; + +// Token and Cache Configuration +const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds +const LOCK_TIMEOUT_MS = 10000; // 10 seconds lock timeout +const CACHE_CHECK_INTERVAL_MS = 1000; // 1 second cache check interval + +// Lock acquisition configuration (can be overridden for testing) +interface LockConfig { + maxAttempts: number; + attemptInterval: number; +} + +const DEFAULT_LOCK_CONFIG: LockConfig = { + maxAttempts: 50, + attemptInterval: 200, +}; + +/** + * Token manager error types for better error classification + */ +export enum TokenError { + REFRESH_FAILED = 'REFRESH_FAILED', + NO_REFRESH_TOKEN = 'NO_REFRESH_TOKEN', + LOCK_TIMEOUT = 'LOCK_TIMEOUT', + FILE_ACCESS_ERROR = 'FILE_ACCESS_ERROR', + NETWORK_ERROR = 'NETWORK_ERROR', +} + +/** + * Custom error class for token manager operations + */ +export class TokenManagerError extends Error { + constructor( + public type: TokenError, + message: string, + public originalError?: unknown, + ) { + super(message); + this.name = 'TokenManagerError'; + } +} + +/** + * Interface for the memory cache state + */ +interface MemoryCache { + credentials: QwenCredentials | null; + fileModTime: number; + lastCheck: number; +} + +/** + * Validates that the given data is a valid QwenCredentials object + * + * @param data - The data to validate + * @returns The validated credentials object + * @throws Error if the data is invalid + */ +function validateCredentials(data: unknown): QwenCredentials { + if (!data || typeof data !== 'object') { + throw new Error('Invalid credentials format'); + } + + const creds = data as Partial; + const requiredFields = [ + 'access_token', + 'refresh_token', + 'token_type', + ] as const; + + // Check required string fields + for (const field of requiredFields) { + if (!creds[field] || typeof creds[field] !== 'string') { + throw new Error(`Invalid credentials: missing ${field}`); + } + } + + // Check expiry_date + if (!creds.expiry_date || typeof creds.expiry_date !== 'number') { + throw new Error('Invalid credentials: missing expiry_date'); + } + + return creds as QwenCredentials; +} + +/** + * Manages OAuth tokens across multiple processes using file-based caching and locking + */ +export class SharedTokenManager { + private static instance: SharedTokenManager | null = null; + + /** + * In-memory cache for credentials and file state tracking + */ + private memoryCache: MemoryCache = { + credentials: null, + fileModTime: 0, + lastCheck: 0, + }; + + /** + * Promise tracking any ongoing token refresh operation + */ + private refreshPromise: Promise | null = null; + + /** + * Whether cleanup handlers have been registered + */ + private cleanupHandlersRegistered = false; + + /** + * Reference to cleanup functions for proper removal + */ + private cleanupFunction: (() => void) | null = null; + + /** + * Lock configuration for testing purposes + */ + private lockConfig: LockConfig = DEFAULT_LOCK_CONFIG; + + /** + * Private constructor for singleton pattern + */ + private constructor() { + this.registerCleanupHandlers(); + } + + /** + * Get the singleton instance + * @returns The shared token manager instance + */ + static getInstance(): SharedTokenManager { + if (!SharedTokenManager.instance) { + SharedTokenManager.instance = new SharedTokenManager(); + } + return SharedTokenManager.instance; + } + + /** + * Set up handlers to clean up lock files when the process exits + */ + private registerCleanupHandlers(): void { + if (this.cleanupHandlersRegistered) { + return; + } + + this.cleanupFunction = () => { + try { + const lockPath = this.getLockFilePath(); + // Use synchronous unlink for process exit handlers + unlinkSync(lockPath); + } catch (_error) { + // Ignore cleanup errors - lock file might not exist or already be cleaned up + } + }; + + process.on('exit', this.cleanupFunction); + process.on('SIGINT', this.cleanupFunction); + process.on('SIGTERM', this.cleanupFunction); + process.on('uncaughtException', this.cleanupFunction); + process.on('unhandledRejection', this.cleanupFunction); + + this.cleanupHandlersRegistered = true; + } + + /** + * Get valid OAuth credentials, refreshing them if necessary + * + * @param qwenClient - The OAuth2 client instance + * @param forceRefresh - If true, refresh token even if current one is still valid + * @returns Promise resolving to valid credentials + * @throws TokenManagerError if unable to obtain valid credentials + */ + async getValidCredentials( + qwenClient: IQwenOAuth2Client, + forceRefresh = false, + ): Promise { + try { + // Check if credentials file has been updated by other sessions + await this.checkAndReloadIfNeeded(); + + // Return valid cached credentials if available (unless force refresh is requested) + if ( + !forceRefresh && + this.memoryCache.credentials && + this.isTokenValid(this.memoryCache.credentials) + ) { + return this.memoryCache.credentials; + } + + // If refresh is already in progress, wait for it to complete + if (this.refreshPromise) { + return this.refreshPromise; + } + + // Start new refresh operation with distributed locking + this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh); + + try { + const credentials = await this.refreshPromise; + return credentials; + } catch (error) { + // Ensure refreshPromise is cleared on error before re-throwing + this.refreshPromise = null; + throw error; + } finally { + this.refreshPromise = null; + } + } catch (error) { + // Convert generic errors to TokenManagerError for better error handling + if (error instanceof TokenManagerError) { + throw error; + } + + throw new TokenManagerError( + TokenError.REFRESH_FAILED, + `Failed to get valid credentials: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + } + + /** + * Check if the credentials file was updated by another process and reload if so + */ + private async checkAndReloadIfNeeded(): Promise { + const now = Date.now(); + + // Limit check frequency to avoid excessive disk I/O + if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) { + return; + } + + this.memoryCache.lastCheck = now; + + try { + const filePath = this.getCredentialFilePath(); + const stats = await fs.stat(filePath); + const fileModTime = stats.mtimeMs; + + // Reload credentials if file has been modified since last cache + if (fileModTime > this.memoryCache.fileModTime) { + await this.reloadCredentialsFromFile(); + this.memoryCache.fileModTime = fileModTime; + } + } catch (error) { + // Handle file access errors + if ( + error instanceof Error && + 'code' in error && + error.code !== 'ENOENT' + ) { + // Clear cache for non-missing file errors + this.memoryCache.credentials = null; + this.memoryCache.fileModTime = 0; + + throw new TokenManagerError( + TokenError.FILE_ACCESS_ERROR, + `Failed to access credentials file: ${error.message}`, + error, + ); + } + + // For missing files (ENOENT), just reset file modification time + // but keep existing valid credentials in memory if they exist + this.memoryCache.fileModTime = 0; + } + } + + /** + * Load credentials from the file system into memory cache + */ + private async reloadCredentialsFromFile(): Promise { + try { + const filePath = this.getCredentialFilePath(); + const content = await fs.readFile(filePath, 'utf-8'); + const parsedData = JSON.parse(content); + const credentials = validateCredentials(parsedData); + this.memoryCache.credentials = credentials; + } catch (error) { + // Log validation errors for debugging but don't throw + if ( + error instanceof Error && + error.message.includes('Invalid credentials') + ) { + console.warn(`Failed to validate credentials file: ${error.message}`); + } + this.memoryCache.credentials = null; + } + } + + /** + * Refresh the OAuth token using file locking to prevent concurrent refreshes + * + * @param qwenClient - The OAuth2 client instance + * @param forceRefresh - If true, skip checking if token is already valid after getting lock + * @returns Promise resolving to refreshed credentials + * @throws TokenManagerError if refresh fails or lock cannot be acquired + */ + private async performTokenRefresh( + qwenClient: IQwenOAuth2Client, + forceRefresh = false, + ): Promise { + const lockPath = this.getLockFilePath(); + + try { + // Check if we have a refresh token before attempting refresh + const currentCredentials = qwenClient.getCredentials(); + if (!currentCredentials.refresh_token) { + throw new TokenManagerError( + TokenError.NO_REFRESH_TOKEN, + 'No refresh token available for token refresh', + ); + } + + // Acquire distributed file lock + await this.acquireLock(lockPath); + + // Double-check if another process already refreshed the token (unless force refresh is requested) + await this.checkAndReloadIfNeeded(); + + // Use refreshed credentials if they're now valid (unless force refresh is requested) + if ( + !forceRefresh && + this.memoryCache.credentials && + this.isTokenValid(this.memoryCache.credentials) + ) { + qwenClient.setCredentials(this.memoryCache.credentials); + return this.memoryCache.credentials; + } + + // Perform the actual token refresh + const response = await qwenClient.refreshAccessToken(); + + if (!response || isErrorResponse(response)) { + const errorData = response as ErrorData; + throw new TokenManagerError( + TokenError.REFRESH_FAILED, + `Token refresh failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`, + ); + } + + const tokenData = response as TokenRefreshData; + + if (!tokenData.access_token) { + throw new TokenManagerError( + TokenError.REFRESH_FAILED, + 'Failed to refresh access token: no token returned', + ); + } + + // Create updated credentials object + const credentials: QwenCredentials = { + access_token: tokenData.access_token, + token_type: tokenData.token_type, + refresh_token: + tokenData.refresh_token || currentCredentials.refresh_token, + resource_url: tokenData.resource_url, + expiry_date: Date.now() + tokenData.expires_in * 1000, + }; + + // Update memory cache and client credentials + this.memoryCache.credentials = credentials; + qwenClient.setCredentials(credentials); + + // Persist to file and update modification time + await this.saveCredentialsToFile(credentials); + + return credentials; + } catch (error) { + if (error instanceof TokenManagerError) { + throw error; + } + + // Handle network-related errors + if ( + error instanceof Error && + (error.message.includes('fetch') || + error.message.includes('network') || + error.message.includes('timeout')) + ) { + throw new TokenManagerError( + TokenError.NETWORK_ERROR, + `Network error during token refresh: ${error.message}`, + error, + ); + } + + throw new TokenManagerError( + TokenError.REFRESH_FAILED, + `Unexpected error during token refresh: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } finally { + // Always release the file lock + await this.releaseLock(lockPath); + } + } + + /** + * Save credentials to file and update the cached file modification time + * + * @param credentials - The credentials to save + */ + private async saveCredentialsToFile( + credentials: QwenCredentials, + ): Promise { + const filePath = this.getCredentialFilePath(); + const dirPath = path.dirname(filePath); + + // Create directory with restricted permissions + try { + await fs.mkdir(dirPath, { recursive: true, mode: 0o700 }); + } catch (error) { + throw new TokenManagerError( + TokenError.FILE_ACCESS_ERROR, + `Failed to create credentials directory: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + + const credString = JSON.stringify(credentials, null, 2); + + try { + // Write file with restricted permissions (owner read/write only) + await fs.writeFile(filePath, credString, { mode: 0o600 }); + } catch (error) { + throw new TokenManagerError( + TokenError.FILE_ACCESS_ERROR, + `Failed to write credentials file: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + + // Update cached file modification time to avoid unnecessary reloads + try { + const stats = await fs.stat(filePath); + this.memoryCache.fileModTime = stats.mtimeMs; + } catch (error) { + // Non-fatal error, just log it + console.warn( + `Failed to update file modification time: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if the token is valid and not expired + * + * @param credentials - The credentials to validate + * @returns true if token is valid and not expired, false otherwise + */ + private isTokenValid(credentials: QwenCredentials): boolean { + if (!credentials.expiry_date || !credentials.access_token) { + return false; + } + return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS; + } + + /** + * Get the full path to the credentials file + * + * @returns The absolute path to the credentials file + */ + private getCredentialFilePath(): string { + return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); + } + + /** + * Get the full path to the lock file + * + * @returns The absolute path to the lock file + */ + private getLockFilePath(): string { + return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME); + } + + /** + * Acquire a file lock to prevent other processes from refreshing tokens simultaneously + * + * @param lockPath - Path to the lock file + * @throws TokenManagerError if lock cannot be acquired within timeout period + */ + private async acquireLock(lockPath: string): Promise { + const { maxAttempts, attemptInterval } = this.lockConfig; + const lockId = randomUUID(); // Use random UUID instead of PID for security + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + // Attempt to create lock file atomically (exclusive mode) + await fs.writeFile(lockPath, lockId, { flag: 'wx' }); + return; // Successfully acquired lock + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + // Lock file already exists, check if it's stale + try { + const stats = await fs.stat(lockPath); + const lockAge = Date.now() - stats.mtimeMs; + + // Remove stale locks that exceed timeout + if (lockAge > LOCK_TIMEOUT_MS) { + try { + await fs.unlink(lockPath); + console.warn( + `Removed stale lock file: ${lockPath} (age: ${lockAge}ms)`, + ); + continue; // Retry lock acquisition + } catch (unlinkError) { + // Log the error but continue trying - another process might have removed it + console.warn( + `Failed to remove stale lock file ${lockPath}: ${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`, + ); + // Still continue - the lock might have been removed by another process + } + } + } catch (statError) { + // Can't stat lock file, it might have been removed, continue trying + console.warn( + `Failed to stat lock file ${lockPath}: ${statError instanceof Error ? statError.message : String(statError)}`, + ); + } + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, attemptInterval)); + } else { + throw new TokenManagerError( + TokenError.FILE_ACCESS_ERROR, + `Failed to create lock file: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + } + } + + throw new TokenManagerError( + TokenError.LOCK_TIMEOUT, + 'Failed to acquire file lock for token refresh: timeout exceeded', + ); + } + + /** + * Release the file lock + * + * @param lockPath - Path to the lock file + */ + private async releaseLock(lockPath: string): Promise { + try { + await fs.unlink(lockPath); + } catch (error) { + // Lock file might already be removed by another process or timeout cleanup + // This is not an error condition, but log for debugging + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn( + `Failed to release lock file ${lockPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + /** + * Clear all cached data and reset the manager to initial state + */ + clearCache(): void { + this.memoryCache = { + credentials: null, + fileModTime: 0, + lastCheck: 0, + }; + this.refreshPromise = null; + } + + /** + * Get the current cached credentials (may be expired) + * + * @returns The currently cached credentials or null + */ + getCurrentCredentials(): QwenCredentials | null { + return this.memoryCache.credentials; + } + + /** + * Check if there's an ongoing refresh operation + * + * @returns true if refresh is in progress, false otherwise + */ + isRefreshInProgress(): boolean { + return this.refreshPromise !== null; + } + + /** + * Set lock configuration for testing purposes + * @param config - Lock configuration + */ + setLockConfig(config: Partial): void { + this.lockConfig = { ...DEFAULT_LOCK_CONFIG, ...config }; + } + + /** + * Clean up event listeners (primarily for testing) + */ + cleanup(): void { + if (this.cleanupFunction && this.cleanupHandlersRegistered) { + this.cleanupFunction(); + + process.removeListener('exit', this.cleanupFunction); + process.removeListener('SIGINT', this.cleanupFunction); + process.removeListener('SIGTERM', this.cleanupFunction); + process.removeListener('uncaughtException', this.cleanupFunction); + process.removeListener('unhandledRejection', this.cleanupFunction); + + this.cleanupHandlersRegistered = false; + this.cleanupFunction = null; + } + } + + /** + * Get a summary of the current state for debugging + * + * @returns Object containing current state information + */ + getDebugInfo(): { + hasCredentials: boolean; + credentialsExpired: boolean; + isRefreshing: boolean; + cacheAge: number; + } { + const hasCredentials = !!this.memoryCache.credentials; + const credentialsExpired = hasCredentials + ? !this.isTokenValid(this.memoryCache.credentials!) + : false; + + return { + hasCredentials, + credentialsExpired, + isRefreshing: this.isRefreshInProgress(), + cacheAge: Date.now() - this.memoryCache.lastCheck, + }; + } +}