mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 01:37:50 +00:00
938 lines
31 KiB
TypeScript
938 lines
31 KiB
TypeScript
/**
|
||
* @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(),
|
||
rename: 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<T>(obj: unknown, property: string): T {
|
||
return (obj as Record<string, T>)[property];
|
||
}
|
||
|
||
/**
|
||
* Helper to set private properties for testing
|
||
*/
|
||
function setPrivateProperty<T>(obj: unknown, property: string, value: T): void {
|
||
(obj as Record<string, T>)[property] = value;
|
||
}
|
||
|
||
/**
|
||
* Creates a mock QwenOAuth2Client for testing
|
||
*/
|
||
function createMockQwenClient(
|
||
initialCredentials: Partial<QwenCredentials> = {},
|
||
): 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> = {},
|
||
): 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> = {},
|
||
): 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> = {},
|
||
): 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.rename.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.rename.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<TokenRefreshData>((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) - need to mock it twice
|
||
// Once for checkAndReloadIfNeeded, once for forceFileCheck during refresh
|
||
mockFs.stat
|
||
.mockRejectedValueOnce(
|
||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||
)
|
||
.mockRejectedValueOnce(
|
||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||
);
|
||
|
||
// Create a delayed refresh response
|
||
let resolveRefresh: (value: TokenRefreshData) => void;
|
||
const refreshPromise = new Promise<TokenRefreshData>((resolve) => {
|
||
resolveRefresh = resolve;
|
||
});
|
||
|
||
mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise);
|
||
|
||
// Mock file operations for lock and save
|
||
mockFs.writeFile.mockResolvedValue(undefined);
|
||
mockFs.rename.mockResolvedValue(undefined);
|
||
mockFs.mkdir.mockResolvedValue(undefined);
|
||
|
||
// 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 () => {
|
||
// Create a fresh token manager instance to avoid state contamination
|
||
setPrivateProperty(SharedTokenManager, 'instance', null);
|
||
const freshTokenManager = SharedTokenManager.getInstance();
|
||
|
||
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<TokenRefreshData>;
|
||
|
||
mockClient.refreshAccessToken = vi
|
||
.fn()
|
||
.mockResolvedValue(invalidResponse);
|
||
|
||
// Completely reset all fs mocks to ensure no contamination
|
||
mockFs.stat.mockReset();
|
||
mockFs.readFile.mockReset();
|
||
mockFs.writeFile.mockReset();
|
||
mockFs.mkdir.mockReset();
|
||
mockFs.rename.mockReset();
|
||
mockFs.unlink.mockReset();
|
||
|
||
// Mock stat for file check to fail (no file initially) - need to mock it twice
|
||
// Once for checkAndReloadIfNeeded, once for forceFileCheck during refresh
|
||
mockFs.stat
|
||
.mockRejectedValueOnce(
|
||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||
)
|
||
.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
|
||
freshTokenManager.clearCache();
|
||
|
||
await expect(
|
||
freshTokenManager.getValidCredentials(mockClient),
|
||
).rejects.toThrow(TokenManagerError);
|
||
|
||
await expect(
|
||
freshTokenManager.getValidCredentials(mockClient),
|
||
).rejects.toThrow('no token returned');
|
||
|
||
// Clean up the fresh instance
|
||
freshTokenManager.cleanup();
|
||
});
|
||
});
|
||
|
||
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.rename.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.rename.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.rename.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 rename and unlink to succeed (for atomic stale lock removal)
|
||
mockFs.rename.mockResolvedValue(undefined);
|
||
mockFs.unlink.mockResolvedValue(undefined);
|
||
mockFs.mkdir.mockResolvedValue(undefined);
|
||
|
||
const result = await tokenManager.getValidCredentials(mockClient);
|
||
|
||
expect(result.access_token).toBe(refreshResponse.access_token);
|
||
expect(mockFs.rename).toHaveBeenCalled(); // Stale lock moved atomically
|
||
expect(mockFs.unlink).toHaveBeenCalled(); // Temp file cleaned up
|
||
});
|
||
});
|
||
|
||
describe('CredentialsClearRequiredError handling', () => {
|
||
it('should clear memory cache when CredentialsClearRequiredError is thrown during refresh', async () => {
|
||
const { CredentialsClearRequiredError } = await import('./qwenOAuth2.js');
|
||
|
||
const tokenManager = SharedTokenManager.getInstance();
|
||
tokenManager.clearCache();
|
||
|
||
// Set up some credentials in memory cache
|
||
const mockCredentials = {
|
||
access_token: 'expired-token',
|
||
refresh_token: 'expired-refresh',
|
||
token_type: 'Bearer',
|
||
expiry_date: Date.now() - 1000, // Expired
|
||
};
|
||
|
||
const memoryCache = getPrivateProperty<{
|
||
credentials: QwenCredentials | null;
|
||
fileModTime: number;
|
||
}>(tokenManager, 'memoryCache');
|
||
memoryCache.credentials = mockCredentials;
|
||
memoryCache.fileModTime = 12345;
|
||
|
||
// Mock the client to throw CredentialsClearRequiredError
|
||
const mockClient = {
|
||
getCredentials: vi.fn().mockReturnValue(mockCredentials),
|
||
setCredentials: vi.fn(),
|
||
getAccessToken: vi.fn(),
|
||
requestDeviceAuthorization: vi.fn(),
|
||
pollDeviceToken: vi.fn(),
|
||
refreshAccessToken: vi
|
||
.fn()
|
||
.mockRejectedValue(
|
||
new CredentialsClearRequiredError(
|
||
'Refresh token expired or invalid',
|
||
{ status: 400, response: 'Bad Request' },
|
||
),
|
||
),
|
||
};
|
||
|
||
// Mock file system operations
|
||
mockFs.writeFile.mockResolvedValue(undefined);
|
||
mockFs.stat.mockResolvedValue({ mtimeMs: 12345 } as Stats);
|
||
mockFs.mkdir.mockResolvedValue(undefined);
|
||
mockFs.rename.mockResolvedValue(undefined);
|
||
mockFs.unlink.mockResolvedValue(undefined);
|
||
|
||
// Attempt to get valid credentials should fail and clear cache
|
||
await expect(
|
||
tokenManager.getValidCredentials(mockClient),
|
||
).rejects.toThrow(TokenManagerError);
|
||
|
||
// Verify memory cache was cleared
|
||
expect(tokenManager.getCurrentCredentials()).toBeNull();
|
||
const memoryCacheAfter = getPrivateProperty<{
|
||
fileModTime: number;
|
||
}>(tokenManager, 'memoryCache');
|
||
const refreshPromise =
|
||
getPrivateProperty<Promise<QwenCredentials> | null>(
|
||
tokenManager,
|
||
'refreshPromise',
|
||
);
|
||
expect(memoryCacheAfter.fileModTime).toBe(0);
|
||
expect(refreshPromise).toBeNull();
|
||
});
|
||
|
||
it('should convert CredentialsClearRequiredError to TokenManagerError', async () => {
|
||
const { CredentialsClearRequiredError } = await import('./qwenOAuth2.js');
|
||
|
||
const tokenManager = SharedTokenManager.getInstance();
|
||
tokenManager.clearCache();
|
||
|
||
const mockCredentials = {
|
||
access_token: 'expired-token',
|
||
refresh_token: 'expired-refresh',
|
||
token_type: 'Bearer',
|
||
expiry_date: Date.now() - 1000,
|
||
};
|
||
|
||
const mockClient = {
|
||
getCredentials: vi.fn().mockReturnValue(mockCredentials),
|
||
setCredentials: vi.fn(),
|
||
getAccessToken: vi.fn(),
|
||
requestDeviceAuthorization: vi.fn(),
|
||
pollDeviceToken: vi.fn(),
|
||
refreshAccessToken: vi
|
||
.fn()
|
||
.mockRejectedValue(
|
||
new CredentialsClearRequiredError('Test error message'),
|
||
),
|
||
};
|
||
|
||
// Mock file system operations
|
||
mockFs.writeFile.mockResolvedValue(undefined);
|
||
mockFs.stat.mockResolvedValue({ mtimeMs: 12345 } as Stats);
|
||
mockFs.mkdir.mockResolvedValue(undefined);
|
||
mockFs.rename.mockResolvedValue(undefined);
|
||
mockFs.unlink.mockResolvedValue(undefined);
|
||
|
||
try {
|
||
await tokenManager.getValidCredentials(mockClient);
|
||
expect.fail('Expected TokenManagerError to be thrown');
|
||
} catch (error) {
|
||
expect(error).toBeInstanceOf(TokenManagerError);
|
||
expect((error as TokenManagerError).type).toBe(
|
||
TokenError.REFRESH_FAILED,
|
||
);
|
||
expect((error as TokenManagerError).message).toBe('Test error message');
|
||
expect((error as TokenManagerError).originalError).toBeInstanceOf(
|
||
CredentialsClearRequiredError,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('should properly clean up timeout when file operation completes before timeout', async () => {
|
||
const tokenManager = SharedTokenManager.getInstance();
|
||
tokenManager.clearCache();
|
||
|
||
const mockClient = {
|
||
getCredentials: vi.fn().mockReturnValue(null),
|
||
setCredentials: vi.fn(),
|
||
getAccessToken: vi.fn(),
|
||
requestDeviceAuthorization: vi.fn(),
|
||
pollDeviceToken: vi.fn(),
|
||
refreshAccessToken: vi.fn(),
|
||
};
|
||
|
||
// Mock clearTimeout to verify it's called
|
||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||
|
||
// Mock file stat to resolve quickly (before timeout)
|
||
mockFs.stat.mockResolvedValue({ mtimeMs: 12345 } as Stats);
|
||
|
||
// Call checkAndReloadIfNeeded which uses withTimeout internally
|
||
const checkMethod = getPrivateProperty(
|
||
tokenManager,
|
||
'checkAndReloadIfNeeded',
|
||
) as (client?: IQwenOAuth2Client) => Promise<void>;
|
||
await checkMethod.call(tokenManager, mockClient);
|
||
|
||
// Verify that clearTimeout was called to clean up the timer
|
||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||
|
||
clearTimeoutSpy.mockRestore();
|
||
});
|
||
});
|
||
});
|