- remediation-service.test.ts: D-04 guard rejection, successful status flip + audit event, note-post-failure-still-commits path for markCampaignAccidentalReport - triage-note-service.test.ts: noteType 18/publish 1 posting, per-ticket isolation, zero-reports path for generateAndPostAccidentalReportNote
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
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<typeof import('./triage-note-format')>();
|
|
return {
|
|
...actual,
|
|
formatTriageNote: (evidence: unknown) => {
|
|
formatTriageNoteSpy(evidence);
|
|
return actual.formatTriageNote(evidence as Parameters<typeof actual.formatTriageNote>[0]);
|
|
},
|
|
};
|
|
});
|
|
|
|
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
|
import {
|
|
generateAndPostTriageNote,
|
|
generateAndPostAcknowledgment,
|
|
generateAndPostAccidentalReportNote,
|
|
} 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<Record<string, unknown>> = {}) {
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('generateAndPostAcknowledgment', () => {
|
|
it('posts a customer-visible (noteType 18, publish 1) thank-you note to every linked ticket', async () => {
|
|
stage({
|
|
reports: [
|
|
report({ id: 'r1', ticket_id: '1001' }),
|
|
report({ id: 'r2', ticket_id: '1002' }),
|
|
],
|
|
});
|
|
|
|
const result = await generateAndPostAcknowledgment('campaign-1');
|
|
|
|
expect(createEntityMock).toHaveBeenCalledTimes(2);
|
|
for (const [entityName, data] of createEntityMock.mock.calls) {
|
|
expect(entityName).toBe('TicketNotes');
|
|
expect(data).toMatchObject({
|
|
description: result.noteText,
|
|
noteType: 18,
|
|
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]);
|
|
expect(result.tickets).toEqual([
|
|
{ ticketId: '1001', posted: true },
|
|
{ ticketId: '1002', posted: true },
|
|
]);
|
|
expect(result.noteText.length).toBeGreaterThan(0);
|
|
// Appreciative, non-evidence-dump body — no evidence/URL/secret interpolation.
|
|
expect(result.noteText).not.toContain('Blast Radius');
|
|
expect(result.noteText).not.toContain('Recommended Actions');
|
|
});
|
|
|
|
it('isolates a single ticket write failure without aborting the remaining writes', async () => {
|
|
stage({
|
|
reports: [
|
|
report({ id: 'r1', ticket_id: '1001' }),
|
|
report({ id: 'r2', ticket_id: '1002' }),
|
|
report({ id: 'r3', ticket_id: '1003' }),
|
|
],
|
|
});
|
|
createEntityMock
|
|
.mockResolvedValueOnce({ id: 1 })
|
|
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
|
|
.mockResolvedValueOnce({ id: 3 });
|
|
|
|
const result = await generateAndPostAcknowledgment('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('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
|
|
stage({ reports: [] });
|
|
|
|
const result = await generateAndPostAcknowledgment('campaign-empty');
|
|
|
|
expect(result.tickets).toEqual([]);
|
|
expect(typeof result.noteText).toBe('string');
|
|
expect(result.noteText.length).toBeGreaterThan(0);
|
|
expect(createEntityMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('generateAndPostAccidentalReportNote', () => {
|
|
it('posts a customer-visible (noteType 18, publish 1) reviewed-report note to every linked ticket', async () => {
|
|
stage({
|
|
reports: [
|
|
report({ id: 'r1', ticket_id: '1001' }),
|
|
report({ id: 'r2', ticket_id: '1002' }),
|
|
],
|
|
});
|
|
|
|
const result = await generateAndPostAccidentalReportNote('campaign-1');
|
|
|
|
expect(createEntityMock).toHaveBeenCalledTimes(2);
|
|
for (const [entityName, data] of createEntityMock.mock.calls) {
|
|
expect(entityName).toBe('TicketNotes');
|
|
expect(data).toMatchObject({
|
|
description: result.noteText,
|
|
noteType: 18,
|
|
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]);
|
|
expect(result.tickets).toEqual([
|
|
{ ticketId: '1001', posted: true },
|
|
{ ticketId: '1002', posted: true },
|
|
]);
|
|
expect(result.noteText.length).toBeGreaterThan(0);
|
|
// Fixed template, zero evidence/URL/classification interpolation (T-23-01).
|
|
expect(result.noteText).not.toContain('Blast Radius');
|
|
expect(result.noteText).not.toContain('Recommended Actions');
|
|
});
|
|
|
|
it('isolates a single ticket write failure without aborting the remaining writes', async () => {
|
|
stage({
|
|
reports: [
|
|
report({ id: 'r1', ticket_id: '1001' }),
|
|
report({ id: 'r2', ticket_id: '1002' }),
|
|
report({ id: 'r3', ticket_id: '1003' }),
|
|
],
|
|
});
|
|
createEntityMock
|
|
.mockResolvedValueOnce({ id: 1 })
|
|
.mockRejectedValueOnce(new Error('Autotask API unavailable'))
|
|
.mockResolvedValueOnce({ id: 3 });
|
|
|
|
const result = await generateAndPostAccidentalReportNote('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('resolves { noteText, tickets: [] } for a campaign with zero linked reports, without throwing', async () => {
|
|
stage({ reports: [] });
|
|
|
|
const result = await generateAndPostAccidentalReportNote('campaign-empty');
|
|
|
|
expect(result.tickets).toEqual([]);
|
|
expect(typeof result.noteText).toBe('string');
|
|
expect(result.noteText.length).toBeGreaterThan(0);
|
|
expect(createEntityMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|