mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed safe Patrol rule-removal guidance
Preserve the exact documentation and regression-test candidate; native browser follow-through and the missing UI remain owned separately. 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,154 @@
|
||||
// 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/test-results/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;
|
||||
}
|
||||
if (!request.url.startsWith('/api/ai/patrol/suppressions')) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
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 === 'changed-org') {
|
||||
await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]);
|
||||
}
|
||||
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);
|
||||
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,108 @@
|
||||
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("User-Agent", "test-agent")
|
||||
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