test(260717-v6c): mirror test coverage for accidental-report service functions
- 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
This commit is contained in:
parent
74e43e23c4
commit
0d6cd25008
2 changed files with 149 additions and 1 deletions
|
|
@ -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)
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue