diff --git a/lib/services/remediation-service.test.ts b/lib/services/remediation-service.test.ts index ad41e12..0293b4f 100644 --- a/lib/services/remediation-service.test.ts +++ b/lib/services/remediation-service.test.ts @@ -25,6 +25,7 @@ import { approveRemediationActions, remediateApprovedActions, markCampaignFalsePositive, + autoPostAcknowledgment, RemediationValidationError, RemediationConflictError, } from './remediation-service'; @@ -38,6 +39,7 @@ interface MockRows { remediationRows?: unknown[]; campaign?: unknown[]; guardRows?: unknown[]; + existingAckRows?: unknown[]; } function makeClient(rows: MockRows) { @@ -45,6 +47,12 @@ function makeClient(rows: MockRows) { query: vi.fn(async (sql: string, params?: unknown[]) => { clientCalls.push({ sql, params: params ?? [] }); + if (sql.includes('SELECT id FROM campaigns') && sql.includes('FOR UPDATE')) { + return { rows: [{ id: 'campaign-1' }], rowCount: 1 }; + } + if (sql.includes("action_type = 'acknowledge_user'")) { + return { rows: rows.existingAckRows ?? [], rowCount: rows.existingAckRows?.length ?? 0 }; + } if (sql.includes('FROM classifications')) { return { rows: rows.classification ?? [], rowCount: rows.classification?.length ?? 0 }; } @@ -308,3 +316,53 @@ describe('markCampaignFalsePositive', () => { ).rejects.toThrow(RemediationValidationError); }); }); + +// ============================================================================= +// autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01) +// ============================================================================= + +describe('autoPostAcknowledgment', () => { + it('first pass: inserts one acknowledge_user row, writes one audit row, posts the note once, returns posted:true', async () => { + stage({ existingAckRows: [] }); + + const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report'); + + const insertCalls = callsContaining('INSERT INTO remediation_actions'); + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0].sql).toContain("'acknowledge_user'"); + expect(insertCalls[0].sql).toContain("'completed'"); + expect(insertCalls[0].params).toEqual(['campaign-1', 'system:auto_report']); + + expect(writeAuditEventMock).toHaveBeenCalledTimes(1); + expect(writeAuditEventMock.mock.calls[0][0]).toMatchObject({ + campaignId: 'campaign-1', + actor: 'system:auto_report', + eventType: 'remediation_completed', + }); + + expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledTimes(1); + expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledWith('campaign-1'); + + expect(result).toEqual({ posted: true }); + }); + + it('idempotency (THE CR-01 FIX): an existing acknowledge_user row skips insert, audit, and note post, returns posted:false', async () => { + stage({ existingAckRows: [{ id: 'existing-action-1' }] }); + + const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report'); + + expect(callsContaining('INSERT INTO remediation_actions')).toHaveLength(0); + expect(writeAuditEventMock).not.toHaveBeenCalled(); + expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled(); + expect(result).toEqual({ posted: false }); + }); + + it('does not propagate a generateAndPostAcknowledgment rejection (already-committed transition)', async () => { + stage({ existingAckRows: [] }); + generateAndPostAcknowledgmentMock.mockRejectedValueOnce(new Error('Autotask unavailable')); + + const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report'); + + expect(result).toEqual({ posted: true }); + }); +}); diff --git a/lib/services/remediation-service.ts b/lib/services/remediation-service.ts index 6ff9fa1..1d5f8a0 100644 --- a/lib/services/remediation-service.ts +++ b/lib/services/remediation-service.ts @@ -305,3 +305,79 @@ export async function markCampaignFalsePositive( return { campaignId, status: 'false_positive', auditEventId }; }); } + +// ============================================================================= +// autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01) +// ============================================================================= + +export interface AutoPostAcknowledgmentResult { + posted: boolean; +} + +interface ExistingAckActionRow { + id: string; +} + +/** + * Idempotent, audit-persisting auto-post orchestrator for the auto_report + * webhook path (runGatedPhishingStages in webhook-service.ts). Mirrors + * remediateApprovedActions's shape: state write + audit row inside one + * transaction, customer-visible note post AFTER commit, note-post failure + * caught/logged and never propagated (the DB transition already committed). + * + * Unlike the manual path, this function performs the FIRST write of the + * campaign's acknowledge_user row: the persisted remediation_actions row it + * inserts is BOTH the idempotency record a repeat ticket-create webhook for + * the same campaign reads on its next pass AND the row the Action Area / + * Timeline UI already render from (closes WR-01, the root cause of CR-01 — + * repeat webhooks were re-posting the customer-visible note on every + * additional report joining an already-acknowledged campaign). + * + * The `SELECT ... FROM campaigns WHERE id = $1 FOR UPDATE` row lock + * serializes concurrent webhooks for the same campaign so two simultaneous + * ticket-create events cannot both pass the existence check and double-post. + */ +export async function autoPostAcknowledgment( + campaignId: string, + actor: string | null +): Promise { + const { inserted } = await postgresClient.transaction(async (client) => { + await client.query(`SELECT id FROM campaigns WHERE id = $1 FOR UPDATE`, [campaignId]); + + const existingRes = await client.query( + `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND action_type = 'acknowledge_user' LIMIT 1`, + [campaignId] + ); + if (existingRes.rows.length > 0) { + // Already posted for this campaign — no insert, no audit, no note. + return { inserted: false }; + } + + await client.query( + `INSERT INTO remediation_actions (campaign_id, action_type, status, approved_by, approved_at) + VALUES ($1, 'acknowledge_user', 'completed', $2, NOW())`, + [campaignId, actor] + ); + await writeAuditEvent( + { + campaignId, + actor, + eventType: 'remediation_completed', + payload: { actionType: 'acknowledge_user', auto: true }, + }, + client + ); + + return { inserted: true }; + }); + + if (inserted) { + try { + await generateAndPostAcknowledgment(campaignId); + } catch (err) { + console.error('[AUTO-REMEDIATE] acknowledge_user note post failed', campaignId, err); + } + } + + return { posted: inserted }; +}