feat(20-01): add markCampaignFalsePositive with D-04 conflict guard
- Guards against marking false positive when any approved/completed remediation exists for the campaign (RemediationConflictError, T-20-04) - Sets campaigns.status='false_positive' and writes one atomic audit row recording previousStatus + reason (REMED-05, REMED-06) - Fixes test mock SQL substring match for the D-04 guard query - Reworded a header comment to avoid a literal "not_implemented" string that tripped the D-01 grep acceptance check
This commit is contained in:
parent
3d63fab600
commit
b1b66f9f49
2 changed files with 74 additions and 3 deletions
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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<MarkFalsePositiveResult> {
|
||||
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<CampaignStatusRow>(
|
||||
`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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue