feat(23-06): add idempotent autoPostAcknowledgment to remediation-service

- New autoPostAcknowledgment(campaignId, actor) mirrors the VERIFIED
  remediateApprovedActions shape: campaign row lock, idempotency check
  against an existing acknowledge_user remediation_actions row, insert +
  audit inside one transaction, note post after commit (non-fatal on
  failure)
- Closes CR-01/WR-01: gives the auto_report webhook path a persisted
  record so a repeat ticket-create webhook joining an already-acked
  campaign does not re-insert/re-audit/re-post
- Adds Test A/B/C in remediation-service.test.ts covering first-pass
  insert, idempotent skip, and non-fatal note-post failure
This commit is contained in:
lorentz 2026-07-16 23:17:28 -04:00
parent a918b72d04
commit c79af9b448
2 changed files with 134 additions and 0 deletions

View file

@ -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 });
});
});

View file

@ -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<AutoPostAcknowledgmentResult> {
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<ExistingAckActionRow>(
`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 };
}