diff --git a/lib/services/remediation-service.test.ts b/lib/services/remediation-service.test.ts index 15ae5c5..aa62da6 100644 --- a/lib/services/remediation-service.test.ts +++ b/lib/services/remediation-service.test.ts @@ -53,7 +53,7 @@ function makeClient(rows: MockRows) { if (sql.includes('UPDATE remediation_actions SET status')) { return { rows: [], rowCount: 1 }; } - if (sql.includes('FROM remediation_actions WHERE campaign_id') && sql.includes("status IN")) { + if (sql.includes('FROM remediation_actions') && sql.includes('status IN')) { return { rows: rows.guardRows ?? [], rowCount: rows.guardRows?.length ?? 0 }; } if (sql.includes('SELECT status FROM campaigns')) { diff --git a/lib/services/remediation-service.ts b/lib/services/remediation-service.ts index 1cdbad5..d3c2682 100644 --- a/lib/services/remediation-service.ts +++ b/lib/services/remediation-service.ts @@ -10,8 +10,8 @@ * audit_events row, atomically, via writeAuditEvent(client) inside the same * postgresClient.transaction as the state write (REMED-06). * - * D-01: this file NEVER returns a `not_implemented` code path. The - * remediate step's "external effect" is a simulated internal transition + * 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 * zero-approved-actions case (REMED-03) — remediating with nothing approved @@ -205,3 +205,74 @@ export async function remediateApprovedActions(campaignId: string, actor: string return { campaignId, actions }; }); } + +// ============================================================================= +// markCampaignFalsePositive (D-04 guard, REMED-05, REMED-06) +// ============================================================================= + +interface CampaignStatusRow { + status: string; +} + +export interface MarkFalsePositiveResult { + campaignId: string; + status: 'false_positive'; + auditEventId: string; +} + +/** + * D-04 guard: rejects with RemediationConflictError when any + * approved/completed remediation exists for the campaign — a campaign can + * never be both remediated and false-positive. Otherwise sets + * campaigns.status='false_positive' and writes one atomic audit row + * recording the previous status and the optional reason. False-positive + * reversibility is out of scope (CONTEXT.md Deferred Ideas) — no un-mark + * path exists. + */ +export async function markCampaignFalsePositive( + campaignId: string, + actor: string | null, + reason?: string +): Promise { + return postgresClient.transaction(async (client) => { + const guardRes = await client.query<{ id: string }>( + `SELECT id FROM remediation_actions + WHERE campaign_id = $1 AND status IN ('approved', 'completed') + FOR UPDATE + LIMIT 1`, + [campaignId] + ); + if (guardRes.rows.length > 0) { + throw new RemediationConflictError( + 'Cannot mark false positive: campaign already has approved or completed remediation' + ); + } + + const campaignRes = await client.query( + `SELECT status FROM campaigns WHERE id = $1`, + [campaignId] + ); + const campaign = campaignRes.rows[0]; + if (!campaign) { + throw new RemediationValidationError('Campaign not found'); + } + const previousStatus = campaign.status; + + await client.query( + `UPDATE campaigns SET status = 'false_positive', updated_at = NOW() WHERE id = $1`, + [campaignId] + ); + + const auditEventId = await writeAuditEvent( + { + campaignId, + actor, + eventType: 'campaign_marked_false_positive', + payload: { previousStatus, reason: reason ?? null }, + }, + client + ); + + return { campaignId, status: 'false_positive', auditEventId }; + }); +}