mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Document safe removal of mistakenly created Patrol rules
Explain the missing v6.4.5 undo control and the existing scoped API. Verify exact selection, confirmation, browser session and CSRF, failure handling and readback without copying credentials or deleting unrelated rules. Contract-Neutral: Documentation and regression-proof coverage of existing Patrol suppression APIs only; no runtime, route, authority or subsystem contract changes. Change-source: pulse-maintainer
This commit is contained in:
+84
-1
@@ -230,10 +230,93 @@ Findings can be managed via the UI or API:
|
||||
|
||||
- **Get help**: Chat with AI to troubleshoot the issue
|
||||
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
|
||||
- **Dismiss**: Mark as expected behavior (creates suppression rule)
|
||||
- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
|
||||
- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
|
||||
|
||||
Dismissed and resolved findings persist across Pulse restarts.
|
||||
|
||||
#### Remove a rule created by mistake
|
||||
|
||||
In v6.4.5, **Create rule** has no corresponding rule-list or delete control in
|
||||
the UI. **Reopen** undoes an individual finding's dismissal, not a separately
|
||||
created rule. The existing API can list rules and delete one by its exact ID.
|
||||
Deleting a created rule allows future matching findings to appear again; it
|
||||
does not resolve a problem or automatically reopen previously dismissed
|
||||
findings. It leaves the other rules and finding history intact.
|
||||
|
||||
For a rule made with **Create rule**, sign in normally to Pulse in an
|
||||
authorised administrator's browser session, select the correct organisation,
|
||||
and open that Pulse tab's developer console. Review the following snippet
|
||||
before running it. It lists only manually created rules, asks for one exact
|
||||
ID, and confirms its resource, category and reason before deleting it. Cancel
|
||||
either prompt to leave the rules unchanged. Keep the list private: resource
|
||||
names and reasons can describe your infrastructure.
|
||||
|
||||
No password, API token or session cookie needs to be copied or pasted. The
|
||||
browser supplies its existing session; the snippet reads only the normal
|
||||
CSRF and organisation cookies locally. Do not switch organisation while it
|
||||
runs, use it on another website, disable authentication/CSRF protection, or
|
||||
edit Pulse's data files. A token-only login or an expired session may not
|
||||
support this workaround: stop on an authentication or missing-CSRF error
|
||||
instead of inserting credentials into the snippet.
|
||||
|
||||
```javascript
|
||||
(async () => {
|
||||
const cookie = (name) => {
|
||||
const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`));
|
||||
return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : '';
|
||||
};
|
||||
const org = cookie('pulse_org_id') || 'default';
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-Pulse-Org-ID': org,
|
||||
};
|
||||
const readRules = async () => {
|
||||
if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
|
||||
const response = await fetch('/api/ai/patrol/suppressions', {
|
||||
credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`);
|
||||
const rules = await response.json();
|
||||
if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.');
|
||||
return rules.filter((rule) => rule && rule.created_from === 'manual');
|
||||
};
|
||||
const rules = await readRules();
|
||||
console.table(rules.map((rule) => ({
|
||||
id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource',
|
||||
category: rule.category || 'Any category', reason: rule.description,
|
||||
})));
|
||||
const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.');
|
||||
if (id === null || !id.trim()) return;
|
||||
const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim());
|
||||
if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.');
|
||||
const rule = matches[0];
|
||||
if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return;
|
||||
if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
|
||||
const csrf = cookie('pulse_csrf');
|
||||
if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.');
|
||||
const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, {
|
||||
method: 'DELETE', credentials: 'same-origin', redirect: 'error',
|
||||
headers: { ...headers, 'X-CSRF-Token': csrf },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`);
|
||||
if ((await readRules()).some((remaining) => remaining.id === rule.id)) {
|
||||
throw new Error('Rule is still listed; removal was not verified.');
|
||||
}
|
||||
console.info('Selected rule is no longer listed. Other rules were not deleted.');
|
||||
})().catch((error) => console.error(error.message));
|
||||
```
|
||||
|
||||
A failed or lost response is not confirmation of removal. Do not blindly run
|
||||
the deletion again: refresh the rule list first (open
|
||||
`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the
|
||||
selected ID. If the rule is absent, it was already removed. If you cannot
|
||||
verify the result, stop and report only the HTTP status or error, not cookies,
|
||||
tokens or the full rule list. Entries whose `created_from` is `finding` or
|
||||
`dismissed` are individual finding decisions and are deliberately excluded
|
||||
from this snippet.
|
||||
|
||||
Every active finding shown or returned to a Patrol run must receive an
|
||||
explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an
|
||||
all-clear signal. `present` refreshes current evidence, `resolved` remains
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Run from the Pulse workspace root with pulse-worker-browser.
|
||||
// This exercises the documented snippet against a synthetic same-origin API,
|
||||
// not a deployed Pulse installation or the still-missing rule-management UI.
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
const guide = fs.readFileSync('docs/AI.md', 'utf8');
|
||||
const section = guide.split('#### Remove a rule created by mistake\n')[1];
|
||||
const snippet = section.match(/```javascript\n([\s\S]*?)\n```/)[1];
|
||||
const output = 'frontend-modern/browser-tests/artifacts/patrol-rule-help';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const escape = (text) => text.replaceAll('&', '&').replaceAll('<', '<');
|
||||
const selected = { id: 'rule-one', created_from: 'manual', resource_name: 'Test VM',
|
||||
resource_id: 'vm-1', category: 'backup', description: 'Created by mistake' };
|
||||
const other = { ...selected, id: 'rule-two', resource_id: 'vm-2', resource_name: 'Other VM' };
|
||||
let scenario;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/') {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
response.setHeader('Set-Cookie', [
|
||||
'pulse_session=synthetic-session; Path=/; HttpOnly; SameSite=Lax',
|
||||
'pulse_org_id=org-a; Path=/; SameSite=Lax',
|
||||
...(scenario.noCSRF ? [] : ['pulse_csrf=synthetic-csrf; Path=/; SameSite=Lax']),
|
||||
]);
|
||||
response.end(`<h1>Rule removal help — synthetic browser check</h1>
|
||||
<p>Cancel keeps both rules. A confirmed removal targets one exact ID.</p>
|
||||
<p id="result">No removal confirmed</p>
|
||||
<pre style="white-space:pre-wrap;max-width:1000px">${escape(snippet)}</pre>`);
|
||||
return;
|
||||
}
|
||||
assert.ok(request.url.startsWith('/api/ai/patrol/suppressions'));
|
||||
const hasSession = (request.headers.cookie || '').includes('pulse_session=synthetic-session');
|
||||
const expectedOrg = request.headers['x-pulse-org-id'] === 'org-a';
|
||||
const validCSRF = request.headers['x-csrf-token'] === 'synthetic-csrf';
|
||||
scenario.requests.push({ method: request.method, path: request.url,
|
||||
hasSession, expectedOrg, validCSRF });
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (!hasSession || !expectedOrg) {
|
||||
response.writeHead(403).end('{}');
|
||||
return;
|
||||
}
|
||||
if (request.method === 'DELETE') {
|
||||
assert.equal(request.url, '/api/ai/patrol/suppressions/rule-one');
|
||||
if (!validCSRF || scenario.rejectDelete) {
|
||||
response.writeHead(403).end('{}');
|
||||
return;
|
||||
}
|
||||
scenario.removed = true;
|
||||
response.end('{"success":true}');
|
||||
return;
|
||||
}
|
||||
assert.equal(request.method, 'GET');
|
||||
assert.equal(request.url, '/api/ai/patrol/suppressions');
|
||||
if (scenario.removed && scenario.readbackFailure) {
|
||||
response.writeHead(503).end('{}');
|
||||
return;
|
||||
}
|
||||
response.end(JSON.stringify(scenario.removed ? [other] : [selected, other]));
|
||||
});
|
||||
|
||||
(async () => {
|
||||
let browser;
|
||||
const receipts = [];
|
||||
try {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${server.address().port}/`;
|
||||
for (const [engineName, engine] of [['chromium', chromium], ['webkit', webkit]]) {
|
||||
browser = await engine.launch(engineName === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const cases = engineName === 'chromium'
|
||||
? ['prompt-cancel', 'confirm-cancel', 'success', 'missing-csrf', 'changed-org',
|
||||
'delete-rejected', 'readback-failed']
|
||||
: ['prompt-cancel', 'success'];
|
||||
for (const name of cases) {
|
||||
scenario = { name, requests: [], removed: false,
|
||||
noCSRF: name === 'missing-csrf', rejectDelete: name === 'delete-rejected',
|
||||
readbackFailure: name === 'readback-failed' };
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const messages = [];
|
||||
const dialogs = [];
|
||||
page.on('console', (message) => {
|
||||
if (['info', 'error'].includes(message.type())) {
|
||||
messages.push({ kind: message.type(), message: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push({ type: dialog.type(), message: dialog.message() });
|
||||
if (dialog.type() === 'prompt') {
|
||||
if (name === 'prompt-cancel') await dialog.dismiss();
|
||||
else await dialog.accept('rule-one');
|
||||
} else {
|
||||
if (name === 'confirm-cancel') await dialog.dismiss();
|
||||
else await dialog.accept();
|
||||
}
|
||||
});
|
||||
await page.goto(url);
|
||||
if (name === 'changed-org') {
|
||||
// Change context at the documented confirmation boundary.
|
||||
const changedSnippet = snippet.replace(
|
||||
"const csrf = cookie('pulse_csrf');",
|
||||
"const csrf = cookie('pulse_csrf');");
|
||||
// The browser test changes the cookie when the prompt opens, before
|
||||
// the exact, unchanged snippet reaches its organisation recheck.
|
||||
page.once('dialog', async () => {
|
||||
await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]);
|
||||
});
|
||||
assert.equal(changedSnippet, snippet);
|
||||
}
|
||||
await page.evaluate((code) => window.eval(code), snippet);
|
||||
const deletes = scenario.requests.filter((request) => request.method === 'DELETE');
|
||||
const success = messages.some((message) => message.kind === 'info');
|
||||
if (name === 'success') {
|
||||
assert.equal(deletes.length, 1);
|
||||
assert.ok(deletes[0].validCSRF);
|
||||
assert.equal(scenario.requests.length, 3);
|
||||
assert.ok(scenario.removed);
|
||||
assert.ok(success);
|
||||
} else {
|
||||
assert.equal(success, false);
|
||||
if (['delete-rejected', 'readback-failed'].includes(name)) {
|
||||
assert.equal(deletes.length, 1);
|
||||
} else {
|
||||
assert.equal(deletes.length, 0);
|
||||
}
|
||||
}
|
||||
assert.ok(scenario.requests.every((request) => request.hasSession && request.expectedOrg));
|
||||
assert.ok(messages.every((message) => !message.message.includes('synthetic-csrf')));
|
||||
assert.ok(dialogs.every((dialog) => !dialog.message.includes('synthetic-csrf')));
|
||||
await page.locator('#result').evaluate((element, text) => { element.textContent = text; },
|
||||
success ? 'Selected rule absent after readback; other rule retained' : `${name}: no removal confirmed`);
|
||||
let screenshot;
|
||||
if (['success', 'confirm-cancel', 'delete-rejected'].includes(name)) {
|
||||
screenshot = path.join(output, `${engineName}-${name}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
}
|
||||
receipts.push({ engine: engineName, browserVersion: browser.version(), name,
|
||||
requests: scenario.requests, messages, dialogs, removed: scenario.removed, screenshot });
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
const result = { guideSHA256: crypto.createHash('sha256').update(guide).digest('hex'),
|
||||
playwrightVersion: require('playwright/package.json').version,
|
||||
evidenceKind: 'synthetic same-origin browser/API workaround; no installed Pulse or rule-management UI',
|
||||
scenarios: receipts };
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify({ passed: receipts.length, guideSHA256: result.guideSHA256,
|
||||
playwrightVersion: result.playwrightVersion, result: path.join(output, 'result.json') }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -230,10 +230,93 @@ Findings can be managed via the UI or API:
|
||||
|
||||
- **Get help**: Chat with AI to troubleshoot the issue
|
||||
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
|
||||
- **Dismiss**: Mark as expected behavior (creates suppression rule)
|
||||
- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
|
||||
- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
|
||||
|
||||
Dismissed and resolved findings persist across Pulse restarts.
|
||||
|
||||
#### Remove a rule created by mistake
|
||||
|
||||
In v6.4.5, **Create rule** has no corresponding rule-list or delete control in
|
||||
the UI. **Reopen** undoes an individual finding's dismissal, not a separately
|
||||
created rule. The existing API can list rules and delete one by its exact ID.
|
||||
Deleting a created rule allows future matching findings to appear again; it
|
||||
does not resolve a problem or automatically reopen previously dismissed
|
||||
findings. It leaves the other rules and finding history intact.
|
||||
|
||||
For a rule made with **Create rule**, sign in normally to Pulse in an
|
||||
authorised administrator's browser session, select the correct organisation,
|
||||
and open that Pulse tab's developer console. Review the following snippet
|
||||
before running it. It lists only manually created rules, asks for one exact
|
||||
ID, and confirms its resource, category and reason before deleting it. Cancel
|
||||
either prompt to leave the rules unchanged. Keep the list private: resource
|
||||
names and reasons can describe your infrastructure.
|
||||
|
||||
No password, API token or session cookie needs to be copied or pasted. The
|
||||
browser supplies its existing session; the snippet reads only the normal
|
||||
CSRF and organisation cookies locally. Do not switch organisation while it
|
||||
runs, use it on another website, disable authentication/CSRF protection, or
|
||||
edit Pulse's data files. A token-only login or an expired session may not
|
||||
support this workaround: stop on an authentication or missing-CSRF error
|
||||
instead of inserting credentials into the snippet.
|
||||
|
||||
```javascript
|
||||
(async () => {
|
||||
const cookie = (name) => {
|
||||
const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`));
|
||||
return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : '';
|
||||
};
|
||||
const org = cookie('pulse_org_id') || 'default';
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-Pulse-Org-ID': org,
|
||||
};
|
||||
const readRules = async () => {
|
||||
if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
|
||||
const response = await fetch('/api/ai/patrol/suppressions', {
|
||||
credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`);
|
||||
const rules = await response.json();
|
||||
if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.');
|
||||
return rules.filter((rule) => rule && rule.created_from === 'manual');
|
||||
};
|
||||
const rules = await readRules();
|
||||
console.table(rules.map((rule) => ({
|
||||
id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource',
|
||||
category: rule.category || 'Any category', reason: rule.description,
|
||||
})));
|
||||
const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.');
|
||||
if (id === null || !id.trim()) return;
|
||||
const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim());
|
||||
if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.');
|
||||
const rule = matches[0];
|
||||
if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return;
|
||||
if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
|
||||
const csrf = cookie('pulse_csrf');
|
||||
if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.');
|
||||
const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, {
|
||||
method: 'DELETE', credentials: 'same-origin', redirect: 'error',
|
||||
headers: { ...headers, 'X-CSRF-Token': csrf },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`);
|
||||
if ((await readRules()).some((remaining) => remaining.id === rule.id)) {
|
||||
throw new Error('Rule is still listed; removal was not verified.');
|
||||
}
|
||||
console.info('Selected rule is no longer listed. Other rules were not deleted.');
|
||||
})().catch((error) => console.error(error.message));
|
||||
```
|
||||
|
||||
A failed or lost response is not confirmation of removal. Do not blindly run
|
||||
the deletion again: refresh the rule list first (open
|
||||
`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the
|
||||
selected ID. If the rule is absent, it was already removed. If you cannot
|
||||
verify the result, stop and report only the HTTP status or error, not cookies,
|
||||
tokens or the full rule list. Entries whose `created_from` is `finding` or
|
||||
`dismissed` are individual finding decisions and are deliberately excluded
|
||||
from this snippet.
|
||||
|
||||
Every active finding shown or returned to a Patrol run must receive an
|
||||
explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an
|
||||
all-clear signal. `present` refreshes current evidence, `resolved` remains
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
// The rule-removal help uses existing session and CSRF protection, not a token
|
||||
// pasted into a command. Exercise the actual handlers/store and Router middleware.
|
||||
func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
|
||||
router, session := newRouterWithSession(t)
|
||||
handler := newTestAISettingsHandler(router.config, config.NewConfigPersistence(router.config.DataPath), nil)
|
||||
handler.defaultAIService.SetStateProvider(&stubStateProvider{})
|
||||
t.Cleanup(handler.defaultAIService.Stop)
|
||||
patrol := handler.defaultAIService.GetPatrolService()
|
||||
if patrol == nil {
|
||||
t.Fatal("expected Patrol service")
|
||||
}
|
||||
store := patrol.GetFindings()
|
||||
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)))
|
||||
router.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleDeleteSuppressionRule)))
|
||||
|
||||
request := func(method, path, csrf string, authenticated bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
req.Header.Set("X-Pulse-Org-ID", "default")
|
||||
if authenticated {
|
||||
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: session})
|
||||
}
|
||||
if csrf != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
unauthenticated := request(http.MethodGet, "/api/ai/patrol/suppressions", "", false)
|
||||
if unauthenticated.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated list status = %d, want 401", unauthenticated.Code)
|
||||
}
|
||||
list := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated list status = %d, want 200", list.Code)
|
||||
}
|
||||
var rules []ai.SuppressionRule
|
||||
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))
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.CreatedFrom != "manual" {
|
||||
t.Fatalf("created_from = %q, want manual", rule.CreatedFrom)
|
||||
}
|
||||
}
|
||||
csrf := ""
|
||||
for _, cookie := range list.Result().Cookies() {
|
||||
if cookie.Name == "pulse_csrf" {
|
||||
csrf = cookie.Value
|
||||
}
|
||||
}
|
||||
if csrf == "" {
|
||||
t.Fatal("authenticated GET must issue a browser CSRF cookie")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
removed := request(http.MethodDelete, path, csrf, true)
|
||||
if removed.Code != http.StatusOK {
|
||||
t.Fatalf("deletion status = %d, want 200", removed.Code)
|
||||
}
|
||||
if store.MatchesSuppressionRule("vm-1", ai.FindingCategoryBackup) {
|
||||
t.Fatal("selected rule still suppresses future matching findings")
|
||||
}
|
||||
if !store.MatchesSuppressionRule("vm-2", ai.FindingCategoryCapacity) {
|
||||
t.Fatal("unselected rule must remain effective")
|
||||
}
|
||||
readback := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true)
|
||||
if readback.Code != http.StatusOK {
|
||||
t.Fatalf("readback status = %d, want 200", readback.Code)
|
||||
}
|
||||
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")
|
||||
}
|
||||
notFound := request(http.MethodDelete, path, csrf, true)
|
||||
if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 1 {
|
||||
t.Fatalf("already-removed deletion status = %d; remaining rule must survive", notFound.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Exercise the exact documented browser workaround with synthetic state only."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GUIDE = ROOT / "docs" / "AI.md"
|
||||
SHIPPED_GUIDE = ROOT / "frontend-modern" / "public" / "docs" / "AI.md"
|
||||
|
||||
|
||||
def removal_snippet():
|
||||
section = GUIDE.read_text().split("#### Remove a rule created by mistake\n", 1)[1]
|
||||
return re.search(r"```javascript\n(.*?)\n```", section, re.S).group(1)
|
||||
|
||||
|
||||
HARNESS = r"""
|
||||
const vm = require('node:vm');
|
||||
let input = '';
|
||||
process.stdin.on('data', chunk => input += chunk);
|
||||
process.stdin.on('end', async () => {
|
||||
const options = JSON.parse(input);
|
||||
const calls = [], messages = [], tables = [], confirmations = [];
|
||||
let cookie = options.cookie ?? 'pulse_org_id=org-a; pulse_csrf=synthetic-csrf';
|
||||
const context = {
|
||||
document: { get cookie() { return cookie; } },
|
||||
prompt: () => options.id === undefined ? null : options.id,
|
||||
confirm: message => {
|
||||
confirmations.push(message);
|
||||
if (options.changedOrg) cookie = 'pulse_org_id=org-b; pulse_csrf=synthetic-csrf';
|
||||
return options.confirm ?? true;
|
||||
},
|
||||
fetch: async (path, init) => {
|
||||
calls.push({path, ...init});
|
||||
const result = options.responses[calls.length - 1];
|
||||
if (!result) throw new Error('Unexpected additional request');
|
||||
if (result.networkFailure) throw new Error('Synthetic network failure');
|
||||
return {ok: result.status >= 200 && result.status < 300,
|
||||
status: result.status, json: async () => result.body};
|
||||
},
|
||||
console: {
|
||||
table: rows => tables.push(rows),
|
||||
info: message => messages.push({kind: 'info', message}),
|
||||
error: message => messages.push({kind: 'error', message}),
|
||||
},
|
||||
};
|
||||
await vm.runInNewContext(options.snippet, context, {timeout: 1000});
|
||||
process.stdout.write(JSON.stringify({calls, messages, tables, confirmations}));
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
RULE = {"id": "rule-one", "created_from": "manual", "resource_id": "vm-1",
|
||||
"resource_name": "Test VM", "category": "backup", "description": "Test reason"}
|
||||
OTHER_RULE = {**RULE, "id": "rule-two", "resource_id": "vm-2"}
|
||||
DISMISSAL = {**RULE, "id": "finding_one", "created_from": "dismissed"}
|
||||
|
||||
|
||||
class PatrolRuleHelpTests(unittest.TestCase):
|
||||
def run_snippet(self, **options):
|
||||
options.setdefault("responses", [{"status": 200, "body": [RULE, OTHER_RULE, DISMISSAL]}])
|
||||
options["snippet"] = removal_snippet()
|
||||
result = subprocess.run(["node", "-e", HARNESS], input=json.dumps(options),
|
||||
text=True, capture_output=True, check=True, timeout=5)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def assert_no_delete(self, result):
|
||||
self.assertFalse(any(call.get("method") == "DELETE" for call in result["calls"]))
|
||||
self.assertFalse(any(message["kind"] == "info" for message in result["messages"]))
|
||||
|
||||
def test_shipped_help_matches_source(self):
|
||||
self.assertEqual(GUIDE.read_bytes(), SHIPPED_GUIDE.read_bytes())
|
||||
|
||||
def test_cancel_is_read_only_and_hides_finding_decisions(self):
|
||||
result = self.run_snippet()
|
||||
self.assert_no_delete(result)
|
||||
self.assertEqual(len(result["calls"]), 1)
|
||||
self.assertEqual([row["id"] for row in result["tables"][0]], ["rule-one", "rule-two"])
|
||||
|
||||
def test_unknown_blank_and_finding_ids_cannot_delete(self):
|
||||
for rule_id in ["missing", " ", "finding_one"]:
|
||||
with self.subTest(rule_id=rule_id):
|
||||
self.assert_no_delete(self.run_snippet(id=rule_id))
|
||||
|
||||
def test_duplicate_id_cannot_delete(self):
|
||||
self.assert_no_delete(self.run_snippet(id="rule-one", responses=[
|
||||
{"status": 200, "body": [RULE, RULE]}]))
|
||||
|
||||
def test_confirmation_cancel_is_read_only(self):
|
||||
result = self.run_snippet(id="rule-one", confirm=False)
|
||||
self.assert_no_delete(result)
|
||||
self.assertIn("Test VM", result["confirmations"][0])
|
||||
self.assertIn("backup", result["confirmations"][0])
|
||||
self.assertIn("Test reason", result["confirmations"][0])
|
||||
|
||||
def test_missing_csrf_and_changed_organisation_stop(self):
|
||||
for options in [{"cookie": "pulse_org_id=org-a"}, {"changedOrg": True}]:
|
||||
with self.subTest(options=options):
|
||||
self.assert_no_delete(self.run_snippet(id="rule-one", **options))
|
||||
|
||||
def test_failed_or_malformed_list_cannot_delete(self):
|
||||
for response in [{"status": 401}, {"status": 403}, {"status": 200, "body": {}},
|
||||
{"networkFailure": True}]:
|
||||
with self.subTest(response=response):
|
||||
self.assert_no_delete(self.run_snippet(id="rule-one", responses=[response]))
|
||||
|
||||
def test_delete_failure_or_lost_response_is_not_retried_or_reported_as_success(self):
|
||||
for response in [{"status": 403}, {"status": 404}, {"status": 503},
|
||||
{"networkFailure": True}]:
|
||||
with self.subTest(response=response):
|
||||
result = self.run_snippet(id="rule-one", responses=[
|
||||
{"status": 200, "body": [RULE, OTHER_RULE]}, response])
|
||||
self.assertEqual(len(result["calls"]), 2)
|
||||
self.assertFalse(any(m["kind"] == "info" for m in result["messages"]))
|
||||
|
||||
def test_readback_must_succeed_and_show_selected_rule_absent(self):
|
||||
for response in [{"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 403},
|
||||
{"networkFailure": True}]:
|
||||
with self.subTest(response=response):
|
||||
result = self.run_snippet(id="rule-one", responses=[
|
||||
{"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 200}, response])
|
||||
self.assertEqual(len(result["calls"]), 3)
|
||||
self.assertFalse(any(m["kind"] == "info" for m in result["messages"]))
|
||||
|
||||
def test_only_selected_rule_is_deleted_using_bound_browser_context(self):
|
||||
rule = {**RULE, "id": "rule /?other"}
|
||||
result = self.run_snippet(id=rule["id"], responses=[
|
||||
{"status": 200, "body": [rule, OTHER_RULE, DISMISSAL]}, {"status": 200},
|
||||
{"status": 200, "body": [OTHER_RULE, DISMISSAL]}])
|
||||
self.assertEqual(len(result["calls"]), 3)
|
||||
delete = result["calls"][1]
|
||||
self.assertEqual(delete["path"], "/api/ai/patrol/suppressions/rule%20%2F%3Fother")
|
||||
self.assertEqual(delete["headers"]["X-CSRF-Token"], "synthetic-csrf")
|
||||
for call in result["calls"]:
|
||||
self.assertEqual(call["credentials"], "same-origin")
|
||||
self.assertEqual(call["redirect"], "error")
|
||||
self.assertEqual(call["headers"]["X-Pulse-Org-ID"], "org-a")
|
||||
self.assertNotIn("X-API-Token", call["headers"])
|
||||
self.assertEqual(result["messages"][0]["kind"], "info")
|
||||
self.assertNotIn("synthetic-csrf", json.dumps(result["tables"]))
|
||||
self.assertNotIn("synthetic-csrf", json.dumps(result["messages"]))
|
||||
|
||||
def test_wildcard_scope_is_explicit_in_list_and_confirmation(self):
|
||||
rule = {**RULE, "resource_id": "", "resource_name": "", "category": ""}
|
||||
result = self.run_snippet(id=rule["id"], confirm=False,
|
||||
responses=[{"status": 200, "body": [rule]}])
|
||||
self.assert_no_delete(result)
|
||||
self.assertEqual(result["tables"][0][0]["resource"], "Any resource")
|
||||
self.assertEqual(result["tables"][0][0]["category"], "Any category")
|
||||
self.assertIn("Any resource", result["confirmations"][0])
|
||||
self.assertIn("Any category", result["confirmations"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user