From ca17a7d055cdaaf7f58e69ad2fc35f0673532e07 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 14 May 2026 10:08:39 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20Cover=20admin=20audit-log?= =?UTF-8?q?=20handler=20with=20unit=20tests=20for=20auth,=20validation,=20?= =?UTF-8?q?tenant=20isolation,=20CSV=20output,=20and=20abort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling admin handlers (grants, groups, roles, users) all have handler specs; this one was missing. The new suite covers 401 on a missing `req.user`, 400 on malformed ISO `from` / `to`, 400 on limit > 500, 400 on negative offset, 400 on an unknown action or `targetPrincipalType`, 400 on a non-ObjectId `:id`, 404 when the methods layer returns null, that the caller's `tenantId` (not a forged query-string `tenantId`) is the one passed to the methods layer, that `actorQuery` / `targetQuery` round-trip, that the deprecated `actorId` / `targetPrincipalId` aliases still map through, that the CSV stream emits the BOM as the first chunk with CRLF line endings and the expected header labels, that quotes, commas, and newlines are properly escaped, that the formula-injection prefixes (`=` `+` `-` `@` tab CR) are defanged, that an `isCancelled` callback reaches the methods layer and flips to true on client `close`, and that `res.end` is skipped when the client disconnected mid-stream. --- packages/api/src/admin/auditLog.spec.ts | 501 ++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 packages/api/src/admin/auditLog.spec.ts diff --git a/packages/api/src/admin/auditLog.spec.ts b/packages/api/src/admin/auditLog.spec.ts new file mode 100644 index 0000000000..c60466eb7b --- /dev/null +++ b/packages/api/src/admin/auditLog.spec.ts @@ -0,0 +1,501 @@ +import { EventEmitter } from 'events'; +import { Types } from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; +import type { AdminAuditLogEntry, AuditLogPage } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; +import type { AdminAuditLogDeps } from './auditLog'; +import { createAdminAuditLogHandlers } from './auditLog'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const validObjectId = new Types.ObjectId().toString(); + +function mockEntry(overrides: Partial = {}): AdminAuditLogEntry { + return { + id: new Types.ObjectId().toString(), + action: 'grant_assigned', + actorId: new Types.ObjectId().toString(), + actorName: 'Alice Admin', + targetPrincipalType: PrincipalType.USER, + targetPrincipalId: new Types.ObjectId().toString(), + targetName: 'Bob User', + capability: 'manage:users', + timestamp: new Date('2025-01-15T10:30:00.000Z').toISOString(), + ...overrides, + }; +} + +function createReqRes( + overrides: { + params?: Record; + query?: Record; + user?: { _id?: Types.ObjectId; id?: string; role?: string; tenantId?: string } | undefined; + } = {}, +) { + const req = { + params: overrides.params ?? {}, + query: overrides.query ?? {}, + body: {}, + user: 'user' in overrides ? overrides.user : { _id: new Types.ObjectId(), role: 'admin' }, + } as unknown as ServerRequest; + + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { status, json } as unknown as Response; + + return { req, res, status, json }; +} + +interface CsvCaptureContext { + req: ServerRequest; + res: Response; + emitter: EventEmitter; + reqEmitter: EventEmitter; + chunks: string[]; + endCalled: () => boolean; + emitClose: () => void; +} + +/** + * Wraps the request/response in EventEmitter shims so the streaming + * handler can register `close` / `aborted` / `drain` listeners and the + * test can drive them. + */ +function createCsvContext( + overrides: { + query?: Record; + user?: { _id?: Types.ObjectId; id?: string; role?: string; tenantId?: string } | undefined; + writeReturns?: boolean; + } = {}, +): CsvCaptureContext { + const emitter = new EventEmitter(); + const reqEmitter = new EventEmitter(); + const chunks: string[] = []; + let ended = false; + + const res = Object.assign(emitter, { + setHeader: jest.fn(), + write: jest.fn((chunk: string) => { + chunks.push(chunk); + return overrides.writeReturns ?? true; + }), + end: jest.fn(() => { + ended = true; + }), + headersSent: false, + status: jest.fn().mockReturnValue({ json: jest.fn() }), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Response; + + const req = Object.assign(reqEmitter, { + params: {}, + query: overrides.query ?? {}, + body: {}, + user: 'user' in overrides ? overrides.user : { _id: new Types.ObjectId(), role: 'admin' }, + removeListener: reqEmitter.removeListener.bind(reqEmitter), + }) as unknown as ServerRequest; + + return { + req, + res, + emitter, + reqEmitter, + chunks, + endCalled: () => ended, + emitClose: () => emitter.emit('close'), + }; +} + +function createDeps(overrides: Partial = {}): AdminAuditLogDeps { + return { + listAuditLogPage: jest + .fn, unknown[]>() + .mockResolvedValue({ entries: [], total: 0 }), + findAuditLogEntry: jest + .fn, unknown[]>() + .mockResolvedValue(null), + streamAuditLogEntries: jest.fn, unknown[]>().mockResolvedValue(0), + ...overrides, + }; +} + +describe('createAdminAuditLogHandlers', () => { + describe('listAuditLog', () => { + it('returns 401 when req.user is missing', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ user: undefined }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('returns 200 with the page payload', async () => { + const entries = [mockEntry()]; + const deps = createDeps({ + listAuditLogPage: jest.fn().mockResolvedValue({ entries, total: 1 }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ entries, total: 1 }); + }); + + it('rejects malformed `from` with 400', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ query: { from: '2025-01-01 10:00' } }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/from/); + expect(deps.listAuditLogPage).not.toHaveBeenCalled(); + }); + + it('rejects local-time ISO without a zone offset', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status } = createReqRes({ query: { to: '2025-01-01T10:00:00' } }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + }); + + it('accepts ISO timestamps with a `+HH:MM` offset', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status } = createReqRes({ + query: { from: '2025-01-01T10:00:00+02:00' }, + }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(200); + }); + + it('rejects limit > 500 with 400', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ query: { limit: '501' } }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/limit/); + }); + + it('rejects negative offset with 400', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ query: { offset: '-1' } }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/offset/); + }); + + it('rejects an unknown `action` with 400', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ query: { action: 'invalid_action' } }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/action/i); + }); + + it('rejects an unknown `targetPrincipalType` with 400', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ + query: { targetPrincipalType: 'unknown' }, + }); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/targetPrincipalType/); + }); + + it('uses caller.tenantId from req.user and ignores forged `tenantId` query', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res } = createReqRes({ + query: { tenantId: 'attacker-tenant' } as Record, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'real-tenant' }, + }); + + await handlers.listAuditLog(req, res); + + expect(deps.listAuditLogPage).toHaveBeenCalledWith('real-tenant', expect.any(Object)); + const tenantArg = (deps.listAuditLogPage as jest.Mock).mock.calls[0][0]; + expect(tenantArg).toBe('real-tenant'); + }); + + it('forwards `actorQuery` / `targetQuery` straight through', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res } = createReqRes({ + query: { actorQuery: 'alice', targetQuery: 'bob' }, + }); + + await handlers.listAuditLog(req, res); + + expect(deps.listAuditLogPage).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ actorQuery: 'alice', targetQuery: 'bob' }), + ); + }); + + it('maps deprecated `actorId` to `actorQuery` and `targetPrincipalId` to `targetQuery`', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res } = createReqRes({ + query: { actorId: 'alice', targetPrincipalId: 'bob' }, + }); + + await handlers.listAuditLog(req, res); + + expect(deps.listAuditLogPage).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ actorQuery: 'alice', targetQuery: 'bob' }), + ); + }); + + it('returns 500 when the dep throws', async () => { + const deps = createDeps({ + listAuditLogPage: jest.fn().mockRejectedValue(new Error('db down')), + }); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listAuditLog(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to fetch audit log' }); + }); + }); + + describe('getAuditLogEntry', () => { + it('returns 401 when req.user is missing', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { id: validObjectId }, + user: undefined, + }); + + await handlers.getAuditLogEntry(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('returns 400 when id is not a 24-char hex ObjectId', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ params: { id: 'not-an-objectid' } }); + + await handlers.getAuditLogEntry(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid id' }); + expect(deps.findAuditLogEntry).not.toHaveBeenCalled(); + }); + + it('returns 404 when the dep returns null', async () => { + const deps = createDeps({ + findAuditLogEntry: jest.fn().mockResolvedValue(null), + }); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ params: { id: validObjectId } }); + + await handlers.getAuditLogEntry(req, res); + + expect(status).toHaveBeenCalledWith(404); + expect(json).toHaveBeenCalledWith({ error: 'Not found' }); + }); + + it('returns 200 with the entry payload', async () => { + const entry = mockEntry(); + const deps = createDeps({ + findAuditLogEntry: jest.fn().mockResolvedValue(entry), + }); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ params: { id: validObjectId } }); + + await handlers.getAuditLogEntry(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ entry }); + }); + + it('passes caller.tenantId, not a query param tenantId, to the dep', async () => { + const entry = mockEntry(); + const deps = createDeps({ + findAuditLogEntry: jest.fn().mockResolvedValue(entry), + }); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res } = createReqRes({ + params: { id: validObjectId }, + query: { tenantId: 'attacker' } as Record, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'real-tenant' }, + }); + + await handlers.getAuditLogEntry(req, res); + + expect(deps.findAuditLogEntry).toHaveBeenCalledWith('real-tenant', validObjectId); + }); + }); + + describe('exportAuditLogCsv', () => { + it('returns 401 when req.user is missing', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ user: undefined }); + + await handlers.exportAuditLogCsv(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('emits BOM as the first chunk and CRLF line endings', async () => { + const entry = mockEntry(); + const deps = createDeps({ + streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => { + await (onEntry as (e: AdminAuditLogEntry) => Promise)(entry); + return 1; + }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + expect(ctx.chunks[0]).toBe(''); + expect(ctx.chunks).toContain('\r\n'); + expect(ctx.endCalled()).toBe(true); + }); + + it('writes a header row matching the column labels', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + const header = ctx.chunks[1]; + expect(header).toBe( + 'Timestamp,Action,Actor,Actor ID,Target type,Target ID,Target,Capability', + ); + }); + + it('escapes commas, quotes, and newlines inside cells', async () => { + const entry = mockEntry({ + actorName: 'Alice "Quoted", Admin', + targetName: 'multi\nline target', + }); + const deps = createDeps({ + streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => { + await (onEntry as (e: AdminAuditLogEntry) => Promise)(entry); + return 1; + }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + const rowChunk = ctx.chunks.find((c) => c.includes('Alice') && c.includes('Quoted')); + expect(rowChunk).toBeDefined(); + expect(rowChunk).toContain('"Alice ""Quoted"", Admin"'); + expect(rowChunk).toContain('"multi\nline target"'); + }); + + it('defangs formula-injection prefixes (= + - @ tab CR) by quoting the cell', async () => { + const cases: string[] = ['=SUM(A1)', '+1+1', '-1+2', '@SUM(A1)', '\tTAB', '\rCR']; + for (const dangerous of cases) { + const entry = mockEntry({ actorName: dangerous }); + const deps = createDeps({ + streamAuditLogEntries: jest.fn(async (_t, _f, onEntry) => { + await (onEntry as (e: AdminAuditLogEntry) => Promise)(entry); + return 1; + }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + const rowChunk = ctx.chunks.find( + (c) => c.includes("'") && c.includes(dangerous.replace(/[\r\n]/g, '')), + ); + expect(rowChunk).toBeDefined(); + const expectedGuarded = `'${dangerous}`; + expect(rowChunk).toContain( + expectedGuarded.includes(',') ? `"${expectedGuarded}"` : expectedGuarded, + ); + } + }); + + it('passes isCancelled to the stream dep and flips to true when client aborts mid-stream', async () => { + let capturedIsCancelled: (() => boolean) | undefined; + const deps = createDeps({ + streamAuditLogEntries: jest.fn(async (_t, _f, _onEntry, options) => { + capturedIsCancelled = (options as { isCancelled?: () => boolean })?.isCancelled; + expect(capturedIsCancelled?.()).toBe(false); + ctx.emitClose(); + expect(capturedIsCancelled?.()).toBe(true); + return 0; + }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + expect(capturedIsCancelled).toBeDefined(); + }); + + it('skips res.end when the client has already disconnected mid-stream', async () => { + const deps = createDeps({ + streamAuditLogEntries: jest.fn(async (_t, _f, _onEntry, _options) => { + ctx.emitClose(); + return 0; + }), + }); + const handlers = createAdminAuditLogHandlers(deps); + const ctx = createCsvContext(); + + await handlers.exportAuditLogCsv(ctx.req, ctx.res); + + expect(ctx.endCalled()).toBe(false); + }); + + it('rejects malformed `from` with 400 before opening the stream', async () => { + const deps = createDeps(); + const handlers = createAdminAuditLogHandlers(deps); + const { req, res, status, json } = createReqRes({ query: { from: 'not-a-date' } }); + + await handlers.exportAuditLogCsv(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json.mock.calls[0][0].error).toMatch(/from/); + expect(deps.streamAuditLogEntries).not.toHaveBeenCalled(); + }); + }); +});