diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 83a8a073de..975c62f408 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-tests/resource-evidence-access.cjs b/frontend-modern/browser-tests/resource-evidence-access.cjs new file mode 100644 index 0000000000..55a01c1fdc --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.cjs @@ -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; }); diff --git a/frontend-modern/browser-tests/resource-evidence-access.html b/frontend-modern/browser-tests/resource-evidence-access.html new file mode 100644 index 0000000000..6198edc8f8 --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.html @@ -0,0 +1,2 @@ + +Resource evidence access verification
diff --git a/frontend-modern/browser-tests/resource-evidence-access.tsx b/frontend-modern/browser-tests/resource-evidence-access.tsx new file mode 100644 index 0000000000..e0f39cb4bd --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.tsx @@ -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( + () => ( + true } as any}> + document.documentElement.classList.contains('dark')}> +
+

Resource evidence access verification

+

+ Synthetic PBS evidence; no appliance, release or installed authorisation acceptance. +

+ + + + +
+
+
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 353d9f0ce2..bd2176689c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx index a14fc47b69..f69839590b 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx @@ -215,7 +215,10 @@ export const ResourceActionHistory: Component = (pro -
+