From 34a0269e9b1e3abdf8138d4ff2f342a5011a5ff4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 12:13:18 -0400 Subject: [PATCH] test(21-02): add failing test for triage-note service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: generateAndPostTriageNote does not exist yet — covers per-ticket write loop, D-05 partial-failure isolation, D-06 note-text-always-returned, indicator-URL sanitization flow, and NUMERIC confidence coercion. --- lib/services/triage-note-service.test.ts | 259 +++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 lib/services/triage-note-service.test.ts diff --git a/lib/services/triage-note-service.test.ts b/lib/services/triage-note-service.test.ts new file mode 100644 index 0000000..4d7e5da --- /dev/null +++ b/lib/services/triage-note-service.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient (default export), autotask-factory, and mimecast-blast-radius +// BEFORE importing the module under test — mirrors remediation-service.test.ts's +// vi.mock + vi.fn() dispatch-by-SQL-substring pattern. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + __esModule: true, + default: { query: (...args: unknown[]) => queryMock(...args) }, +})); + +const createEntityMock = vi.fn(); +vi.mock('./autotask-factory', () => ({ + getAutotaskClient: () => ({ createEntity: (...args: unknown[]) => createEntityMock(...args) }), +})); + +const getBlastRadiusMock = vi.fn(); +vi.mock('./mimecast-blast-radius', () => ({ + getBlastRadius: (...args: unknown[]) => getBlastRadiusMock(...args), +})); + +// Wrap the real formatTriageNote so tests can inspect the exact evidence +// object it was called with (e.g. asserting `confidence` is a JS number), +// while still exercising the real sanitize/format logic for noteText +// assertions. +const formatTriageNoteSpy = vi.fn(); +vi.mock('./triage-note-format', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatTriageNote: (evidence: unknown) => { + formatTriageNoteSpy(evidence); + return actual.formatTriageNote(evidence as Parameters[0]); + }, + }; +}); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { generateAndPostTriageNote } from './triage-note-service'; + +interface MockRows { + reports?: unknown[]; + classification?: unknown[]; + remediation?: unknown[]; + urlIndicators?: unknown[]; +} + +function stage(rows: MockRows) { + queryMock.mockImplementation(async (sql: string) => { + if (sql.includes('FROM reports WHERE campaign_id')) { + return { rows: rows.reports ?? [] }; + } + if (sql.includes('FROM classifications')) { + return { rows: rows.classification ?? [] }; + } + if (sql.includes('FROM remediation_actions')) { + return { rows: rows.remediation ?? [] }; + } + if (sql.includes('FROM indicators') && sql.includes("indicator_type = 'url'")) { + return { rows: rows.urlIndicators ?? [] }; + } + throw new Error(`Unstaged query in test mock: ${sql}`); + }); +} + +const FIXED_BLAST_RADIUS = { + status: 'ok' as const, + matched: 3, + delivered: 2, + held: 1, + rejected: 0, + clicked: 0, + perRecipient: [], + source: 'fan-out' as const, +}; + +function report(overrides: Partial> = {}) { + return { + id: 'report-1', + ticket_id: '1001', + ticket_number: 'T-1001', + title: 'Suspicious email', + company_name: 'Acme Corp', + requester_contact_id: 55, + evidence: {}, + created_at: '2026-07-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + queryMock.mockReset(); + createEntityMock.mockReset(); + createEntityMock.mockResolvedValue({ id: 999 }); + getBlastRadiusMock.mockReset(); + getBlastRadiusMock.mockResolvedValue(FIXED_BLAST_RADIUS); + formatTriageNoteSpy.mockReset(); +}); + +describe('generateAndPostTriageNote', () => { + it('posts one TicketNote per linked report, all posted:true when every write succeeds', async () => { + stage({ + reports: [ + report({ id: 'r1', ticket_id: '1001' }), + report({ id: 'r2', ticket_id: '1002' }), + report({ id: 'r3', ticket_id: '1003' }), + ], + classification: [ + { + verdict: 'THREAT', + confidence: 0.8, + summary: 'summary', + reasons: ['reason one'], + recommended_actions: ['block_sender'], + requires_approval: true, + }, + ], + }); + + const result = await generateAndPostTriageNote('campaign-1'); + + expect(createEntityMock).toHaveBeenCalledTimes(3); + for (const [entityName, data] of createEntityMock.mock.calls) { + expect(entityName).toBe('TicketNotes'); + expect(data).toMatchObject({ + description: result.noteText, + noteType: 1, + publish: 1, + }); + expect(typeof (data as { ticketID: unknown }).ticketID).toBe('number'); + } + expect(createEntityMock.mock.calls.map((c) => (c[1] as { ticketID: number }).ticketID)).toEqual([ + 1001, 1002, 1003, + ]); + + expect(result.tickets).toEqual([ + { ticketId: '1001', posted: true }, + { ticketId: '1002', posted: true }, + { ticketId: '1003', posted: true }, + ]); + expect(result.noteText.length).toBeGreaterThan(0); + }); + + it('captures a single ticket write failure without aborting the remaining writes (D-05)', async () => { + stage({ + reports: [ + report({ id: 'r1', ticket_id: '1001' }), + report({ id: 'r2', ticket_id: '1002' }), + report({ id: 'r3', ticket_id: '1003' }), + ], + classification: [ + { + verdict: 'SPAM', + confidence: 0.5, + summary: 'summary', + reasons: [], + recommended_actions: ['no_action'], + requires_approval: false, + }, + ], + }); + createEntityMock + .mockResolvedValueOnce({ id: 1 }) + .mockRejectedValueOnce(new Error('Autotask API unavailable')) + .mockResolvedValueOnce({ id: 3 }); + + const result = await generateAndPostTriageNote('campaign-1'); + + expect(createEntityMock).toHaveBeenCalledTimes(3); + expect(result.tickets[0]).toEqual({ ticketId: '1001', posted: true }); + expect(result.tickets[1]).toMatchObject({ ticketId: '1002', posted: false }); + expect(result.tickets[1].error).toBe('Autotask API unavailable'); + expect(result.tickets[2]).toEqual({ ticketId: '1003', posted: true }); + }); + + it('always returns non-empty noteText even when a write fails (D-06)', async () => { + stage({ + reports: [report({ id: 'r1', ticket_id: '1001' })], + classification: [], + }); + createEntityMock.mockRejectedValueOnce(new Error('boom')); + + const result = await generateAndPostTriageNote('campaign-1'); + + expect(typeof result.noteText).toBe('string'); + expect(result.noteText.length).toBeGreaterThan(0); + expect(result.tickets).toEqual([{ ticketId: '1001', posted: false, error: 'boom' }]); + }); + + it('flows a sanitized indicator URL (token query param stripped) into the posted note text', async () => { + stage({ + reports: [report({ id: 'r1', ticket_id: '1001' })], + classification: [], + urlIndicators: [{ value: 'http://evil.example/p?token=leak' }], + }); + + const result = await generateAndPostTriageNote('campaign-1'); + + expect(result.noteText).toContain('http://evil.example/p'); + expect(result.noteText).not.toContain('token=leak'); + }); + + it('resolves with evidence.urls === [] and still succeeds when there are no url indicators', async () => { + stage({ + reports: [report({ id: 'r1', ticket_id: '1001' })], + classification: [], + urlIndicators: [], + }); + + await generateAndPostTriageNote('campaign-1'); + + const evidenceArg = formatTriageNoteSpy.mock.calls[0][0] as { urls: string[] }; + expect(evidenceArg.urls).toEqual([]); + }); + + it('coerces a string-typed NUMERIC confidence (e.g. "0.92" from the mocked query) to a JS number', async () => { + stage({ + reports: [report({ id: 'r1', ticket_id: '1001' })], + classification: [ + { + verdict: 'THREAT', + confidence: '0.92', // simulates node-pg's real bare-NUMERIC-column behavior + summary: 'summary', + reasons: [], + recommended_actions: ['block_sender'], + requires_approval: true, + }, + ], + }); + + await generateAndPostTriageNote('campaign-1'); + + const evidenceArg = formatTriageNoteSpy.mock.calls[0][0] as { confidence: unknown }; + expect(typeof evidenceArg.confidence).toBe('number'); + expect(evidenceArg.confidence).toBe(0.92); + }); + + it('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => { + stage({ reports: [], classification: [] }); + + const result = await generateAndPostTriageNote('campaign-empty'); + + expect(result.tickets).toEqual([]); + expect(typeof result.noteText).toBe('string'); + expect(result.noteText.length).toBeGreaterThan(0); + expect(createEntityMock).not.toHaveBeenCalled(); + }); + + it('resolves (renders "not yet classified") for a campaign with no classification row', async () => { + stage({ + reports: [report({ id: 'r1', ticket_id: '1001' })], + classification: [], + }); + + const result = await generateAndPostTriageNote('campaign-1'); + + expect(result.noteText).toContain('not yet classified'); + }); +});