366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|