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:
parent
a918b72d04
commit
c79af9b448
2 changed files with 134 additions and 0 deletions
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue