mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
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:
parent
fbf17b271a
commit
ba298b29e2
8 changed files with 496 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
182
frontend-modern/browser-tests/action-review-navigation.cjs
Normal file
182
frontend-modern/browser-tests/action-review-navigation.cjs
Normal 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;
|
||||
});
|
||||
11
frontend-modern/browser-tests/action-review-navigation.html
Normal file
11
frontend-modern/browser-tests/action-review-navigation.html
Normal 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>
|
||||
14
frontend-modern/browser-tests/action-review-navigation.tsx
Normal file
14
frontend-modern/browser-tests/action-review-navigation.tsx
Normal 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')!,
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue