576 lines
16 KiB
TypeScript
576 lines
16 KiB
TypeScript
/**
|
|
* Tests for CredentialStore with numeric and string timestamp handling
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { CredentialStore } from './credential-store.js';
|
|
import { AuthenticationError } from './types.js';
|
|
import type { AuthCredentials } from './types.js';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
// Mock fs module
|
|
vi.mock('fs');
|
|
|
|
// Mock logger
|
|
const mockLogger = {
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn()
|
|
};
|
|
|
|
vi.mock('../logger/index.js', () => ({
|
|
getLogger: () => mockLogger
|
|
}));
|
|
|
|
describe('CredentialStore', () => {
|
|
let store: CredentialStore;
|
|
const testDir = '/test/config';
|
|
const configFile = '/test/config/auth.json';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
store = new CredentialStore({
|
|
configDir: testDir,
|
|
configFile: configFile,
|
|
baseUrl: 'https://api.test.com'
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('getCredentials with timestamp migration', () => {
|
|
it('should handle string ISO timestamp correctly', () => {
|
|
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
|
const mockCredentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
expiresAt: futureDate.toISOString(),
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.token).toBe('test-token');
|
|
// The timestamp should be normalized to numeric milliseconds
|
|
expect(typeof result?.expiresAt).toBe('number');
|
|
expect(result?.expiresAt).toBe(futureDate.getTime());
|
|
});
|
|
|
|
it('should handle numeric timestamp correctly', () => {
|
|
const futureTimestamp = Date.now() + 7200000; // 2 hours from now
|
|
const mockCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-456',
|
|
email: 'test2@example.com',
|
|
expiresAt: futureTimestamp,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.token).toBe('test-token');
|
|
// Numeric timestamp should remain as-is
|
|
expect(typeof result?.expiresAt).toBe('number');
|
|
expect(result?.expiresAt).toBe(futureTimestamp);
|
|
});
|
|
|
|
it('should reject invalid string timestamp', () => {
|
|
const mockCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-789',
|
|
expiresAt: 'invalid-date-string',
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'No valid expiration time provided for token'
|
|
);
|
|
});
|
|
|
|
it('should reject NaN timestamp', () => {
|
|
const mockCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-nan',
|
|
expiresAt: NaN,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'No valid expiration time provided for token'
|
|
);
|
|
});
|
|
|
|
it('should reject Infinity timestamp', () => {
|
|
const mockCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-inf',
|
|
expiresAt: Infinity,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'No valid expiration time provided for token'
|
|
);
|
|
});
|
|
|
|
it('should handle missing expiresAt field', () => {
|
|
const mockCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-no-expiry',
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
// No expiresAt field
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'No valid expiration time provided for token'
|
|
);
|
|
});
|
|
|
|
it('should check token expiration correctly', () => {
|
|
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
|
const mockCredentials = {
|
|
token: 'expired-token',
|
|
userId: 'user-expired',
|
|
expiresAt: expiredTimestamp,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Authentication token has expired'),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('should allow expired tokens when requested', () => {
|
|
const expiredTimestamp = Date.now() - 3600000; // 1 hour ago
|
|
const mockCredentials = {
|
|
token: 'expired-token',
|
|
userId: 'user-expired',
|
|
expiresAt: expiredTimestamp,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify(mockCredentials)
|
|
);
|
|
|
|
const result = store.getCredentials({ allowExpired: true });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.token).toBe('expired-token');
|
|
});
|
|
});
|
|
|
|
describe('saveCredentials with timestamp normalization', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
|
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
|
});
|
|
|
|
it('should normalize string timestamp to ISO string when saving', () => {
|
|
const futureDate = new Date(Date.now() + 3600000);
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-123',
|
|
expiresAt: futureDate.toISOString(),
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
store.saveCredentials(credentials);
|
|
|
|
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('.tmp'),
|
|
expect.stringContaining('"expiresAt":'),
|
|
expect.any(Object)
|
|
);
|
|
|
|
// Check that the written data contains a valid ISO string
|
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
|
.calls[0][1] as string;
|
|
const parsed = JSON.parse(writtenData);
|
|
expect(typeof parsed.expiresAt).toBe('string');
|
|
expect(new Date(parsed.expiresAt).toISOString()).toBe(parsed.expiresAt);
|
|
});
|
|
|
|
it('should convert numeric timestamp to ISO string when saving', () => {
|
|
const futureTimestamp = Date.now() + 7200000;
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-456',
|
|
expiresAt: futureTimestamp,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
store.saveCredentials(credentials);
|
|
|
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
|
.calls[0][1] as string;
|
|
const parsed = JSON.parse(writtenData);
|
|
expect(typeof parsed.expiresAt).toBe('string');
|
|
expect(new Date(parsed.expiresAt).getTime()).toBe(futureTimestamp);
|
|
});
|
|
|
|
it('should reject invalid string timestamp when saving', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-789',
|
|
expiresAt: 'invalid-date' as any,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
let err: unknown;
|
|
try {
|
|
store.saveCredentials(credentials);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(AuthenticationError);
|
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
|
});
|
|
|
|
it('should reject NaN timestamp when saving', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-nan',
|
|
expiresAt: NaN as any,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
let err: unknown;
|
|
try {
|
|
store.saveCredentials(credentials);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(AuthenticationError);
|
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
|
});
|
|
|
|
it('should reject Infinity timestamp when saving', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-inf',
|
|
expiresAt: Infinity as any,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
let err: unknown;
|
|
try {
|
|
store.saveCredentials(credentials);
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeInstanceOf(AuthenticationError);
|
|
expect((err as Error).message).toContain('Invalid expiresAt format');
|
|
});
|
|
|
|
it('should handle missing expiresAt when saving', () => {
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-no-expiry',
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
// No expiresAt
|
|
};
|
|
|
|
store.saveCredentials(credentials);
|
|
|
|
const writtenData = vi.mocked(fs.writeFileSync).mock
|
|
.calls[0][1] as string;
|
|
const parsed = JSON.parse(writtenData);
|
|
expect(parsed.expiresAt).toBeUndefined();
|
|
});
|
|
|
|
it('should not mutate the original credentials object', () => {
|
|
const originalTimestamp = Date.now() + 3600000;
|
|
const credentials: AuthCredentials = {
|
|
token: 'test-token',
|
|
userId: 'user-123',
|
|
expiresAt: originalTimestamp,
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
const originalCredentialsCopy = { ...credentials };
|
|
|
|
store.saveCredentials(credentials);
|
|
|
|
// Original object should not be modified
|
|
expect(credentials).toEqual(originalCredentialsCopy);
|
|
expect(credentials.expiresAt).toBe(originalTimestamp);
|
|
});
|
|
});
|
|
|
|
describe('corrupt file handling', () => {
|
|
it('should quarantine corrupt file on JSON parse error', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(fs.renameSync).toHaveBeenCalledWith(
|
|
configFile,
|
|
expect.stringContaining('.corrupt-')
|
|
);
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('Quarantined corrupt auth file')
|
|
);
|
|
});
|
|
|
|
it('should handle quarantine failure gracefully', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
|
vi.mocked(fs.renameSync).mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
const result = store.getCredentials();
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
expect.stringContaining('Could not quarantine corrupt file')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('clearCredentials', () => {
|
|
it('should delete the auth file when it exists', () => {
|
|
// Mock file exists
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
|
|
|
store.clearCredentials();
|
|
|
|
expect(fs.existsSync).toHaveBeenCalledWith('/test/config/auth.json');
|
|
expect(fs.unlinkSync).toHaveBeenCalledWith('/test/config/auth.json');
|
|
});
|
|
|
|
it('should not throw when auth file does not exist', () => {
|
|
// Mock file does not exist
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
|
|
// Should not throw
|
|
expect(() => store.clearCredentials()).not.toThrow();
|
|
|
|
// Should not try to unlink non-existent file
|
|
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw AuthenticationError when unlink fails', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.unlinkSync).mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
let err: unknown;
|
|
try {
|
|
store.clearCredentials();
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
|
|
expect(err).toBeInstanceOf(AuthenticationError);
|
|
expect((err as Error).message).toContain('Failed to clear credentials');
|
|
expect((err as Error).message).toContain('Permission denied');
|
|
});
|
|
});
|
|
|
|
describe('hasValidCredentials', () => {
|
|
it('should return true when valid unexpired credentials exist', () => {
|
|
const futureDate = new Date(Date.now() + 3600000); // 1 hour from now
|
|
const credentials = {
|
|
token: 'valid-token',
|
|
userId: 'user-123',
|
|
expiresAt: futureDate.toISOString(),
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
|
|
|
expect(store.hasValidCredentials()).toBe(true);
|
|
});
|
|
|
|
it('should return false when credentials are expired', () => {
|
|
const pastDate = new Date(Date.now() - 3600000); // 1 hour ago
|
|
const credentials = {
|
|
token: 'expired-token',
|
|
userId: 'user-123',
|
|
expiresAt: pastDate.toISOString(),
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
|
|
|
expect(store.hasValidCredentials()).toBe(false);
|
|
});
|
|
|
|
it('should return false when no credentials exist', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
|
|
expect(store.hasValidCredentials()).toBe(false);
|
|
});
|
|
|
|
it('should return false when file contains invalid JSON', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('invalid json {');
|
|
vi.mocked(fs.renameSync).mockImplementation(() => undefined);
|
|
|
|
expect(store.hasValidCredentials()).toBe(false);
|
|
});
|
|
|
|
it('should return false for credentials without expiry', () => {
|
|
const credentials = {
|
|
token: 'no-expiry-token',
|
|
userId: 'user-123',
|
|
tokenType: 'standard',
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(credentials));
|
|
|
|
// Credentials without expiry are considered invalid
|
|
expect(store.hasValidCredentials()).toBe(false);
|
|
|
|
// Should log warning about missing expiration
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
'No valid expiration time provided for token'
|
|
);
|
|
});
|
|
|
|
it('should use allowExpired=false by default', () => {
|
|
// Spy on getCredentials to verify it's called with correct params
|
|
const getCredentialsSpy = vi.spyOn(store, 'getCredentials');
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
store.hasValidCredentials();
|
|
|
|
expect(getCredentialsSpy).toHaveBeenCalledWith({ allowExpired: false });
|
|
});
|
|
});
|
|
|
|
describe('cleanupCorruptFiles', () => {
|
|
it('should remove old corrupt files', () => {
|
|
const now = Date.now();
|
|
const oldFile = 'auth.json.corrupt-' + (now - 8 * 24 * 60 * 60 * 1000); // 8 days old
|
|
const newFile = 'auth.json.corrupt-' + (now - 1000); // 1 second old
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
|
{ name: oldFile, isFile: () => true },
|
|
{ name: newFile, isFile: () => true },
|
|
{ name: 'auth.json', isFile: () => true }
|
|
] as any);
|
|
vi.mocked(fs.statSync).mockImplementation((filePath) => {
|
|
if (filePath.includes(oldFile)) {
|
|
return { mtimeMs: now - 8 * 24 * 60 * 60 * 1000 } as any;
|
|
}
|
|
return { mtimeMs: now - 1000 } as any;
|
|
});
|
|
vi.mocked(fs.unlinkSync).mockImplementation(() => undefined);
|
|
|
|
store.cleanupCorruptFiles();
|
|
|
|
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
|
expect.stringContaining(oldFile)
|
|
);
|
|
expect(fs.unlinkSync).not.toHaveBeenCalledWith(
|
|
expect.stringContaining(newFile)
|
|
);
|
|
});
|
|
|
|
it('should handle cleanup errors gracefully', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
// Should not throw
|
|
expect(() => store.cleanupCorruptFiles()).not.toThrow();
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
expect.stringContaining('Error during corrupt file cleanup')
|
|
);
|
|
});
|
|
});
|
|
});
|