mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
225 lines
8.8 KiB
TypeScript
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' },
|
|
});
|
|
});
|
|
});
|