diff --git a/lib/services/triage-note-format.test.ts b/lib/services/triage-note-format.test.ts new file mode 100644 index 0000000..0ad5a73 --- /dev/null +++ b/lib/services/triage-note-format.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { formatTriageNote, type TriageNoteEvidence } from './triage-note-format'; +import type { BlastRadiusResult } from './mimecast-blast-radius'; + +function baseEvidence(overrides: Partial = {}): TriageNoteEvidence { + return { + campaignId: 'campaign-1', + reportCount: 3, + companyName: 'Acme Corp', + subject: 'Your invoice is ready', + verdict: 'THREAT', + confidence: 0.85, + summary: 'Credential-harvesting link found in message body.', + reasons: ['Sender domain not in allowlist', 'URL matches known phishing indicator'], + recommendedActions: ['block_sender', 'purge_message'], + requiresApproval: true, + blastRadius: { status: 'unavailable', reason: 'not_configured' }, + remediationActions: [], + urls: [], + ...overrides, + }; +} + +describe('formatTriageNote', () => { + it('includes the verdict label and confidence value', () => { + const output = formatTriageNote(baseEvidence()); + expect(output).toContain('THREAT'); + expect(output).toContain('0.85'); + }); + + it('includes a Summary line and a Reasons section listing each reason', () => { + const output = formatTriageNote(baseEvidence()); + expect(output).toContain('Credential-harvesting link found in message body.'); + expect(output).toContain('Sender domain not in allowlist'); + expect(output).toContain('URL matches known phishing indicator'); + }); + + it('shows delivered/held/rejected/clicked counts when blast radius is ok', () => { + const ok: BlastRadiusResult = { + status: 'ok', + matched: 5, + delivered: 3, + held: 1, + rejected: 1, + clicked: 2, + perRecipient: [{ recipient: 'user@acme.example', status: 'delivered' }], + source: 'fan-out', + }; + const output = formatTriageNote(baseEvidence({ blastRadius: ok })); + expect(output).toContain('3'); + expect(output).toContain('1'); + expect(output).toContain('2'); + }); + + it('shows an explicit unavailable reason when blast radius is unavailable', () => { + const output = formatTriageNote( + baseEvidence({ blastRadius: { status: 'unavailable', reason: 'not_configured' } }) + ); + expect(output).toContain('unavailable'); + expect(output).toContain('not_configured'); + }); + + it('states no action taken when remediationActions is empty', () => { + const output = formatTriageNote(baseEvidence({ remediationActions: [] })); + expect(output.toLowerCase()).toMatch(/proposed|no action/); + }); + + it('lists each remediation action with status and approver when present', () => { + const output = formatTriageNote( + baseEvidence({ + remediationActions: [ + { actionType: 'block_sender', status: 'approved', approvedBy: 'operator@example.com', approvedAt: '2026-07-01T00:00:00Z' }, + { actionType: 'purge_message', status: 'completed', approvedBy: 'operator@example.com', approvedAt: '2026-07-01T00:00:00Z' }, + ], + }) + ); + expect(output).toContain('block_sender'); + expect(output).toContain('approved'); + expect(output).toContain('purge_message'); + expect(output).toContain('completed'); + expect(output).toContain('operator@example.com'); + }); + + it('renders indicator URLs in sanitized form, dropping query strings', () => { + const output = formatTriageNote(baseEvidence({ urls: ['http://evil.example/p?token=leak'] })); + expect(output).not.toContain('token=leak'); + expect(output).toContain('http://evil.example/p'); + }); + + it('never leaks a secret embedded in free-text fields (sanitizeNoteText applied to whole output)', () => { + const output = formatTriageNote( + baseEvidence({ summary: 'Found link http://evil.example/x?access_token=SECRETVALUE in body' }) + ); + expect(output).not.toContain('access_token=SECRETVALUE'); + }); + + it('handles null verdict/confidence gracefully without throwing', () => { + expect(() => + formatTriageNote(baseEvidence({ verdict: null, confidence: null })) + ).not.toThrow(); + const output = formatTriageNote(baseEvidence({ verdict: null, confidence: null })); + expect(output.toLowerCase()).toContain('not yet classified'); + }); +});