diff --git a/lib/services/remediation-service.test.ts b/lib/services/remediation-service.test.ts index aa62da6..ad41e12 100644 --- a/lib/services/remediation-service.test.ts +++ b/lib/services/remediation-service.test.ts @@ -15,6 +15,11 @@ vi.mock('./phishing-audit', () => ({ writeAuditEvent: (...args: unknown[]) => writeAuditEventMock(...args), })); +const generateAndPostAcknowledgmentMock = vi.fn(); +vi.mock('./triage-note-service', () => ({ + generateAndPostAcknowledgment: (...args: unknown[]) => generateAndPostAcknowledgmentMock(...args), +})); + // eslint-disable-next-line import/first -- imported after vi.mock hoisting import { approveRemediationActions, @@ -82,6 +87,8 @@ beforeEach(() => { transactionMock.mockReset(); writeAuditEventMock.mockReset(); writeAuditEventMock.mockResolvedValue('audit-id'); + generateAndPostAcknowledgmentMock.mockReset(); + generateAndPostAcknowledgmentMock.mockResolvedValue({ noteText: 'thanks', tickets: [] }); clientCalls = []; }); @@ -208,6 +215,51 @@ describe('remediateApprovedActions', () => { ); expect(writeAuditEventMock).not.toHaveBeenCalled(); }); + + it('calls generateAndPostAcknowledgment exactly once with the campaignId when an approved acknowledge_user row is remediated', async () => { + stage({ + remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'approved' }], + }); + + await remediateApprovedActions('campaign-1', 'operator@example.com'); + + expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledTimes(1); + expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledWith('campaign-1'); + }); + + it('does NOT call generateAndPostAcknowledgment for a block_sender/warn_user-only remediation', async () => { + stage({ + remediationRows: [ + { id: 'action-1', action_type: 'block_sender', status: 'approved' }, + { id: 'action-2', action_type: 'warn_user', status: 'approved' }, + ], + }); + + await remediateApprovedActions('campaign-1', 'operator@example.com'); + + expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled(); + }); + + it('does NOT call generateAndPostAcknowledgment when the acknowledge_user row staged is already completed (idempotent re-run)', async () => { + stage({ + remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'completed' }], + }); + + await remediateApprovedActions('campaign-1', 'operator@example.com'); + + expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled(); + }); + + it('does not propagate a generateAndPostAcknowledgment rejection out of remediateApprovedActions (already-committed transition)', async () => { + stage({ + remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'approved' }], + }); + generateAndPostAcknowledgmentMock.mockRejectedValueOnce(new Error('Autotask unavailable')); + + const result = await remediateApprovedActions('campaign-1', 'operator@example.com'); + + expect(result.actions[0]).toMatchObject({ actionType: 'acknowledge_user', status: 'completed' }); + }); }); // ============================================================================= diff --git a/lib/services/remediation-service.ts b/lib/services/remediation-service.ts index d3c2682..6ff9fa1 100644 --- a/lib/services/remediation-service.ts +++ b/lib/services/remediation-service.ts @@ -13,13 +13,22 @@ * D-01: this file never has an unimplemented/no-op code path for the * remediate step. Its "external effect" is a simulated internal transition * (status='approved' -> 'completed') — no real provider call for any of the - * 7 action types this milestone. The only explicit-failure branch is the + * 7 original action types. The only explicit-failure branch is the * zero-approved-actions case (REMED-03) — remediating with nothing approved * always throws, never silently no-ops as a success. + * + * Phase 23 carve-out (D-04): the ONE exception is the post-commit + * `acknowledge_user` customer-note post below — a non-destructive thank-you + * message, not a security action. It runs AFTER the transaction commits + * (never inside it — the Autotask write is network I/O and must not hold a + * DB transaction open or risk a post-then-rollback), and its failure is + * caught/logged, never propagated (the DB transition already succeeded). + * Every other action type remains a simulated status-only transition. */ import { postgresClient } from './postgres-client'; import { writeAuditEvent } from './phishing-audit'; +import { generateAndPostAcknowledgment } from './triage-note-service'; export class RemediationValidationError extends Error { constructor(message: string) { @@ -171,7 +180,7 @@ export interface RemediateResult { * success for "nothing to do". */ export async function remediateApprovedActions(campaignId: string, actor: string | null): Promise { - return postgresClient.transaction(async (client) => { + const result = await postgresClient.transaction(async (client) => { const rowsRes = await client.query( `SELECT id::text, action_type, status FROM remediation_actions WHERE campaign_id = $1 FOR UPDATE`, [campaignId] @@ -204,6 +213,26 @@ export async function remediateApprovedActions(campaignId: string, actor: string return { campaignId, actions }; }); + + // Phase 23 D-04 carve-out: post the real customer-visible acknowledgment + // note AFTER the transaction has committed — only when an approved + // acknowledge_user row was actually transitioned this pass (never on the + // already-completed idempotent re-run). Never held inside the DB + // transaction (network I/O), and never allowed to propagate (the DB + // transition already succeeded; generateAndPostAcknowledgment already + // isolates per-ticket failures internally). + const shouldPostAcknowledgment = result.actions.some( + (action) => action.actionType === 'acknowledge_user' && action.alreadyCompleted === false + ); + if (shouldPostAcknowledgment) { + try { + await generateAndPostAcknowledgment(campaignId); + } catch (err) { + console.error('[REMEDIATE] acknowledge_user note post failed', campaignId, err); + } + } + + return result; } // =============================================================================