mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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([]);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user