Guard Actions review against stale responses

Keep route-selected action details and inbox tabs bound to their latest reads so delayed replies cannot replace a newer review or reopen one after close. Preserve server-authored identity and prove the overlap in focused tests and responsive browser navigation.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-29 20:52:24 +01:00
parent fbf17b271a
commit ba298b29e2
8 changed files with 496 additions and 28 deletions

View file

@ -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.

View file

@ -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

View file

@ -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;
});

View file

@ -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>

View file

@ -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')!,
);

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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>