/** * Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07). * * Coverage: * createToken: * - inserts token row with correct grantId, peerId, and future expiresAt * - returns { token, expiresAt } with a 64-char hex token * - clamps ttlSeconds to 900 * * redeem — error paths: * - NotFoundException when token row not found * - GoneException when token already used (usedAt set) * - GoneException when token expired (expiresAt < now) * - GoneException when grant status is not pending * * redeem — success path: * - atomically claims token BEFORE cert issuance (claim → issueCert → tx) * - calls CaService.issueCert with correct args * - activates grant + updates peer + writes audit log inside a transaction * - returns { certPem, certChainPem } * * redeem — replay protection: * - GoneException when claim UPDATE returns empty array (concurrent request won) */ import 'reflect-metadata'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GoneException, NotFoundException } from '@nestjs/common'; import type { Db } from '@mosaicstack/db'; import { EnrollmentService } from '../enrollment.service.js'; // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- const GRANT_ID = 'g1111111-1111-1111-1111-111111111111'; const PEER_ID = 'p2222222-2222-2222-2222-222222222222'; const USER_ID = 'u3333333-3333-3333-3333-333333333333'; const TOKEN = 'a'.repeat(64); // 64-char hex const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n'; const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM; const MOCK_SERIAL = 'ABCD1234'; // --------------------------------------------------------------------------- // Factory helpers // --------------------------------------------------------------------------- function makeTokenRow(overrides: Partial> = {}) { return { token: TOKEN, grantId: GRANT_ID, peerId: PEER_ID, expiresAt: new Date(Date.now() + 60_000), // 1 min from now usedAt: null, createdAt: new Date(), ...overrides, }; } function makeGrant(overrides: Partial> = {}) { return { id: GRANT_ID, peerId: PEER_ID, subjectUserId: USER_ID, scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 }, status: 'pending', expiresAt: null, createdAt: new Date(), revokedAt: null, revokedReason: null, ...overrides, }; } // --------------------------------------------------------------------------- // Mock DB builder // --------------------------------------------------------------------------- function makeDb({ tokenRows = [makeTokenRow()], // claimedRows is returned by the .returning() on the token-claim UPDATE. // Empty array = concurrent request won the race (GoneException). claimedRows = [{ token: TOKEN }], }: { tokenRows?: unknown[]; claimedRows?: unknown[]; } = {}) { // insert().values() — for createToken (outer db, not tx) const insertValues = vi.fn().mockResolvedValue(undefined); const insertMock = vi.fn().mockReturnValue({ values: insertValues }); // select().from().where().limit() — for fetching the token row const limitSelect = vi.fn().mockResolvedValue(tokenRows); const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect }); const fromSelect = vi.fn().mockReturnValue({ where: whereSelect }); const selectMock = vi.fn().mockReturnValue({ from: fromSelect }); // update().set().where().returning() — for the atomic token claim (outer db) const returningMock = vi.fn().mockResolvedValue(claimedRows); const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock }); const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate }); const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock }); // transaction(cb) — cb receives txMock; txMock has update + insert const txInsertValues = vi.fn().mockResolvedValue(undefined); const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues }); const txWhereUpdate = vi.fn().mockResolvedValue(undefined); const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate }); const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock }); const txMock = { update: txUpdateMock, insert: txInsertMock }; const transactionMock = vi .fn() .mockImplementation(async (cb: (tx: typeof txMock) => Promise) => cb(txMock)); return { insert: insertMock, select: selectMock, update: claimUpdateMock, transaction: transactionMock, _mocks: { insertValues, insertMock, limitSelect, whereSelect, fromSelect, selectMock, returningMock, whereClaimUpdate, setClaimMock, claimUpdateMock, txInsertValues, txInsertMock, txWhereUpdate, txSetMock, txUpdateMock, txMock, transactionMock, }, }; } // --------------------------------------------------------------------------- // Mock CaService // --------------------------------------------------------------------------- function makeCaService() { return { issueCert: vi.fn().mockResolvedValue({ certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL, }), }; } // --------------------------------------------------------------------------- // Mock GrantsService // --------------------------------------------------------------------------- function makeGrantsService(grantOverrides: Partial> = {}) { return { getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)), activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })), }; } // --------------------------------------------------------------------------- // Helper: build service under test // --------------------------------------------------------------------------- function buildService({ db = makeDb(), caService = makeCaService(), grantsService = makeGrantsService(), }: { db?: ReturnType; caService?: ReturnType; grantsService?: ReturnType; } = {}) { return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never); } // --------------------------------------------------------------------------- // Tests: createToken // --------------------------------------------------------------------------- describe('EnrollmentService.createToken', () => { it('inserts a token row and returns { token, expiresAt }', async () => { const db = makeDb(); const service = buildService({ db }); const result = await service.createToken({ grantId: GRANT_ID, peerId: PEER_ID, ttlSeconds: 900, }); expect(result.token).toHaveLength(64); // 32 bytes hex expect(result.expiresAt).toBeDefined(); expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now()); expect(db._mocks.insertValues).toHaveBeenCalledWith( expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }), ); }); it('clamps ttlSeconds to 900', async () => { const db = makeDb(); const service = buildService({ db }); const before = Date.now(); const result = await service.createToken({ grantId: GRANT_ID, peerId: PEER_ID, ttlSeconds: 9999, }); const after = Date.now(); const expiresMs = new Date(result.expiresAt).getTime(); // Should be at most 900s from now expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100); expect(expiresMs - after).toBeGreaterThanOrEqual(0); }); }); // --------------------------------------------------------------------------- // Tests: redeem — error paths // --------------------------------------------------------------------------- describe('EnrollmentService.redeem — error paths', () => { it('throws NotFoundException when token row not found', async () => { const db = makeDb({ tokenRows: [] }); const service = buildService({ db }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException); }); it('throws GoneException when usedAt is set (already redeemed)', async () => { const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] }); const service = buildService({ db }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException); }); it('throws GoneException when token has expired', async () => { const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] }); const service = buildService({ db }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException); }); it('throws GoneException when grant status is not pending', async () => { const db = makeDb(); const grantsService = makeGrantsService({ status: 'active' }); const service = buildService({ db, grantsService }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException); }); it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => { const db = makeDb({ claimedRows: [] }); const caService = makeCaService(); const grantsService = makeGrantsService(); const service = buildService({ db, caService, grantsService }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException); }); it('does NOT call issueCert when token claim fails (no double minting)', async () => { const db = makeDb({ claimedRows: [] }); const caService = makeCaService(); const service = buildService({ db, caService }); await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException); expect(caService.issueCert).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Tests: redeem — success path // --------------------------------------------------------------------------- describe('EnrollmentService.redeem — success path', () => { let db: ReturnType; let caService: ReturnType; let grantsService: ReturnType; let service: EnrollmentService; beforeEach(() => { db = makeDb(); caService = makeCaService(); grantsService = makeGrantsService(); service = buildService({ db, caService, grantsService }); }); it('claims token BEFORE calling issueCert (prevents double minting)', async () => { const callOrder: string[] = []; db._mocks.returningMock.mockImplementation(async () => { callOrder.push('claim'); return [{ token: TOKEN }]; }); caService.issueCert.mockImplementation(async () => { callOrder.push('issueCert'); return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL }; }); await service.redeem(TOKEN, MOCK_CERT_PEM); expect(callOrder).toEqual(['claim', 'issueCert']); }); it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => { await service.redeem(TOKEN, MOCK_CERT_PEM); expect(caService.issueCert).toHaveBeenCalledWith( expect.objectContaining({ grantId: GRANT_ID, subjectUserId: USER_ID, csrPem: MOCK_CERT_PEM, ttlSeconds: 300, }), ); }); it('runs activate grant + peer update + audit inside a transaction', async () => { await service.redeem(TOKEN, MOCK_CERT_PEM); expect(db._mocks.transactionMock).toHaveBeenCalledOnce(); // tx.update called twice: activate grant + update peer expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2); // tx.insert called once: audit log expect(db._mocks.txInsertMock).toHaveBeenCalledOnce(); }); it('activates grant (sets status=active) inside the transaction', async () => { await service.redeem(TOKEN, MOCK_CERT_PEM); expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); }); it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => { await service.redeem(TOKEN, MOCK_CERT_PEM); expect(db._mocks.txSetMock).toHaveBeenCalledWith( expect.objectContaining({ certPem: MOCK_CERT_PEM, certSerial: MOCK_SERIAL, state: 'active', }), ); }); it('inserts an audit log row inside the transaction', async () => { await service.redeem(TOKEN, MOCK_CERT_PEM); expect(db._mocks.txInsertValues).toHaveBeenCalledWith( expect.objectContaining({ peerId: PEER_ID, grantId: GRANT_ID, verb: 'enrollment', }), ); }); it('returns { certPem, certChainPem } from CaService', async () => { const result = await service.redeem(TOKEN, MOCK_CERT_PEM); expect(result).toEqual({ certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, }); }); });