Files
qwen-code/packages/core/src/qwen/qwenOAuth2.test.ts
2025-08-20 22:24:53 +08:00

1298 lines
38 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'events';
import { type ChildProcess } from 'child_process';
import type { Config } from '../config/config.js';
import {
generateCodeChallenge,
generateCodeVerifier,
generatePKCEPair,
isDeviceAuthorizationSuccess,
isDeviceTokenPending,
isDeviceTokenSuccess,
isErrorResponse,
QwenOAuth2Client,
type DeviceAuthorizationResponse,
type DeviceTokenResponse,
type ErrorData,
} from './qwenOAuth2.js';
// Mock qrcode-terminal
vi.mock('qrcode-terminal', () => ({
default: {
generate: vi.fn(),
},
}));
// Mock open
vi.mock('open', () => ({
default: vi.fn(),
}));
// Mock process.stdout.write
vi.mock('process', () => ({
stdout: {
write: vi.fn(),
},
}));
// Mock file system operations
vi.mock('node:fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
},
}));
describe('PKCE Code Generation', () => {
describe('generateCodeVerifier', () => {
it('should generate a code verifier with correct length', () => {
const codeVerifier = generateCodeVerifier();
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/);
});
it('should generate different verifiers on subsequent calls', () => {
const verifier1 = generateCodeVerifier();
const verifier2 = generateCodeVerifier();
expect(verifier1).not.toBe(verifier2);
});
});
describe('generateCodeChallenge', () => {
it('should generate code challenge from verifier', () => {
const verifier = 'test-verifier-1234567890abcdefghijklmnopqrst';
const challenge = generateCodeChallenge(verifier);
// Should be base64url encoded
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
expect(challenge).not.toBe(verifier);
});
});
describe('generatePKCEPair', () => {
it('should generate valid PKCE pair', () => {
const { code_verifier, code_challenge } = generatePKCEPair();
expect(code_verifier).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(code_challenge).toMatch(/^[A-Za-z0-9_-]+$/);
expect(code_verifier).not.toBe(code_challenge);
});
});
});
describe('Type Guards', () => {
describe('isDeviceAuthorizationSuccess', () => {
it('should return true for successful authorization response', () => {
const expectedBaseUrl = process.env.DEBUG
? 'https://pre4-chat.qwen.ai'
: 'https://chat.qwen.ai';
const successResponse: DeviceAuthorizationResponse = {
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: `${expectedBaseUrl}/device`,
verification_uri_complete: `${expectedBaseUrl}/device?code=TEST123`,
expires_in: 1800,
};
expect(isDeviceAuthorizationSuccess(successResponse)).toBe(true);
});
it('should return false for error response', () => {
const errorResponse: DeviceAuthorizationResponse = {
error: 'INVALID_REQUEST',
error_description: 'The request parameters are invalid',
};
expect(isDeviceAuthorizationSuccess(errorResponse)).toBe(false);
});
});
describe('isDeviceTokenPending', () => {
it('should return true for pending response', () => {
const pendingResponse: DeviceTokenResponse = {
status: 'pending',
};
expect(isDeviceTokenPending(pendingResponse)).toBe(true);
});
it('should return false for success response', () => {
const successResponse: DeviceTokenResponse = {
access_token: 'valid-access-token',
refresh_token: 'valid-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
};
expect(isDeviceTokenPending(successResponse)).toBe(false);
});
it('should return false for error response', () => {
const errorResponse: DeviceTokenResponse = {
error: 'ACCESS_DENIED',
error_description: 'User denied the authorization request',
};
expect(isDeviceTokenPending(errorResponse)).toBe(false);
});
});
describe('isDeviceTokenSuccess', () => {
it('should return true for successful token response', () => {
const successResponse: DeviceTokenResponse = {
access_token: 'valid-access-token',
refresh_token: 'valid-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
};
expect(isDeviceTokenSuccess(successResponse)).toBe(true);
});
it('should return false for pending response', () => {
const pendingResponse: DeviceTokenResponse = {
status: 'pending',
};
expect(isDeviceTokenSuccess(pendingResponse)).toBe(false);
});
it('should return false for error response', () => {
const errorResponse: DeviceTokenResponse = {
error: 'ACCESS_DENIED',
error_description: 'User denied the authorization request',
};
expect(isDeviceTokenSuccess(errorResponse)).toBe(false);
});
it('should return false for null access token', () => {
const nullTokenResponse: DeviceTokenResponse = {
access_token: null,
token_type: 'Bearer',
expires_in: 3600,
};
expect(isDeviceTokenSuccess(nullTokenResponse)).toBe(false);
});
it('should return false for empty access token', () => {
const emptyTokenResponse: DeviceTokenResponse = {
access_token: '',
token_type: 'Bearer',
expires_in: 3600,
};
expect(isDeviceTokenSuccess(emptyTokenResponse)).toBe(false);
});
});
describe('isErrorResponse', () => {
it('should return true for error responses', () => {
const errorResponse: ErrorData = {
error: 'INVALID_REQUEST',
error_description: 'The request parameters are invalid',
};
expect(isErrorResponse(errorResponse)).toBe(true);
});
it('should return false for successful responses', () => {
const successResponse: DeviceAuthorizationResponse = {
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,
};
expect(isErrorResponse(successResponse)).toBe(false);
});
});
});
describe('QwenOAuth2Client', () => {
let client: QwenOAuth2Client;
let originalFetch: typeof global.fetch;
beforeEach(() => {
// Create client instance
client = new QwenOAuth2Client({ proxy: undefined });
// Mock fetch
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
describe('requestDeviceAuthorization', () => {
it('should successfully request device authorization', 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);
const result = await client.requestDeviceAuthorization({
scope: 'openid profile email model.completion',
code_challenge: 'test-challenge',
code_challenge_method: 'S256',
});
expect(result).toEqual({
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,
});
});
it('should handle error response', async () => {
const mockResponse = {
ok: true,
json: async () => ({
error: 'INVALID_REQUEST',
error_description: 'The request parameters are invalid',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await expect(
client.requestDeviceAuthorization({
scope: 'openid profile email model.completion',
code_challenge: 'test-challenge',
code_challenge_method: 'S256',
}),
).rejects.toThrow(
'Device authorization failed: INVALID_REQUEST - The request parameters are invalid',
);
});
});
describe('refreshAccessToken', () => {
beforeEach(() => {
// Set up client with credentials
client.setCredentials({
access_token: 'old-token',
refresh_token: 'test-refresh-token',
token_type: 'Bearer',
});
});
it('should successfully refresh access token', async () => {
const mockResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
resource_url: 'https://new-endpoint.com',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
const result = await client.refreshAccessToken();
expect(result).toEqual({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
resource_url: 'https://new-endpoint.com',
});
// Verify credentials were updated
const credentials = client.getCredentials();
expect(credentials.access_token).toBe('new-access-token');
});
it('should handle refresh error', async () => {
const mockResponse = {
ok: true,
json: async () => ({
error: 'INVALID_GRANT',
error_description: 'The refresh token is invalid',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await expect(client.refreshAccessToken()).rejects.toThrow(
'Token refresh failed: INVALID_GRANT - The refresh token is invalid',
);
});
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);
const mockResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
resource_url: 'https://new-endpoint.com',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await client.refreshAccessToken();
// Verify that cacheQwenCredentials was called by checking if writeFile was called
expect(mockMkdir).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalled();
// 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({
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();
});
it('should use new refresh token if provided in response', async () => {
const { promises: fs } = await import('node:fs');
const mockWriteFile = vi.mocked(fs.writeFile);
const mockResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token', // New refresh token provided
resource_url: 'https://new-endpoint.com',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
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');
});
});
describe('getAccessToken', () => {
it('should return access token if valid and not expired', async () => {
// Set valid credentials
client.setCredentials({
access_token: 'valid-token',
expiry_date: Date.now() + 60 * 60 * 1000, // 1 hour from now
});
const result = await client.getAccessToken();
expect(result.token).toBe('valid-token');
});
it('should refresh token if access token is expired', async () => {
// Set expired credentials with refresh token
client.setCredentials({
access_token: 'expired-token',
refresh_token: 'valid-refresh-token',
expiry_date: Date.now() - 1000, // 1 second ago
});
const mockRefreshResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
}),
};
vi.mocked(global.fetch).mockResolvedValue(
mockRefreshResponse as Response,
);
const result = await client.getAccessToken();
expect(result.token).toBe('new-access-token');
});
it('should return undefined if no access token and no refresh token', async () => {
client.setCredentials({});
const result = await client.getAccessToken();
expect(result.token).toBeUndefined();
});
});
describe('pollDeviceToken', () => {
it('should successfully poll for device token', async () => {
const mockResponse = {
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',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
const result = await client.pollDeviceToken({
device_code: 'test-device-code',
code_verifier: 'test-code-verifier',
});
expect(result).toEqual({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
});
});
it('should return pending status when authorization is pending', async () => {
const mockResponse = {
ok: true,
json: async () => ({
status: 'pending',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
const result = await client.pollDeviceToken({
device_code: 'test-device-code',
code_verifier: 'test-code-verifier',
});
expect(result).toEqual({
status: 'pending',
});
});
it('should handle HTTP error responses', async () => {
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'Invalid device code',
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await expect(
client.pollDeviceToken({
device_code: 'invalid-device-code',
code_verifier: 'test-code-verifier',
}),
).rejects.toThrow('Device token poll failed: 400 Bad Request');
});
it('should include status code in error for better handling', async () => {
const mockResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: async () => 'Rate limited',
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
try {
await client.pollDeviceToken({
device_code: 'test-device-code',
code_verifier: 'test-code-verifier',
});
} catch (error) {
expect((error as Error & { status?: number }).status).toBe(429);
}
});
it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => {
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
const result = await client.pollDeviceToken({
device_code: 'test-device-code',
code_verifier: 'test-code-verifier',
});
expect(result).toEqual({
status: 'pending',
});
});
it('should handle slow_down with HTTP 429 according to RFC 8628', async () => {
const mockResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: async () => ({
error: 'slow_down',
error_description: 'The client is polling too frequently',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
const result = await client.pollDeviceToken({
device_code: 'test-device-code',
code_verifier: 'test-code-verifier',
});
expect(result).toEqual({
status: 'pending',
slowDown: true,
});
});
});
describe('refreshAccessToken error handling', () => {
beforeEach(() => {
client.setCredentials({
access_token: 'old-token',
refresh_token: 'test-refresh-token',
token_type: 'Bearer',
});
});
it('should throw error if no refresh token available', async () => {
client.setCredentials({ access_token: 'token' });
await expect(client.refreshAccessToken()).rejects.toThrow(
'No refresh token available',
);
});
it('should handle 400 status as expired refresh token', async () => {
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'Refresh token expired',
};
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.",
);
});
it('should handle other HTTP error statuses', async () => {
const mockResponse = {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: async () => 'Server error',
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await expect(client.refreshAccessToken()).rejects.toThrow(
'Token refresh failed: 500 Internal Server Error',
);
});
});
describe('credentials management', () => {
it('should set and get credentials correctly', () => {
const credentials = {
access_token: 'test-token',
refresh_token: 'test-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
client.setCredentials(credentials);
expect(client.getCredentials()).toEqual(credentials);
});
it('should handle empty credentials', () => {
client.setCredentials({});
expect(client.getCredentials()).toEqual({});
});
});
});
describe('getQwenOAuthClient', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
beforeEach(() => {
mockConfig = {
getProxy: vi.fn().mockReturnValue(undefined),
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config;
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
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 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));
// Mock successful refresh
const mockRefreshResponse = {
ok: true,
json: async () => ({
access_token: 'refreshed-token',
token_type: 'Bearer',
expires_in: 3600,
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse as Response);
const client = await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
expect(client).toBeInstanceOf(Object);
expect(fs.readFile).toHaveBeenCalled();
});
it('should handle cached credentials refresh failure', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.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',
};
vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse 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');
});
});
describe('clearQwenCredentials', () => {
it('should successfully clear credentials file', async () => {
const { promises: fs } = await import('node:fs');
const { clearQwenCredentials } = await import('./qwenOAuth2.js');
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await expect(clearQwenCredentials()).resolves.not.toThrow();
expect(fs.unlink).toHaveBeenCalled();
});
it('should handle file not found error gracefully', async () => {
const { promises: fs } = await import('node:fs');
const { clearQwenCredentials } = await import('./qwenOAuth2.js');
const notFoundError = new Error('File not found');
(notFoundError as Error & { code: string }).code = 'ENOENT';
vi.mocked(fs.unlink).mockRejectedValue(notFoundError);
await expect(clearQwenCredentials()).resolves.not.toThrow();
});
it('should handle other file system errors gracefully', async () => {
const { promises: fs } = await import('node:fs');
const { clearQwenCredentials } = await import('./qwenOAuth2.js');
const permissionError = new Error('Permission denied');
vi.mocked(fs.unlink).mockRejectedValue(permissionError);
// Should not throw but may log warning
await expect(clearQwenCredentials()).resolves.not.toThrow();
});
});
describe('QwenOAuth2Client - Additional Error Scenarios', () => {
let client: QwenOAuth2Client;
let originalFetch: typeof global.fetch;
beforeEach(() => {
client = new QwenOAuth2Client({ proxy: undefined });
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
describe('requestDeviceAuthorization HTTP errors', () => {
it('should handle HTTP error response with non-ok status', async () => {
const mockResponse = {
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: async () => 'Server error occurred',
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
await expect(
client.requestDeviceAuthorization({
scope: 'openid profile email model.completion',
code_challenge: 'test-challenge',
code_challenge_method: 'S256',
}),
).rejects.toThrow(
'Device authorization failed: 500 Internal Server Error. Response: Server error occurred',
);
});
});
describe('isTokenValid edge cases', () => {
it('should return false when expiry_date is undefined', () => {
client.setCredentials({
access_token: 'token',
// expiry_date is undefined
});
// Access private method for testing
const isValid = (
client as unknown as { isTokenValid(): boolean }
).isTokenValid();
expect(isValid).toBe(false);
});
});
});
describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
beforeEach(() => {
mockConfig = {
getProxy: vi.fn().mockReturnValue(undefined),
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config;
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
it('should handle generic refresh token errors', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'some-refresh-token',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
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',
};
vi.mocked(global.fetch).mockResolvedValue(mockRefreshResponse 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',
);
});
it('should handle different authentication failure reasons - timeout', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock device authorization to succeed but polling to timeout
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: 0.1, // Very short timeout for testing
}),
};
const mockPendingResponse = {
ok: true,
json: async () => ({
status: 'pending',
}),
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockPendingResponse as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication timed out');
});
it('should handle authentication failure reason - rate limit', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock device authorization to succeed but polling to get rate limited
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 mockRateLimitResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: async () => 'Rate limited',
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockRateLimitResponse as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow(
'Too many request for Qwen OAuth authentication, please try again later.',
);
});
it('should handle authentication failure reason - error', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock device authorization to 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 OAuth authentication failed');
});
});
describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
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();
// Mock setTimeout to avoid real delays in tests
vi.useFakeTimers();
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
vi.useRealTimers();
});
it('should handle device authorization error response', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_client',
error_description: 'Client authentication failed',
}),
};
vi.mocked(global.fetch).mockResolvedValue(mockAuthResponse as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
});
it('should handle successful authentication flow', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
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',
}),
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
const client = await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
expect(client).toBeInstanceOf(Object);
});
it('should handle 401 error during token polling', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
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 mock401Response = {
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'Device code expired',
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mock401Response as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
});
it('should handle token polling with browser launch suppressed', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock browser launch as suppressed
mockConfig.isBrowserLaunchSuppressed = vi.fn().mockReturnValue(true);
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',
}),
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
const client = await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
expect(client).toBeInstanceOf(Object);
expect(mockConfig.isBrowserLaunchSuppressed).toHaveBeenCalled();
});
});
describe('Browser Launch and Error Handling', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
beforeEach(() => {
mockConfig = {
getProxy: vi.fn().mockReturnValue(undefined),
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config;
originalFetch = global.fetch;
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
it('should handle browser launch failure gracefully', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock open to throw error
const open = await import('open');
vi.mocked(open.default).mockRejectedValue(
new Error('Browser launch failed'),
);
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',
}),
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
const client = await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
expect(client).toBeInstanceOf(Object);
});
it('should handle browser child process error gracefully', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(
new Error('No cached credentials'),
);
// Mock open to return a child process that will emit error
const open = await import('open');
const mockChildProcess = {
on: vi.fn((event: string, callback: (error: Error) => void) => {
if (event === 'error') {
// Call the error handler immediately for testing
setTimeout(() => callback(new Error('Process spawn failed')), 0);
}
}),
};
vi.mocked(open.default).mockResolvedValue(
mockChildProcess as unknown as ChildProcess,
);
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',
}),
};
vi.mocked(global.fetch)
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
const client = await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
expect(client).toBeInstanceOf(Object);
});
});
describe('Event Emitter Integration', () => {
it('should export qwenOAuth2Events as EventEmitter', async () => {
const { qwenOAuth2Events } = await import('./qwenOAuth2.js');
expect(qwenOAuth2Events).toBeInstanceOf(EventEmitter);
});
it('should define correct event enum values', async () => {
const { QwenOAuth2Event } = await import('./qwenOAuth2.js');
expect(QwenOAuth2Event.AuthUri).toBe('auth-uri');
expect(QwenOAuth2Event.AuthProgress).toBe('auth-progress');
expect(QwenOAuth2Event.AuthCancel).toBe('auth-cancel');
});
});