import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockRedisGet, mockRedisSet, mockRedisDel, mockRedisKeys } = vi.hoisted(() => ({ mockRedisGet: vi.fn(), mockRedisSet: vi.fn(), mockRedisDel: vi.fn(), mockRedisKeys: vi.fn(), })); vi.mock('../redis.js', () => ({ default: { get: mockRedisGet, set: mockRedisSet, del: mockRedisDel, keys: mockRedisKeys, }, })); const mockTryRefreshToken = vi.hoisted(() => vi.fn()); vi.mock('./token-refresh.js', () => ({ tryRefreshToken: mockTryRefreshToken })); // Use a real 32-byte key so AES-256-GCM doesn't throw process.env.CREDENTIAL_ENCRYPTION_KEY = '0'.repeat(64); import { getCredential, storeCredential } from './credential-store.js'; function encryptCreds(creds: object): string { // We can't call encrypt() directly (not exported), so we'll round-trip through storeCredential // For tests, we'll use a helper approach: just test behavior using storeCredential to set up state. return JSON.stringify(creds); // placeholder — see note below } describe('getCredential', () => { beforeEach(() => { vi.clearAllMocks(); mockRedisSet.mockResolvedValue('OK'); }); it('returns null when no credential stored', async () => { mockRedisGet.mockResolvedValue(null); const result = await getCredential('cust1', 'linkedin'); expect(result).toBeNull(); }); it('returns credential when not expired', async () => { // Store then retrieve using real encryption const creds = { accessToken: 'tok', expiresAt: Date.now() + 3_600_000 }; await storeCredential('cust1', 'linkedin', creds); const stored = mockRedisSet.mock.calls[0][1] as string; mockRedisGet.mockResolvedValue(stored); const result = await getCredential('cust1', 'linkedin'); expect(result).toMatchObject({ accessToken: 'tok' }); expect(mockTryRefreshToken).not.toHaveBeenCalled(); }); it('attempts refresh when token is within 60s of expiry', async () => { const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() + 30_000 }; await storeCredential('cust1', 'linkedin', creds); const stored = mockRedisSet.mock.calls[0][1] as string; mockRedisGet.mockResolvedValue(stored); mockTryRefreshToken.mockResolvedValue({ accessToken: 'new', expiresAt: Date.now() + 3_600_000 }); const result = await getCredential<{ accessToken: string }>('cust1', 'linkedin'); expect(mockTryRefreshToken).toHaveBeenCalledWith('cust1', 'linkedin', expect.objectContaining({ accessToken: 'old' })); expect(result?.accessToken).toBe('new'); }); it('returns null when token expired and no refresh token', async () => { const creds = { accessToken: 'old', expiresAt: Date.now() - 1000 }; await storeCredential('cust1', 'linkedin', creds); const stored = mockRedisSet.mock.calls[0][1] as string; mockRedisGet.mockResolvedValue(stored); const result = await getCredential('cust1', 'linkedin'); expect(result).toBeNull(); expect(mockTryRefreshToken).not.toHaveBeenCalled(); }); it('returns null when refresh fails', async () => { const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() - 1000 }; await storeCredential('cust1', 'linkedin', creds); const stored = mockRedisSet.mock.calls[0][1] as string; mockRedisGet.mockResolvedValue(stored); mockTryRefreshToken.mockResolvedValue(null); const result = await getCredential('cust1', 'linkedin'); expect(result).toBeNull(); }); it('returns non-OAuth credentials without expiry check', async () => { const creds = { host: 'imap.gmail.com', port: 993, user: 'u', password: 'p' }; await storeCredential('cust1', 'email', creds); const stored = mockRedisSet.mock.calls[0][1] as string; mockRedisGet.mockResolvedValue(stored); const result = await getCredential('cust1', 'email'); expect(result).toMatchObject({ host: 'imap.gmail.com' }); expect(mockTryRefreshToken).not.toHaveBeenCalled(); }); });