test(auth): add token service coverage

This commit is contained in:
William Valentin
2025-09-23 10:24:44 -07:00
parent c1c8e28f01
commit 71c37f4b7b

View File

@@ -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<string, any>;
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();
});
});