test(auth): add token service coverage
This commit is contained in:
365
services/auth/__tests__/token.service.test.ts
Normal file
365
services/auth/__tests__/token.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user