/** * 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') ); }); }); });