Make permanent Patrol suppression rules reversible

Provide manual-rule list, exact scoped confirmation, one deletion and readback inside the existing Activity flow. Invalidate stale tenant/access context and uncertain outcomes without altering finding Reopen or server permission boundaries.

Fixes #2528

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 19:02:00 +01:00
parent 53ec5fae93
commit cc31153a63
22 changed files with 1503 additions and 12 deletions
@@ -25,6 +25,18 @@ that same result. Successful reads retain their content and execution provenance
## Purpose
### Permanent suppression rules remain reversible — issue #2528
The existing manual suppression API/store semantics remain unchanged. Patrol
Activity now exposes scoped list/confirm/remove/readback for explicitly manual
rules. Deleting a rule permits future matches but does not reopen historical
dismissals, erase findings, broaden creation scope or modify other rules.
Finding-specific Reopen and remembered decisions remain distinct. Frontend
API/component controls plus authenticated handler/store controls verify these
boundaries; source/browser proof does not establish installed or reporter
acceptance.
### Fully cached Patrol usage — issues #2118 / #2350
Zero ordinary input tokens do not mean a run was free or skipped. Any positive
@@ -20,6 +20,20 @@
## Purpose
### Confirmed Patrol rule operations preserve tenant context — issue #2528
The frontend consumes the existing GET/DELETE suppression endpoints without a
wire or permission change. The optional client expectedOrgID pins the request
header and checks context before and after CSRF preparation; retry:false
disables auth, invalid-organisation, CSRF and rate-limit retries for this flow.
Other callers retain their defaults. Current auth, scope and CSRF checks remain
authoritative; an abort or mismatched context stops dispatch. The suppression
client rejects malformed or duplicate rows, accepts the existing empty nil
slice, and only deletes a rule_ identity with explicit manual origin and no
finding/dismissal attribution. Transport success alone is not removal: the UI
must read the same collection back and establish exact-ID absence.
### Organisation deletion retains data after incomplete monitoring shutdown
Authenticated organisation-owner deletion waits for tenant-loop exit and sealed
@@ -15,6 +15,18 @@
## Purpose
### Opt-in confirmed-operation transport keeps paid contexts unchanged
The shared frontend client adds expectedOrgID and retry:false for the existing
Patrol manual-rule reversal flow. It binds confirmed reads/deletion to the
selected organisation and stops automatic auth, invalid-org, CSRF and
rate-limit replay only when requested. Paid APIs retain their existing default
request behaviour, tenant enforcement and entitlement boundaries. The strict
consumer rechecks context after CSRF preparation and discards stale responses;
this adds no paid endpoint, cross-tenant fallback or scope expansion. Shared
client tests cover unchanged defaults and explicit one-attempt context binding.
Own cloud plan/version semantics, entitlement limits, hosted billing/runtime
agreement, the Pulse Cloud control plane, provider-hosted MSP account
bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement
@@ -20,6 +20,15 @@
## Purpose
### Confirmed operations opt out of silent transport fallback
The shared API client accepts expectedOrgID and retry:false for confirmed
Patrol manual-rule removal. It pins and rechecks the selected organisation at
dispatch, respects cancellation after asynchronous CSRF preparation and makes
no automatic auth/tenant/CSRF/rate-limit replay. Existing caller defaults are
unchanged. Tests exercise these safeguards with real request options.
All drawer History fallbacks must distinguish current observations from retained, unavailable or freshness-unknown guest readings, using original memory evidence and filesystem read reasons.
### Canonical drawer History preserves guest read provenance
@@ -15,6 +15,27 @@
## Purpose
### Manual suppression reversal — issue #2528
Patrol Activity exposes the existing suppression collection independently of
active findings. It lists only explicitly manual rules with resource and
category scope, exact ID and reason; wildcard scopes are labelled both in the
list and confirmation. Create-rule controls link to this existing Activity
section. Finding dismissals, automatic/legacy rules and history are not removed
or reopened by manual deletion. Reopen finding remains a separate action.
A confirmation is bound to its organisation, invalidates on organisation or
membership change/disposal, verifies the unchanged manual row with a fresh
authorised read, sends one exact-ID DELETE and requires readback absence before
claiming removal. Any failed or uncertain step hides stale controls and requires
explicit reload, not an automatic mutation retry. Other rules can still cover
the same scope. Component/API tests cover cancellation, stale scope/origin,
authorisation loss, malformed data, concurrent removal, late results and
readback failure. Browser evidence must cover creation through removal and the
actual existing links on desktop and narrow viewports. Synthetic proof is not
publication or the reporter's own removal.
### Canonical Patrol and Assistant continuation, 2026-09-07
Canonical action state and legacy approval state remain distinct in Assistant
@@ -6947,7 +6947,8 @@
"frontend-modern/src/utils/patrolRuntimeActions.ts",
"frontend-modern/src/utils/patrolRuntimePresentation.ts",
"frontend-modern/src/utils/patrolSummaryPresentation.ts",
"frontend-modern/src/utils/remediationPresentation.ts"
"frontend-modern/src/utils/remediationPresentation.ts",
"frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx"
],
"verification": {
"allow_same_subsystem_tests": false,
@@ -7098,6 +7099,19 @@
"frontend-modern/src/utils/__tests__/patrolRunPresentation.test.ts",
"frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts"
]
},
{
"id": "patrol-manual-rule-reversal",
"label": "Patrol manual-rule reversal proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/features/patrol/__tests__/PatrolSuppressionRules.test.tsx"
]
}
],
"match_files": null
@@ -0,0 +1,439 @@
// Full production Patrol surface. Only local API responses are synthetic.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const files = [
'src/api/patrol.ts',
'src/utils/apiClient.ts',
'src/components/AI/FindingsPanel.tsx',
'src/features/patrol/PatrolIntelligenceSurface.tsx',
'src/features/patrol/PatrolAttentionWorkbench.tsx',
'src/features/patrol/PatrolSuppressionRules.tsx',
];
const output = '/workspace/tmp/patrol-rule-proof/browser';
const report = {
playwright: require('playwright/package.json').version,
content_sha256: {},
cases: [],
captures: [],
cleanup: {},
};
fs.mkdirSync(output, { recursive: true });
for (const file of files)
report.content_sha256[`frontend-modern/${file}`] = hash(`/workspace/frontend-modern/${file}`);
assert.equal(
report.playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
async function journey(root, engine, width, parent = false) {
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, `vite-${parent ? 'parent' : engine}-${width}`),
server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null },
});
let browser;
const result = {
parent,
engine,
width,
checks: [],
requests: [],
errors: [],
offOrigin: [],
cleanup: {},
};
report.cases.push(result);
const now = '2026-10-05T12:00:00Z';
const broad = {
id: 'rule_any_any_2',
resource_id: '',
category: '',
description: 'Intentional maintenance',
created_from: 'manual',
created_at: now,
};
const other = {
id: 'rule_keep_capacity_3',
resource_id: 'fixture-keep',
resource_name: 'Keep VM',
category: 'capacity',
description: 'Keep this unrelated rule',
created_from: 'manual',
created_at: now,
};
const historical = {
id: 'finding_history',
finding_id: 'history',
resource_id: 'fixture-db',
resource_name: 'Database VM',
category: 'backup',
description: 'Retain this dismissal and note',
created_from: 'dismissed',
created_at: now,
};
const legacy = { ...other, id: 'rule_legacy', created_from: 'suppress' };
const rules = [broad, other, historical, legacy];
const initialUntouched = JSON.stringify(rules);
const item = {
id: 'record-1',
operationalRecordId: 'record-1',
subjectResourceId: 'fixture-db',
subjectResourceName: 'Database VM',
subjectResourceType: 'vm',
kind: 'backup',
title: 'Backup age on Database VM',
plainLanguageSummary: 'The off-site backup is older than expected.',
severity: 'warning',
state: 'open',
firstObservedAt: now,
lastObservedAt: now,
evidenceFreshness: 'fresh',
evidenceCompleteness: 'complete',
impact: 'Check the backup schedule.',
relatedResources: [],
recommendedNextStep: 'Review the off-site backup.',
availableActions: [],
verificationState: 'not_available',
};
const finding = {
id: 'fixture-finding',
resource_id: 'fixture-db',
resource_name: 'Database VM',
resource_type: 'vm',
category: 'backup',
severity: 'warning',
title: 'Off-site backup age',
description: 'The off-site backup is older than expected.',
detected_at: now,
last_seen_at: now,
times_raised: 1,
suppressed: false,
auto_resolved: false,
mirrors_alert_id: 'fixture-db::backup',
mirrors_alert_type: 'backup',
};
const summary = {
activeCount: 1,
openCount: 1,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 0,
calm: false,
coverageState: 'current',
evaluatedAt: now,
};
let loseResponse = false;
let denied = false;
let created;
try {
await server.listen();
browser =
engine === 'webkit'
? await webkit.launch({ headless: true })
: await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
result.browser = browser.version();
const page = await browser.newPage({
viewport: { width, height: 900 },
colorScheme: width === 320 ? 'dark' : 'light',
isMobile: width === 320,
hasTouch: width === 320,
});
page.setDefaultTimeout(12000);
page.on('pageerror', (error) => result.errors.push(error.message));
const origin = 'http://127.0.0.1:5297';
await page.context().addCookies([
{ name: 'pulse_csrf', value: 'synthetic-csrf', url: origin },
{ name: 'pulse_session', value: 'synthetic-session', url: origin },
]);
await page.addInitScript(() => {
if (matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.add('dark');
});
await page.routeWebSocket('**/ws*', () => {});
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url()),
method = request.method(),
p = url.pathname;
if (url.origin !== origin) {
result.offOrigin.push(url.origin);
return route.abort();
}
if (!p.startsWith('/api/')) return route.continue();
const org = request.headers()['x-pulse-org-id'];
result.requests.push({ path: p, method, org });
if (p === '/api/ai/patrol/suppressions') {
assert.equal(org, 'fixture-tenant-a');
if (method === 'GET') {
if (denied)
return route.fulfill({
status: 403,
json: { error: 'access_revoked' },
headers: { 'X-CSRF-Token': 'synthetic-replacement' },
});
return route.fulfill({ json: rules });
}
assert.equal(method, 'POST');
assert.equal(request.headers()['x-csrf-token'], 'synthetic-csrf');
const input = request.postDataJSON();
assert.equal(input.resource_id, 'fixture-db');
assert.equal(input.category, 'backup');
assert.equal(input.allow_broad_scope, undefined);
created = {
...input,
id: 'rule_fixture-db_backup_1',
created_from: 'manual',
created_at: now,
};
rules.push(created);
return route.fulfill({ json: { success: true, rule: created } });
}
if (p.startsWith('/api/ai/patrol/suppressions/')) {
assert.equal(method, 'DELETE');
assert.equal(org, 'fixture-tenant-a');
assert.equal(request.headers()['x-csrf-token'], 'synthetic-csrf');
const id = decodeURIComponent(p.slice('/api/ai/patrol/suppressions/'.length));
const index = rules.findIndex((r) => r.id === id);
assert(index >= 0);
assert.equal(rules[index].created_from, 'manual');
rules.splice(index, 1);
if (loseResponse) {
loseResponse = false;
return route.abort('failed');
}
return route.fulfill({ json: { success: true } });
}
assert.equal(method, 'GET', `unexpected mutation ${p}`);
let json = {};
if (p === '/api/settings/ai')
json = {
patrol_enabled: true,
patrol_autonomy_level: 'monitor',
patrol_readiness: { status: 'ready', ready: true, cause: 'none' },
};
else if (p === '/api/ai/patrol/status')
json = {
enabled: true,
runtime_state: 'idle',
running: false,
readiness: { status: 'ready', ready: true, cause: 'none' },
summary: { active: 1, critical: 0, warning: 1, info: 0 },
};
else if (p === '/api/ai/patrol/attention')
json = { data: [item], summary, meta: { page: 1, limit: 50, total: 1, totalPages: 1 } };
else if (p === '/api/ai/patrol/attention/summary') json = summary;
else if (p === '/api/ai/patrol/attention/record-1')
json = {
item,
operationalRecord: {
id: item.id,
kind: item.kind,
state: item.state,
severity: item.severity,
title: item.title,
subjectResourceId: item.subjectResourceId,
subjectResourceName: item.subjectResourceName,
createdAt: now,
updatedAt: now,
},
timeline: [],
evidence: [],
};
else if (p === '/api/ai/patrol/autonomy')
json = { autonomy_level: 'monitor', full_mode_unlocked: false };
else if (p === '/api/ai/patrol/findings') json = [finding];
else if (p === '/api/ai/patrol/runs') json = [];
else if (p === '/api/ai/unified/findings') json = { findings: [] };
else if (p === '/api/ai/approvals') json = { approvals: [] };
else if (p === '/api/ai/models') json = { models: [] };
else if (p === '/api/license/runtime-capabilities')
json = {
capabilities: ['ai_patrol'],
limits: [],
hosted_mode: false,
max_history_days: 7,
runtime: { build: 'community', label: 'Pulse Community runtime' },
blocked_capabilities: [],
};
return route.fulfill({ json });
});
const capture = async (name) => {
const file = path.join(output, `${parent ? 'parent' : engine}-${width}-${name}.png`);
await page.screenshot({ path: file, fullPage: true });
report.captures.push({ path: file.replace('/workspace/', ''), sha256: hash(file) });
const dimensions = await page.evaluate(() => ({
inner: innerWidth,
scroll: document.documentElement.scrollWidth,
}));
assert(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
};
await page.goto(`${origin}/browser-tests/patrol-rule-removal.html`, {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
await page.getByRole('button', { name: 'Open Database VM · Backup age' }).click();
await page
.getByRole('list', { name: 'Lasting decisions' })
.getByRole('button', { name: 'Create rule', exact: true })
.click();
const form = page.getByRole('form', { name: 'Confirm Create rule' });
assert(await form.getByRole('button', { name: 'Confirm: Create rule' }).isDisabled());
await form.getByLabel(/Why this rule/).fill('Backups are deliberately held off-site');
await form.getByRole('button', { name: 'Confirm: Create rule' }).click();
await page.getByRole('status').filter({ hasText: 'Rule created.' }).waitFor();
assert(created);
result.checks.push(
'Production Inbox Create rule sends one narrow scoped POST with a written reason.',
);
if (parent) {
assert.equal(await page.getByRole('link', { name: 'Manage suppression rules' }).count(), 0);
await page.getByRole('tab', { name: 'Activity' }).click();
assert.equal(await page.locator('#patrol-suppression-rules').count(), 0);
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 0);
await capture('no-reversal');
result.checks.push(
'Exact assigned parent creates a permanent rule but has no manual-rule reversal control.',
);
return;
}
await page.getByRole('link', { name: 'Manage suppression rules' }).click();
await page.getByRole('button', { name: 'Remove rule for Database VM, backup' }).waitFor();
assert.equal(await page.locator('#patrol-suppression-rules').getAttribute('open'), '');
await capture('manual-list');
const select = () => page.getByRole('button', { name: 'Remove rule for Database VM, backup' });
await select().click();
let dialog = page.getByRole('dialog', { name: 'Remove suppression rule?' });
await dialog.getByText(created.id, { exact: true }).waitFor();
assert(
await dialog
.getByRole('button', { name: 'Cancel' })
.evaluate((e) => e === document.activeElement),
);
await capture('exact-confirmation');
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 0);
await select().click();
await page.getByRole('dialog').getByRole('button', { name: 'Remove this rule' }).click();
await page.getByRole('status').filter({ hasText: 'Rule removed.' }).waitFor();
assert.equal(JSON.stringify(rules), initialUntouched);
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 1);
assert.equal(await select().count(), 0);
await capture('removed-readback');
result.checks.push(
'Cancellation is non-mutating; exact manual deletion requires pre-read and post-read and preserves unrelated rules and finding-backed history.',
);
// The second existing creation control points at the same reversal flow.
await page.getByRole('button', { name: 'Finding options and history' }).click();
await page.getByRole('button', { name: 'Finding options for Off-site backup age' }).click();
await page.getByRole('button', { name: 'Create rule from this' }).click();
await page.getByRole('textbox', { name: 'Reason for suppression rule' }).waitFor();
assert.equal(
await page.getByRole('link', { name: 'Manage suppression rules' }).getAttribute('href'),
'/patrol/activity#patrol-suppression-rules',
);
await page.getByText(/Reopen finding only undoes an individual dismissal/).waitFor();
await capture('finding-create-link');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
result.checks.push(
'Production Finding options create control has an accessible reason field and the same Activity reversal link; no second POST.',
);
await page
.getByRole('button', { name: 'Remove rule for All resources, All categories' })
.click();
dialog = page.getByRole('dialog');
await dialog.getByText('This broad rule covers all resources in all categories.').waitFor();
await capture('wildcard-confirmation');
loseResponse = true;
await dialog.getByRole('button', { name: 'Remove this rule' }).click();
await page.getByRole('alert').filter({ hasText: 'Removal could not be confirmed.' }).waitFor();
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2);
await capture('uncertain-not-success');
await page.getByRole('button', { name: 'Reload rules' }).click();
await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).waitFor();
assert.equal(
await page
.getByRole('button', { name: 'Remove rule for All resources, All categories' })
.count(),
0,
);
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2);
result.checks.push(
'A lost DELETE response never retries or claims removal; explicit reload reconciles absence and keeps other decisions.',
);
await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).click();
denied = true;
await page.getByRole('dialog').getByRole('button', { name: 'Remove this rule' }).click();
await page.getByRole('alert').filter({ hasText: 'Removal could not be confirmed.' }).waitFor();
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2);
denied = false;
await page.getByRole('button', { name: 'Reload rules' }).click();
await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).click();
await page.evaluate(() => window.__patrolRuleRemoval.switchOrg());
await page.getByRole('alert').filter({ hasText: 'Organisation or access changed.' }).waitFor();
assert.equal(await page.getByRole('dialog').count(), 0);
assert.equal(await page.getByRole('button', { name: /^Remove rule for/ }).count(), 0);
assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2);
await capture('scope-invalidated');
result.checks.push(
'Revoked access blocks before DELETE, then organisation switch discards pending confirmation and old rule data. No cross-tenant action or automatic retry.',
);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.offOrigin, []);
} catch (error) {
result.failure = error.message;
throw error;
} finally {
if (browser) await browser.close();
await server.close();
result.cleanup = { browser_closed: Boolean(browser), server_closed: true };
}
}
(async () => {
try {
await journey(
'/workspace/tmp/patrol-rule-proof/parent/frontend-modern',
'chromium',
1365,
true,
);
await journey('/workspace/frontend-modern', 'chromium', 1365);
await journey('/workspace/frontend-modern', 'webkit', 320);
report.result = 'passed';
} catch (error) {
report.result = 'failed';
report.failure = error.message;
throw error;
} finally {
report.cleanup = {
all_cases_closed: report.cases.every(
(c) => c.cleanup.browser_closed && c.cleanup.server_closed,
),
};
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2));
}
console.log(
JSON.stringify({
result: report.result,
cases: report.cases.length,
captures: report.captures.length,
cleanup: report.cleanup,
}),
);
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/patrol-rule-removal.tsx"></script>
</body>
</html>
@@ -0,0 +1,33 @@
// Mount the production Patrol surface under a local Router; the browser script
// supplies bounded API responses for the manual-rule creation and reversal states.
import { Route, Router } from '@solidjs/router';
import { render } from 'solid-js/web';
import { PatrolIntelligenceSurface } from '../src/features/patrol/PatrolIntelligenceSurface';
import { eventBus } from '../src/stores/events';
import { setOrgID } from '../src/utils/apiClient';
import '../src/index.css';
setOrgID('fixture-tenant-a');
(window as any).__patrolRuleRemoval = {
switchOrg: () => {
setOrgID('fixture-tenant-b');
eventBus.emit('org_switched', 'fixture-tenant-b');
},
revoke: () => eventBus.emit('organizations_changed'),
};
render(
() => (
<Router>
<Route
path="*"
component={() => (
<main class="mx-auto max-w-6xl p-4">
<PatrolIntelligenceSurface />
</main>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -13,6 +13,8 @@ import {
getPatrolRunHistoryWithToolCalls,
getPatrolRunWithToolCalls,
createSuppressionRuleFromFinding,
getSuppressionRules,
deleteManualSuppressionRule,
reopenFinding,
resolveFinding,
triggerPatrolRun,
@@ -35,6 +37,59 @@ describe('patrol api', () => {
apiFetchJSONMock.mockResolvedValue([] as any);
});
const manual = {
id: 'rule_vm/101_backup_1',
created_from: 'manual',
resource_id: 'vm/101',
category: 'backup',
description: 'Off-site copies',
created_at: '2026-10-05T12:00:00Z',
};
it('reads the exact organisation without fallback or retry, including a nil empty store', async () => {
const signal = new AbortController().signal;
apiFetchJSONMock.mockResolvedValueOnce([manual]);
await expect(getSuppressionRules('tenant-a', signal)).resolves.toEqual([manual]);
expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/suppressions', {
expectedOrgID: 'tenant-a',
retry: false,
signal,
});
apiFetchJSONMock.mockResolvedValueOnce(null);
await expect(getSuppressionRules('default')).resolves.toEqual([]);
});
it.each([{}, [null], [{ ...manual, id: '' }], [manual, manual], [{ ...manual, category: 7 }]])(
'rejects an invalid collection instead of presenting false absence: %j',
async (value) => {
apiFetchJSONMock.mockResolvedValueOnce(value as any);
await expect(getSuppressionRules('tenant-a')).rejects.toThrow('invalid rule list');
},
);
it('removes exactly one encoded manual identity, never a finding-backed or unknown-origin row', async () => {
const signal = new AbortController().signal;
apiFetchJSONMock.mockResolvedValueOnce({ success: true });
await deleteManualSuppressionRule(manual, 'tenant-a', signal);
expect(apiFetchJSONMock).toHaveBeenLastCalledWith(
'/api/ai/patrol/suppressions/rule_vm%2F101_backup_1',
{
method: 'DELETE',
expectedOrgID: 'tenant-a',
retry: false,
signal,
},
);
for (const row of [
{ ...manual, id: 'finding_123' },
{ ...manual, created_from: 'suppress' },
{ ...manual, finding_id: '123' },
])
await expect(deleteManualSuppressionRule(row, 'tenant-a')).rejects.toThrow('Only manually');
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
});
it('requires an explicit successful deletion response', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ success: false });
await expect(deleteManualSuppressionRule(manual, 'tenant-a')).rejects.toThrow('not confirmed');
});
it('uses the retained-objective contract with encoded identities and revisions', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ objectives: [] } as any);
await expect(getPatrolObjectives()).resolves.toEqual([]);
+79
View File
@@ -523,6 +523,85 @@ export async function reopenFinding(
});
}
export interface PatrolSuppressionRule {
id: string;
resource_id?: string;
resource_name?: string;
category?: string;
description: string;
created_at: string;
created_from?: string;
finding_id?: string;
dismissed_reason?: string;
}
export function isManualSuppressionRule(rule: PatrolSuppressionRule): boolean {
return (
rule.created_from === 'manual' &&
rule.id.startsWith('rule_') &&
!rule.finding_id &&
!rule.dismissed_reason
);
}
/** Read the existing collection without silently falling back to another tenant. */
export async function getSuppressionRules(
orgID: string,
signal?: AbortSignal,
): Promise<PatrolSuppressionRule[]> {
const data = await apiFetchJSON<unknown>('/api/ai/patrol/suppressions', {
expectedOrgID: orgID,
retry: false,
signal,
});
// The existing Go store serialises an empty nil slice as null.
if (data === null) return [];
const ids = new Set<string>();
if (
!Array.isArray(data) ||
data.some((row) => {
if (!row || typeof row !== 'object') return true;
if (
typeof row.id !== 'string' ||
!row.id.trim() ||
ids.has(row.id) ||
typeof row.description !== 'string' ||
typeof row.created_at !== 'string' ||
[
'resource_id',
'resource_name',
'category',
'created_from',
'finding_id',
'dismissed_reason',
].some((key) => row[key] !== undefined && typeof row[key] !== 'string')
)
return true;
ids.add(row.id);
return false;
})
) {
throw new Error('Patrol returned an invalid rule list. Reload before making changes.');
}
return data as PatrolSuppressionRule[];
}
/** Removing a manual rule never calls the finding-specific Reopen path. */
export async function deleteManualSuppressionRule(
rule: PatrolSuppressionRule,
orgID: string,
signal?: AbortSignal,
): Promise<void> {
if (!isManualSuppressionRule(rule)) {
throw new Error('Only manually created rules can be removed here.');
}
const result = await apiFetchJSON<{ success: boolean }>(
`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`,
{ method: 'DELETE', expectedOrgID: orgID, retry: false, signal },
);
if (result?.success !== true) throw new Error('Rule removal was not confirmed.');
}
/**
* Create a permanent suppression rule from a finding.
*
@@ -13,6 +13,8 @@
import { Component, createSignal, createEffect, Show, For, createMemo } from 'solid-js';
import { useLocation } from '@solidjs/router';
import { Card } from '@/components/shared/Card';
import { ButtonLink } from '@/components/shared/Button';
import { PATROL_SUPPRESSION_RULES_PATH } from '@/features/patrol/PatrolSuppressionRules';
import { FormSelect } from '@/components/shared/FormSelect';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
@@ -1943,12 +1945,21 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
</div>
<p class="text-[11px] text-muted mb-1.5">
Future findings matching this resource and category will be auto-dismissed by Patrol
without surfacing as new findings. You can list or remove rules later from the
suppressions management surface.
without surfacing as new findings. Remove this permanent rule under Activity →
Suppression rules; Reopen finding only undoes an individual dismissal.
</p>
<ButtonLink
href={PATROL_SUPPRESSION_RULES_PATH}
variant="ghost"
size="sm"
class="min-h-11"
>
Manage suppression rules
</ButtonLink>
<textarea
class="w-full text-xs px-2 py-1.5 rounded-sm border border-border bg-surface text-base-content resize-none focus:outline-hidden focus:ring-1 focus:ring-blue-400"
rows={2}
aria-label="Reason for suppression rule"
value={createRuleDescription()}
onInput={(e) => setCreateRuleDescription(e.currentTarget.value)}
placeholder="Why this rule? (required — e.g. 'delly backups are intentionally off-site, ignore failures')"
@@ -618,6 +618,19 @@ describe('FindingsPanel resource links', () => {
expect(screen.getByRole('button', { name: 'Dismiss: Later' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create rule from this' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Create rule from this' }));
expect(
screen.getByRole('textbox', { name: 'Reason for suppression rule' }),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Manage suppression rules' })).toHaveAttribute(
'href',
'/patrol/activity#patrol-suppression-rules',
);
expect(
screen.getByText(/Reopen finding only undoes an individual dismissal/),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Cancel$/ }));
fireEvent.click(options);
expect(options).toHaveAttribute('aria-expanded', 'false');
expect(
@@ -37,6 +37,7 @@ import {
unsuppressPatrolAttention,
} from '@/api/patrolAttention';
import { createSuppressionRuleFromFinding } from '@/api/patrol';
import { PATROL_SUPPRESSION_RULES_PATH } from './PatrolSuppressionRules';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button, ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { FormSelect } from '@/components/shared/FormSelect';
@@ -1660,6 +1661,14 @@ function AttentionLastingDecisions(props: {
{error()}
</p>
</Show>
<ButtonLink
href={PATROL_SUPPRESSION_RULES_PATH}
variant="ghost"
size="sm"
class="mt-3 min-h-11"
>
Manage suppression rules
</ButtonLink>
</div>
</DetailSection>
);
@@ -21,6 +21,7 @@ import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
import { PatrolObjectivesPanel } from './PatrolObjectivesPanel';
import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel';
import { PatrolWeeklyDigestCard } from './PatrolWeeklyDigestCard';
import { PatrolSuppressionRules } from './PatrolSuppressionRules';
import type { AttentionItem } from '@/api/patrolAttention';
interface FindingResourceScope {
@@ -242,6 +243,8 @@ export function PatrolIntelligenceSurface() {
</div>
</section>
<PatrolSuppressionRules openForLink={location.hash === '#patrol-suppression-rules'} />
<details
ref={findingsPanel}
id="patrol-operational-records"
@@ -0,0 +1,347 @@
import {
createEffect,
createMemo,
createSignal,
For,
onCleanup,
onMount,
Show,
untrack,
} from 'solid-js';
import {
deleteManualSuppressionRule,
getSuppressionRules,
isManualSuppressionRule,
type PatrolSuppressionRule,
} from '@/api/patrol';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { eventBus } from '@/stores/events';
import { getOrgID } from '@/utils/apiClient';
export const PATROL_SUPPRESSION_RULES_PATH = '/patrol/activity#patrol-suppression-rules';
const ruleScope = (rule: PatrolSuppressionRule) => ({
resource: rule.resource_id ? rule.resource_name || rule.resource_id : 'All resources',
category: rule.category || 'All categories',
});
const sameRule = (left: PatrolSuppressionRule, right: PatrolSuppressionRule) =>
[
'id',
'resource_id',
'resource_name',
'category',
'description',
'created_at',
'created_from',
'finding_id',
].every(
(key) => left[key as keyof PatrolSuppressionRule] === right[key as keyof PatrolSuppressionRule],
);
/** A reversible manual-rule control inside Patrol's existing Activity flow. */
export function PatrolSuppressionRules(props: { openForLink?: boolean } = {}) {
const [open, setOpen] = createSignal(false);
const [rules, setRules] = createSignal<PatrolSuppressionRule[]>();
const [selected, setSelected] = createSignal<PatrolSuppressionRule>();
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal('');
const [notice, setNotice] = createSignal('');
const manualRules = createMemo(() => rules()?.filter(isManualSuppressionRule) ?? []);
const otherCount = createMemo(() => (rules()?.length ?? 0) - manualRules().length);
let loadedOrg = '';
let generation = 0;
let request: AbortController | undefined;
let summary: HTMLElement | undefined;
let trigger: HTMLElement | undefined;
const currentOrg = () => getOrgID() || 'default';
const begin = () => {
request?.abort();
request = new AbortController();
return { generation: ++generation, controller: request, org: currentOrg() };
};
const isCurrent = (run: ReturnType<typeof begin>) =>
run.generation === generation && !run.controller.signal.aborted && run.org === currentOrg();
const invalidate = () => {
++generation;
request?.abort();
setRules(undefined);
setSelected(undefined);
setBusy(false);
setNotice('');
setError('Organisation or access changed. Reload rules before making changes.');
};
const offOrg = eventBus.on('org_switched', invalidate);
const offMembership = eventBus.on('organizations_changed', invalidate);
onCleanup(() => {
++generation;
request?.abort();
offOrg();
offMembership();
});
const load = async () => {
if (busy()) return;
const run = begin();
setBusy(true);
setSelected(undefined);
setRules(undefined);
setError('');
setNotice('');
try {
const data = await getSuppressionRules(run.org, run.controller.signal);
if (!isCurrent(run)) return;
loadedOrg = run.org;
setRules(data);
} catch {
if (isCurrent(run))
setError('Rules could not be loaded. Check your access and reload rules.');
} finally {
if (run.generation === generation) {
if (run.org !== currentOrg()) invalidate();
else setBusy(false);
}
}
};
const choose = (rule: PatrolSuppressionRule, button: HTMLElement) => {
if (busy() || !rules() || !isManualSuppressionRule(rule)) return;
if (loadedOrg !== currentOrg()) return invalidate();
trigger = button;
setNotice('');
setSelected({ ...rule });
};
const remove = async () => {
const rule = selected();
if (!rule || busy() || !rules()) return;
if (loadedOrg !== currentOrg()) return invalidate();
const run = begin();
setBusy(true);
setError('');
try {
// A fresh authorised read verifies the exact displayed scope/origin.
// Do not act on a stale, edited, missing or cross-tenant confirmation.
const before = await getSuppressionRules(run.org, run.controller.signal);
if (!isCurrent(run)) return;
const current = before.find((entry) => entry.id === rule.id);
if (!current || !isManualSuppressionRule(current) || !sameRule(current, rule)) {
throw new Error('The confirmed rule is no longer current.');
}
await deleteManualSuppressionRule(current, run.org, run.controller.signal);
if (!isCurrent(run)) return;
const after = await getSuppressionRules(run.org, run.controller.signal);
if (!isCurrent(run)) return;
if (after.some((entry) => entry.id === rule.id)) throw new Error('Rule still present.');
// The deleted row is gone; return focus to a stable control, not its
// detached button. Cancellation returns to the selected row instead.
trigger = summary;
setRules(after);
setSelected(undefined);
setNotice(
'Rule removed. Future matching findings can appear. Dismissed findings and history were not reopened or removed; other rules may still cover the same scope.',
);
} catch {
if (!isCurrent(run)) return;
trigger = summary;
setSelected(undefined);
setRules(undefined);
setError('Removal could not be confirmed. Reload rules before trying again.');
} finally {
if (run.generation === generation) {
if (run.org !== currentOrg()) invalidate();
else setBusy(false);
}
}
};
const openFromLink = () => {
setOpen(true);
if (!rules() && !busy()) void load();
queueMicrotask(() => summary?.focus());
};
const openFromHash = () => {
if (window.location.hash === '#patrol-suppression-rules') openFromLink();
};
onMount(() => {
openFromHash();
window.addEventListener('hashchange', openFromHash);
onCleanup(() => window.removeEventListener('hashchange', openFromHash));
});
createEffect(() => {
// Router state can update before pushState changes window.location. The
// confirmed route hash supplied by the owner is already authoritative.
if (props.openForLink) untrack(openFromLink);
});
return (
<details
id="patrol-suppression-rules"
class="rounded-lg border border-border bg-surface"
open={open()}
onToggle={(event) => {
const next = event.currentTarget.open;
setOpen(next);
if (next && !rules() && !busy() && !error()) void load();
}}
>
<summary
ref={summary}
class="min-h-11 cursor-pointer rounded-lg px-4 py-3 text-sm font-semibold text-base-content focus-visible:outline-2 focus-visible:outline-blue-500 sm:px-5"
>
Suppression rules
</summary>
<Show when={open()}>
<div class="space-y-3 border-t border-border p-4 sm:p-5">
<p class="text-sm leading-5 text-muted">
Review permanent rules created with Create rule. Removing one allows future matching
Patrol findings; it does not reopen dismissed findings or erase history. Use Reopen
finding in Finding options and history to undo an individual dismissal.
</p>
<Button
variant="secondary"
size="sm"
class="min-h-11"
disabled={busy()}
onClick={() => void load()}
>
{busy() ? 'Checking rules…' : 'Reload rules'}
</Button>
<Show when={error()}>
<p role="alert" class="text-sm text-red-700 dark:text-red-300">
{error()}
</p>
</Show>
<Show when={notice()}>
<p role="status" class="text-sm text-base-content">
{notice()}
</p>
</Show>
<Show when={rules()}>
<Show
when={manualRules().length > 0}
fallback={<p class="text-sm text-muted">No manually created suppression rules.</p>}
>
<ul class="space-y-3" aria-label="Manually created suppression rules">
<For each={manualRules()}>
{(rule) => (
<li class="rounded-md border border-border p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1 text-sm text-base-content">
<p class="break-words font-medium">
{ruleScope(rule).resource} · {ruleScope(rule).category}
</p>
<Show when={!rule.resource_id || !rule.category}>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
Broad rule: matches{' '}
{!rule.resource_id ? 'all resources' : 'this resource'} in{' '}
{!rule.category ? 'all categories' : rule.category}.
</p>
</Show>
<p class="whitespace-pre-wrap break-words">
{rule.description || 'No reason recorded.'}
</p>
<Show when={rule.resource_id}>
<p class="break-all text-xs text-muted">
Resource ID: {rule.resource_id}
</p>
</Show>
<p class="break-all text-xs text-muted">Rule ID: {rule.id}</p>
</div>
<Button
variant="secondary"
size="sm"
class="min-h-11 shrink-0 self-start"
disabled={busy()}
aria-label={`Remove rule for ${ruleScope(rule).resource}, ${ruleScope(rule).category}`}
onClick={(event) => choose(rule, event.currentTarget)}
>
Remove rule
</Button>
</div>
</li>
)}
</For>
</ul>
</Show>
<Show when={otherCount() > 0}>
<p class="text-xs leading-5 text-muted">
{otherCount()} other remembered{' '}
{otherCount() === 1 ? 'decision is' : 'decisions are'} not manual rules and cannot
be removed here. Finding dismissals remain in Finding options and history.
</p>
</Show>
</Show>
</div>
</Show>
<Dialog
isOpen={Boolean(selected())}
ariaLabel="Remove suppression rule?"
onClose={() => {
if (!busy()) setSelected(undefined);
}}
returnFocus={() => trigger}
>
<Show when={selected()}>
{(rule) => (
<div class="space-y-4 p-5">
<h2 class="text-base font-semibold text-base-content">Remove suppression rule?</h2>
<p class="text-sm leading-5 text-muted">
Only this exact manual rule will be removed. Future matching findings can appear;
existing dismissals and history stay unchanged.
</p>
<dl class="space-y-2 text-sm text-base-content">
<div>
<dt class="font-medium">Resource</dt>
<dd class="break-words">{ruleScope(rule()).resource}</dd>
</div>
<Show when={rule().resource_id}>
<div>
<dt class="font-medium">Resource ID</dt>
<dd class="break-all">{rule().resource_id}</dd>
</div>
</Show>
<div>
<dt class="font-medium">Category</dt>
<dd>{ruleScope(rule()).category}</dd>
</div>
<div>
<dt class="font-medium">Reason</dt>
<dd class="whitespace-pre-wrap break-words">
{rule().description || 'No reason recorded.'}
</dd>
</div>
<div>
<dt class="font-medium">Rule ID</dt>
<dd class="break-all">{rule().id}</dd>
</div>
</dl>
<Show when={!rule().resource_id || !rule().category}>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
This broad rule covers {!rule().resource_id ? 'all resources' : 'this resource'}{' '}
in {!rule().category ? 'all categories' : rule().category}.
</p>
</Show>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
class="min-h-11"
autofocus
disabled={busy()}
onClick={() => setSelected(undefined)}
>
Cancel
</Button>
<Button
variant="danger"
class="min-h-11"
disabled={busy()}
onClick={() => void remove()}
>
{busy() ? 'Verifying removal…' : 'Remove this rule'}
</Button>
</div>
</div>
)}
</Show>
</Dialog>
</details>
);
}
@@ -865,6 +865,10 @@ describe('PatrolAttentionWorkbench', () => {
);
expect(apiMocks.loadPatrolFindings).toHaveBeenCalled();
expect(await screen.findByRole('status')).toHaveTextContent(/Rule created/i);
expect(screen.getByRole('link', { name: 'Manage suppression rules' })).toHaveAttribute(
'href',
'/patrol/activity#patrol-suppression-rules',
);
});
it('shows the remembered decision and a reopen path instead of re-offering it', async () => {
@@ -143,6 +143,15 @@ describe('PatrolIntelligenceSurface finding handoff', () => {
await vi.waitFor(() => expect(window.location.pathname).toBe('/patrol'));
});
it('keeps manual-rule reversal reachable in Activity even with the findings panel closed', () => {
window.history.replaceState(null, '', '/patrol/activity');
renderSurface();
const rules = document.getElementById('patrol-suppression-rules');
expect(rules?.querySelector('summary')).toHaveTextContent('Suppression rules');
expect(rules).not.toHaveAttribute('open');
expect(document.getElementById('patrol-operational-records')).not.toHaveAttribute('open');
});
it('lands the finding handoff on the findings panel without a scroll to the top', async () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
const scrollIntoView = vi.fn();
@@ -0,0 +1,261 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { eventBus } from '@/stores/events';
import { PatrolSuppressionRules } from '../PatrolSuppressionRules';
import {
deleteManualSuppressionRule,
getSuppressionRules,
type PatrolSuppressionRule,
} from '@/api/patrol';
const context = vi.hoisted(() => ({ org: 'tenant-a' }));
vi.mock('@/utils/apiClient', () => ({ getOrgID: () => context.org }));
vi.mock('@/api/patrol', async (original) => ({
...(await original<typeof import('@/api/patrol')>()),
getSuppressionRules: vi.fn(),
deleteManualSuppressionRule: vi.fn(),
}));
const read = vi.mocked(getSuppressionRules);
const remove = vi.mocked(deleteManualSuppressionRule);
const manual: PatrolSuppressionRule = {
id: 'rule_vm-101_backup_1',
resource_id: 'vm-101',
resource_name: 'Database VM',
category: 'backup',
description: 'Backups are held off-site',
created_from: 'manual',
created_at: '2026-10-05T12:00:00Z',
};
const broad: PatrolSuppressionRule = {
...manual,
id: 'rule_any_any_2',
resource_id: '',
resource_name: '',
category: '',
description: 'Intentional maintenance',
};
const dismissed: PatrolSuppressionRule = {
...manual,
id: 'finding_important',
finding_id: 'important',
created_from: 'dismissed',
};
const legacy: PatrolSuppressionRule = { ...manual, id: 'rule_legacy', created_from: 'suppress' };
const initial = [manual, broad, dismissed, legacy];
async function openRules() {
const view = render(() => <PatrolSuppressionRules />);
const details = document.getElementById('patrol-suppression-rules') as HTMLDetailsElement;
details.open = true;
fireEvent(details, new Event('toggle'));
await screen.findByRole('button', { name: 'Remove rule for Database VM, backup' });
return view;
}
async function chooseManual() {
fireEvent.click(screen.getByRole('button', { name: 'Remove rule for Database VM, backup' }));
return screen.findByRole('dialog', { name: 'Remove suppression rule?' });
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe('Patrol manual-rule reversal', () => {
beforeEach(() => {
context.org = 'tenant-a';
window.history.replaceState(null, '', '/patrol/activity');
read.mockReset().mockResolvedValue(initial);
remove.mockReset().mockResolvedValue(undefined);
});
afterEach(cleanup);
it('does not load until requested; shows exact IDs, reasons, wildcard scope and non-manual distinction', async () => {
render(() => <PatrolSuppressionRules />);
expect(read).not.toHaveBeenCalled();
const details = document.getElementById('patrol-suppression-rules') as HTMLDetailsElement;
details.open = true;
fireEvent(details, new Event('toggle'));
await screen.findByText('Rule ID: rule_vm-101_backup_1');
expect(screen.getByText('Resource ID: vm-101')).toBeInTheDocument();
expect(screen.getByText('Backups are held off-site')).toBeInTheDocument();
expect(
screen.getByText('Broad rule: matches all resources in all categories.'),
).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /^Remove rule for/ })).toHaveLength(2);
expect(screen.getByText(/2 other remembered decisions/)).toBeInTheDocument();
expect(screen.queryByText('Rule ID: finding_important')).not.toBeInTheDocument();
expect(read).toHaveBeenCalledWith('tenant-a', expect.any(AbortSignal));
});
it('confirms the exact scope with safe initial focus and permits cancellation without a mutation', async () => {
await openRules();
const trigger = screen.getByRole('button', { name: 'Remove rule for Database VM, backup' });
trigger.focus();
const dialog = await chooseManual();
expect(within(dialog).getByText(manual.id)).toBeInTheDocument();
expect(within(dialog).getByText(manual.description)).toBeInTheDocument();
await waitFor(() =>
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toHaveFocus(),
);
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(remove).not.toHaveBeenCalled();
expect(read).toHaveBeenCalledTimes(1);
await waitFor(() => expect(trigger).toHaveFocus());
});
it('revalidates, deletes one exact manual ID and confirms absence while keeping unrelated decisions', async () => {
await openRules();
read.mockResolvedValueOnce(initial).mockResolvedValueOnce([broad, dismissed, legacy]);
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
await screen.findByRole('status');
expect(remove).toHaveBeenCalledExactlyOnceWith(manual, 'tenant-a', expect.any(AbortSignal));
expect(read).toHaveBeenCalledTimes(3);
expect(screen.queryByText(`Rule ID: ${manual.id}`)).not.toBeInTheDocument();
expect(screen.getByText(`Rule ID: ${broad.id}`)).toBeInTheDocument();
expect(screen.getByText(/2 other remembered decisions/)).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(
'Dismissed findings and history were not reopened or removed',
);
await waitFor(() =>
expect(
document.getElementById('patrol-suppression-rules')?.querySelector('summary'),
).toHaveFocus(),
);
});
it('shows all-resource/all-category scope again at confirmation', async () => {
await openRules();
fireEvent.click(
screen.getByRole('button', { name: 'Remove rule for All resources, All categories' }),
);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('All resources')).toBeInTheDocument();
expect(within(dialog).getByText('All categories')).toBeInTheDocument();
expect(
within(dialog).getByText('This broad rule covers all resources in all categories.'),
).toBeInTheDocument();
});
it.each([
['scope changed', [{ ...manual, category: 'security' }, broad]],
['manual origin changed', [{ ...manual, created_from: 'finding' }, broad]],
['already removed', [broad]],
])('stops without DELETE when the confirmed rule is %s', async (_, changed) => {
await openRules();
read.mockResolvedValueOnce(changed);
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
await screen.findByRole('alert');
expect(remove).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: /^Remove rule for/ })).not.toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('invalidates the confirmation and old data on an organisation switch', async () => {
await openRules();
await chooseManual();
context.org = 'tenant-b';
eventBus.emit('org_switched', context.org);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByText(`Rule ID: ${manual.id}`)).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Organisation or access changed');
expect(remove).not.toHaveBeenCalled();
read.mockResolvedValueOnce([]);
fireEvent.click(screen.getByRole('button', { name: 'Reload rules' }));
await screen.findByText('No manually created suppression rules.');
expect(read).toHaveBeenLastCalledWith('tenant-b', expect.any(AbortSignal));
});
it('drops a late preflight after access revocation without deleting or reloading automatically', async () => {
await openRules();
const pending = deferred<PatrolSuppressionRule[]>();
read.mockReturnValueOnce(pending.promise);
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
const signal = read.mock.lastCall![1]!;
eventBus.emit('organizations_changed');
pending.resolve(initial);
await waitFor(() => expect(signal.aborted).toBe(true));
expect(remove).not.toHaveBeenCalled();
expect(read).toHaveBeenCalledTimes(2);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('checks context even when a switch did not emit an event', async () => {
await openRules();
await chooseManual();
context.org = 'tenant-b';
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
expect(remove).not.toHaveBeenCalled();
expect(read).toHaveBeenCalledTimes(1);
expect(screen.getByRole('alert')).toHaveTextContent('Reload rules');
});
it.each(['transport', '403'])(
'requires explicit readback after %s uncertainty and never retries DELETE',
async () => {
await openRules();
remove.mockRejectedValueOnce(new Error('Uncertain or refused request'));
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
await screen.findByRole('alert');
expect(remove).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(2);
expect(screen.getByRole('alert')).toHaveTextContent('Removal could not be confirmed');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
read.mockResolvedValueOnce([]);
fireEvent.click(screen.getByRole('button', { name: 'Reload rules' }));
await screen.findByText('No manually created suppression rules.');
expect(remove).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
},
);
it('does not claim success when readback fails or still contains the deleted ID', async () => {
await openRules();
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
await screen.findByRole('alert');
expect(remove).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(3);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('prevents duplicate submission and aborts late readback on disposal', async () => {
const view = await openRules();
const pending = deferred<PatrolSuppressionRule[]>();
read.mockResolvedValueOnce(initial).mockReturnValueOnce(pending.promise);
await chooseManual();
fireEvent.click(screen.getByRole('button', { name: 'Remove this rule' }));
await waitFor(() => expect(read).toHaveBeenCalledTimes(3));
fireEvent.click(screen.getByRole('button', { name: 'Verifying removal…' }));
expect(remove).toHaveBeenCalledTimes(1);
const signal = read.mock.lastCall![1]!;
view.unmount();
expect(signal.aborted).toBe(true);
pending.resolve([broad]);
});
it('opens a linked rule section on mount without making a mutation', async () => {
window.history.replaceState(null, '', '/patrol/activity#patrol-suppression-rules');
render(() => <PatrolSuppressionRules />);
await screen.findByText(`Rule ID: ${manual.id}`);
expect(document.getElementById('patrol-suppression-rules')).toHaveAttribute('open');
expect(remove).not.toHaveBeenCalled();
});
it('honours router hash state before browser history has caught up', async () => {
// Solid Router can mount the Activity view before its pushState effect.
expect(window.location.hash).toBe('');
render(() => <PatrolSuppressionRules openForLink />);
await screen.findByText(`Rule ID: ${manual.id}`);
expect(document.getElementById('patrol-suppression-rules')).toHaveAttribute('open');
expect(read).toHaveBeenCalledTimes(1);
expect(remove).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
apiFetch,
apiClient,
apiFetchJSON,
clearApiToken,
getApiToken,
@@ -42,6 +43,94 @@ describe('apiClient org context', () => {
expect(window.sessionStorage.getItem('pulse_org_id')).toBe('acme');
});
it.each([400, 403, 429])('does not replay a confirmed mutation after HTTP %i', async (status) => {
setOrgID('tenant-a');
document.cookie = 'pulse_csrf=synthetic-csrf; Path=/';
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ error: 'invalid_org' }), {
status,
headers: { 'X-CSRF-Token': 'replacement-csrf', 'Retry-After': '1' },
}),
);
const response = await apiFetch('/api/ai/patrol/suppressions/rule_one', {
method: 'DELETE',
retry: false,
expectedOrgID: 'tenant-a',
});
expect(response.status).toBe(status);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('tenant-a');
expect(mockFetch.mock.calls[0][1].headers['X-CSRF-Token']).toBeTruthy();
expect(mockFetch.mock.calls[0][1].credentials).toBe('include');
expect(getOrgID()).toBe('tenant-a');
expect(mockFetch.mock.calls[0][1]).not.toHaveProperty('retry');
expect(mockFetch.mock.calls[0][1]).not.toHaveProperty('expectedOrgID');
});
it('binds default explicitly and stops stale confirmations or aborted dispatch', async () => {
setOrgID('default');
mockFetch.mockResolvedValue(new Response('[]'));
await apiFetch('/api/ai/patrol/suppressions', { retry: false, expectedOrgID: 'default' });
expect(mockFetch.mock.calls[0][1].headers['X-Pulse-Org-ID']).toBe('default');
setOrgID('tenant-b');
await expect(
apiFetch('/api/ai/patrol/suppressions/rule_one', {
method: 'DELETE',
expectedOrgID: 'default',
retry: false,
}),
).rejects.toThrow('Organisation changed');
const controller = new AbortController();
controller.abort();
await expect(
apiFetch('/api/ai/patrol/suppressions', {
expectedOrgID: 'tenant-b',
retry: false,
signal: controller.signal,
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does not fall back to another authentication method after a strict-session refusal', async () => {
setApiToken('synthetic-token');
setOrgID('tenant-a');
mockFetch.mockResolvedValue(new Response('{}', { status: 401 }));
const result = await apiFetch('/api/state', {
preferSessionAuth: true,
retry: false,
expectedOrgID: 'tenant-a',
});
expect(result.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][1].headers['X-API-Token']).toBeUndefined();
});
it('checks the confirmed organisation again after asynchronous CSRF preparation', async () => {
setOrgID('tenant-a');
let finish!: (token: string) => void;
const preparation = vi.spyOn(apiClient as any, 'ensureCSRFToken').mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
const tokenRead = vi.spyOn(apiClient as any, 'loadCSRFToken').mockReturnValueOnce(null);
try {
const pending = apiFetch('/api/ai/patrol/suppressions/rule_one', {
method: 'DELETE',
retry: false,
expectedOrgID: 'tenant-a',
});
setOrgID('tenant-b');
finish('synthetic-csrf');
await expect(pending).rejects.toThrow('Organisation changed');
expect(mockFetch).not.toHaveBeenCalled();
} finally {
preparation.mockRestore();
tokenRead.mockRestore();
}
});
it('reuses a server-issued org cookie when storage is empty', async () => {
document.cookie = 'pulse_org_id=tenant-1; Path=/; SameSite=Lax';
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
+24 -2
View File
@@ -76,6 +76,10 @@ interface FetchOptions extends Omit<RequestInit, 'headers'> {
preferSessionAuth?: boolean;
skipAuth?: boolean;
skipOrgContext?: boolean;
// Reviewed destructive operations can opt out of auth/CSRF/rate-limit
// retries and bind dispatch to the organisation whose data was confirmed.
retry?: boolean;
expectedOrgID?: string;
}
type APIErrorDetails = Record<string, string>;
@@ -593,12 +597,23 @@ class ApiClient {
preferSessionAuth = false,
skipAuth = false,
skipOrgContext = false,
retry = true,
expectedOrgID,
headers = {},
...fetchOptions
} = options;
// Build headers object
const finalHeaders: Record<string, string> = { ...headers };
const checkExpectedOrg = () => {
if (expectedOrgID !== undefined && (this.getOrgID() || 'default') !== expectedOrgID) {
throw new Error('Organisation changed. Reload before making changes.');
}
};
checkExpectedOrg();
if (expectedOrgID !== undefined) {
finalHeaders[ORG_HEADER_NAME] = expectedOrgID;
}
// Always add headers to prevent browser auth popup for API calls
if (url.startsWith('/api/')) {
@@ -647,6 +662,10 @@ class ApiClient {
credentials: 'include', // Important for session cookies
};
// CSRF preparation may have awaited a request while the operator switched
// organisations or left the view. Do not dispatch a stale confirmation.
checkExpectedOrg();
fetchOptions.signal?.throwIfAborted();
let response = await fetch(url, finalOptions);
// Session-preferred reads intentionally omit the API token on their first
@@ -655,6 +674,7 @@ class ApiClient {
// retrying an unauthenticated idempotent response once with their token.
if (
response.status === 401 &&
retry &&
preferSessionAuth &&
!skipAuth &&
this.apiToken &&
@@ -672,6 +692,8 @@ class ApiClient {
// Handle stale/invalid org context by clearing it and retrying once against default org.
if (
response.status === 400 &&
retry &&
expectedOrgID === undefined &&
!skipOrgContext &&
url.startsWith('/api/') &&
finalHeaders[ORG_HEADER_NAME] &&
@@ -720,7 +742,7 @@ class ApiClient {
}
// Handle CSRF token failures - the 403 response should have set a new CSRF cookie
if (response.status === 403) {
if (response.status === 403 && retry) {
// First try the response header (backend sends new token in X-CSRF-Token header)
let refreshedToken = response.headers.get('X-CSRF-Token');
@@ -745,7 +767,7 @@ class ApiClient {
}
// Handle rate limiting with automatic retry for idempotent requests only.
if (response.status === 429) {
if (response.status === 429 && retry) {
if (!IDEMPOTENT_METHODS.has(method)) {
return response;
}
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -22,6 +23,14 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
t.Fatal("expected Patrol service")
}
store := patrol.GetFindings()
store.Add(&ai.Finding{ID: "retained-history", ResourceID: "vm-1", ResourceName: "Test VM", Category: ai.FindingCategoryBackup, Title: "Historical backup finding", Severity: ai.FindingSeverityWarning})
if !store.Dismiss("retained-history", "expected_behavior", "Keep this operator decision") {
t.Fatal("expected an existing dismissed finding")
}
historyBefore, err := json.Marshal(store.Get("retained-history"))
if err != nil {
t.Fatal(err)
}
selected := store.AddSuppressionRule("vm-1", "Test VM", ai.FindingCategoryBackup, "Test reason")
other := store.AddSuppressionRule("vm-2", "Other VM", ai.FindingCategoryCapacity, "Keep this rule")
router.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleGetSuppressionRules)))
@@ -57,10 +66,16 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
if err := json.Unmarshal(list.Body.Bytes(), &rules); err != nil {
t.Fatal(err)
}
if len(rules) != 2 {
t.Fatalf("listed %d rules, want 2", len(rules))
if len(rules) != 3 {
t.Fatalf("listed %d rules, want 2 manual rules and a finding dismissal", len(rules))
}
for _, rule := range rules {
if rule.ID == "finding_retained-history" {
if rule.CreatedFrom != "dismissed" || rule.FindingID != "retained-history" {
t.Fatal("finding dismissal must stay distinct from manual rules")
}
continue
}
if rule.CreatedFrom != "manual" {
t.Fatalf("created_from = %q, want manual", rule.CreatedFrom)
}
@@ -77,8 +92,8 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
path := "/api/ai/patrol/suppressions/" + selected.ID
for _, token := range []string{"", "invalid-csrf"} {
rec := request(http.MethodDelete, path, token, true)
if rec.Code != http.StatusForbidden || len(store.GetSuppressionRules()) != 2 {
t.Fatalf("invalid-CSRF deletion status = %d; both rules must remain", rec.Code)
if rec.Code != http.StatusForbidden || len(store.GetSuppressionRules()) != 3 {
t.Fatalf("invalid-CSRF deletion status = %d; both rules and dismissal must remain", rec.Code)
}
}
removed := request(http.MethodDelete, path, csrf, true)
@@ -98,11 +113,20 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
if err := json.Unmarshal(readback.Body.Bytes(), &rules); err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].ID != other.ID {
t.Fatal("readback must retain only the unselected rule")
if len(rules) != 2 {
t.Fatal("readback must retain the unselected rule and finding dismissal")
}
for _, rule := range rules {
if rule.ID != other.ID && rule.ID != "finding_retained-history" {
t.Fatalf("unexpected remaining row %q", rule.ID)
}
}
historyAfter, err := json.Marshal(store.Get("retained-history"))
if err != nil || !bytes.Equal(historyBefore, historyAfter) {
t.Fatal("manual deletion must preserve the complete historical finding, note, dismissal and lifecycle")
}
notFound := request(http.MethodDelete, path, csrf, true)
if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 1 {
if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 2 {
t.Fatalf("already-removed deletion status = %d; remaining rule must survive", notFound.Code)
}
}