Files
edr-platform/apps/edr-passenger-api/src/common/audit.service.spec.ts

225 lines
8.8 KiB
TypeScript

import { AuditService } from './audit.service';
import { snapshot, auditPayload, changedFields } from './audit-snapshot';
/**
* The audit primitive is the reason ~47 of the 57 existing call sites wrote a NULL actor: it
* held the request (for IP/user-agent) but never read `request.user`. These pin the fix — the
* actor is resolved from the guarded session, callers cannot be trusted to pass it, and a
* logging failure never propagates into the business operation it describes.
*/
describe('AuditService', () => {
const REQUEST = {
user: {
id: 'iam-user-1',
name: { en: 'Abebe Kebede', am: 'አበበ ከበደ' },
username: 'abebe.k',
email: 'abebe@edr.et',
phoneNumber: '+251911223344',
},
headers: {
'x-forwarded-for': '10.1.2.3, 172.16.0.1',
'user-agent': 'Mozilla/5.0 (Backoffice)',
},
socket: { remoteAddress: '127.0.0.1' },
};
const build = (request?: any) => {
const create = jest.fn().mockResolvedValue({});
const prisma = { auditLog: { create, count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() } };
return { service: new AuditService(prisma as any, request), create, prisma };
};
const written = (create: jest.Mock) => create.mock.calls[0][0].data;
describe('actor resolution', () => {
it('takes the actor from the authenticated request when the caller omits one', async () => {
const { service, create } = build(REQUEST);
await service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' });
expect(written(create)).toMatchObject({
iamUserId: 'iam-user-1',
userName: 'Abebe Kebede',
userPhone: '+251911223344',
});
});
it('ignores nothing the request says — a body cannot smuggle in a different actor', async () => {
const { service, create } = build({
...REQUEST,
// A request body field never reaches AuditService; only `request.user` does.
body: { validatorId: 'someone-else', waivedBy: 'not-me' },
});
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-1' });
expect(written(create).iamUserId).toBe('iam-user-1');
});
it('lets a system path pass its own actor explicitly', async () => {
const { service, create } = build(REQUEST);
await service.log({
action: 'STATUS_CHANGE',
entityType: 'Schedule',
entityId: 'sc-1',
userId: 'SYSTEM',
userName: 'Reconciliation cron',
});
expect(written(create)).toMatchObject({ iamUserId: 'SYSTEM', userName: 'Reconciliation cron' });
});
it('records a null actor outside an HTTP request rather than inventing one', async () => {
const { service, create } = build(undefined);
await service.log({ action: 'PAY', entityType: 'ExcessBaggageCharge', entityId: 'c-1' });
expect(written(create)).toMatchObject({ iamUserId: null, userName: null, userPhone: null });
});
it('falls back through the name shapes IAM actually returns', async () => {
const { service, create } = build({ user: { sub: 'legacy-id', username: 'gate.agent' }, headers: {} });
await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-2' });
expect(written(create)).toMatchObject({ iamUserId: 'legacy-id', userName: 'gate.agent' });
});
});
describe('request metadata', () => {
it('captures the client IP and user-agent', async () => {
const { service, create } = build(REQUEST);
await service.log({ action: 'UPDATE', entityType: 'Train', entityId: 'tr-1' });
expect(written(create)).toMatchObject({
ipAddress: '10.1.2.3',
userAgent: 'Mozilla/5.0 (Backoffice)',
});
});
it('still writes the row when the request carries no headers', async () => {
// Queue consumers resolve this service with a bare stub; reading through `headers`
// unguarded used to throw and silently drop the whole row.
const { service, create } = build({ user: { id: 'iam-user-1' }, ip: '10.9.9.9' });
await service.log({ action: 'PAY', entityType: 'SupplementaryCharge', entityId: 'sc-1' });
expect(create).toHaveBeenCalledTimes(1);
expect(written(create)).toMatchObject({ ipAddress: '10.9.9.9', userAgent: '' });
});
it('leaves IP and user-agent blank with no request at all', async () => {
const { service, create } = build(undefined);
await service.log({ action: 'DELETE', entityType: 'Seat', entityId: 's-1' });
expect(written(create)).toMatchObject({ ipAddress: '', userAgent: '' });
});
});
describe('failure containment', () => {
it('swallows a write failure instead of breaking the operation it describes', async () => {
const create = jest.fn().mockRejectedValue(new Error('db down'));
const service = new AuditService({ auditLog: { create } } as any, REQUEST);
await expect(
service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' }),
).resolves.toBeUndefined();
});
});
describe('getLogs', () => {
const withRows = () => {
const findMany = jest.fn().mockResolvedValue([]);
const count = jest.fn().mockResolvedValue(0);
const prisma = { auditLog: { findMany, count, create: jest.fn() } };
return { service: new AuditService(prisma as any, REQUEST), findMany, count };
};
it('caps the page size so a caller cannot ask for the whole table', async () => {
const { service, findMany } = withRows();
await service.getLogs({ limit: 5000 });
expect(findMany.mock.calls[0][0].take).toBe(200);
});
it('defaults to the newest 50 rows', async () => {
const { service, findMany } = withRows();
await service.getLogs();
expect(findMany.mock.calls[0][0]).toMatchObject({
take: 50,
skip: 0,
orderBy: { createdAt: 'desc' },
});
});
it('filters by exact actor and a date range', async () => {
const { service, findMany } = withRows();
await service.getLogs({ iamUserId: 'iam-user-1', from: '2026-08-01', to: '2026-08-18' });
const where = findMany.mock.calls[0][0].where;
expect(where.iamUserId).toBe('iam-user-1');
expect(where.createdAt.gte).toEqual(new Date('2026-08-01'));
expect(where.createdAt.lte).toEqual(new Date('2026-08-18'));
});
it('ignores an unparseable date instead of returning nothing', async () => {
const { service, findMany } = withRows();
await service.getLogs({ from: 'not-a-date' });
expect(findMany.mock.calls[0][0].where.createdAt).toBeUndefined();
});
it('searches the denormalized actor name and phone, not just ids', async () => {
const { service, findMany } = withRows();
await service.getLogs({ search: 'abebe' });
const fields = findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]);
expect(fields).toEqual(
expect.arrayContaining(['entityId', 'iamUserId', 'userName', 'userPhone']),
);
});
});
});
describe('audit snapshot helpers', () => {
it('keeps only the listed fields', () => {
const entity = { id: 'x', name: 'Dire Dawa', code: 'DD', internalNote: 'do not log' };
expect(snapshot(entity, ['name', 'code'])).toEqual({ name: 'Dire Dawa', code: 'DD' });
});
it('drops absent fields rather than writing undefined', () => {
expect(snapshot({ name: 'X', code: undefined } as any, ['name', 'code'])).toEqual({ name: 'X' });
});
it('returns undefined for a missing entity so oldData is simply omitted', () => {
expect(snapshot(null, ['name'] as any)).toBeUndefined();
});
it('redacts credentials even when a caller asks for them', () => {
const charge = { paymentToken: 'tok-live-1', amountMinor: 1000 } as any;
expect(snapshot(charge, ['paymentToken', 'amountMinor'])).toEqual({
paymentToken: '[redacted]',
amountMinor: 1000,
});
});
it('redacts on word boundaries, not raw substrings', () => {
// "shipping" contains the letters of "pin"; it is not a credential.
const row = { shippingNote: 'leave at gate', otp: '4530', national_id: 'ETH-1' } as any;
expect(auditPayload(row)).toEqual({
shippingNote: 'leave at gate',
otp: '[redacted]',
national_id: '[redacted]',
});
});
it('flattens dates so two snapshots can be compared', () => {
const at = new Date('2026-08-18T06:00:00.000Z');
expect(snapshot({ departureAt: at } as any, ['departureAt'])).toEqual({
departureAt: '2026-08-18T06:00:00.000Z',
});
});
it('reduces a before/after pair to the fields that actually moved', () => {
const before = { status: 'SCHEDULED', departureTime: '08:00' };
const after = { status: 'CANCELLED', departureTime: '08:00' };
expect(changedFields(before, after)).toEqual({
oldData: { status: 'SCHEDULED' },
newData: { status: 'CANCELLED' },
});
});
});