From c54aa9e3e7f309e18c2b250959497295e1cb5608 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:13 +0100 Subject: [PATCH] Reject unreadable backup responses before publishing inventory Validate backup envelopes, source collections and consumed row fields without coercing completion flags, dropping bad rows or treating missing data as empty. Preserve independent evidence and explicit nil PVE collections, keep format errors private and retry only the failed source. Include parser and mounted regressions, subsystem contracts and inspected Chromium/WebKit final-content proof. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 11 + .../internal/subsystems/storage-recovery.md | 11 + .../internal/subsystems/unified-resources.md | 7 + .../browser-tests/backup-inventory-format.cjs | 278 ++++++++++++++++++ .../78c9d8427bdfa307.json | 53 ++++ .../features/proxmox/ProxmoxBackupsTable.tsx | 19 +- .../__tests__/ProxmoxBackupsTable.test.tsx | 73 +++++ .../__tests__/proxmoxBackupInventory.test.ts | 153 ++++++++++ .../proxmox/proxmoxBackupInventory.ts | 131 +++++++++ 9 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-tests/backup-inventory-format.cjs create mode 100644 frontend-modern/browser-verification/78c9d8427bdfa307.json create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupInventory.test.ts create mode 100644 frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index dd225b090f..0bf42b1a70 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -88,6 +88,17 @@ API context; late responses cannot repopulate old evidence. Disposal aborts reads and removes subscriptions. This uses the existing query lifecycle, without new inventory polling or a remount cache. +Successful HTTP responses still need a readable backup envelope, collections +and consumed row fields. Invalid JSON, missing data and malformed rows produce +a source-local unavailable state with fixed format guidance, never raw response +content, a fabricated empty inventory or a page-level rendering failure that +hides another source. No invalid rows are silently dropped, no completion flags +are coerced, and only a validated response clears the warning. Explicitly nil +PVE collections remain compatible with the existing Go encoding. +`proxmoxBackupInventory.test.ts`, mounted backup-table controls and +`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated +keyboard/touch recovery, not native backup or released availability. + Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending, partial/both-source failure, isolated retry, fulfilled empty counts, 401/403, organisation replacement, late response and disposal. Production component, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0d7f50f36c..3711fff084 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -41,6 +41,17 @@ disposal. `browser-tests/backup-inventory.cjs` uses production components, API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses; these controls are not native backup, permission, thaw or release acceptance. +An HTTP success alone does not establish readable recovery evidence. The client +validates the data envelope, source collections and consumed row fields before +publishing that inventory. Malformed or undecodable responses invalidate that +source atomically, not a silently truncated list or confirmed empty inventory. +PVE's explicitly nil Go collections remain valid empty observations; absent +fields are not nil collections. Optional unknown facts and additional fields +retain their existing meaning, and completion flags are never coerced. +`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and +`browser-tests/backup-inventory-format.cjs` cover this response boundary, source +isolation and recovery without extending native protection or thaw claims. + ### Backup identity, phone storage and History consent composition The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 19287752f7..98e6481a30 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -35,6 +35,13 @@ count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows and source notices on desktop/phone. This changes no resource identity, native provider assessment or permission policy. +The backup inventory count also remains unavailable when an HTTP200 response +fails the client backup-envelope/row validation; transport success is not an +observed zero. A malformed source cannot remove independently readable canonical +server health or the other source's artifacts. Fixed diagnostics expose no +response body. The inventory parser, mounted table and +`browser-tests/backup-inventory-format.cjs` exercise that combined boundary. + ### Guest disk freshness presentation Existing workload disk evidence retains its source reason through the row, diff --git a/frontend-modern/browser-tests/backup-inventory-format.cjs b/frontend-modern/browser-tests/backup-inventory-format.cjs new file mode 100644 index 0000000000..1d0fce2be9 --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory-format.cjs @@ -0,0 +1,278 @@ +// Production backup readers/table/router/CSS, synthetic HTTP only. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const phase = 'final'; +const root = '/workspace/frontend-modern'; +const output = `/workspace/tmp/backup-format-${phase}`; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } }; +const emptyPBS = { data: { backups: [] } }; +const archive = { + id: 'archive-112', + instance: 'pve-a', + node: 'pve-a', + storage: 'local', + type: 'ct', + vmid: 112, + time: '2026-10-03T01:00:00Z', + format: 'zst', + volid: 'local:backup/vzdump-lxc-112.tar.zst', +}; +const snapshot = { + id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z', + instance: 'pbs-main', + datastore: 'main', + backupType: 'ct', + vmid: '112', + backupTime: '2026-10-03T02:00:00Z', + files: ['index.json.blob'], +}; +const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } }; +const pbsPayload = { data: { backups: [snapshot] } }; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5298, strictPort: true }, + }); + 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 viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }; + const page = await browser.newPage({ + viewport, + isMobile: phone, + hasTouch: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + screenshots = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + const sources = { pve: emptyPVE, pbs: {} }; + let retryGate; + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5298') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: request.method() }); + const key = + url.pathname === '/api/backups/pbs' + ? 'pbs' + : url.pathname === '/api/backups/pve' + ? 'pve' + : null; + const payload = key ? sources[key] : { data: [], policy: {}, meta: {} }; + if (key === 'pbs' && retryGate) await retryGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + }); + const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]'); + const notice = (source) => + page.getByRole('status').filter({ + hasText: `${source} backup inventory is unavailable`, + }); + const load = async () => { + await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', { + waitUntil: 'domcontentloaded', + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + }; + const capture = async (suffix) => { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const file = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: file, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) }); + }; + + await load(); + if (phase === 'baseline') { + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await notice('PBS').count(), 0); + checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet'); + } else { + await notice('PBS').waitFor(); + await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor(); + assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0); + await page.getByText(/The response format is invalid/).waitFor(); + if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor(); + checks.push( + 'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero', + ); + } + await capture('missing-data'); + + sources.pve = pvePayload; + sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary', + ); + } else { + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + assert.match(await rows().first().innerText(), /PVE file/); + assert.equal(await page.getByRole('alert').count(), 0); + assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0); + checks.push( + 'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed', + ); + } + await capture('pbs-invalid'); + + sources.pbs = pbsPayload; + sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary', + ); + } else { + await notice('PVE').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + await rows().first().getByText('PBS', { exact: true }).waitFor(); + await rows().first().getByText('Snapshot', { exact: true }).waitFor(); + assert.equal(await page.getByRole('alert').count(), 0); + checks.push('Malformed PVE is source-local and independent PBS evidence remains usable'); + } + await capture('pve-invalid'); + + if (phase === 'final') { + sources.pve = pvePayload; + sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } }; + await load(); + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal( + await rows().count(), + 1, + 'a mistyped completion flag must not become recovery evidence', + ); + const before = requests.filter((r) => r.path === '/api/backups/pve').length; + sources.pbs = pbsPayload; + let release; + retryGate = new Promise((resolve) => (release = resolve)); + const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true }); + if (phone) await retry.tap(); + else { + await retry.focus(); + await page.keyboard.press('Enter'); + } + assert.equal(await retry.isDisabled(), true); + await notice('PBS').waitFor(); + assert.equal(await rows().count(), 1); + release(); + await page.waitForFunction( + () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2, + ); + retryGate = null; + assert.equal(await notice('PBS').count(), 0); + assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before); + await capture('recovered'); + checks.push( + 'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery', + ); + + sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } }; + sources.pbs = emptyPBS; + await load(); + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0); + checks.push('Explicit Go nil PVE collections remain a valid empty observation'); + } + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + 1, + ); + assert.equal(overflow, false); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert(requests.every((r) => r.method === 'GET')); + observations.push({ + name, + browserVersion: browser.version(), + viewport, + checks, + requests, + errors, + offOrigin, + overflow, + screenshots, + }); + await browser.close(); + browser = null; + } + const runtime = [ + 'src/features/proxmox/ProxmoxBackupsTable.tsx', + 'src/features/proxmox/proxmoxBackupInventory.ts', + ].filter((p) => fs.existsSync(path.join(root, p))); + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + phase, + playwrightVersion, + runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])), + observations, + }, + null, + 2, + ) + '\n', + ); + console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification/78c9d8427bdfa307.json b/frontend-modern/browser-verification/78c9d8427bdfa307.json new file mode 100644 index 0000000000..099102e7a1 --- /dev/null +++ b/frontend-modern/browser-verification/78c9d8427bdfa307.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "base_sha": "f5925942f49b088689695678b98e703af9ead9ef", + "verified_at": "2026-10-03T21:03:10.375831Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095" + }, + "routes": [ + "/browser-tests/backup-inventory.html?view=date" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "HTTP200 missing PBS envelope stays incomplete, never no-backups/zero", + "Malformed PBS/PVE collections preserve independent source and server health without a rendering failure", + "Mistyped incomplete flags are unavailable, not completed restore points", + "Explicit Go nil PVE collections remain valid empty observations" + ], + "interactions": [ + "Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success", + "Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium-desktop", + "version": "141.0.7390.37" + }, + { + "engine": "webkit-phone", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "limitations": [ + "Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.", + "Production/embed build is separately source-proved; no new route, polling, source cache or authority." + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index e3d51a93b9..c0bf4513ea 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -29,9 +29,7 @@ import type { GuestSnapshot, PBSBackup, PBSBackupsPayload, - PBSBackupsResponse, PVEBackupsPayload, - PVEBackupsResponse, StorageBackup, } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -70,6 +68,12 @@ import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip'; import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable'; import { ProxmoxCoverageTable } from './ProxmoxCoverageTable'; import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable'; +import { + BackupInventoryFormatError, + parsePBSBackupInventory, + parsePVEBackupInventory, + readBackupInventoryJSON, +} from './proxmoxBackupInventory'; // One backups surface, two operator views: a chronological recoverable-artifact // feed for "what ran when", and a guest coverage table for "what is protected". @@ -97,8 +101,7 @@ async function fetchPVEBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PVEBackupsResponse; - return payload?.data ?? EMPTY_PVE_BACKUPS; + return parsePVEBackupInventory(await readBackupInventoryJSON(response)); } async function fetchPBSBackups(signal: AbortSignal): Promise { @@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PBSBackupsResponse; - return payload?.data ?? EMPTY_PBS_BACKUPS; + return parsePBSBackupInventory(await readBackupInventoryJSON(response)); } export const ProxmoxBackupsTable: Component<{ @@ -702,6 +704,11 @@ export const ProxmoxBackupsTable: Component<{ {label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts are incomplete. {getAPIReadAccessErrorMessage(query.error())} + + {' '} + The response format is invalid. Missing restore points do not mean no backups + exist. +