diff --git a/services/auth/__tests__/token.service.test.ts b/services/auth/__tests__/token.service.test.ts new file mode 100644 index 0000000..c1e93f9 --- /dev/null +++ b/services/auth/__tests__/token.service.test.ts @@ -0,0 +1,365 @@ +import type { EmailVerificationToken } from '../auth.types'; +import type { PasswordResetToken } from '../token.service'; +import { logger } from '../../logging'; + +jest.mock('../../../config/unified.config', () => { + const actual = jest.requireActual('../../../config/unified.config'); + return { + ...actual, + getDatabaseConfig: jest.fn(), + }; +}); + +const { getDatabaseConfig } = jest.requireMock( + '../../../config/unified.config' +) as { + getDatabaseConfig: jest.MockedFunction< + () => { + url: string; + username: string; + password: string; + useMock?: boolean; + } + >; +}; + +getDatabaseConfig.mockReturnValue({ + url: '', + username: '', + password: '', + useMock: true, +}); + +let TokenServiceClass: typeof import('../token.service').TokenService; + +beforeAll(async () => { + ({ TokenService: TokenServiceClass } = await import('../token.service')); +}); + +describe('TokenService (localStorage fallback)', () => { + let service: TokenService; + + beforeEach(() => { + getDatabaseConfig.mockReturnValue({ + url: '', + username: '', + password: '', + useMock: true, + }); + + service = new TokenServiceClass(); + localStorage.clear(); + }); + + it('stores and retrieves verification tokens with Date instances', async () => { + const expiresAt = new Date('2025-01-01T00:00:00.000Z'); + const token: EmailVerificationToken = { + userId: 'user-1', + email: 'user@example.com', + token: 'verify-123', + expiresAt, + }; + + await service.saveVerificationToken(token); + + const stored = await service.findVerificationToken('verify-123'); + expect(stored).not.toBeNull(); + expect(stored?.expiresAt).toBeInstanceOf(Date); + expect(stored?.expiresAt.getTime()).toBe(expiresAt.getTime()); + }); + + it('removes verification tokens only for the targeted user', async () => { + const expiry = new Date(Date.now() + 60_000); + await service.saveVerificationToken({ + userId: 'user-a', + email: 'a@example.com', + token: 'token-a', + expiresAt: expiry, + }); + await service.saveVerificationToken({ + userId: 'user-b', + email: 'b@example.com', + token: 'token-b', + expiresAt: expiry, + }); + + await service.deleteVerificationTokensForUser('user-a'); + + expect(await service.findVerificationToken('token-a')).toBeNull(); + expect(await service.findVerificationToken('token-b')).not.toBeNull(); + }); + + it('supports password reset token lifecycle (save, find, delete)', async () => { + const expiresAt = new Date(Date.now() + 120_000); + const token: PasswordResetToken = { + userId: 'reset-user', + email: 'reset@example.com', + token: 'reset-123', + expiresAt, + }; + + await service.savePasswordResetToken(token); + + const stored = await service.findPasswordResetToken('reset-123'); + expect(stored).not.toBeNull(); + expect(stored?.expiresAt).toBeInstanceOf(Date); + + await service.deletePasswordResetToken('reset-123'); + + expect(await service.findPasswordResetToken('reset-123')).toBeNull(); + }); + + it('removes password reset tokens by user id', async () => { + const expiresAt = new Date(Date.now() + 120_000); + await service.savePasswordResetToken({ + userId: 'reset-a', + email: 'a@example.com', + token: 'reset-a-token', + expiresAt, + }); + await service.savePasswordResetToken({ + userId: 'reset-b', + email: 'b@example.com', + token: 'reset-b-token', + expiresAt, + }); + + await service.deletePasswordResetTokensForUser('reset-a'); + + expect(await service.findPasswordResetToken('reset-a-token')).toBeNull(); + expect( + await service.findPasswordResetToken('reset-b-token') + ).not.toBeNull(); + }); + + it('cleans up expired tokens from both verification and reset stores', async () => { + const past = new Date(Date.now() - 60_000); + const future = new Date(Date.now() + 60_000); + + await service.saveVerificationToken({ + userId: 'expired', + email: 'expired@example.com', + token: 'expired-ver', + expiresAt: past, + }); + await service.saveVerificationToken({ + userId: 'valid', + email: 'valid@example.com', + token: 'valid-ver', + expiresAt: future, + }); + + await service.savePasswordResetToken({ + userId: 'expired', + email: 'expired@example.com', + token: 'expired-reset', + expiresAt: past, + }); + await service.savePasswordResetToken({ + userId: 'valid', + email: 'valid@example.com', + token: 'valid-reset', + expiresAt: future, + }); + + const removed = await service.cleanupExpiredTokens(); + + expect(removed).toBe(2); + expect(await service.findVerificationToken('expired-ver')).toBeNull(); + expect(await service.findVerificationToken('valid-ver')).not.toBeNull(); + expect(await service.findPasswordResetToken('expired-reset')).toBeNull(); + expect(await service.findPasswordResetToken('valid-reset')).not.toBeNull(); + }); +}); + +describe('TokenService (CouchDB mode)', () => { + let service: TokenService; + let docs: Record; + let revCounter: number; + + beforeEach(() => { + getDatabaseConfig.mockReturnValue({ + url: 'http://couch.local', + username: 'admin', + password: 'secret', + useMock: false, + }); + + service = new TokenServiceClass(); + docs = {}; + revCounter = 1; + + jest.spyOn(service as any, 'ensureDatabase').mockResolvedValue(undefined); + (service as any).usingCouch = true; + (service as any).initialized = true; + (service as any).couchBaseUrl = 'http://couch.local'; + (service as any).couchAuthHeader = 'Basic test'; + + jest + .spyOn(service as any, 'makeCouchRequest') + .mockImplementation(async (method: string, path: string, body?: any) => { + const [resourcePath] = path.split('?'); + const segments = resourcePath.split('/'); + const docId = segments[2]; + + if (method === 'GET' && resourcePath === '/auth_tokens/_all_docs') { + return { + rows: Object.values(docs).map((doc: any) => ({ + id: doc._id, + value: {}, + doc, + })), + }; + } + + if (method === 'POST' && resourcePath === '/auth_tokens/_find') { + const selector = body.selector || {}; + const matching = Object.values(docs).filter((doc: any) => { + return Object.entries(selector).every( + ([key, value]) => doc[key] === value + ); + }); + return { docs: matching }; + } + + if (method === 'GET') { + const doc = docs[docId]; + if (!doc) { + throw new Error('not found'); + } + return doc; + } + + if (method === 'PUT') { + const newRev = `rev-${revCounter++}`; + const doc = { ...body, _rev: newRev }; + docs[doc._id] = doc; + return { id: doc._id, rev: newRev }; + } + + if (method === 'DELETE') { + if (!docs[docId]) { + throw new Error('missing'); + } + delete docs[docId]; + return { ok: true }; + } + + throw new Error(`Unhandled request: ${method} ${path}`); + }); + + jest.spyOn(logger.db, 'error').mockImplementation(() => undefined); + jest.spyOn(logger.db, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('persists and retrieves verification tokens via Couch paths', async () => { + const expiresAt = new Date('2025-01-02T00:00:00.000Z'); + await service.saveVerificationToken({ + userId: 'user-1', + email: 'user@example.com', + token: 'ver-1', + expiresAt, + }); + + const found = await service.findVerificationToken('ver-1'); + expect(found).not.toBeNull(); + expect(found?.expiresAt).toBeInstanceOf(Date); + expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime()); + + expect(Object.keys(docs)).toContain('ver-ver-1'); + }); + + it('deletes verification tokens for a user using Mango query results', async () => { + const future = new Date(Date.now() + 60_000).toISOString(); + docs['ver-keep'] = { + _id: 'ver-keep', + _rev: 'rev-1', + tokenType: 'verification', + token: 'keep', + userId: 'user-keep', + email: 'keep@example.com', + expiresAt: future, + createdAt: future, + }; + docs['ver-drop'] = { + _id: 'ver-drop', + _rev: 'rev-2', + tokenType: 'verification', + token: 'drop', + userId: 'user-drop', + email: 'drop@example.com', + expiresAt: future, + createdAt: future, + }; + + await service.deleteVerificationTokensForUser('user-drop'); + + expect(docs['ver-drop']).toBeUndefined(); + expect(docs['ver-keep']).toBeDefined(); + }); + + it('handles password reset token lifecycle via Couch calls', async () => { + const expiresAt = new Date('2025-01-03T00:00:00.000Z'); + await service.savePasswordResetToken({ + userId: 'reset-user', + email: 'reset@example.com', + token: 'rst-1', + expiresAt, + }); + + const found = await service.findPasswordResetToken('rst-1'); + expect(found).not.toBeNull(); + expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime()); + + await service.deletePasswordResetToken('rst-1'); + + expect(docs['rst-rst-1']).toBeUndefined(); + }); + + it('cleans up expired Couch documents and keeps valid ones', async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + + docs['ver-expired'] = { + _id: 'ver-expired', + _rev: 'rev-5', + tokenType: 'verification', + token: 'expired', + userId: 'user-expired', + email: 'expired@example.com', + expiresAt: past, + createdAt: past, + }; + docs['rst-expired'] = { + _id: 'rst-expired', + _rev: 'rev-6', + tokenType: 'reset', + token: 'rst-expired', + userId: 'user-expired', + email: 'expired@example.com', + expiresAt: past, + createdAt: past, + }; + docs['ver-valid'] = { + _id: 'ver-valid', + _rev: 'rev-7', + tokenType: 'verification', + token: 'valid', + userId: 'user-valid', + email: 'valid@example.com', + expiresAt: future, + createdAt: future, + }; + + const removed = await service.cleanupExpiredTokens(); + + expect(removed).toBe(2); + expect(docs['ver-expired']).toBeUndefined(); + expect(docs['rst-expired']).toBeUndefined(); + expect(docs['ver-valid']).toBeDefined(); + }); +});