Compose reviewed backup response validation

Preserve reviewed c54aa9e3 unchanged alongside canonical recovery help and storage presentation. Retain its exact-source proof; obtain owning-lane proof for the remaining combined upstream model.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 23:19:47 +01:00
9 changed files with 730 additions and 6 deletions
@@ -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,
@@ -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
@@ -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,
@@ -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;
});
@@ -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."
]
}
@@ -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<PVEBackupsPayload>
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<PBSBackupsPayload> {
@@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise<PBSBackupsPayload>
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<{
<span>
{label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts
are incomplete. {getAPIReadAccessErrorMessage(query.error())}
<Show when={query.error() instanceof BackupInventoryFormatError}>
{' '}
The response format is invalid. Missing restore points do not mean no backups
exist.
</Show>
</span>
<Show when={query.error()}>
<button
@@ -735,6 +735,79 @@ describe('independent Proxmox backup inventory reads', () => {
window.history.replaceState({}, '', '/proxmox/backups/date');
apiFetchJSONMock.mockResolvedValue({ data: [], policy: {}, meta: {} });
});
it.each(['pve', 'pbs'] as const)(
'keeps malformed HTTP200 %s responses source-local and recovers with an isolated retry',
async (source) => {
const bad = source === 'pbs' ? { data: { backups: [null] } } : { data: {} };
let response = Promise.resolve(jsonResponse(bad));
apiFetchMock.mockImplementation((url) =>
url.endsWith(`/${source}`)
? response
: Promise.resolve(jsonResponse(source === 'pve' ? pbsPayload : readPvePayload)),
);
mountBackupReadState();
await screen.findByText(/The response format is invalid/);
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
expect(screen.queryByText('No backups yet')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
if (source === 'pbs') expect(serverTable()).toHaveTextContent('Unavailable');
const pending = deferred<Response>();
response = pending.promise;
const retry = screen.getByRole('button', { name: `Retry ${source.toUpperCase()} inventory` });
fireEvent.click(retry);
expect(retry).toBeDisabled();
expect(screen.getByText(/The response format is invalid/)).toBeInTheDocument();
expect(recoverableRows()).toHaveLength(1);
pending.resolve(jsonResponse(source === 'pbs' ? pbsPayload : readPvePayload));
await waitFor(() => expect(recoverableRows()).toHaveLength(2));
expect(screen.queryByText(/The response format is invalid/)).not.toBeInTheDocument();
expect(sourceCalls(source === 'pbs' ? 'pve' : 'pbs')).toHaveLength(1);
},
);
it('does not infer empty PBS inventory from a missing data envelope', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(jsonResponse(url.endsWith('/pbs') ? {} : emptyPVE)),
);
mountBackupReadState();
await screen.findByText('Backup inventory is incomplete');
expect(screen.queryByText('No backups yet')).not.toBeInTheDocument();
expect(serverTable()).toHaveTextContent('Unavailable');
});
it('keeps undecodable successful bodies out of user-facing diagnostics', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(
url.endsWith('/pbs')
? new Response('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY', { status: 200 })
: jsonResponse(readPvePayload),
),
);
mountBackupReadState();
await screen.findByText(/The response format is invalid/);
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
expect(document.body).not.toHaveTextContent('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY');
});
it('accepts Go nil PVE collections as explicitly observed empty inventory', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(
jsonResponse(
url.endsWith('/pbs')
? emptyPBS
: { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } },
),
),
);
mountBackupReadState();
await screen.findByText('No backups yet');
expect(screen.queryByText(/inventory is unavailable/)).not.toBeInTheDocument();
expect(
within(serverTable() as HTMLElement).getByText('0', { exact: true }),
).toBeInTheDocument();
});
it('does not present an unread PBS inventory as empty or a measured zero', async () => {
const pending = deferred<Response>();
apiFetchMock.mockImplementation((url) =>
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import {
BackupInventoryFormatError,
parsePBSBackupInventory,
parsePVEBackupInventory,
readBackupInventoryJSON,
} from '../proxmoxBackupInventory';
const task = { id: 'task-112', vmid: 112, type: 'ct', status: 'OK', startTime: '' };
const archive = { id: 'archive-112', vmid: 112, type: 'ct', time: '' };
const snapshot = { id: 'snapshot-112', vmid: 112, type: 'ct', time: '' };
const pbs = { id: 'pbs-112', vmid: '112', backupType: 'ct', backupTime: '', files: [] };
const pveData = { backupTasks: [task], storageBackups: [archive], guestSnapshots: [snapshot] };
const pbsData = { backups: [pbs] };
describe('backup inventory response boundary', () => {
it('keeps observed rows, missing optional facts and future fields without coercion', () => {
const data = {
...pveData,
storageBackups: [{ ...archive, inProgress: true, futureField: { enabled: true } }],
};
const result = parsePVEBackupInventory({ data });
expect(result).toEqual(data);
expect(result.storageBackups).toBe(data.storageBackups);
expect(result.storageBackups[0].size).toBeUndefined();
expect(parsePBSBackupInventory({ data: pbsData })).toEqual(pbsData);
});
it('accepts empty arrays and explicitly nil PVE collections, not missing collections', () => {
const empty = { backupTasks: [], storageBackups: [], guestSnapshots: [] };
expect(parsePVEBackupInventory({ data: empty })).toEqual(empty);
expect(
parsePVEBackupInventory({
data: { backupTasks: null, storageBackups: null, guestSnapshots: null },
}),
).toEqual(empty);
expect(parsePBSBackupInventory({ data: { backups: [] } })).toEqual({ backups: [] });
expect(() => parsePBSBackupInventory({ data: { backups: null } })).toThrow(
BackupInventoryFormatError,
);
});
it.each([undefined, null, false, 'response', [], {}, { data: null }, { data: [] }, { data: {} }])(
'rejects a missing or invalid envelope: %j',
(payload) => {
expect(() => parsePVEBackupInventory(payload)).toThrow(BackupInventoryFormatError);
expect(() => parsePBSBackupInventory(payload)).toThrow(BackupInventoryFormatError);
},
);
it.each(['backupTasks', 'storageBackups', 'guestSnapshots'] as const)(
'rejects missing, scalar, object and invalid %s rows without silently dropping them',
(key) => {
for (const value of [undefined, false, '', {}, [null], ['row'], [pveData[key][0], {}]]) {
expect(() => parsePVEBackupInventory({ data: { ...pveData, [key]: value } })).toThrow(
BackupInventoryFormatError,
);
}
},
);
it.each([undefined, false, '', {}, [null], ['row'], [pbs, {}]])(
'rejects a malformed PBS collection or row: %j',
(backups) => {
expect(() => parsePBSBackupInventory({ data: { backups } })).toThrow(
BackupInventoryFormatError,
);
},
);
it.each([
['id', ''],
['vmid', 112],
['backupType', {}],
['backupTime', null],
['files', undefined],
['files', null],
['files', {}],
['files', [42]],
['instance', {}],
['datastore', []],
['namespace', 1],
['size', '1000'],
['size', -1],
['size', Number.NaN],
['protected', 'false'],
['verified', 1],
['inProgress', 'true'],
['writeActivityObserved', {}],
['writeActive', null],
])(
'rejects mistyped PBS %s without inventing an identity or completion state',
(field, value) => {
expect(() =>
parsePBSBackupInventory({ data: { backups: [{ ...pbs, [field as string]: value }] } }),
).toThrow(BackupInventoryFormatError);
},
);
it.each([
['backupTasks', task, 'status', {}],
['backupTasks', task, 'startTime', null],
['backupTasks', task, 'endTime', 1],
['backupTasks', task, 'error', []],
['storageBackups', archive, 'id', ' '],
['storageBackups', archive, 'vmid', '112'],
['storageBackups', archive, 'vmid', 1.5],
['storageBackups', archive, 'vmid', -1],
['storageBackups', archive, 'time', {}],
['storageBackups', archive, 'type', null],
['storageBackups', archive, 'node', {}],
['storageBackups', archive, 'instance', 1],
['storageBackups', archive, 'storage', []],
['storageBackups', archive, 'format', {}],
['storageBackups', archive, 'volid', []],
['storageBackups', archive, 'size', Number.POSITIVE_INFINITY],
['storageBackups', archive, 'protected', 'false'],
['storageBackups', archive, 'verified', 1],
['storageBackups', archive, 'isPBS', 'false'],
['storageBackups', archive, 'inProgress', 'true'],
['guestSnapshots', snapshot, 'name', {}],
['guestSnapshots', snapshot, 'description', []],
['guestSnapshots', snapshot, 'sizeBytes', -1],
] as const)('rejects mistyped PVE %s.%s', (key, row, field, value) => {
expect(() =>
parsePVEBackupInventory({ data: { ...pveData, [key]: [{ ...row, [field]: value }] } }),
).toThrow(BackupInventoryFormatError);
});
it('preserves valid zero values, host identifiers and boolean completion facts', () => {
const row = {
...pbs,
vmid: '',
backupType: 'host',
namespace: '',
size: 0,
protected: false,
verified: false,
inProgress: true,
writeActivityObserved: true,
writeActive: false,
};
expect(parsePBSBackupInventory({ data: { backups: [row] } }).backups[0]).toEqual(row);
});
it('replaces JSON decoding errors with fixed copy that contains no response content', async () => {
const response = new Response('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY');
await expect(readBackupInventoryJSON(response)).rejects.toEqual(
new BackupInventoryFormatError(),
);
await expect(readBackupInventoryJSON(new Response('null'))).resolves.toBeNull();
});
});
@@ -0,0 +1,131 @@
import type {
BackupTask,
GuestSnapshot,
PBSBackup,
PBSBackupsPayload,
PVEBackupsPayload,
StorageBackup,
} from '@/types/api';
export class BackupInventoryFormatError extends Error {
constructor() {
// Never include response bodies, provider identifiers or JSON parser text.
super('The response format is invalid. Missing restore points do not mean no backups exist.');
this.name = 'BackupInventoryFormatError';
}
}
type FieldCheck = (value: unknown) => boolean;
type RowShape = Readonly<Record<string, FieldCheck>>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const isString: FieldCheck = (value) => typeof value === 'string';
const isID: FieldCheck = (value) => typeof value === 'string' && value.trim().length > 0;
const isBoolean: FieldCheck = (value) => typeof value === 'boolean';
const isNonnegativeNumber: FieldCheck = (value) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
const isGuestID: FieldCheck = (value) => isNonnegativeNumber(value) && Number.isSafeInteger(value);
const optional =
(check: FieldCheck): FieldCheck =>
(value) =>
value === undefined || check(value);
const isFiles: FieldCheck = (value) => Array.isArray(value) && value.every(isString);
// Validate what the recovery model consumes, without guessing missing identity,
// coercing completion flags, silently dropping bad rows or rejecting extra fields.
// Optional display fields retain the model's existing unknown/fallback behaviour.
const pveGuestShape: RowShape = {
id: isID,
vmid: isGuestID,
type: isString,
node: optional(isString),
instance: optional(isString),
};
const taskShape: RowShape = {
...pveGuestShape,
status: isString,
startTime: isString,
endTime: optional(isString),
error: optional(isString),
};
const archiveShape: RowShape = {
...pveGuestShape,
time: isString,
storage: optional(isString),
format: optional(isString),
volid: optional(isString),
size: optional(isNonnegativeNumber),
protected: optional(isBoolean),
isPBS: optional(isBoolean),
verified: optional(isBoolean),
inProgress: optional(isBoolean),
};
const snapshotShape: RowShape = {
...pveGuestShape,
time: isString,
name: optional(isString),
description: optional(isString),
sizeBytes: optional(isNonnegativeNumber),
};
const pbsShape: RowShape = {
id: isID,
vmid: isString,
backupType: isString,
backupTime: isString,
files: isFiles,
instance: optional(isString),
datastore: optional(isString),
namespace: optional(isString),
size: optional(isNonnegativeNumber),
protected: optional(isBoolean),
verified: optional(isBoolean),
inProgress: optional(isBoolean),
writeActivityObserved: optional(isBoolean),
writeActive: optional(isBoolean),
};
function inventoryData(payload: unknown): Record<string, unknown> {
if (!isRecord(payload) || !isRecord(payload.data)) throw new BackupInventoryFormatError();
return payload.data;
}
function collection<T>(
data: Record<string, unknown>,
key: string,
shape: RowShape,
allowNil = false,
): T[] {
const value = data[key];
// The PVE handler can encode its empty Go slices as null. A missing field
// is not that observation. PBS normalizes both its list and files to arrays.
if (allowNil && value === null) return [];
const fields = Object.entries(shape);
if (
!Array.isArray(value) ||
!value.every((row) => isRecord(row) && fields.every(([field, check]) => check(row[field])))
) {
throw new BackupInventoryFormatError();
}
return value as T[];
}
export function parsePVEBackupInventory(payload: unknown): PVEBackupsPayload {
const data = inventoryData(payload);
return {
backupTasks: collection<BackupTask>(data, 'backupTasks', taskShape, true),
storageBackups: collection<StorageBackup>(data, 'storageBackups', archiveShape, true),
guestSnapshots: collection<GuestSnapshot>(data, 'guestSnapshots', snapshotShape, true),
};
}
export function parsePBSBackupInventory(payload: unknown): PBSBackupsPayload {
return { backups: collection<PBSBackup>(inventoryData(payload), 'backups', pbsShape) };
}
export async function readBackupInventoryJSON(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
throw new BackupInventoryFormatError();
}
}