mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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')!,
|
||||
);
|
||||
@@ -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">
|
||||
|
||||
+64
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+166
@@ -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();
|
||||
});
|
||||
});
|
||||
+55
-18
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user