diff --git a/lib/services/remediation-service.test.ts b/lib/services/remediation-service.test.ts index ca81a56..c21b5d9 100644 --- a/lib/services/remediation-service.test.ts +++ b/lib/services/remediation-service.test.ts @@ -16,8 +16,10 @@ vi.mock('./phishing-audit', () => ({ })); const generateAndPostAcknowledgmentMock = vi.fn(); +const generateAndPostAccidentalReportNoteMock = vi.fn(); vi.mock('./triage-note-service', () => ({ generateAndPostAcknowledgment: (...args: unknown[]) => generateAndPostAcknowledgmentMock(...args), + generateAndPostAccidentalReportNote: (...args: unknown[]) => generateAndPostAccidentalReportNoteMock(...args), })); // eslint-disable-next-line import/first -- imported after vi.mock hoisting @@ -25,6 +27,7 @@ import { approveRemediationActions, remediateApprovedActions, markCampaignFalsePositive, + markCampaignAccidentalReport, autoPostAcknowledgment, RemediationValidationError, RemediationConflictError, @@ -97,6 +100,8 @@ beforeEach(() => { writeAuditEventMock.mockResolvedValue('audit-id'); generateAndPostAcknowledgmentMock.mockReset(); generateAndPostAcknowledgmentMock.mockResolvedValue({ noteText: 'thanks', tickets: [] }); + generateAndPostAccidentalReportNoteMock.mockReset(); + generateAndPostAccidentalReportNoteMock.mockResolvedValue({ noteText: 'no action needed', tickets: [] }); clientCalls = []; }); @@ -346,6 +351,79 @@ describe('markCampaignFalsePositive', () => { }); }); +// ============================================================================= +// markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c) +// ============================================================================= + +describe('markCampaignAccidentalReport', () => { + it('throws RemediationConflictError when any remediation_actions row has status approved/completed', async () => { + stage({ + guardRows: [{ id: 'action-1' }], + }); + + await expect( + markCampaignAccidentalReport('campaign-1', 'operator@example.com') + ).rejects.toThrow(RemediationConflictError); + expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(0); + expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled(); + }); + + it('sets campaigns.status to accidental_report and writes one audit row when there are no approved/completed rows', async () => { + stage({ + guardRows: [], + campaign: [{ status: 'open' }], + }); + + const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com', 'reported by mistake'); + + expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1); + expect(writeAuditEventMock).toHaveBeenCalledTimes(1); + const [auditArgs] = writeAuditEventMock.mock.calls[0]; + expect(auditArgs).toMatchObject({ + campaignId: 'campaign-1', + eventType: 'campaign_marked_accidental_report', + payload: { previousStatus: 'open', reason: 'reported by mistake' }, + }); + expect(result).toMatchObject({ + campaignId: 'campaign-1', + status: 'accidental_report', + notePosted: true, + }); + expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledTimes(1); + expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledWith('campaign-1'); + }); + + it('throws RemediationValidationError when the campaign does not exist', async () => { + stage({ + guardRows: [], + campaign: [], + }); + + await expect( + markCampaignAccidentalReport('campaign-missing', 'operator@example.com') + ).rejects.toThrow(RemediationValidationError); + expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled(); + }); + + it('still returns the committed status change with notePosted:false and noteError set when the note post fails', async () => { + stage({ + guardRows: [], + campaign: [{ status: 'open' }], + }); + generateAndPostAccidentalReportNoteMock.mockRejectedValueOnce(new Error('Autotask unavailable')); + + const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com'); + + expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1); + expect(result).toMatchObject({ + campaignId: 'campaign-1', + status: 'accidental_report', + notePosted: false, + noteError: 'Autotask unavailable', + }); + }); +}); + // ============================================================================= // autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01) // ============================================================================= diff --git a/lib/services/triage-note-service.test.ts b/lib/services/triage-note-service.test.ts index ee809c3..ef673da 100644 --- a/lib/services/triage-note-service.test.ts +++ b/lib/services/triage-note-service.test.ts @@ -36,7 +36,11 @@ vi.mock('./triage-note-format', async (importOriginal) => { }); // eslint-disable-next-line import/first -- imported after vi.mock hoisting -import { generateAndPostTriageNote, generateAndPostAcknowledgment } from './triage-note-service'; +import { + generateAndPostTriageNote, + generateAndPostAcknowledgment, + generateAndPostAccidentalReportNote, +} from './triage-note-service'; interface MockRows { reports?: unknown[]; @@ -323,3 +327,69 @@ describe('generateAndPostAcknowledgment', () => { 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(); + }); +});