mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 01:37:50 +00:00
fix: sync token among multiple qwen sessions (#443)
* fix: sync token among multiple qwen sessions * fix: adjust cleanup function
This commit is contained in:
758
packages/core/src/qwen/sharedTokenManager.test.ts
Normal file
758
packages/core/src/qwen/sharedTokenManager.test.ts
Normal file
@@ -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<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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user