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(); }); });