diff --git a/frontend-modern/browser-tests/maintenance-evidence.cjs b/frontend-modern/browser-tests/maintenance-evidence.cjs new file mode 100644 index 0000000000..f20c6d165b --- /dev/null +++ b/frontend-modern/browser-tests/maintenance-evidence.cjs @@ -0,0 +1,416 @@ +// Read-only browser acceptance of the changed report presentation/query flow. +// Explicit review/rerun business calls are covered by the runtime component +// suite. Every POST is refused here, so a clock-induced operation fails proof. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/maintenance-evidence/browser-read-only'; +const origin = 'http://127.0.0.1:5347'; +const start = Date.parse('2026-10-11T04:00:00Z'); +const iso = (offset) => new Date(start + offset).toISOString(); +const reports = ['healthy', 'pending', 'needs_review', 'failed_verification'].map((status) => ({ + id: 'report:' + status, + resourceId: 'pbs-a', + status, + trigger: 'maintenance_window_ended', + startedAt: iso(-30_000), + completedAt: status === 'pending' ? '0001-01-01T00:00:00Z' : iso(-30_000), + windowStartedAt: iso(-90_000_000), + windowEndedAt: iso(-86_400_000), + evidence: { + activeCriticalAlerts: status === 'failed_verification' ? 1 : 0, + activeWarningAlerts: 2, + activeCriticalFindings: 0, + activeWarningFindings: 1, + failedActionsSinceWindowStart: 0, + metricRecovery: { samplesAfterEnd: 3, trend: 'improving' }, + }, + linkedFindingIds: [], + linkedAlertIds: [], + linkedActionIds: [], + recommendation: 'Recorded evidence, not a fresh health observation.', + ...(status === 'healthy' + ? { + userOutcome: 'reviewed', + reviewedAt: iso(-10_000), + reviewedBy: 'fixture-admin', + reviewNote: 'Earlier check retained. A completed backup does not prove guest recovery.', + } + : {}), +})); +async function bounded(promise, label) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Fixture observation deadline: ' + label)), + 10_000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} +(async () => { + fs.mkdirSync(output, { recursive: true }); + const binding = JSON.parse( + fs.readFileSync('/workspace/tmp/maintenance-evidence/source-binding.json'), + ); + for (const [file, digest] of Object.entries(binding.content_sha256)) { + assert.equal( + crypto + .createHash('sha256') + .update(fs.readFileSync('/workspace/' + file)) + .digest('hex'), + digest, + file, + ); + } + 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 result = { + source: binding, + playwright, + cases: [], + captures: [], + completed: false, + browsersClosed: false, + serverClosed: false, + }; + const { createServer } = await import(root + '/node_modules/vite/dist/node/index.js'); + const server = await createServer({ + root, + configFile: root + '/vite.config.ts', + cacheDir: output + '/vite-cache', + server: { host: '127.0.0.1', port: 5347, strictPort: true, watch: null }, + }); + let browser, context; + try { + await server.listen(); + for (const [name, engine, width, height, dark] of [ + ['desktop', chromium, 1365, 900, false], + ['phone', webkit, 390, 844, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + context = await browser.newContext({ + viewport: { width, height }, + isMobile: dark, + hasTouch: dark, + locale: 'en-GB', + timezoneId: 'UTC', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + page.setDefaultNavigationTimeout(45_000); + await page.clock.install({ time: new Date(start - 1000) }); + await page.clock.pauseAt(new Date(start)); + await page.addInitScript((dark) => { + localStorage.clear(); + sessionStorage.clear(); + document.addEventListener( + 'DOMContentLoaded', + () => document.documentElement.classList.toggle('dark', dark), + { once: true }, + ); + }, dark); + const record = { + name, + browserVersion: browser.version(), + viewport: { width, height }, + dark, + checks: [], + reads: [], + writes: [], + errors: [], + unexpected: [], + offOrigin: [], + completed: false, + contextClosed: false, + }; + result.cases.push(record); + let mode = 'pending', + releaseRead, + observeRead; + const readStarted = new Promise((resolve) => { + observeRead = resolve; + }); + page.on('pageerror', (error) => record.errors.push(error.stack || error.message)); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + record.offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (req.method() !== 'GET') { + record.writes.push(url.pathname); + return route.fulfill({ + status: 403, + json: { error: 'Read-only fixture refuses operations' }, + }); + } + record.reads.push(url.pathname); + let data; + if (url.pathname.endsWith('/maintenance-verifications')) { + if (url.pathname.includes('/pending-resource/')) { + if (mode === 'pending') { + await bounded( + new Promise((resolve) => { + releaseRead = resolve; + observeRead(); + }), + 'held new-resource read', + ); + return route.fulfill({ + status: 503, + json: { error: 'Synthetic unavailable reader' }, + }); + } + if (mode === 'denied') + return route.fulfill({ status: 403, json: { error: 'Synthetic access denial' } }); + return route.fulfill({ + json: { data: [], meta: { resourceId: 'pending-resource', limit: 25, total: 0 } }, + }); + } + data = { data: reports, meta: { resourceId: 'pbs-a', limit: 25, total: reports.length } }; + } else if (url.pathname.endsWith('/operator-state')) + data = { + configured: true, + state: { + canonicalId: 'pbs-a', + neverAutoRemediate: true, + monitoringMode: 'expected_offline', + lifecycleState: 'retired', + note: 'Keep the saved protection', + setAt: iso(-60_000), + }, + }; + else if (url.pathname.endsWith('/facets')) + data = { capabilities: [], relationships: [], recentChanges: [], counts: {} }; + else if (url.pathname === '/api/ai/intelligence') + data = { resourceId: 'pbs-a', findings: [], investigations: [] }; + else if (url.pathname === '/api/audit/actions') + data = { audits: [], count: 0, available: false }; + else if (url.pathname === '/api/license/runtime-capabilities') + data = { + capabilities: [], + limits: [], + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }; + else if (url.pathname === '/api/ai/settings') data = { discovery_enabled: false }; + else if (url.pathname === '/api/security/status') + data = { hasAuthentication: true, requiresAuth: false, sessionCapabilities: {} }; + else { + record.unexpected.push(url.pathname); + return route.fulfill({ status: 404, json: { error: 'Unspecified synthetic read' } }); + } + return route.fulfill({ json: data }); + }); + const check = async (label, action) => { + result.phase = name + ': ' + label; + console.log(result.phase); + await action(); + record.checks.push(label); + }; + const capture = async (label) => { + const file = output + '/' + name + '-' + label + '.png'; + await page.screenshot({ path: file, fullPage: true, animations: 'disabled' }); + result.captures.push(file.replace('/workspace/', '')); + }; + result.phase = name + ': mount full drawer'; + await page.goto(origin + '/browser-tests/maintenance-evidence.html'); + await page.getByRole('tab', { name: 'Manage', exact: true }).click(); + const section = page.getByTestId('maintenance-verification-section'); + await section.getByText('Checked 30s ago', { exact: true }).first().waitFor(); + await page.getByLabel('Operator note', { exact: true }).fill('Unsaved protected note'); + await check( + 'separate recorded check/window/review ages, unknown pending completion and all four verdicts', + async () => { + assert.equal( + await section.getByText('Window ended 1 day ago', { exact: true }).count(), + 4, + ); + assert.equal(await section.getByText('Checked unknown', { exact: true }).count(), 1); + assert.equal( + await section.getByText('Reviewed 10s ago by fixture-admin', { exact: true }).count(), + 1, + ); + assert.match(await section.innerText(), /Reports record past checks, not current health/); + for (const status of ['healthy', 'pending', 'needs_review', 'failed_verification']) + assert.equal( + await section.getByTestId('maintenance-verification-status-' + status).count(), + 1, + ); + }, + ); + await capture('recorded'); + const reads = record.reads.length; + await page.clock.fastForward(60_000); + await check( + 'held events age past a minute without reads/writes, changing verdicts or discarding edits', + async () => { + await section.getByText('Checked 1 min ago', { exact: true }).first().waitFor(); + assert.equal( + await section.getByText('Reviewed 1 min ago by fixture-admin', { exact: true }).count(), + 1, + ); + assert.equal( + await page.getByLabel('Operator note', { exact: true }).inputValue(), + 'Unsaved protected note', + ); + assert.equal(record.reads.length, reads); + assert.equal(record.writes.length, 0); + }, + ); + await page.clock.fastForward(3_600_000); + await check( + 'an hour-old verdict stays historical with its evidence, review admission and saved restrictions', + async () => { + await section.getByText('Checked 1 hour ago', { exact: true }).first().waitFor(); + assert.equal( + await section + .getByText('Reviewed 1 hour ago by fixture-admin', { exact: true }) + .count(), + 1, + ); + assert.equal(await section.getByTestId('maintenance-verification-review').count(), 3); + assert.equal( + await page + .getByLabel('Never auto-remediate this resource', { exact: true }) + .getAttribute('aria-pressed'), + 'true', + ); + assert.equal( + await page.getByLabel('Monitoring', { exact: true }).inputValue(), + 'expected_offline', + ); + assert.equal(await page.getByLabel('Lifecycle', { exact: true }).inputValue(), 'retired'); + assert.equal(record.reads.length, reads); + assert.equal(record.writes.length, 0); + }, + ); + await capture('hour-old'); + await page.evaluate(() => window.__maintenanceEvidence.select('pending-resource')); + // The real drawer deliberately remounts at a different canonical ID and + // starts on Overview. Open Manage as an operator would; do not assume a + // held tab or misattribute that existing protection to this repair. + await page.getByRole('tab', { name: 'Manage', exact: true }).click(); + await bounded(readStarted, 'new-resource GET observed'); + await check( + 'changing resource immediately withdraws prior reports while its own read waits', + async () => { + await section.getByText('Loading verification reports…', { exact: true }).waitFor(); + assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0); + assert.equal(await section.getByText(/No verification reports yet/).count(), 0); + await page.clock.fastForward(60_000); + assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0); + }, + ); + await capture('other-resource-pending'); + releaseRead(); + await check( + 'a failed first read is unavailable, not empty history or another resource evidence', + async () => { + await section.getByTestId('maintenance-verification-load-error').waitFor(); + assert.equal(await section.getByText(/No verification reports yet/).count(), 0); + assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0); + const before = record.reads.length; + await page.clock.fastForward(60_000); + assert.equal(record.reads.length, before); + }, + ); + await capture('unavailable'); + mode = 'denied'; + await section.getByRole('button', { name: 'Retry reports', exact: true }).focus(); + await section.getByRole('button', { name: 'Retry reports', exact: true }).press('Enter'); + await check( + 'explicit keyboard read retry shows bounded denied guidance without reviving reports', + async () => { + await section + .getByText('Access denied. Check your permissions and license plan.', { exact: true }) + .waitFor(); + const before = record.reads.length; + await page.clock.fastForward(60_000); + assert.equal(record.reads.length, before); + assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0); + assert.equal(record.writes.length, 0); + }, + ); + await capture('denied'); + mode = 'empty'; + await section.getByRole('button', { name: 'Retry reports', exact: true }).click(); + await check( + 'only a successful empty read is empty history, with no subsequent clock retry', + async () => { + await section.getByText(/No verification reports yet/).waitFor(); + assert.equal(await section.getByTestId('maintenance-verification-load-error').count(), 0); + const before = record.reads.length; + await page.clock.fastForward(60_000); + assert.equal(record.reads.length, before); + assert.equal(await section.getByTestId('maintenance-verification-report').count(), 0); + }, + ); + await capture('empty'); + await check( + 'no page error, unspecified/off-origin request, operation or horizontal overflow', + async () => { + assert.deepEqual(record.errors, []); + assert.deepEqual(record.unexpected, []); + assert.deepEqual(record.offOrigin, []); + assert.deepEqual(record.writes, []); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + true, + ); + }, + ); + record.completed = true; + await context.close(); + record.contextClosed = true; + context = undefined; + await browser.close(); + browser = undefined; + } + result.completed = true; + } catch (error) { + result.error = { name: error.name, message: error.message }; + throw error; + } finally { + if (context) await context.close(); + if (browser) await browser.close(); + result.browsersClosed = true; + await server.close(); + result.serverClosed = true; + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + console.log( + JSON.stringify({ + completed: result.completed, + phase: result.phase, + checks: result.cases.map((c) => ({ + name: c.name, + count: c.checks.length, + errors: c.errors, + })), + captures: result.captures.length, + serverClosed: result.serverClosed, + }), + ); + } +})(); diff --git a/frontend-modern/browser-tests/maintenance-evidence.html b/frontend-modern/browser-tests/maintenance-evidence.html new file mode 100644 index 0000000000..c1a585a96b --- /dev/null +++ b/frontend-modern/browser-tests/maintenance-evidence.html @@ -0,0 +1,12 @@ + + + + + + Recorded maintenance evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/maintenance-evidence.tsx b/frontend-modern/browser-tests/maintenance-evidence.tsx new file mode 100644 index 0000000000..6ebb688bda --- /dev/null +++ b/frontend-modern/browser-tests/maintenance-evidence.tsx @@ -0,0 +1,53 @@ +// The full production resource drawer, transport/query, shared clock and CSS. +// Only snapshots and HTTP responses are synthetic. No real operation runs. +import { createSignal } 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'; + +syncAIRuntimeSettings({ discovery_enabled: false }); +const [resource, setResource] = createSignal({ + id: 'pbs-a', + name: 'Recorded backup server', + displayName: 'Recorded backup server', + type: 'pbs', + platformId: 'pbs-a', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + cpu: { current: 12 }, + lastSeen: Date.parse('2026-10-11T04:00:00Z'), +}); +(window as any).__maintenanceEvidence = { + withdraw: () => + setResource((current) => ({ ...current, id: 'empty-resource', name: 'No reports' })), + select: (id: string) => + setResource((current) => ({ + ...current, + id, + name: 'Different resource', + displayName: 'Different resource', + })), +}; + +render( + () => ( + true } as any} + > + document.documentElement.classList.contains('dark')}> +
+

Recorded maintenance evidence

+

+ Synthetic local responses. No operation reaches a server. +

+ +
+
+
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/73544eb32505b148.json b/frontend-modern/browser-verification/73544eb32505b148.json new file mode 100644 index 0000000000..16831e8e69 --- /dev/null +++ b/frontend-modern/browser-verification/73544eb32505b148.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "base_sha": "80ef7a2923b5bee25d3aa48ce678f2d4b286725f", + "verified_at": "2026-10-11T04:29:14.340458Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx": "af1eac63570ab102272f25fb1ec5cade2ee3508284600a131ff71a9c860efa87" + }, + "routes": ["/browser-tests/maintenance-evidence.html"], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Full production ResourceDetailDrawer Manage, actual report component, query/transport, CSS and shared clock; synthetic local-only snapshots/HTTP. Desktop Chromium 141.0.7390.37/light and phone WebKit 26.0/dark. Playwright 1.56.1 matches integration lock.", + "Checked, Window ended and Reviewed remain distinct historical events through minute/hour boundaries; all four verdicts, evidence, operator locks and unsaved note preserved. Go zero-time completion stays unknown.", + "Loading, 503 unavailable, 403 denied and successful empty read remain distinct. Explicit keyboard read-only retry; no unsolicited retry or mutation on a tick. The full drawer already remounts at a different ID; opening Manage there shows only its own report state. Component-level held-prop isolation and same-resource failed-refresh retention are separately covered by runtime tests.", + "Two completed cases / 16 controls / twelve inspected screenshots, no page error, unspecified/off-origin request, operation or horizontal overflow. No full App, actual authentication, backend review/rerun, appliance, guest recovery, native notification, installed or released acceptance." + ], + "interactions": [ + "Advance only the page clock with held reports. Observe stable verdict/counts and saved restrictions; preserve unsaved operator note.", + "Synthetic resource selection uses existing keyed drawer reset, then explicitly open Manage. Held GET returns 503, keyboard Retry reports returns 403, another explicit GET returns empty; reports stay absent through ticks.", + "All twelve resulting captures visually inspected. Both contexts, browsers and Vite closed; host descendant cleanup is not independently reported. Earlier failed/cancelled/unknown attempts are retained, not rebound or counted here." + ], + "control_count": 16 +} diff --git a/frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx b/frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx index 9d3cc78dcb..e1d57f4f88 100644 --- a/frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx +++ b/frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx @@ -1,6 +1,8 @@ import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { notificationStore } from '@/stores/notifications'; +import { Button } from '@/components/shared/Button'; +import { InlineNotice } from '@/components/shared/InlineNotice'; import { listMaintenanceVerificationsForResource, rerunMaintenanceVerification, @@ -9,6 +11,8 @@ import { type MaintenanceVerificationStatus, } from '@/api/maintenanceVerification'; import { formatRelativeTime } from '@/utils/format'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; +import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; /** * MaintenanceVerificationSection surfaces Maintenance Verification @@ -50,22 +54,23 @@ const STATUS_CLASSES: Record = { export const MaintenanceVerificationSection: Component = ( props, ) => { - const [refreshTick, setRefreshTick] = createSignal(0); - + const now = useRelativeTimeNow(); + // Age only the recorded events. Ticks neither refresh the report nor change + // its verdict, and a rerun's completion time is not the window's end time. + const reportAge = (timestamp?: string) => { + if (!timestamp || timestamp.startsWith('0001-01-01')) return 'unknown'; + return formatRelativeTime(timestamp, { now: now() }) || 'unknown'; + }; const query = createNonSuspendingQuery({ - source: () => (props.resourceId ? `${props.resourceId}:${refreshTick()}` : null), - fetcher: async () => { - try { - const res = await listMaintenanceVerificationsForResource(props.resourceId); - return res.data ?? []; - } catch (err) { - notificationStore.error( - err instanceof Error ? err.message : 'Failed to load Maintenance Verification Reports', - ); - return []; - } + source: () => props.resourceId || null, + fetcher: async (resourceId) => { + const res = await listMaintenanceVerificationsForResource(resourceId); + return res.data ?? []; }, initialValue: [] as MaintenanceVerificationReport[], + // A held report belongs only to its resource. The query withdraws denied + // reads, but retains last-loaded records on a same-resource transient error. + retainPreviousValueOnSourceChange: false, cacheKey: (key: string) => `maintenance-verifications:${key}`, }); @@ -75,7 +80,7 @@ export const MaintenanceVerificationSection: Component(null); - const refresh = () => setRefreshTick((n) => n + 1); + const refresh = () => void query.refetch(); const handleRerun = async () => { if (rerunning() || !props.resourceId) return; @@ -121,8 +126,8 @@ export const MaintenanceVerificationSection: Component

- Pulse runs deterministic checks each time a maintenance window ends and writes a - Maintenance Verification Report. The result sticks here for review. + Pulse records the result when a maintenance window ends. Reports record past checks, not + current health.

+ + + - No verification reports yet. A report is written automatically the next time this - resource exits a maintenance window. - + +
+ {query.loading() + ? 'Loading verification reports…' + : 'No verification reports yet. A report is written automatically the next time this resource exits a maintenance window.'} +
+
} >
    @@ -160,9 +199,9 @@ export const MaintenanceVerificationSection: Component {STATUS_LABELS[report.status]} + Checked {reportAge(report.completedAt)} - Window ended{' '} - {report.windowEndedAt ? formatRelativeTime(report.windowEndedAt) : 'unknown'} + Window ended {reportAge(report.windowEndedAt)} @@ -178,7 +217,7 @@ export const MaintenanceVerificationSection: Component - Reviewed {report.reviewedAt ? formatRelativeTime(report.reviewedAt) : ''} + Reviewed {reportAge(report.reviewedAt)} {report.reviewedBy ? ` by ${report.reviewedBy}` : ''} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/MaintenanceVerificationSection.render.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/MaintenanceVerificationSection.render.test.tsx new file mode 100644 index 0000000000..196d06212b --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/MaintenanceVerificationSection.render.test.tsx @@ -0,0 +1,407 @@ +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MaintenanceVerificationReport } from '@/api/maintenanceVerification'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock'; + +const api = vi.hoisted(() => ({ apiFetchJSON: vi.fn() })); +vi.mock('@/utils/apiClient', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetchJSON: api.apiFetchJSON, +})); +vi.mock('@/stores/notifications', () => ({ + notificationStore: { error: vi.fn(), success: vi.fn() }, +})); + +import { MaintenanceVerificationSection } from '../MaintenanceVerificationSection'; + +const start = Date.parse('2026-10-11T04:00:00Z'); +const iso = (offset: number) => new Date(start + offset).toISOString(); +const resourceId = 'cluster:pve-a:101'; +const report = ( + overrides: Partial = {}, +): MaintenanceVerificationReport => ({ + id: 'report:window', + resourceId, + trigger: 'maintenance_window_ended', + status: 'healthy', + startedAt: iso(-30_000), + completedAt: iso(-30_000), + windowStartedAt: iso(-3_600_000), + windowEndedAt: iso(-30_000), + evidence: { + activeCriticalAlerts: 0, + activeWarningAlerts: 2, + activeCriticalFindings: 0, + activeWarningFindings: 1, + failedActionsSinceWindowStart: 0, + metricRecovery: { samplesAfterEnd: 3, trend: 'improving' }, + }, + linkedFindingIds: [], + linkedAlertIds: [], + linkedActionIds: [], + recommendation: 'Recorded recovery evidence', + ...overrides, +}); +const response = (reports: MaintenanceVerificationReport[]) => ({ data: reports }); +const mount = async (reports: MaintenanceVerificationReport[]) => { + api.apiFetchJSON.mockResolvedValue(response(reports)); + const rendered = render(() => ); + await vi.advanceTimersByTimeAsync(0); + return rendered; +}; +const mutations = () => + api.apiFetchJSON.mock.calls.filter(([, options]) => options?.method === 'POST'); + +describe('durable maintenance verification evidence', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.setSystemTime(start); + api.apiFetchJSON.mockReset(); + }); + afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.useRealTimers(); + }); + + it('ages held window and review events without fetching, rewriting or rerunning the report', async () => { + const saved = report({ + userOutcome: 'reviewed', + reviewedAt: iso(-30_000), + reviewedBy: 'fixture-admin', + }); + const original = JSON.stringify(saved); + await mount([saved]); + expect(screen.getByText('Window ended 30s ago')).toBeInTheDocument(); + expect(screen.getByText('Reviewed 30s ago by fixture-admin')).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS); + expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument(); + expect(screen.getByText('Reviewed 1 min ago by fixture-admin')).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(3_600_000); + expect(screen.getByText('Window ended 1 hour ago')).toBeInTheDocument(); + expect(screen.getByText('Reviewed 1 hour ago by fixture-admin')).toBeInTheDocument(); + expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent( + 'Healthy', + ); + expect(screen.queryByTestId('maintenance-verification-review')).not.toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(1); + expect(mutations()).toEqual([]); + expect(JSON.stringify(saved)).toBe(original); + }); + + it('shows when a check completed, separately from the earlier maintenance window and review', async () => { + await mount([ + report({ + windowEndedAt: iso(-86_400_000), + userOutcome: 'reviewed', + reviewedAt: iso(-10_000), + }), + ]); + expect(screen.getByText('Checked 30s ago')).toBeInTheDocument(); + expect(screen.getByText('Window ended 1 day ago')).toBeInTheDocument(); + expect(screen.getByText('Reviewed 10s ago')).toBeInTheDocument(); + expect( + screen.getByText(/Reports record past checks, not current health\./), + ).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.getByText('Checked 1 min ago')).toBeInTheDocument(); + expect(screen.getByText('Reviewed 1 min ago')).toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(1); + }); + + it('keeps every recorded verdict and its counts unchanged as several reports age', async () => { + const states = ['pending', 'healthy', 'needs_review', 'failed_verification'] as const; + const records = states.map((status) => report({ id: `report:${status}`, status })); + const original = JSON.stringify(records); + await mount(records); + await vi.advanceTimersByTimeAsync(120_000); + for (const status of states) { + const row = screen.getByTestId(`maintenance-verification-status-${status}`).closest('li')!; + expect(within(row).getByText('Window ended 2 mins ago')).toBeInTheDocument(); + expect(within(row).getByText('Checked 2 mins ago')).toBeInTheDocument(); + expect(within(row).getByText('Warning alerts').nextElementSibling).toHaveTextContent('2'); + expect(within(row).getByText('Metric samples').nextElementSibling).toHaveTextContent('3'); + expect(within(row).getByTestId('maintenance-verification-review')).toBeEnabled(); + } + expect(api.apiFetchJSON).toHaveBeenCalledTimes(1); + expect(JSON.stringify(records)).toBe(original); + }); + + it.each(['', 'invalid', '0001-01-01T00:00:00Z'])( + 'keeps absent or malformed event times unknown (%j)', + async (time) => { + await mount([ + report({ + windowEndedAt: time, + completedAt: time, + userOutcome: 'reviewed', + reviewedAt: time, + }), + ]); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.getByText('Window ended unknown')).toBeInTheDocument(); + expect(screen.getByText('Checked unknown')).toBeInTheDocument(); + expect(screen.getByText('Reviewed unknown')).toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(1); + }, + ); + + it('keeps clock-skewed past events as ages rather than inventing a scheduled operation', async () => { + await mount([report({ completedAt: iso(30_000), windowEndedAt: iso(30_000) })]); + expect(screen.getByText('Checked 0s ago')).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(90_000); + expect(screen.getByText('Checked 1 min ago')).toBeInTheDocument(); + expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument(); + expect(mutations()).toEqual([]); + }); + + it('withdraws the old resource reports and ignores a late old-resource response across ticks', async () => { + let finishOld!: (value: ReturnType) => void; + api.apiFetchJSON.mockImplementation((path: string) => + path.includes('old-resource') + ? new Promise((resolve) => { + finishOld = resolve; + }) + : Promise.resolve(response([])), + ); + const [id, setId] = createSignal('old-resource'); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + setId('empty-resource'); + await vi.advanceTimersByTimeAsync(0); + finishOld(response([report()])); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument(); + expect(screen.getByText(/No verification reports yet/)).toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(2); + }); + + it('does not revive withdrawn reports after an access-denied resource read', async () => { + api.apiFetchJSON + .mockResolvedValueOnce(response([report()])) + .mockRejectedValueOnce(new Error('Access denied')); + const [id, setId] = createSignal(resourceId); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument(); + setId('denied-resource'); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(2); + }); + + it('requires an explicit review, keeps pending admission disabled and uses the returned review fields', async () => { + const saved = report(); + await mount([saved]); + let finishReview!: (value: MaintenanceVerificationReport) => void; + api.apiFetchJSON.mockImplementationOnce( + () => + new Promise((resolve) => { + finishReview = resolve; + }), + ); + const button = screen.getByTestId('maintenance-verification-review'); + fireEvent.click(button); + await vi.advanceTimersByTimeAsync(60_000); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent('Marking…'); + fireEvent.click(button); + expect(mutations()).toHaveLength(1); + expect(mutations()[0][0]).toBe('/api/maintenance-verifications/report%3Awindow/review'); + expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent( + 'Healthy', + ); + expect(screen.queryByText(/Reviewed/)).not.toBeInTheDocument(); + const reviewed = { + ...saved, + userOutcome: 'reviewed' as const, + reviewedAt: iso(60_000), + reviewedBy: 'fixture-admin', + }; + api.apiFetchJSON.mockResolvedValue(response([reviewed])); + finishReview(reviewed); + await vi.advanceTimersByTimeAsync(0); + expect(screen.queryByTestId('maintenance-verification-review')).not.toBeInTheDocument(); + expect(screen.getByText('Reviewed 0s ago by fixture-admin')).toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.getByText('Reviewed 1 min ago by fixture-admin')).toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(3); + }); + + it('reruns only after a click, keeps the pending action disabled and retains the old report beside the new check', async () => { + const saved = report(); + await mount([saved]); + let finishRerun!: (value: MaintenanceVerificationReport) => void; + api.apiFetchJSON.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRerun = resolve; + }), + ); + const button = screen.getByTestId('maintenance-verification-rerun'); + fireEvent.click(button); + await vi.advanceTimersByTimeAsync(60_000); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(mutations()).toHaveLength(1); + expect(mutations()[0][0]).toBe( + '/api/resources/cluster%3Apve-a%3A101/maintenance-verifications/rerun', + ); + const rerun = report({ + id: 'report:window-rerun-1', + completedAt: iso(60_000), + status: 'needs_review', + }); + api.apiFetchJSON.mockResolvedValue(response([rerun, saved])); + finishRerun(rerun); + await vi.advanceTimersByTimeAsync(0); + expect(button).toBeEnabled(); + expect(screen.getAllByTestId('maintenance-verification-report')).toHaveLength(2); + expect(screen.getByText('Checked 0s ago')).toBeInTheDocument(); + expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent( + 'Healthy', + ); + expect(screen.getByTestId('maintenance-verification-status-needs_review')).toHaveTextContent( + 'Needs review', + ); + await vi.advanceTimersByTimeAsync(60_000); + expect(mutations()).toHaveLength(1); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(3); + }); + + it.each(['review', 'rerun'])( + 'does not retry a refused %s on a clock tick or alter the report', + async (action) => { + await mount([report()]); + api.apiFetchJSON.mockRejectedValueOnce(new Error('Synthetic write refusal')); + fireEvent.click(screen.getByTestId(`maintenance-verification-${action}`)); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.getByTestId(`maintenance-verification-${action}`)).toBeEnabled(); + expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent( + 'Healthy', + ); + expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument(); + expect(mutations()).toHaveLength(1); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(2); + }, + ); + + it('does not describe a pending first read as an empty report history', async () => { + let finishRead!: (value: ReturnType) => void; + api.apiFetchJSON.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByText('Loading verification reports…')).toBeInTheDocument(); + expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument(); + finishRead(response([])); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByText(/No verification reports yet/)).toBeInTheDocument(); + }); + + it('withdraws loaded reports immediately while another resource read is pending', async () => { + api.apiFetchJSON + .mockResolvedValueOnce(response([report()])) + .mockImplementationOnce(() => new Promise(() => {})); + const [id, setId] = createSignal(resourceId); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument(); + setId('new-resource-pending'); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument(); + expect(screen.getByText('Loading verification reports…')).toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(2); + }); + + it('distinguishes a failed first read from empty history and retries only after an explicit read request', async () => { + api.apiFetchJSON.mockRejectedValueOnce( + Object.assign(new Error('Synthetic server internals must not render'), { status: 503 }), + ); + render(() => ); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent( + 'Verification reports unavailable', + ); + expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Synthetic server internals/)).not.toBeInTheDocument(); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(1); + let finishRetry!: (value: ReturnType) => void; + api.apiFetchJSON.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve; + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Retry reports' })); + expect(screen.getByRole('button', { name: 'Retry reports' })).toBeDisabled(); + await vi.advanceTimersByTimeAsync(60_000); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(2); + finishRetry(response([report()])); + await vi.advanceTimersByTimeAsync(0); + expect(screen.queryByTestId('maintenance-verification-load-error')).not.toBeInTheDocument(); + expect(screen.getByTestId('maintenance-verification-report')).toBeInTheDocument(); + expect(mutations()).toEqual([]); + }); + + it('retains qualified historical reports after a same-resource refresh fails, without inventing a successful review', async () => { + const saved = report(); + await mount([saved]); + api.apiFetchJSON + .mockResolvedValueOnce(saved) + .mockRejectedValueOnce(Object.assign(new Error('Synthetic read failure'), { status: 503 })); + fireEvent.click(screen.getByTestId('maintenance-verification-review')); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent( + 'Last loaded reports remain below.', + ); + expect(screen.getByTestId('maintenance-verification-status-healthy')).toHaveTextContent( + 'Healthy', + ); + expect(screen.getByText('Window ended 1 min ago')).toBeInTheDocument(); + expect(screen.queryByText(/Reviewed/)).not.toBeInTheDocument(); + expect(mutations()).toHaveLength(1); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(3); + }); + + it.each([401, 403])( + 'withdraws reports on a denied same-resource refresh (%s), rather than offering them as authorised history', + async (status) => { + const saved = report(); + await mount([saved]); + api.apiFetchJSON + .mockResolvedValueOnce(saved) + .mockRejectedValueOnce( + Object.assign(new Error('Synthetic access internals must not render'), { status }), + ); + fireEvent.click(screen.getByTestId('maintenance-verification-rerun')); + await vi.advanceTimersByTimeAsync(60_000); + expect(screen.queryByTestId('maintenance-verification-report')).not.toBeInTheDocument(); + expect(screen.queryByText(/No verification reports yet/)).not.toBeInTheDocument(); + expect(screen.getByTestId('maintenance-verification-load-error')).toHaveTextContent( + status === 401 + ? 'Sign in again or check your API token.' + : 'Access denied. Check your permissions and license plan.', + ); + expect(screen.queryByText(/Synthetic access internals/)).not.toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(3); + const reviewed = report({ userOutcome: 'reviewed', reviewedAt: iso(60_000) }); + api.apiFetchJSON.mockResolvedValueOnce(response([reviewed])); + fireEvent.click(screen.getByRole('button', { name: 'Retry reports' })); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByText('Reviewed 0s ago')).toBeInTheDocument(); + expect(screen.queryByTestId('maintenance-verification-load-error')).not.toBeInTheDocument(); + expect(api.apiFetchJSON).toHaveBeenCalledTimes(4); + expect(mutations()).toHaveLength(1); + }, + ); +});