mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user