mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep action reviews safe and notify when incidents continue (#2335)
## What changed and why This batch makes Actions review ownership safer when responses arrive out of order, restores continuing-symptom notification handling after an incident's primary recovery, and keeps the provider control-plane and integration mock on the same pinned Go 1.26.8 builder. The first two address unsafe update-review state and missed alert follow-through; the builder pin follows the reviewed #2275 dependency update without changing the prebuilt release runtime. ## Included source Pulse `main` after `30bb963b6cbd9f0575e1b9ac86c88c3cd45a512a` through `91d8822d76c5a7214995c0e370be637b6e6336e3`: reviewed Actions commits `ba298b29e264f671f917b49139cf9031678b87da` and `e57bb692ae79c75c2df1fa4c91d90b84d5aa0fb4`, notification commit `fd9e836ca458e34f5b6736b456375de51a625b18`, builder pin `91d8822d76c5a7214995c0e370be637b6e6336e3`, and additive upstream/integration merges `d50b902797f9989eb3d786b502adc385a9a8fe37` and `75d72f586881ba2356263dd0e664735b6baae4e9`. ## Verification and limits The reviewed Actions candidate included focused component/browser coverage; the alert candidate added focused incident-synthesis tests; the builder candidate's exact-source install-test proof passed (`source-ow_zygny`). Publication provenance and range diff checks pass at this tip. A new combined-tip source-proof request could not start because all executor slots were occupied; it is not a passing result. Exact-head PR checks and independent review remain required before landing. The builder image still needs the required exact Docker qualification in its disposable executor; this PR does not establish that the digest can be pulled or built, release qualification, or installed behaviour. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
@@ -12,7 +12,7 @@ COPY SECURITY.md TERMS.md /app/
|
||||
RUN --mount=type=cache,id=pulse-control-plane-npm-cache,target=/root/.npm \
|
||||
npm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
@@ -2833,11 +2833,16 @@ notification delivery, with the typed `correlated_primary` reason and primary
|
||||
alert ID in the event log. The primary remains the one delivery owner. Supporting
|
||||
members also skip escalation levels and critical repeats while that supported
|
||||
cause is current; an already queued escalation is revalidated before delivery.
|
||||
If the supported primary recovers or the causal link is lost while its symptom
|
||||
still fails, the symptom regains independent delivery without a new occurrence.
|
||||
It receives its first notification only if it was never dispatched before
|
||||
grouping; an already-notified symptom does not duplicate on group dissolution.
|
||||
Acknowledgement, rate limits and other ordinary delivery gates still apply.
|
||||
`observation-set` members continue to notify independently because Pulse has
|
||||
not established causality. `incident_synthesis_test.go` pins classification,
|
||||
contradiction downgrade, bounded evidence, duplicate-delivery suppression,
|
||||
partial-recovery behavior, escalation suppression, and queued-callback
|
||||
revalidation without muting observation sets.
|
||||
partial-recovery delivery and no duplicate after prior dispatch, escalation
|
||||
suppression, and queued-callback revalidation without muting observation sets.
|
||||
|
||||
### Alert hydration is not resource admission
|
||||
|
||||
|
||||
@@ -3724,6 +3724,13 @@ vulnerabilities in the current patch level, the canonical fix is to advance the
|
||||
governed release toolchain and immutable Go builder digest together, not to
|
||||
suppress the scanner or produce release artifacts with an older patched-over
|
||||
runtime.
|
||||
The hosted control-plane source builder and integration mock builder are both
|
||||
on the reviewed `golang:1.26.8-alpine` digest
|
||||
`sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c`.
|
||||
`TestProviderMSPGoBuilderMatchesIntegrationMock` pins their exact agreement;
|
||||
it does not pull the image, prove its registry contents, or qualify a deployed
|
||||
control plane. The release publisher's prebuilt control-plane target is a
|
||||
separate path and does not rebuild its binary from this stage.
|
||||
As of 2026-08-27, the governed release floor is Go `1.26.7`. It supersedes
|
||||
`1.26.5`, whose standard library is reachable through seven vulnerable Pulse
|
||||
call paths reported by `govulncheck`, including HTTP/TLS, URL parsing, SAML XML
|
||||
|
||||
@@ -6815,6 +6815,13 @@ the matching Open or History subtab from server-authored lifecycle state, and
|
||||
removes the query when the dialog closes. Feature pages may summarize action
|
||||
context, but they must not recreate approve, reject, run, progress, or outcome
|
||||
controls outside the shared Actions review.
|
||||
The Actions route owns overlapping reads by request generation. A slower detail
|
||||
response or late dialog refresh must not replace a newer URL-selected action or
|
||||
reopen a closed review; a mismatched server action id is rejected. An older Open
|
||||
or History list response must not overwrite the newer tab's results or error.
|
||||
`Actions.requestOwnership.test.tsx` covers both response orders and close while
|
||||
a receipt re-read is pending; the browser navigation proof checks the rendered
|
||||
dialog and URL at desktop and phone widths without sending an action mutation.
|
||||
The Actions ledger is a peer top-level navigation destination. Patrol remains
|
||||
the primary detection and investigation home and may expose a route-backed
|
||||
Actions handoff, but Actions owns its pending-approval count and selected state.
|
||||
|
||||
@@ -2951,6 +2951,13 @@ missing provenance remains an immediate fail-closed warning rather than hidden
|
||||
detail. Actions is the canonical browser hub for action review, execution
|
||||
progress, and recorded outcomes; contextual sources such as Patrol link into an
|
||||
exact action review instead of duplicating those mutations locally.
|
||||
When a user selects a different action before a prior detail read completes,
|
||||
only the detail matching the current `action` URL may open the review. Closing
|
||||
the review invalidates pending detail and receipt reads, so a late response
|
||||
cannot reopen it or substitute another resource's action. The page also keeps
|
||||
Open and History list responses scoped to their latest request; an older tab
|
||||
result or error cannot replace the current tab. This is a browser ownership
|
||||
rule over server-authored action identity, not a new action or receipt state.
|
||||
|
||||
APT review presents server-recorded policy provenance and distinguishes the
|
||||
elevated update posture from low-risk-eligible cache cleanup. Both typed actions
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Prove that late action reads cannot change the route-backed review or reopen it.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const action = (id) => ({
|
||||
id,
|
||||
createdAt: '2026-09-29T18:00:00Z',
|
||||
updatedAt: '2026-09-29T18:01:00Z',
|
||||
state: 'executing',
|
||||
decisionRevision: 1,
|
||||
request: {
|
||||
requestId: `request-${id}`,
|
||||
resourceId: `docker:container:${id}`,
|
||||
capabilityName: 'update',
|
||||
reason: `Update ${id}`,
|
||||
requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: id,
|
||||
requestId: `request-${id}`,
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
approvalPolicy: 'none',
|
||||
rollbackAvailable: false,
|
||||
expiresAt: '2026-09-29T21:00:00Z',
|
||||
planHash: `sha256:plan-${id}`,
|
||||
},
|
||||
verificationOutcome: { status: 'unknown' },
|
||||
});
|
||||
const detail = (id) => ({
|
||||
audit: action(id),
|
||||
events: [],
|
||||
readiness: {
|
||||
ready: false,
|
||||
code: 'receipt_pending',
|
||||
message: 'Awaiting receipt',
|
||||
refreshable: false,
|
||||
checkedAt: '2026-09-29T18:01:00Z',
|
||||
},
|
||||
attempt: {
|
||||
id: `attempt-${id}`,
|
||||
actionId: id,
|
||||
state: 'receipt_pending',
|
||||
createdAt: '2026-09-29T18:00:00Z',
|
||||
updatedAt: '2026-09-29T18:01:00Z',
|
||||
dispatchCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'node_modules', 'action-review-navigation-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
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'),
|
||||
server: { host: '127.0.0.1', port: 5204, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
channel: 'chromium',
|
||||
args: ['--no-sandbox'],
|
||||
});
|
||||
const results = [];
|
||||
for (const width of [1365, 390]) {
|
||||
let releaseA;
|
||||
let releaseReceipt;
|
||||
let bReads = 0;
|
||||
const requests = [];
|
||||
const errors = [];
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== 'http://127.0.0.1:5204') return route.abort();
|
||||
if (url.pathname.startsWith('/api/')) requests.push(`${request.method()} ${url.pathname}`);
|
||||
if (url.pathname === '/api/actions') {
|
||||
const view = url.searchParams.get('view');
|
||||
return route.fulfill({
|
||||
json: {
|
||||
view,
|
||||
actions: view === 'pending' ? [action('edge-a'), action('edge-b')] : [],
|
||||
count: view === 'pending' ? 2 : 0,
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/actions/edge-a') {
|
||||
await new Promise((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
return route.fulfill({ json: detail('edge-a') });
|
||||
}
|
||||
if (url.pathname === '/api/actions/edge-b') {
|
||||
bReads += 1;
|
||||
if (bReads === 2)
|
||||
await new Promise((resolve) => {
|
||||
releaseReceipt = resolve;
|
||||
});
|
||||
return route.fulfill({ json: detail('edge-b') });
|
||||
}
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: false },
|
||||
},
|
||||
});
|
||||
if (url.pathname.startsWith('/api/'))
|
||||
return route.fulfill({ status: 503, json: { error: 'fixture unavailable' } });
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5204/browser-tests/action-review-navigation.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.getByRole('button', { name: /Review Update on docker:container:edge-a/ }).click();
|
||||
await page.waitForFunction(() => location.search === '?action=edge-a');
|
||||
await page.getByRole('button', { name: /Review Update on docker:container:edge-b/ }).click();
|
||||
await page.getByRole('dialog').getByText('docker:container:edge-b').first().waitFor();
|
||||
releaseA();
|
||||
await page.waitForTimeout(300);
|
||||
assert.match(await page.getByRole('dialog').innerText(), /docker:container:edge-b/);
|
||||
assert.doesNotMatch(await page.getByRole('dialog').innerText(), /docker:container:edge-a/);
|
||||
assert.equal(new URL(page.url()).search, '?action=edge-b');
|
||||
await page.screenshot({ path: path.join(artifacts, `newest-review-${width}.png`) });
|
||||
|
||||
const receiptRead = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === '/api/actions/edge-b' && request.method() === 'GET',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Check for receipt' }).click();
|
||||
await receiptRead;
|
||||
await page.waitForTimeout(50);
|
||||
// The pending route is observable through the second GET, not a mutation.
|
||||
assert.equal(bReads, 2);
|
||||
await page.getByRole('button', { name: 'Close action review' }).click();
|
||||
await page.waitForFunction(() => location.search === '');
|
||||
releaseReceipt();
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal(await page.getByRole('dialog').count(), 0);
|
||||
assert.equal(new URL(page.url()).search, '');
|
||||
assert.ok(
|
||||
requests.every((item) => item.startsWith('GET ')),
|
||||
JSON.stringify(requests),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
results.push({ width, requests, errors, dimensions });
|
||||
await page.close();
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: 'passed',
|
||||
browser: browser.version(),
|
||||
playwright: '1.56.1',
|
||||
results,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
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/action-review-navigation.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
// Exercise the production Actions page with scripted, read-only action responses.
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { render } from 'solid-js/web';
|
||||
import Actions from '../src/pages/Actions';
|
||||
import '../src/index.css';
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="*" component={Actions} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
|
||||
"verified_at": "2026-09-29T19:09:52Z",
|
||||
"base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
|
||||
"verified_at": "2026-09-29T19:50:45Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
|
||||
"frontend-modern/src/pages/Actions.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
|
||||
"frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
|
||||
"/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -23,27 +23,70 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"fresh executing/receipt_pending, including after a same-action re-read",
|
||||
"failed status re-read with unknown outcome preserved",
|
||||
"completed action after a recorded receipt"
|
||||
"detail for edge-a held while edge-b is selected and rendered",
|
||||
"late edge-a response cannot replace edge-b review or URL",
|
||||
"pending receipt re-read finishes after edge-b review closes"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop pointer and phone keyboard Check for receipt",
|
||||
"GET same action under pending, read error and completion; no POST",
|
||||
"desktop and phone layout and horizontal-overflow checks"
|
||||
"select edge-a then edge-b before the first detail response arrives",
|
||||
"release older response and inspect current review at desktop and phone widths",
|
||||
"start same-action receipt GET, close review, release response; no dialog reopens",
|
||||
"assert all scripted API requests are GET and no horizontal overflow or page errors"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png"
|
||||
],
|
||||
"notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest.",
|
||||
"notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
|
||||
"verified_at": "2026-09-29T19:09:52Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"fresh executing/receipt_pending, including after a same-action re-read",
|
||||
"failed status re-read with unknown outcome preserved",
|
||||
"completed action after a recorded receipt"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop pointer and phone keyboard Check for receipt",
|
||||
"GET same action under pending, read error and completion; no POST",
|
||||
"desktop and phone layout and horizontal-overflow checks"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
|
||||
],
|
||||
"notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
|
||||
import Actions from '../../../pages/Actions';
|
||||
|
||||
vi.mock('@/api/resourceActions', () => ({
|
||||
ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
|
||||
vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) }));
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const audit = (id: string, state: ActionAuditRecord['state'] = 'executing'): ActionAuditRecord => ({
|
||||
id,
|
||||
createdAt: '2026-09-29T18:00:00Z',
|
||||
updatedAt: '2026-09-29T18:01:00Z',
|
||||
state,
|
||||
decisionRevision: 1,
|
||||
request: {
|
||||
requestId: `request-${id}`,
|
||||
resourceId: `docker:container:${id}`,
|
||||
capabilityName: 'update',
|
||||
reason: `Update ${id}`,
|
||||
requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: id,
|
||||
requestId: `request-${id}`,
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
approvalPolicy: 'none',
|
||||
rollbackAvailable: false,
|
||||
expiresAt: '2026-09-29T21:00:00Z',
|
||||
planHash: `sha256:plan-${id}`,
|
||||
},
|
||||
verificationOutcome: { status: 'unknown' },
|
||||
});
|
||||
|
||||
const detail = (id: string): ActionDetailResponse => ({
|
||||
audit: audit(id),
|
||||
events: [],
|
||||
readiness: {
|
||||
ready: false,
|
||||
code: 'receipt_pending',
|
||||
message: 'Awaiting receipt',
|
||||
refreshable: false,
|
||||
checkedAt: '2026-09-29T18:01:00Z',
|
||||
},
|
||||
attempt: {
|
||||
id: `attempt-${id}`,
|
||||
actionId: id,
|
||||
state: 'receipt_pending',
|
||||
createdAt: '2026-09-29T18:00:00Z',
|
||||
updatedAt: '2026-09-29T18:01:00Z',
|
||||
dispatchCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const renderActions = () =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="*" component={Actions} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({
|
||||
actions: [audit('edge-a'), audit('edge-b')],
|
||||
count: 2,
|
||||
view: 'pending',
|
||||
readOnly: false,
|
||||
});
|
||||
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: false },
|
||||
} as Awaited<ReturnType<typeof SecurityAPI.getStatus>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('Actions request ownership', () => {
|
||||
it('keeps the newest selected action when an older detail response arrives last', async () => {
|
||||
const first = deferred<ActionDetailResponse>();
|
||||
vi.mocked(ResourceActionsAPI.getAction).mockImplementation((id) =>
|
||||
id === 'edge-a' ? first.promise : Promise.resolve(detail('edge-b')),
|
||||
);
|
||||
renderActions();
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /Review Update on docker:container:edge-b/ }),
|
||||
);
|
||||
expect(await screen.findByText('docker:container:edge-b')).toBeVisible();
|
||||
|
||||
first.resolve(detail('edge-a'));
|
||||
await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('docker:container:edge-b');
|
||||
expect(screen.getByRole('dialog')).not.toHaveTextContent('docker:container:edge-a');
|
||||
expect(window.location.search).toBe('?action=edge-b');
|
||||
});
|
||||
|
||||
it('does not reopen a closed review when a receipt re-read finishes late', async () => {
|
||||
const read = deferred<ActionDetailResponse>();
|
||||
vi.mocked(ResourceActionsAPI.getAction)
|
||||
.mockResolvedValueOnce(detail('edge-a'))
|
||||
.mockReturnValueOnce(read.promise);
|
||||
renderActions();
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Check for receipt' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close action review' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(window.location.search).toBe(''));
|
||||
|
||||
read.resolve({ ...detail('edge-a'), audit: audit('edge-a', 'completed') });
|
||||
await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
it('refuses details returned for a different action id', async () => {
|
||||
vi.mocked(ResourceActionsAPI.getAction).mockResolvedValue(detail('edge-b'));
|
||||
renderActions();
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
|
||||
);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Action details did not match the selected action.',
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show an old Open-list response under History', async () => {
|
||||
const open = deferred<Awaited<ReturnType<typeof ResourceActionsAPI.listActions>>>();
|
||||
vi.mocked(ResourceActionsAPI.listActions).mockImplementation((requestedView) =>
|
||||
requestedView === 'pending'
|
||||
? open.promise
|
||||
: Promise.resolve({
|
||||
actions: [audit('finished', 'completed')],
|
||||
count: 1,
|
||||
view: 'settled',
|
||||
readOnly: false,
|
||||
}),
|
||||
);
|
||||
renderActions();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
expect(await screen.findByRole('button', { name: /docker:container:finished/ })).toBeVisible();
|
||||
|
||||
open.resolve({ actions: [audit('edge-a')], count: 1, view: 'pending', readOnly: false });
|
||||
await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('button', { name: /docker:container:finished/ })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /docker:container:edge-a/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ export function Actions() {
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [readOnly, setReadOnly] = createSignal(false);
|
||||
const [patrolWatchOnly, setPatrolWatchOnly] = createSignal(false);
|
||||
let listRequestGeneration = 0;
|
||||
let detailRequestGeneration = 0;
|
||||
|
||||
// The Open tab is where users land wondering why nothing is queued. When
|
||||
// Patrol runs Watch only it never proposes fixes, so the calm state must say
|
||||
@@ -77,18 +79,22 @@ export function Actions() {
|
||||
);
|
||||
|
||||
const loadActions = async () => {
|
||||
const generation = ++listRequestGeneration;
|
||||
const requestedView = view();
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const response = await ResourceActionsAPI.listActions(view());
|
||||
const response = await ResourceActionsAPI.listActions(requestedView);
|
||||
if (generation !== listRequestGeneration) return;
|
||||
setActions(response.actions);
|
||||
setReadOnly(response.readOnly === true);
|
||||
} catch (cause) {
|
||||
if (generation !== listRequestGeneration) return;
|
||||
setActions([]);
|
||||
setReadOnly(false);
|
||||
setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (generation === listRequestGeneration) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,14 +114,26 @@ export function Actions() {
|
||||
view() === 'pending' ? sortOpenActionsForReview(actions()) : actions(),
|
||||
);
|
||||
|
||||
const openActionById = async (actionId: string) => {
|
||||
setDetailError('');
|
||||
const openActionById = async (actionId: string, generation: number) => {
|
||||
try {
|
||||
const detail = await ResourceActionsAPI.getAction(actionId);
|
||||
if (
|
||||
generation !== detailRequestGeneration ||
|
||||
parseActionReviewId(location.search) !== actionId
|
||||
)
|
||||
return;
|
||||
if (detail.audit.id !== actionId) {
|
||||
setDetailError('Action details did not match the selected action. Open it again.');
|
||||
return;
|
||||
}
|
||||
setSelected(detail);
|
||||
setView(getInboxViewForState(detail.audit.state));
|
||||
} catch (cause) {
|
||||
setSelected(null);
|
||||
if (
|
||||
generation !== detailRequestGeneration ||
|
||||
parseActionReviewId(location.search) !== actionId
|
||||
)
|
||||
return;
|
||||
setDetailError(cause instanceof Error ? cause.message : 'Action details are unavailable.');
|
||||
}
|
||||
};
|
||||
@@ -125,6 +143,7 @@ export function Actions() {
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
++detailRequestGeneration;
|
||||
setSelected(null);
|
||||
setDetailError('');
|
||||
setSearchParams({ [ACTION_REVIEW_QUERY_PARAM]: null }, { replace: true });
|
||||
@@ -137,7 +156,10 @@ export function Actions() {
|
||||
|
||||
createEffect(() => {
|
||||
const actionId = parseActionReviewId(location.search);
|
||||
if (actionId) void openActionById(actionId);
|
||||
const generation = ++detailRequestGeneration;
|
||||
setSelected(null);
|
||||
setDetailError('');
|
||||
if (actionId) void openActionById(actionId, generation);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -330,9 +352,17 @@ export function Actions() {
|
||||
detail={selected()}
|
||||
onClose={closeAction}
|
||||
onChanged={async (detail) => {
|
||||
// A read or mutation started in an earlier review must not replace
|
||||
// the action currently selected by the URL (or reopen a closed one).
|
||||
if (
|
||||
selected()?.audit.id !== detail.audit.id ||
|
||||
parseActionReviewId(location.search) !== detail.audit.id
|
||||
)
|
||||
return;
|
||||
setSelected(detail);
|
||||
setView(getInboxViewForState(detail.audit.state));
|
||||
await loadActions();
|
||||
const nextView = getInboxViewForState(detail.audit.state);
|
||||
if (view() === nextView) await loadActions();
|
||||
else setView(nextView); // the view effect loads the new list once
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +235,113 @@ func TestInfrastructureIncidentSynthesisShowsPartialRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedSymptomNotifiesWhenPrimaryRecoversButSymptomPersists(t *testing.T) {
|
||||
manager := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig())
|
||||
startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
|
||||
hostID := "agent:edge-1"
|
||||
endpointID := "availability:checkout"
|
||||
host := unifiedresources.Resource{
|
||||
ID: hostID, Type: unifiedresources.ResourceTypeAgent, Name: "Edge host", Status: unifiedresources.StatusOffline,
|
||||
Incidents: []unifiedresources.ResourceIncident{{
|
||||
Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical,
|
||||
Source: "agent.heartbeat", Summary: "Edge host is offline", StartedAt: startedAt,
|
||||
ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
|
||||
}},
|
||||
}
|
||||
endpoint := unifiedresources.Resource{
|
||||
ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Name: "Checkout", Status: unifiedresources.StatusOffline,
|
||||
Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"},
|
||||
Relationships: []unifiedresources.ResourceRelationship{{
|
||||
SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true,
|
||||
}},
|
||||
Incidents: []unifiedresources.ResourceIncident{{
|
||||
Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical,
|
||||
Source: "availability.probe", Summary: "Checkout is unreachable", StartedAt: startedAt.Add(time.Second),
|
||||
ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
|
||||
}},
|
||||
}
|
||||
var delivered []string
|
||||
manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) })
|
||||
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 1 || delivered[0] != hostID {
|
||||
t.Fatalf("grouped delivery = %v, want only the primary host", delivered)
|
||||
}
|
||||
for _, alert := range manager.GetActiveAlerts() {
|
||||
if alert.ResourceID == endpointID && (!isSupportedInfrastructureSymptom(&alert) || alert.LastNotified != nil) {
|
||||
t.Fatalf("grouped symptom = %+v, want supported and not separately dispatched", alert)
|
||||
}
|
||||
}
|
||||
|
||||
// The host is healthy again, but the endpoint still fails. It must regain
|
||||
// its own notification path without requiring a new alert occurrence.
|
||||
host.Status = unifiedresources.StatusOnline
|
||||
host.Incidents = nil
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 2 || delivered[1] != endpointID {
|
||||
t.Fatalf("after primary recovery delivery = %v, want continuing symptom", delivered)
|
||||
}
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 2 {
|
||||
t.Fatalf("unchanged independent symptom sent again: %v", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviouslyNotifiedSymptomDoesNotDuplicateWhenPrimaryRecovers(t *testing.T) {
|
||||
manager := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig())
|
||||
startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
|
||||
hostID := "agent:edge-1"
|
||||
endpointID := "availability:checkout"
|
||||
host := unifiedresources.Resource{
|
||||
ID: hostID, Type: unifiedresources.ResourceTypeAgent, Status: unifiedresources.StatusOnline,
|
||||
}
|
||||
endpoint := unifiedresources.Resource{
|
||||
ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Status: unifiedresources.StatusOffline,
|
||||
Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"},
|
||||
Relationships: []unifiedresources.ResourceRelationship{{
|
||||
SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true,
|
||||
}},
|
||||
Incidents: []unifiedresources.ResourceIncident{{
|
||||
Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical,
|
||||
Source: "availability.probe", StartedAt: startedAt.Add(time.Second), ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
|
||||
}},
|
||||
}
|
||||
var delivered []string
|
||||
manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) })
|
||||
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 1 || delivered[0] != endpointID {
|
||||
t.Fatalf("standalone symptom delivery = %v, want endpoint", delivered)
|
||||
}
|
||||
host.Status = unifiedresources.StatusOffline
|
||||
host.Incidents = []unifiedresources.ResourceIncident{{
|
||||
Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical,
|
||||
Source: "agent.heartbeat", StartedAt: startedAt, ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
|
||||
}}
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 2 || delivered[1] != hostID {
|
||||
t.Fatalf("new primary delivery = %v, want endpoint then host", delivered)
|
||||
}
|
||||
var grouped bool
|
||||
for _, alert := range manager.GetActiveAlerts() {
|
||||
if alert.ResourceID == endpointID {
|
||||
grouped = isSupportedInfrastructureSymptom(&alert)
|
||||
}
|
||||
}
|
||||
if !grouped {
|
||||
t.Fatal("endpoint did not gain a supported primary")
|
||||
}
|
||||
|
||||
host.Status = unifiedresources.StatusOnline
|
||||
host.Incidents = nil
|
||||
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
|
||||
if len(delivered) != 2 {
|
||||
t.Fatalf("already-notified endpoint duplicated after primary recovery: %v", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyIncidentFailureSeparatesApplicationCertificateAndCoverage(t *testing.T) {
|
||||
applicationResource := unifiedresources.Resource{Availability: &unifiedresources.AvailabilityData{
|
||||
AggregateState: "unavailable", TransportOutcome: "reachable", ApplicationOutcome: "failed",
|
||||
|
||||
@@ -260,6 +260,17 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
|
||||
m.clearAlertNoLock(storageKey)
|
||||
}
|
||||
|
||||
// Synthesis replaces its previous links in place. Remember which retained
|
||||
// incidents had a primary delivery owner before that information is cleared.
|
||||
// If the primary recovers while a symptom remains active, the symptom needs
|
||||
// its own first notification even though its occurrence did not restart.
|
||||
previouslySupported := make(map[string]bool)
|
||||
for storageKey := range desired {
|
||||
if existing, exists := m.getActiveAlertNoLock(storageKey); exists && isSupportedInfrastructureSymptom(existing) {
|
||||
previouslySupported[storageKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize over the post-recovery live set before updating or dispatching
|
||||
// any detector. Supporting symptoms retain their independent lifecycle but
|
||||
// can share one evidence-backed incident presentation and delivery decision.
|
||||
@@ -297,12 +308,19 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
|
||||
if !m.setActiveAlertNoLock(storageKey, existing) {
|
||||
continue
|
||||
}
|
||||
// Like metric alerts, an existing provider incident becoming critical
|
||||
// must notify again. Keep its lifecycle and all delivery policy gates;
|
||||
// unchanged severity and downgrades must not create notification noise.
|
||||
if oldLevel != existing.Level && existing.Level == AlertLevelCritical &&
|
||||
!existing.Acknowledged &&
|
||||
m.allowNotificationByRateLimit(storageKey, existing, "critical-escalation") {
|
||||
// A still-failing symptom which loses its supported primary needs its
|
||||
// first notification. An alert already dispatched before grouping must
|
||||
// not be duplicated when the group dissolves. As with metric alerts, a
|
||||
// separate increase to critical may also notify through normal gates.
|
||||
newlyIndependent := previouslySupported[storageKey] &&
|
||||
!isSupportedInfrastructureSymptom(existing) && existing.LastNotified == nil
|
||||
becameCritical := oldLevel != existing.Level && existing.Level == AlertLevelCritical
|
||||
notificationReason := "critical-escalation"
|
||||
if newlyIndependent {
|
||||
notificationReason = "supported-primary-ended"
|
||||
}
|
||||
if (newlyIndependent || becameCritical) && !existing.Acknowledged &&
|
||||
m.allowNotificationByRateLimit(storageKey, existing, notificationReason) {
|
||||
m.dispatchAlert(existing, false)
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -4,12 +4,34 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestProviderMSPGoBuilderMatchesIntegrationMock(t *testing.T) {
|
||||
// These builders use the same Go patch level and immutable image. The
|
||||
// mock's build must not exercise a different compiler from the hosted
|
||||
// control plane merely because Dependabot advanced one Dockerfile.
|
||||
const expected = "golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
|
||||
pattern := regexp.MustCompile(`(?m)^FROM (?:--platform=\$BUILDPLATFORM )?(golang:[^[:space:]]+) AS builder$`)
|
||||
for _, path := range [][]string{
|
||||
{"deploy", "provider-msp", "Dockerfile.control-plane"},
|
||||
{"tests", "integration", "mock-github-server", "Dockerfile"},
|
||||
} {
|
||||
content, err := os.ReadFile(repoFile(path...))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", filepath.Join(path...), err)
|
||||
}
|
||||
match := pattern.FindStringSubmatch(string(content))
|
||||
if len(match) != 2 || match[1] != expected {
|
||||
t.Errorf("%s Go builder = %q, want %q", filepath.Join(path...), match, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPControlPlaneImageConsumesExactCandidate(t *testing.T) {
|
||||
dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder
|
||||
FROM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY go.mod ./
|
||||
|
||||
Reference in New Issue
Block a user