Keep denied resource evidence out of drawer fallbacks

Final facet access failures must not re-display embedded or unfiltered history. Retain transient snapshots, and show failed action reads with an available retry rather than false empty/loaded states. Preserve the existing unavailable-plan client boundary. Add rejecting parent controls and desktop/touch browser proof.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 05:13:32 +01:00
parent 227657b1b0
commit 6e5acd9c02
11 changed files with 514 additions and 58 deletions
@@ -23,6 +23,26 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Resource evidence fallback access boundaries
When the existing resource-facet reader receives a final HTTP 401/403, its
drawer must not substitute embedded snapshot events, counts, capabilities or
relationships for the withdrawn response. A denied filtered read must not
substitute the unfiltered bundle. Denial remains visible during that request's
retry, and fresh success restores its evidence. Independently successful
unfiltered reads retain their own ownership; this is not a global revocation
broadcast. Transient failures still retain applicable snapshots, and compact
snapshot-only rows still make no remote history reads.
Failed change/action reads are unavailable, not “loaded” or empty. The Manage
tab keeps a failed action read's retry visible even without retained rows,
without changing the action client's existing unavailable-plan responses.
The hook access tests and mounted ResourceDetailDrawer.history tests protect
these boundaries; browser-tests/resource-evidence-access.cjs exercises the
production drawer/client/CSS on desktop Chromium and dark touch WebKit. Its
synthetic HTTP responses do not establish native authorisation, a reporter's
cause, installed recovery or release availability.
### Detail snapshot and selection ownership
ResourceDetailDrawer reads the current resource through a reactive getter in
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const parent = false;
(async () => {
const root = parent ? '/workspace/tmp/resource-access-parent/frontend-modern' : '/workspace/frontend-modern';
const artifacts = `/workspace/tmp/resource-evidence-${parent ? 'parent' : 'final'}-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'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5267, strictPort: true, fs: { allow: ['/workspace'] } } });
const playwright = require('playwright/package.json').version;
assert.equal(playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [['chromium-desktop', chromium, false], ['webkit-phone', webkit, true]]) {
browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
const page = await browser.newPage({ viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, hasTouch: phone, isMobile: phone });
page.setDefaultTimeout(60000);
let facetMode = 'success', actionMode = 'success', actor = 'Authorised response actor';
const requests = [], errors = [], offOrigin = [], checks = [], held = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5267') { offOrigin.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname.endsWith('/facets')) {
const mode = facetMode;
requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), mode });
if (mode === 'hold') await new Promise(resolve => held.push(resolve));
if (mode === '403' || mode === '503') return route.fulfill({ status: Number(mode), json: { error: 'Fixture service unavailable' } });
return route.fulfill({ json: { capabilities: [], relationships: [], counts: { recentChanges: 1 }, recentChanges: [{ id: 'remote-event', resourceId: 'pbs-a', observedAt: '2026-10-03T03:00:00Z', kind: 'restart', sourceType: 'platform_event', confidence: 'high', actor }] } });
}
if (url.pathname === '/api/audit/actions') {
requests.push({ path: url.pathname, mode: actionMode });
return actionMode === '503' ? route.fulfill({ status: 503, json: { error: 'Fixture actions unavailable' } }) : route.fulfill({ json: { audits: [], count: 0 } });
}
return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { capabilities: [], relationships: [], recentChanges: [], audits: [], count: 0, data: [], enabled: false } });
});
const record = (name, pass) => { checks.push({ name, pass }); };
const screenshot = state => page.screenshot({ path: path.join(artifacts, `${name}-${state}.png`), fullPage: true });
const changes = page.getByTestId('resource-change-history-section');
const actions = page.getByTestId('resource-action-history-section');
await page.goto('http://127.0.0.1:5267/browser-tests/resource-evidence-access.html', { waitUntil: 'domcontentloaded', timeout: 120000 });
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
await changes.getByText('Authorised response actor', { exact: true }).waitFor();
record('successful remote evidence replaces snapshot', !(await changes.getByText('Embedded snapshot actor').count()));
await screenshot('loaded');
facetMode = '403'; actionMode = '503';
await page.getByRole('button', { name: 'Remount details' }).click();
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
await page.waitForTimeout(500);
record('denial withdraws embedded evidence', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
record('bounded denial guidance, not empty history', (await changes.getByText('Access denied. Check your permissions and license plan.', { exact: true }).count()) === 1 && !(await changes.getByText('No events yet.', { exact: true }).count()));
record('denial is labelled unavailable', (await changes.getByText('Changes unavailable', { exact: true }).count()) === 1);
await page.getByRole('tab', { name: 'Manage', exact: true }).click();
record('failed first action read has visible retry, not empty history', (await actions.count()) === 1 && (await actions.getByRole('button', { name: 'Retry', exact: true }).count()) === 1 && !(await actions.getByText('No actions yet.', { exact: true }).count()));
await screenshot('action-failed');
await page.getByRole('tab', { name: 'Overview', exact: true }).click();
await screenshot('denied');
facetMode = 'hold';
await changes.getByRole('button', { name: 'Retry', exact: true }).focus();
await changes.getByRole('button', { name: 'Retry', exact: true }).press('Enter');
await changes.getByText('Refreshing changes...', { exact: true }).waitFor();
record('retry keeps denied evidence withdrawn', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count()));
await screenshot('retry-pending');
actor = 'Fresh response actor'; facetMode = 'success';
held.splice(0).forEach(resolve => resolve());
await changes.getByText('Fresh response actor', { exact: true }).waitFor();
record('fresh successful retry restores evidence and clears guidance', !(await changes.getByText('Access denied. Check your permissions and license plan.').count()));
await screenshot('recovered');
await changes.getByRole('button', { name: 'Filter history', exact: true }).click();
facetMode = '403';
await changes.getByLabel('Change kind', { exact: true }).selectOption('restart');
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
record('filtered denial cannot substitute authorised unfiltered events', !(await changes.getByText('Fresh response actor').count()) && (await changes.getByText('Filtered changes unavailable', { exact: true }).count()) === 1);
await screenshot('filtered-denied');
await changes.getByRole('button', { name: 'Clear filters', exact: true }).click();
await changes.getByText('Fresh response actor', { exact: true }).waitFor();
record('clearing filter restores independently authorised base request', true);
facetMode = '503';
await page.getByRole('button', { name: 'Remount details' }).click();
await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor();
record('transient failure preserves embedded snapshot evidence', (await changes.getByText('Embedded snapshot actor', { exact: true }).count()) === 1);
await screenshot('transient');
record('no page errors or off-origin requests', errors.length === 0 && offOrigin.length === 0);
observations.push({ name, browser: browser.version(), viewport: page.viewportSize(), requests, errors, offOrigin, checks });
await browser.close(); browser = null;
}
} finally {
if (browser) await browser.close();
await server.close();
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify({ parent, playwright, observations }, null, 2));
}
console.log(JSON.stringify({ parent, checks: observations.flatMap(o => o.checks).length, failures: observations.flatMap(o => o.checks).filter(c => !c.pass) }));
if (!parent) assert.ok(observations.flatMap(o => o.checks).every(c => c.pass));
})().catch(error => { console.error(error.stack); process.exitCode = 1; });
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Resource evidence access verification</title></head><body><div id="root"></div><script type="module" src="/browser-tests/resource-evidence-access.tsx"></script></body></html>
@@ -0,0 +1,62 @@
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
// Synthetic snapshots and HTTP responses; production drawer, query/client and CSS.
syncAIRuntimeSettings({ discovery_enabled: false });
const resource: Resource = {
id: 'pbs-a',
name: 'PBS A',
displayName: 'PBS A',
type: 'pbs',
platformId: 'pbs-a',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: 'online',
lastSeen: Date.now(),
cpu: { current: 12 },
recentChanges: [
{
id: 'embedded-event',
resourceId: 'pbs-a',
observedAt: '2026-10-03T03:00:00Z',
kind: 'restart',
sourceType: 'platform_event',
confidence: 'high',
actor: 'Embedded snapshot actor',
},
],
facetCounts: { recentChanges: 7 },
};
const [mounted, setMounted] = createSignal(true);
render(
() => (
<WebSocketContext.Provider value={{ state: { pmg: [] }, connected: () => true } as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Resource evidence access verification</h1>
<p>
Synthetic PBS evidence; no appliance, release or installed authorisation acceptance.
</p>
<button
class="min-h-11 rounded border border-border px-3"
onClick={() => {
setMounted(false);
setTimeout(() => setMounted(true), 0);
}}
>
Remount details
</button>
<Show when={mounted()}>
<ResourceDetailDrawer resource={resource} />
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
),
document.getElementById('root')!,
);
+26 -30
View File
@@ -1,25 +1,23 @@
{
"version": 1,
"result": "passed",
"base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287",
"verified_at": "2026-10-03T01:44:27.336168Z",
"base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
"verified_at": "2026-10-03T04:13:25.499572Z",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts",
"frontend-modern/src/utils/apiAccessError.ts"
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb",
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7",
"frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730"
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
},
"routes": [
"Existing PBS/Machine/resource drawer History batch reader",
"Existing Storage physical disk shared History reader"
"Existing PBS/resource detail Overview change history",
"Existing resource detail Manage action history"
],
"viewports": [
{
@@ -32,25 +30,23 @@
}
],
"states": [
"Real same-origin 503 responses retain stored samples with explicit warnings in both production readers",
"Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends",
"Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow",
"The existing focusable scoped retry remains mounted and ignores duplicate activation while busy",
"Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending",
"Only a new successful read restores History and clears denial; old values do not return",
"Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests"
"Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
"Denied filtered facets do not fall back to the unfiltered bundle",
"Retry retains denial while pending; fresh successful response restores events and clears guidance",
"First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
"Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
"All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
],
"interactions": [
"Inspect by desktop keyboard and phone tap",
"Poll through 503 then 403",
"Retry twice while busy",
"Change 24h/1h range",
"Unmount and remount batch History",
"Resume with newly successful observations"
"Remount full production resource drawer with denied/transient reads",
"Activate scoped Retry with keyboard Enter",
"Apply and clear Change kind filter",
"Switch between Overview and Manage"
],
"limitations": [
"Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.",
"401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.",
"Independently supplied current readings remain distinctly current during a new pending query; they are not stored History."
"Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
"401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
"Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
"Independent reader authorisation remains independent; this is not a global session revocation broadcast."
]
}
@@ -215,7 +215,10 @@ export const ResourceActionHistory: Component<ResourceActionHistoryProps> = (pro
</div>
<Show when={props.error}>
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
<div
role="alert"
class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200"
>
<div class="flex items-start justify-between gap-2">
<span>{props.error}</span>
<button
@@ -232,9 +235,11 @@ export const ResourceActionHistory: Component<ResourceActionHistoryProps> = (pro
<Show
when={props.audits.length > 0}
fallback={
<div class="mt-3 rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No actions yet.
</div>
<Show when={!props.error}>
<div class="mt-3 rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No actions yet.
</div>
</Show>
}
>
<div class="mt-3 space-y-2">
@@ -219,7 +219,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
<Show when={presentation() !== 'table-row'}>
<MaintenanceVerificationSection resourceId={props.resource.id} />
</Show>
<Show when={drawer.actionAuditAvailable()}>
<Show when={drawer.actionAuditAvailable() || drawer.actionAuditError()}>
<ResourceActionHistory
audits={drawer.sortedActionAudits()}
count={drawer.actionAuditCount()}
@@ -526,7 +526,10 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</Show>
<Show when={drawer.facetBundleError()}>
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
<div
role="alert"
class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200"
>
<div class="flex items-start justify-between gap-2">
<span>{drawer.facetBundleError()}</span>
<button
@@ -543,9 +546,11 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<Show
when={drawer.sortedResourceTimeline().length > 0}
fallback={
<div class="mt-3 rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No events yet.
</div>
<Show when={!drawer.facetBundleError()}>
<div class="mt-3 rounded border border-dashed border-border bg-surface-hover px-2 py-2 text-[10px] text-muted">
No events yet.
</div>
</Show>
}
>
<div class="mt-3 space-y-2">
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render as solidRender, screen, within } from '@solidjs/testing-library';
import {
fireEvent,
render as solidRender,
screen,
within,
waitFor,
} from '@solidjs/testing-library';
import { Suspense } from 'solid-js';
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
@@ -1404,4 +1410,61 @@ describe('ResourceDetailDrawer change history section', () => {
expect(await panel.findByText('CPU spike detected')).toBeInTheDocument();
expect(panel.queryByText('Routine restart requested')).toBeNull();
});
it('does not re-display embedded changes after a denied facet read', async () => {
facetBundleMock.getFacetBundle.mockRejectedValueOnce(
Object.assign(new Error('private response detail'), { status: 403 }),
);
render(() => (
<ResourceDetailDrawer
resource={baseResource({
recentChanges: [
{
id: 'embedded',
resourceId: 'resource-1',
observedAt: '2026-10-03T03:00:00Z',
kind: 'restart',
sourceType: 'platform_event',
confidence: 'high',
actor: 'Embedded snapshot actor',
},
],
facetCounts: { recentChanges: 7 },
})}
/>
));
const history = screen.getByTestId('resource-change-history-section');
await waitFor(() =>
expect(within(history).getByRole('alert')).toHaveTextContent(
'Access denied. Check your permissions and license plan.',
),
);
expect(history).not.toHaveTextContent('Embedded snapshot actor');
expect(history).not.toHaveTextContent('No events yet.');
expect(history).not.toHaveTextContent('Changes loaded');
expect(within(history).getByRole('button', { name: /^Retry$/ })).toBeInTheDocument();
});
it('keeps a failed first action read and retry visible in Manage', async () => {
actionAuditMock.listActionAudits.mockRejectedValueOnce(
Object.assign(new Error('Actions temporarily unavailable'), { status: 503 }),
);
render(() => <ResourceDetailDrawer resource={baseResource({})} />);
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
await waitFor(() =>
expect(screen.getByTestId('resource-action-history-section')).toHaveTextContent(
'Actions temporarily unavailable',
),
);
const actions = screen.getByTestId('resource-action-history-section');
expect(actions).toHaveTextContent('Actions unavailable');
expect(actions).not.toHaveTextContent('No actions yet.');
actionAuditMock.listActionAudits.mockResolvedValueOnce({
audits: [],
count: 0,
available: true,
});
fireEvent.click(within(actions).getByRole('button', { name: /^Retry$/ }));
await waitFor(() => expect(actions).toHaveTextContent('No actions yet.'));
expect(actions).not.toHaveTextContent('Actions temporarily unavailable');
});
});
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { useResourceDetailDrawerHistoryState } from '../useResourceDetailDrawerHistoryState';
import type { Resource, ResourceChange } from '@/types/resource';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
const api = vi.hoisted(() => ({ facets: vi.fn(), intelligence: vi.fn(), actions: vi.fn() }));
vi.mock('@/api/resources', () => ({ ResourceAPI: { getFacetBundle: api.facets } }));
vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: api.intelligence } }));
vi.mock('@/api/actionAudit', () => ({ ActionAuditAPI: { listActionAudits: api.actions } }));
const change = (id: string): ResourceChange => ({
id,
resourceId: 'pbs-a',
observedAt: '2026-10-03T03:00:00Z',
kind: 'restart',
sourceType: 'platform_event',
confidence: 'high',
});
const resource: Resource = {
id: 'pbs-a',
name: 'PBS A',
displayName: 'PBS A',
type: 'pbs',
platformId: 'pbs-a',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: 'online',
lastSeen: 1,
recentChanges: [change('embedded-event')],
facetCounts: { recentChanges: 7 },
};
const bundle = (id = 'remote-event') => ({
recentChanges: [change(id)],
counts: { recentChanges: 1 },
});
const failure = (status: number) =>
Object.assign(new Error('untrusted response detail'), { status });
function Probe(props: { remote?: boolean }) {
const state = useResourceDetailDrawerHistoryState({
resource,
enableRemoteHistory: props.remote,
});
return (
<>
<output data-testid="history-state">
{JSON.stringify({
ids: state.historyRecentChanges().map((event) => event.id),
count: state.resourceTimelineCount(),
error: state.facetBundleError(),
label: state.historyLoadingLabel(),
actionsError: state.actionAuditError(),
actionsLabel: state.actionAuditLoadingLabel(),
available: state.actionAuditAvailable(),
})}
</output>
<button onClick={() => void state.refetchHistoryFacets()}>Refresh changes</button>
<button onClick={() => state.setTimelineKindFilter('restart')}>Filter changes</button>
<button onClick={() => state.setTimelineKindFilter('')}>Clear changes filter</button>
<button onClick={() => void state.refetchActionAudits()}>Refresh actions</button>
</>
);
}
const read = () => JSON.parse(screen.getByTestId('history-state').textContent!);
beforeEach(() => {
api.facets.mockReset().mockResolvedValue(bundle());
api.intelligence.mockReset().mockResolvedValue(null);
api.actions.mockReset().mockResolvedValue({ audits: [], count: 0, available: false });
});
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
});
describe('resource evidence access boundaries', () => {
it.each([401, 403])('does not substitute embedded events after final HTTP %s', async (status) => {
render(() => <Probe />);
await waitFor(() => expect(read().ids).toEqual(['remote-event']));
api.facets.mockRejectedValueOnce(failure(status));
fireEvent.click(screen.getByText('Refresh changes'));
await waitFor(() => expect(read().error).not.toBe(''));
expect(read().ids).toEqual([]);
expect(read().count).toBe(0);
expect(read().error).toBe(
status === 401
? 'Sign in again or check your API token.'
: 'Access denied. Check your permissions and license plan.',
);
expect(read().label).toBe('Changes unavailable');
});
it('withdraws embedded events when the initial read is denied', async () => {
api.facets.mockRejectedValueOnce(failure(403));
render(() => <Probe />);
await waitFor(() => expect(read().error).not.toBe(''));
expect(read().ids).toEqual([]);
expect(read().count).toBe(0);
});
it('does not substitute an unfiltered bundle after a filtered access denial', async () => {
render(() => <Probe />);
await waitFor(() => expect(read().ids).toEqual(['remote-event']));
api.facets.mockRejectedValueOnce(failure(403));
fireEvent.click(screen.getByText('Filter changes'));
await waitFor(() => expect(read().error).not.toBe(''));
expect(read().ids).toEqual([]);
expect(read().count).toBe(0);
// The unfiltered request retains its own successful authorisation.
fireEvent.click(screen.getByText('Clear changes filter'));
await waitFor(() => expect(read().ids).toEqual(['remote-event']));
});
it('keeps denied evidence withdrawn during retry, then accepts a fresh success', async () => {
render(() => <Probe />);
await waitFor(() => expect(read().ids).toEqual(['remote-event']));
api.facets.mockRejectedValueOnce(failure(403));
fireEvent.click(screen.getByText('Refresh changes'));
await waitFor(() => expect(read().error).not.toBe(''));
let resolve!: (value: ReturnType<typeof bundle>) => void;
api.facets.mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done;
}),
);
fireEvent.click(screen.getByText('Refresh changes'));
await waitFor(() => expect(read().label).toBe('Refreshing changes...'));
expect(read().ids).toEqual([]);
resolve(bundle('fresh-event'));
await waitFor(() => expect(read().ids).toEqual(['fresh-event']));
expect(read().error).toBe('');
});
it.each([503])(
'exposes an action read failure even without retained actions: %s',
async (status) => {
render(() => <Probe />);
await waitFor(() => expect(read().actionsLabel).toBe('Actions loaded'));
api.actions.mockRejectedValueOnce(failure(status));
fireEvent.click(screen.getByText('Refresh actions'));
await waitFor(() => expect(read().actionsError).not.toBe(''));
expect(read().actionsLabel).toBe('Actions unavailable');
if (status === 401 || status === 403) expect(read().actionsError).not.toContain('untrusted');
expect(read().available).toBe(false);
},
);
it('retains a successful remote snapshot after a transient outage', async () => {
render(() => <Probe />);
await waitFor(() => expect(read().ids).toEqual(['remote-event']));
api.facets.mockRejectedValueOnce(failure(503));
fireEvent.click(screen.getByText('Refresh changes'));
await waitFor(() => expect(read().error).not.toBe(''));
expect(read().ids).toEqual(['remote-event']);
expect(read().count).toBe(1);
});
it('keeps snapshot-only embedded history without making remote reads', async () => {
render(() => <Probe remote={false} />);
expect(read().ids).toEqual(['embedded-event']);
expect(read().count).toBe(7);
expect(api.facets).not.toHaveBeenCalled();
expect(api.actions).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,7 @@ import { ActionAuditAPI } from '@/api/actionAudit';
import { ResourceAPI } from '@/api/resources';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import type { ActionAuditListResponse } from '@/types/actionAudit';
import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError';
interface UseResourceDetailDrawerHistoryStateOptions {
resource: Resource;
@@ -123,26 +124,44 @@ export const useResourceDetailDrawerHistoryState = (
const timelineFacets = timelineFacetsState.value;
const refetchTimelineFacets = timelineFacetsState.refetch;
const resourceTimeline = createMemo(
() => resourceFacets()?.recentChanges ?? resource().recentChanges ?? [],
const resourceFacetsAccessError = createMemo(() =>
getAPIReadAccessErrorMessage(resourceFacetsState.error()),
);
const resourceFacetCapabilities = createMemo<readonly ResourceCapability[]>(
() => resourceFacets()?.capabilities ?? resource().capabilities ?? [],
const timelineFacetsAccessError = createMemo(() =>
timelineFacetRequest() ? getAPIReadAccessErrorMessage(timelineFacetsState.error()) : null,
);
const resourceFacetRelationships = createMemo<readonly ResourceRelationship[]>(
() => resourceFacets()?.relationships ?? resource().relationships ?? [],
// The query owner withdraws its value on denial. Snapshot/unfiltered
// fallbacks must not put that evidence back under the denied request.
const resourceTimeline = createMemo(() =>
resourceFacetsAccessError()
? []
: (resourceFacets()?.recentChanges ?? resource().recentChanges ?? []),
);
const resourceFacetCounts = createMemo(
() => resourceFacets()?.counts ?? resource().facetCounts ?? null,
const resourceFacetCapabilities = createMemo<readonly ResourceCapability[]>(() =>
resourceFacetsAccessError()
? []
: (resourceFacets()?.capabilities ?? resource().capabilities ?? []),
);
const resourceFacetRelationships = createMemo<readonly ResourceRelationship[]>(() =>
resourceFacetsAccessError()
? []
: (resourceFacets()?.relationships ?? resource().relationships ?? []),
);
const resourceFacetCounts = createMemo(() =>
resourceFacetsAccessError()
? null
: (resourceFacets()?.counts ?? resource().facetCounts ?? null),
);
const historyFacetBundle = createMemo(() =>
timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(),
);
const historyFacetCounts = createMemo(
() => historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null,
const historyFacetCounts = createMemo(() =>
timelineFacetsAccessError()
? null
: (historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null),
);
const historyRecentChanges = createMemo(
() => historyFacetBundle()?.recentChanges ?? resourceTimeline(),
const historyRecentChanges = createMemo(() =>
timelineFacetsAccessError() ? [] : (historyFacetBundle()?.recentChanges ?? resourceTimeline()),
);
const historyTimeline = createMemo(() => historyRecentChanges());
const hasTimelineFilters = createMemo(() =>
@@ -152,9 +171,15 @@ export const useResourceDetailDrawerHistoryState = (
if (timelineFacetRequest()) {
return timelineFacetsState.loading()
? 'Refreshing filtered changes...'
: 'Filtered changes loaded';
: timelineFacetsState.error()
? 'Filtered changes unavailable'
: 'Filtered changes loaded';
}
return resourceFacetsState.loading() ? 'Refreshing changes...' : 'Changes loaded';
return resourceFacetsState.loading()
? 'Refreshing changes...'
: resourceFacetsState.error()
? 'Changes unavailable'
: 'Changes loaded';
});
const resourceTimelineCount = createMemo(
() => historyFacetCounts()?.recentChanges ?? historyRecentChanges().length,
@@ -176,7 +201,11 @@ export const useResourceDetailDrawerHistoryState = (
Boolean(actionAuditResponse().available || resourceActionAudits().length > 0),
);
const actionAuditLoadingLabel = createMemo(() =>
actionAuditState.loading() ? 'Refreshing actions...' : 'Actions loaded',
actionAuditState.loading()
? 'Refreshing actions...'
: actionAuditState.error()
? 'Actions unavailable'
: 'Actions loaded',
);
const sortedActionAudits = createMemo(() =>
[...resourceActionAudits()].sort((left, right) => {
@@ -192,12 +221,20 @@ export const useResourceDetailDrawerHistoryState = (
? timelineFacetsState.error()
: resourceFacetsState.error();
if (!error) return '';
return (error as Error)?.message || 'Failed to load resource history';
return (
getAPIReadAccessErrorMessage(error) ||
(error as Error)?.message ||
'Failed to load resource history'
);
});
const actionAuditError = createMemo(() => {
const error = actionAuditState.error();
if (!error || !actionAuditAvailable()) return '';
return (error as Error)?.message || 'Failed to load action history';
if (!error) return '';
return (
getAPIReadAccessErrorMessage(error) ||
(error as Error)?.message ||
'Failed to load action history'
);
});
const refetchHistoryFacets = () => {