337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
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<string, string> | undefined;
|
|
const method = init?.method || 'GET';
|
|
const body = init?.body as string | undefined;
|
|
return await new Promise<Response>((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<string, string>();
|
|
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<boolean> => {
|
|
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
|
|
);
|
|
});
|