374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>> = {}) {
|
|
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<Record<string, unknown>> = {}) {
|
|
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<void>) => 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<Record<string, unknown>> = {}) {
|
|
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<typeof makeDb>;
|
|
caService?: ReturnType<typeof makeCaService>;
|
|
grantsService?: ReturnType<typeof makeGrantsService>;
|
|
} = {}) {
|
|
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<typeof makeDb>;
|
|
let caService: ReturnType<typeof makeCaService>;
|
|
let grantsService: ReturnType<typeof makeGrantsService>;
|
|
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,
|
|
});
|
|
});
|
|
});
|