import type { EmailVerificationToken } from '../../auth.types'; // Integration tests for TokenService against a live CouchDB instance. // These tests require a local CouchDB at http://localhost:5984 with admin:password // or credentials provided via environment variables. // // To run only these tests: // jest meds/services/auth/__tests__/integration/token.service.integration.test.ts // // Skips automatically if CouchDB is not reachable. describe('TokenService (integration with CouchDB)', () => { const COUCH_URL = process.env.VITE_COUCHDB_URL || process.env.COUCHDB_URL || 'http://localhost:5984'; const COUCH_USER = process.env.VITE_COUCHDB_USER || process.env.COUCHDB_USER || 'admin'; const COUCH_PASS = process.env.VITE_COUCHDB_PASSWORD || process.env.COUCHDB_PASSWORD || 'password'; const BASIC_AUTH = 'Basic ' + Buffer.from(`${COUCH_USER}:${COUCH_PASS}`).toString('base64'); let couchUp = false; let previousFetch: typeof fetch | undefined; let previousHeaders: typeof Headers | undefined; let previousRequest: typeof Request | undefined; let previousResponse: typeof Response | undefined; // TokenService and its dependencies will be imported after configuring env and fetch. let tokenService: any; // Utility: install a custom HTTP(S) fetch to reach real CouchDB (bypass test mocks) const installCustomFetch = async () => { previousFetch = global.fetch; previousHeaders = global.Headers as typeof Headers | undefined; previousRequest = global.Request as typeof Request | undefined; previousResponse = global.Response as typeof Response | undefined; // Use Node core undici (Node 18+) // Install a minimal HTTP(S)-based fetch to bypass test mocks const customFetch = async (url: string, init?: RequestInit) => { const { URL } = await import('node:url'); const http = await import('node:http'); const https = await import('node:https'); const u = new URL(url); const isHttps = u.protocol === 'https:'; const mod = isHttps ? https : http; const headers = init?.headers as Record | undefined; const method = init?.method || 'GET'; const body = init?.body as string | undefined; return await new Promise((resolve, reject) => { const req = mod.request( { protocol: u.protocol, hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.pathname + (u.search || ''), method, headers, }, res => { const chunks: Buffer[] = []; res.on('data', d => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d)) ); res.on('end', () => { const buf = Buffer.concat(chunks); const status = res.statusCode || 0; const headersMap = new Map(); for (const [k, v] of Object.entries(res.headers)) { if (Array.isArray(v)) headersMap.set(k, v.join(', ')); else if (v != null) headersMap.set(k, String(v)); } const responseLike = { ok: status >= 200 && status < 300, status, headers: { get: (k: string) => headersMap.get(k.toLowerCase()) || headersMap.get(k) || null, has: (k: string) => headersMap.has(k.toLowerCase()) || headersMap.has(k), }, json: async () => { const txt = buf.toString('utf8'); return txt ? JSON.parse(txt) : {}; }, text: async () => buf.toString('utf8'), } as unknown as Response; resolve(responseLike); }); } ); req.on('error', reject); if (body) req.write(body); req.end(); }); }; // Override global fetch temporarily // @ts-ignore - test environment mutation global.fetch = customFetch as unknown as typeof fetch; }; const restoreMockFetch = () => { if (previousFetch) global.fetch = previousFetch; if (previousHeaders) global.Headers = previousHeaders as typeof Headers; if (previousRequest) global.Request = previousRequest as typeof Request; if (previousResponse) global.Response = previousResponse as typeof Response; }; const pingCouch = async (): Promise => { try { const res = await fetch(`${COUCH_URL}/`, { headers: { Authorization: BASIC_AUTH }, }); return res.ok; } catch { return false; } }; beforeAll(async () => { // Use development settings and disable mock DB so TokenService uses CouchDB process.env.NODE_ENV = 'development'; process.env.USE_MOCK_DB = 'false'; process.env.VITE_COUCHDB_URL = COUCH_URL; process.env.VITE_COUCHDB_USER = COUCH_USER; process.env.VITE_COUCHDB_PASSWORD = COUCH_PASS; await installCustomFetch(); // Verify CouchDB is reachable couchUp = await pingCouch(); if (!couchUp) { console.warn( `⚠️ CouchDB not reachable at ${COUCH_URL}. Skipping TokenService integration tests.` ); restoreMockFetch(); return; } // Ensure a clean module graph and import TokenService fresh with current env jest.resetModules(); // import after env is set so unified config picks up these values const mod = await import('../../token.service'); tokenService = mod.tokenService; // Trigger DB provisioning just in case // Save a no-op token then delete it; this ensures database exists. const bootstrapToken = `bootstrap-${Date.now()}`; await tokenService.savePasswordResetToken({ userId: 'bootstrap', email: 'bootstrap@example.com', token: bootstrapToken, expiresAt: new Date(Date.now() + 60_000), }); await tokenService.deletePasswordResetToken(bootstrapToken); }, 30000); afterAll(async () => { if (couchUp) { try { // Best-effort cleanup of expired tokens await tokenService.cleanupExpiredTokens(); } catch { // ignore } } // Restore original mocked fetch for the rest of the test suite restoreMockFetch(); }); const itIf = (cond: boolean) => (cond ? it : it.skip); itIf(couchUp)( 'saves and retrieves a verification token', async () => { const tokenValue = `ver-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const userId = `u-${Date.now()}`; const email = `user-${Date.now()}@example.com`; const expiresAt = new Date(Date.now() + 5 * 60_000); const token: EmailVerificationToken = { userId, email, token: tokenValue, expiresAt, }; await tokenService.saveVerificationToken(token); const fetched = await tokenService.findVerificationToken(tokenValue); expect(fetched).toBeTruthy(); expect(fetched.userId).toBe(userId); expect(fetched.email).toBe(email); expect(new Date(fetched.expiresAt).getTime()).toBe(expiresAt.getTime()); // Cleanup await tokenService.deleteVerificationTokensForUser(userId); const after = await tokenService.findVerificationToken(tokenValue); expect(after).toBeNull(); }, 30000 ); itIf(couchUp)( 'deletes only verification tokens for the specified user', async () => { const tokenA = `verA-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const tokenB = `verB-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const userA = `userA-${Date.now()}`; const userB = `userB-${Date.now()}`; const exp = new Date(Date.now() + 10 * 60_000); await tokenService.saveVerificationToken({ userId: userA, email: 'a@example.com', token: tokenA, expiresAt: exp, }); await tokenService.saveVerificationToken({ userId: userB, email: 'b@example.com', token: tokenB, expiresAt: exp, }); // Delete tokens for userA await tokenService.deleteVerificationTokensForUser(userA); // Verify A is deleted const fa = await tokenService.findVerificationToken(tokenA); expect(fa).toBeNull(); // Verify B still exists const fb = await tokenService.findVerificationToken(tokenB); expect(fb).toBeTruthy(); expect(fb.userId).toBe(userB); // Cleanup B await tokenService.deleteVerificationTokensForUser(userB); const fbAfter = await tokenService.findVerificationToken(tokenB); expect(fbAfter).toBeNull(); }, 30000 ); itIf(couchUp)( 'password reset token lifecycle (save, find, delete)', async () => { const tokenValue = `rst-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const userId = `u-${Date.now()}`; const email = `reset-${Date.now()}@example.com`; const exp = new Date(Date.now() + 5 * 60_000); await tokenService.savePasswordResetToken({ userId, email, token: tokenValue, expiresAt: exp, }); const fetched = await tokenService.findPasswordResetToken(tokenValue); expect(fetched).toBeTruthy(); expect(fetched.userId).toBe(userId); expect(fetched.email).toBe(email); await tokenService.deletePasswordResetToken(tokenValue); const after = await tokenService.findPasswordResetToken(tokenValue); expect(after).toBeNull(); }, 30000 ); itIf(couchUp)( 'cleanup removes expired tokens', async () => { const expiredToken = `expired-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const userId = `u-exp-${Date.now()}`; const email = `expired-${Date.now()}@example.com`; const past = new Date(Date.now() - 60_000); // Save one expired verification token await tokenService.saveVerificationToken({ userId, email, token: expiredToken, expiresAt: past, }); // And one valid token to ensure only expired is removed const validToken = `valid-${Date.now()}-${Math.random() .toString(16) .slice(2)}`; const future = new Date(Date.now() + 60_000); await tokenService.saveVerificationToken({ userId, email, token: validToken, expiresAt: future, }); const removed = await tokenService.cleanupExpiredTokens(); expect(removed).toBeGreaterThanOrEqual(1); const expiredFetched = await tokenService.findVerificationToken(expiredToken); expect(expiredFetched).toBeNull(); const validFetched = await tokenService.findVerificationToken(validToken); expect(validFetched).toBeTruthy(); // Cleanup valid await tokenService.deleteVerificationTokensForUser(userId); const validAfter = await tokenService.findVerificationToken(validToken); expect(validAfter).toBeNull(); }, 30000 ); });