qwen-code/packages/core/src/qwen/sharedTokenManager.test.ts
Mingholy 009e083b73
fix: sync token among multiple qwen sessions (#443)
* fix: sync token among multiple qwen sessions

* fix: adjust cleanup function
2025-08-27 13:17:28 +08:00

758 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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<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.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<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)
mockFs.stat.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.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<TokenRefreshData>;
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
});
});
});