From 2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:27:13 +0100 Subject: [PATCH 01/24] Reproduce live guest discovery offered during backup deferrals Exercise the real guest drawer and Discovery run controls plus retained handler calls and in-flight completion. Saved evidence and ordinary non-affected actions stay available. Change-source: pulse-maintainer --- .../__tests__/DiscoveryRun.safety.test.tsx | 112 ++++++++++++++++++ .../GuestDrawer.discovery-safety.test.tsx | 91 ++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx new file mode 100644 index 0000000000..16ac814dfa --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx @@ -0,0 +1,112 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ResourceDiscovery } from '@/types/discovery'; +import { DiscoveryTab } from '../DiscoveryTab'; +import { useDiscoveryTabState } from '../useDiscoveryTabState'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import * as discoveryApi from '@/api/discovery'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); + +const reason = 'Discovery paused for this guest. Saved results remain available.'; +const target = { resourceType: 'vm' as const, agentId: 'node-agent', resourceId: '100', hostname: 'guest' }; +const saved = { + id: 'vm:node-agent:100', resource_type: 'vm', resource_id: '100', target_id: 'node-agent', + hostname: 'guest', service_type: 'home-assistant', service_name: 'Saved service', category: 'app', + facts: [], config_paths: [], data_paths: [], log_paths: [], ports: [], user_notes: '', + discovered_at: '2026-10-03T12:00:00Z', updated_at: '2026-10-03T12:00:00Z', confidence: 0.9, +} as ResourceDiscovery; + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null); + vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(null); +}); +afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); + +describe('manual discovery safety', () => { + it.each([ + ['loading', () => new Promise(() => undefined)], + ['absent', async () => null], + ['empty', async () => ({ ...saved, service_type: '', service_name: '', confidence: 0 })], + ['saved', async () => saved], + ] as const)('blocks all run affordances with %s discovery while retaining passive reads', async (_name, lookup) => { + vi.mocked(discoveryApi.getDiscovery).mockImplementation(lookup); + const props = { ...target, runBlockReason: reason }; + render(() => ); + await waitFor(() => expect(discoveryApi.getDiscoveryInfo).toHaveBeenCalled()); + await waitFor(() => { + const buttons = screen.getAllByRole('button', { name: /^(Run|Re-scan|Update) Discovery/ }); + for (const button of buttons) expect(button).toBeDisabled(); + }); + expect(screen.getByTestId('discovery-run-block')).toHaveTextContent(reason); + expect(discoveryApi.getDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '100'); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it('keeps saved evidence mounted and restores only the explicit run action, not an automatic scan', async () => { + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(saved); + const [block, setBlock] = createSignal(reason); + const props = { ...target, get runBlockReason() { return block(); }, showManualRunAction: true }; + render(() => ); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + const evidence = await screen.findByText('Saved service'); + expect(evidence).toBeVisible(); + setBlock(null); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Saved service')).toBe(evidence); + expect(screen.queryByTestId('discovery-run-block')).toBeNull(); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); + + it('checks the current block inside the handler even when a caller retained the old enabled verdict', async () => { + const [block, setBlock] = createSignal(null); + const props = { ...target, get runBlockReason() { return block(); } }; + let state!: ReturnType; + render(() => { state = useDiscoveryTabState(props); return state; }); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true)); + setBlock(reason); + await state.handleTriggerDiscovery(true); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(state.isScanning()).toBe(false); + expect(state.scanSuccess()).toBe(false); + }); + + it('does not cancel or hide an in-flight scan when a new snapshot blocks further runs', async () => { + let finish!: (value: ResourceDiscovery | null) => void; + vi.mocked(discoveryApi.triggerDiscovery).mockReturnValue(new Promise((resolve) => { finish = resolve; })); + const [block, setBlock] = createSignal(null); + const props = { ...target, get runBlockReason() { return block(); } }; + let state!: ReturnType; + render(() => { state = useDiscoveryTabState(props); return state; }); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true)); + const original = state.handleTriggerDiscovery(true); + expect(state.isScanning()).toBe(true); + setBlock(reason); + await state.handleTriggerDiscovery(true); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1); + expect(state.isScanning()).toBe(true); + finish(saved); + await original; + expect(state.isScanning()).toBe(false); + expect(state.discovery()).toBe(saved); + expect(state.scanSuccess()).toBe(true); + setBlock(null); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx new file mode 100644 index 0000000000..23e1b84f8e --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx @@ -0,0 +1,91 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkloadGuest } from '@/types/workloads'; +import { GuestDrawer } from '../GuestDrawer'; +import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import * as discoveryApi from '@/api/discovery'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); +vi.mock('@/api/charts', async (importOriginal) => ({ + ...(await importOriginal()), + ChartsAPI: { getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })) }, +})); +vi.mock('@/stores/license', () => ({ isRangeLocked: () => false, loadRuntimeCapabilities: vi.fn(), maxHistoryDays: () => 90 })); + +const guest = (patch: Partial = {}): WorkloadGuest => ({ + id: 'inst:node:100', instance: 'inst', node: 'node', vmid: 100, name: 'Backup guest', type: 'qemu', + status: 'running', cpu: 0.25, cpus: 2, memory: { total: 1024, used: 512, free: 512, usage: 50 }, + disk: { total: 1024, used: 512, free: 512, usage: 50 }, + networkIn: 1, networkOut: 2, diskRead: 3, diskWrite: 4, uptime: 100, template: false, + lastBackup: 0, tags: [], lock: '', lastSeen: '2026-10-04T12:00:00Z', guestAgentStatus: 'available', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, ...patch, +} as WorkloadGuest); + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + resetCreateNonSuspendingQueryCacheForTest(); +}); +afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); + +describe('guest drawer and real Discovery safety wiring', () => { + it.each([ + ...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]), + ])('does not offer a live scan for %s, even with an assigned node agent and available metadata', async (reason) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); + }); + + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])('guards the native operation signal %j without fabricating a disk read reason', async (patch) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled()); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => { + const [value, setValue] = createSignal(guest({ lock: 'backup' })); + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + setValue(guest()); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Run Discovery' })).toBe(button); + expect(screen.getByRole('tab', { name: 'Discovery' })).toHaveAttribute('aria-selected', 'true'); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + setValue(guest({ diskStatusReason: 'prev-agent-timeout' })); + await waitFor(() => expect(button).toBeDisabled()); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it.each([ + { type: 'qemu' }, + { type: 'lxc', lock: 'backup', diskStatusReason: 'prev-agent-timeout', discoveryTarget: { resourceType: 'system-container', agentId: 'node-agent', resourceId: '100' } }, + { type: 'vm', platformType: 'vmware', guestAgentStatus: '', lock: '', discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' } }, + ] as const)('preserves ordinary manual scans outside the Proxmox guest safety condition %j', async (patch) => { + render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); +}); From ea6a52d43c8500f8ca087800d810ae34bfc07495 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:42:30 +0100 Subject: [PATCH 02/24] Pause manual guest discovery during backup read deferrals Guard every run control and the retained handler with the current guest precaution. Keep saved discovery readable, same-guest selection stable and already-dispatched completion visible; clearing a pause neither proves thaw nor starts a scan. Desktop and phone Chromium/WebKit rendering verified with synthetic APIs. Contract-Neutral: Existing drawer-only manual discovery safety gating; no canonical resource, API, identity, permission or polling contract delta. Change-source: pulse-maintainer --- .../browser-tests/guest-discovery-safety.cjs | 329 ++++++++++++++++++ .../browser-tests/guest-discovery-safety.html | 12 + .../browser-tests/guest-discovery-safety.tsx | 126 +++++++ .../430ebb1acb72552b.json | 67 ++++ .../src/components/Discovery/DiscoveryTab.tsx | 24 +- .../__tests__/DiscoveryRun.safety.test.tsx | 125 +++++-- .../Discovery/useDiscoveryTabState.ts | 14 +- .../src/components/Workloads/GuestDrawer.tsx | 5 + .../GuestDrawer.discovery-safety.test.tsx | 150 +++++--- 9 files changed, 777 insertions(+), 75 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.cjs create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.html create mode 100644 frontend-modern/browser-tests/guest-discovery-safety.tsx create mode 100644 frontend-modern/browser-verification/430ebb1acb72552b.json diff --git a/frontend-modern/browser-tests/guest-discovery-safety.cjs b/frontend-modern/browser-tests/guest-discovery-safety.cjs new file mode 100644 index 0000000000..8629e6cc4e --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.cjs @@ -0,0 +1,329 @@ +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 root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/discovery-safety-proof/browser'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', + 'frontend-modern/src/components/Discovery/DiscoveryTab.tsx', + 'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const initial = { + state: 'deferred', + reason: 'prev-vm-locked', + lock: 'backup', + backup: true, + version: '6.4.5', + assignment: true, + diskUsed: 5 * 1024 ** 3, +}; +const healthy = { ...initial, state: 'available', reason: '', lock: '', backup: false }; +const saved = { + id: 'vm:fixture-node-agent:101', + resource_type: 'vm', + resource_id: '101', + target_id: 'fixture-node-agent', + hostname: 'backup-guest', + service_type: 'home-assistant', + service_name: 'Saved Home Assistant', + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-03T12:00:00Z', + updated_at: '2026-10-03T12:00:00Z', + discovery_engine_version: 1, +}; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash(path.join('/workspace', p))])), + playwright: require('playwright/package.json').version, + groups: [], + screenshots: [], + cleanup: {}, + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null }, + }); + const origin = 'http://127.0.0.1:5297'; + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1365, false], + ['chromium-desktop-dark', chromium, 1365, true], + ['webkit-phone-dark', webkit, 390, true], + ['webkit-narrow-light', webkit, 320, false], + ]) { + phase = name; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(60000); + const errors = [], + requests = [], + offOrigin = [], + checks = []; + report.groups.push({ + name, + width, + dark, + browser: browser.version(), + checks, + requests, + errors, + offOrigin, + }); + await page.routeWebSocket('**/ws*', () => {}); + let hold = false, + release; + page.on('pageerror', (e) => errors.push(e.message)); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ + path: url.pathname, + query: url.search, + method: req.method(), + body: req.postData(), + }); + if (url.pathname === '/api/discovery/vm/fixture-node-agent/101') { + if (req.method() === 'POST') { + if (hold) + await new Promise((resolve) => { + release = resolve; + }); + return route.fulfill({ json: saved }); + } + return route.fulfill({ json: saved }); + } + assert.equal(req.method(), 'GET'); + if (url.pathname.startsWith('/api/discovery/info/')) + return route.fulfill({ + json: { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + }); + if (url.pathname === '/api/ai/agents') + return route.fulfill({ + json: { + count: 1, + agents: [ + { + agent_id: 'fixture-node-agent', + hostname: 'pve-a', + version: '6.4.5', + platform: 'linux', + }, + ], + }, + }); + if (url.pathname.includes('metrics-store/history')) + return route.fulfill({ + json: { + resourceType: 'vm', + resourceId: url.searchParams.get('resourceId'), + range: '24h', + start: Date.UTC(2026, 9, 3, 12), + end: Date.UTC(2026, 9, 4, 12), + metrics: {}, + source: 'store', + }, + }); + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: [], total: 0, page: 1, limit: 500 } }); + if (url.pathname.includes('license')) + return route.fulfill({ json: { capabilities: [], limits: [], max_history_days: 90 } }); + if (url.pathname.includes('metadata')) return route.fulfill({ json: {} }); + return route.fulfill({ + json: { success: true, data: [], alerts: [], anomalies: [], config: null }, + }); + }); + await page.goto(`${origin}/browser-tests/guest-discovery-safety.html`, { + waitUntil: 'domcontentloaded', + }); + await page.waitForFunction(() => Boolean(window.__guestDiscoverySafety)); + const row = page.locator('[data-guest-id]').first(); + if (width > 500) { + await row.getByRole('button', { name: 'Expand backup-guest', exact: true }).focus(); + await page.keyboard.press('Enter'); + } else await row.tap(); + await page.getByRole('tab', { name: 'Discovery', exact: true }).click(); + const run = page.getByRole('button', { name: 'Run Discovery', exact: true }); + await run.waitFor(); + const update = async (value) => + page.evaluate((v) => window.__guestDiscoverySafety.update(v), value); + const postCount = () => requests.filter((r) => r.method === 'POST').length; + const block = page.getByTestId('discovery-run-block'); + const capture = async (state) => { + const filename = `${name}-${state}.png`, + file = path.join(output, filename); + await run.scrollIntoViewIfNeeded(); + await page.screenshot({ path: file, fullPage: true }); + report.screenshots.push({ filename, sha256: hash(file) }); + }; + const layout = async () => { + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + for (const loc of [block, page.getByTestId('guest-read-precaution')]) { + const box = await loc.boundingBox(); + assert(box && box.x >= 0 && box.x + box.width <= width + 1); + assert(await loc.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); + } + }; + await page.getByText('Saved Home Assistant', { exact: true }).first().waitFor(); + assert(await run.isDisabled()); + assert.equal(postCount(), 0); + assert((await block.innerText()).includes('does not prove thaw')); + assert(await run.getAttribute('aria-describedby')); + assert.equal(await block.getAttribute('id'), await run.getAttribute('aria-describedby')); + await layout(); + await capture('locked-saved'); + checks.push( + 'Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason', + ); + await page.evaluate(() => { + window.__originalSafetyButton = document.querySelector( + '[aria-describedby^="discovery-run-block-"]', + ); + }); + for (const reason of [ + 'vm-locked', + 'lock-unverified', + 'agent-busy', + 'agent-cooldown', + 'agent-response-incomplete', + 'agent-capacity', + 'invalid-guest-key', + 'agent-timeout', + ]) { + for (const prefix of ['', 'prev-']) { + await update({ ...healthy, reason: prefix + reason }); + assert(await run.isDisabled()); + assert.equal(postCount(), 0); + assert( + await page.evaluate( + () => + document.querySelector('[aria-describedby^="discovery-run-block-"]') === + window.__originalSafetyButton, + ), + ); + } + } + await update({ ...healthy, reason: 'prev-agent-timeout' }); + await capture('uncertain-saved'); + await page.getByRole('tab', { name: 'History', exact: true }).click(); + assert(await page.getByTestId('guest-read-precaution').isVisible()); + await page.getByRole('tab', { name: 'Discovery', exact: true }).click(); + assert(await run.isDisabled()); + checks.push( + 'All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan', + ); + await update(healthy); + assert(await run.isEnabled()); + assert.equal(await block.count(), 0); + assert.equal(postCount(), 0); + await capture('ready-manual'); + await run.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!')); + assert.equal(postCount(), 1); + const first = requests.find((r) => r.method === 'POST'); + assert.deepEqual(JSON.parse(first.body), { force: true, hostname: 'backup-guest' }); + checks.push( + 'Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing', + ); + await page.waitForFunction(() => !document.body.textContent.includes('Discovery complete!')); + hold = true; + const secondPost = page.waitForRequest( + (req) => + req.method() === 'POST' && + new URL(req.url()).pathname === '/api/discovery/vm/fixture-node-agent/101', + ); + if (width < 500) await run.tap(); + else await run.click(); + await secondPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + assert.equal(postCount(), 2); + await update({ ...initial, reason: 'agent-timeout', lock: '', backup: false }); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + assert(await block.isVisible()); + assert((await page.locator('body').innerText()).includes('Scanning...')); + assert.equal(postCount(), 2); + assert(release); + release(); + await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!')); + assert(await run.isDisabled()); + assert.equal(postCount(), 2); + await capture('inflight-completed-blocked'); + checks.push( + 'A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent', + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + await browser.close(); + browser = null; + } + report.result = 'passed'; + report.verified_at = new Date().toISOString(); + } catch (error) { + report.result = 'failed'; + report.phase = phase; + report.error = String(error.stack || error); + throw error; + } finally { + if (browser) await browser.close(); + report.cleanup.browser_closed = true; + await server.close(); + report.cleanup.server_closed = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-discovery-safety.html b/frontend-modern/browser-tests/guest-discovery-safety.html new file mode 100644 index 0000000000..74a7217db4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.html @@ -0,0 +1,12 @@ + + + + + + Guest Discovery safety verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-discovery-safety.tsx b/frontend-modern/browser-tests/guest-discovery-safety.tsx new file mode 100644 index 0000000000..dfeb8e8018 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.tsx @@ -0,0 +1,126 @@ +// Production canonical resource projection, row, drawer, router and Docs. +// Synthetic observations only; no native QGA, diagnostic or backup operation. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { mergeCanonicalResourceSnapshot } from '../src/utils/resourceStateAdapters'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import Docs from '../src/pages/Docs'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: true }); +interface Observation { + state?: string; + reason?: string; + lock?: string; + backup?: boolean; + version?: string; + assignment?: boolean; + diskUsed?: number; +} +const initial: Observation = { + state: 'deferred', + reason: 'prev-vm-locked', + lock: 'backup', + backup: true, + version: '6.4.5', + assignment: true, + diskUsed: 5 * 1024 ** 3, +}; +const nativeResource = (value: Observation): Resource => ({ + id: 'fixture-pve:pve-a:101', + type: 'vm', + name: 'backup-guest', + displayName: 'backup-guest', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + sourceType: 'api', + status: 'online', + sources: ['proxmox', 'agent'], + lastSeen: Date.UTC(2026, 9, 4, 14), + cpu: { current: 10 }, + memory: { current: 25, used: 1024 ** 3 / 4, total: 1024 ** 3 }, + disk: { + current: ((value.diskUsed ?? 0) / (10 * 1024 ** 3)) * 100, + used: value.diskUsed, + total: 10 * 1024 ** 3, + }, + proxmox: { + instance: 'fixture-pve', + nodeName: 'pve-a', + vmid: 101, + runtimeStatus: 'running', + template: false, + guestAgentStatus: value.state, + diskStatusReason: value.reason, + lock: value.lock, + guestAgentExpected: true, + backupInProgress: value.backup ?? false, + lastBackup: '2026-10-04T06:00:00Z', + disks: [], + }, + agent: { + agentId: 'fixture-pulse-agent', + agentVersion: value.version || '', + hostname: 'backup-guest', + osName: 'Linux', + }, + discoveryTarget: value.assignment + ? { resourceType: 'vm', agentId: 'fixture-node-agent', resourceId: '101' } + : undefined, +}); +function Fixture() { + const [observation, setObservation] = createSignal(initial); + const [snapshot, setSnapshot] = createSignal([nativeResource(initial)]); + const state = useWorkloads(() => true, { resourceSnapshot: snapshot }); + const guest = () => state.workloads()[0]!; + const [expanded, setExpanded] = createSignal(false); + (window as any).__guestDiscoverySafety = { + update: (next: Observation) => { + setObservation(next); + setSnapshot((previous) => mergeCanonicalResourceSnapshot([nativeResource(next)], previous)); + }, + observation, + identity: () => guest().id, + }; + return ( +
+

Guest Discovery safety evidence

+

Synthetic observations. No guest check is sent.

+ + + + setExpanded(!expanded())} + workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'} + /> + +
+ +
+ setExpanded(false)} /> +
+
+
+
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/430ebb1acb72552b.json b/frontend-modern/browser-verification/430ebb1acb72552b.json new file mode 100644 index 0000000000..ce1175ad3d --- /dev/null +++ b/frontend-modern/browser-verification/430ebb1acb72552b.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781", + "changed_paths": [ + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts", + "frontend-modern/src/components/Workloads/GuestDrawer.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "d8ba9c14cbfac0756f8df461bea07c40a39ef56ba907301b71e92286e410facc", + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "01e2d687067df69d4f3eb6ea74742c9ce478f278976ee87c96c3567ff1b82896", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "19388b7e163d5fff79416f4bcb9c47ba0718cede9a56dbdf331065ab09cc00ee" + }, + "routes": [ + "/browser-tests/guest-discovery-safety.html: production canonical resource projection, Workloads row, GuestDrawer and DiscoveryTab with synthetic APIs" + ], + "states": [ + "Desktop light/dark and 390px/320px WebKit phone: locked guest and saved results", + "Every declared deferral and retained variant; same-ID/tab changes", + "Healthy manual action; uncertain read; in-flight scan completion while further runs are blocked" + ], + "interactions": [ + "Keyboard expand/tab/explicit run and touch expand/run; accessible disabled reason; wrapping warning/saved service inspection", + "No POST on blocked/unlock/snapshot/tab changes; exactly one original-body POST per explicit enabled run", + "No remount, cancellation or false in-flight-stop; original completion remains visible and additional run is blocked" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "verified_at": "2026-10-04T15:40:18.829Z", + "script": "frontend-modern/browser-tests/guest-discovery-safety.cjs", + "browsers": [ + { + "group": "chromium-desktop-light", + "version": "141.0.7390.37" + }, + { + "group": "chromium-desktop-dark", + "version": "141.0.7390.37" + }, + { + "group": "webkit-phone-dark", + "version": "26.0" + }, + { + "group": "webkit-narrow-light", + "version": "26.0" + } + ], + "limitations": [ + "Synthetic backend responses, not a native guest/backup or all-path backend admission proof.", + "Vite-transformed production components; production build is separately checked in the source-proof VM.", + "Independent thaw/covered-filesystem writes remain necessary; an unlocked snapshot does not prove them." + ] +} diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 6de5668272..169fd12981 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createMemo } from 'solid-js'; +import { Component, For, Show, createMemo, createUniqueId } from 'solid-js'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; import type { ResourceType } from '../../types/discovery'; @@ -33,6 +33,7 @@ import { import { ButtonLink, CopyValueButton } from '@/components/shared/Button'; import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow'; import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame'; +import { InlineNotice } from '@/components/shared/InlineNotice'; import { useDiscoveryTabState } from './useDiscoveryTabState'; import { orderFactsByActionability } from './factOrdering'; import { deriveCliCommand } from './cliCommand'; @@ -54,6 +55,8 @@ interface DiscoveryTabProps { commandsEnabled?: boolean; /** Show the primary run action at the top of embedded drawer contexts. */ showManualRunAction?: boolean; + /** Safety pause for live manual scans, independent of command permissions. */ + runBlockReason?: string | null; } export const DiscoveryTab: Component = (props) => { @@ -75,6 +78,7 @@ export const DiscoveryTab: Component = (props) => { liveElapsedSeconds, notesText, mutateDiscovery, + runBlockReason, saveError, scanError, scanProgress, @@ -90,6 +94,7 @@ export const DiscoveryTab: Component = (props) => { startEditingNotes, validDiscovery, } = useDiscoveryTabState(props); + const runBlockReasonId = `discovery-run-block-${createUniqueId()}`; const suggestedURLReasonText = createMemo(() => { const current = discovery(); @@ -225,6 +230,19 @@ export const DiscoveryTab: Component = (props) => { + + {(reason) => ( + + {reason()} + + )} + +
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component = (props) => { type="button" onClick={() => handleTriggerDiscovery(true)} disabled={isScanning() || !canTriggerDiscovery()} + aria-describedby={runBlockReason() ? runBlockReasonId : undefined} class="inline-flex min-h-9 items-center justify-center gap-1.5 rounded-sm border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50" > = (props) => {
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx index 23e1b84f8e..f8c98610a1 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.discovery-safety.test.tsx @@ -13,56 +13,104 @@ vi.mock('@/api/discovery', async (importOriginal) => ({ getDiscovery: vi.fn(async () => null), getDiscoveryInfo: vi.fn(async () => ({ ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, - commands: [], command_categories: [], + commands: [], + command_categories: [], })), getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), triggerDiscovery: vi.fn(async () => null), })); vi.mock('@/api/charts', async (importOriginal) => ({ ...(await importOriginal()), - ChartsAPI: { getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })) }, + ChartsAPI: { + getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })), + }, +})); +vi.mock('@/stores/license', () => ({ + isRangeLocked: () => false, + loadRuntimeCapabilities: vi.fn(), + maxHistoryDays: () => 90, })); -vi.mock('@/stores/license', () => ({ isRangeLocked: () => false, loadRuntimeCapabilities: vi.fn(), maxHistoryDays: () => 90 })); -const guest = (patch: Partial = {}): WorkloadGuest => ({ - id: 'inst:node:100', instance: 'inst', node: 'node', vmid: 100, name: 'Backup guest', type: 'qemu', - status: 'running', cpu: 0.25, cpus: 2, memory: { total: 1024, used: 512, free: 512, usage: 50 }, - disk: { total: 1024, used: 512, free: 512, usage: 50 }, - networkIn: 1, networkOut: 2, diskRead: 3, diskWrite: 4, uptime: 100, template: false, - lastBackup: 0, tags: [], lock: '', lastSeen: '2026-10-04T12:00:00Z', guestAgentStatus: 'available', - discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, ...patch, -} as WorkloadGuest); +const guest = (patch: Partial = {}): WorkloadGuest => + ({ + id: 'inst:node:100', + instance: 'inst', + node: 'node', + vmid: 100, + name: 'Backup guest', + type: 'qemu', + status: 'running', + cpu: 0.25, + cpus: 2, + memory: { total: 1024, used: 512, free: 512, usage: 50 }, + disk: { total: 1024, used: 512, free: 512, usage: 50 }, + networkIn: 1, + networkOut: 2, + diskRead: 3, + diskWrite: 4, + uptime: 100, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: '2026-10-04T12:00:00Z', + guestAgentStatus: 'available', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, + ...patch, + }) as WorkloadGuest; beforeEach(() => { resetAIRuntimeState(); syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); resetCreateNonSuspendingQueryCacheForTest(); }); -afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); }); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.clearAllMocks(); +}); describe('guest drawer and real Discovery safety wiring', () => { - it.each([ - ...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]), - ])('does not offer a live scan for %s, even with an assigned node agent and available metadata', async (reason) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - const button = await screen.findByRole('button', { name: 'Run Discovery' }); - await waitFor(() => expect(button).toBeDisabled()); - fireEvent.click(button); - expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); - expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); - }); + it.each([...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`])])( + 'does not offer a live scan for %s, even with an assigned node agent and available metadata', + async (reason) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + expect(screen.getByTestId('guest-read-precaution')).toBeVisible(); + }, + ); - it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])('guards the native operation signal %j without fabricating a disk read reason', async (patch) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled()); - expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); - }); + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])( + 'guards the native operation signal %j without fabricating a disk read reason', + async (patch) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled(), + ); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + }, + ); it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => { const [value, setValue] = createSignal(guest({ lock: 'backup' })); - render(() => ); + render(() => ( + + + + )); fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); const button = await screen.findByRole('button', { name: 'Run Discovery' }); await waitFor(() => expect(button).toBeDisabled()); @@ -78,14 +126,36 @@ describe('guest drawer and real Discovery safety wiring', () => { it.each([ { type: 'qemu' }, - { type: 'lxc', lock: 'backup', diskStatusReason: 'prev-agent-timeout', discoveryTarget: { resourceType: 'system-container', agentId: 'node-agent', resourceId: '100' } }, - { type: 'vm', platformType: 'vmware', guestAgentStatus: '', lock: '', discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' } }, - ] as const)('preserves ordinary manual scans outside the Proxmox guest safety condition %j', async (patch) => { - render(() => ); - fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); - const button = await screen.findByRole('button', { name: 'Run Discovery' }); - await waitFor(() => expect(button).toBeEnabled()); - fireEvent.click(button); - await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); - }); + { + type: 'lxc', + lock: 'backup', + diskStatusReason: 'prev-agent-timeout', + discoveryTarget: { + resourceType: 'system-container', + agentId: 'node-agent', + resourceId: '100', + }, + }, + { + type: 'vm', + platformType: 'vmware', + guestAgentStatus: '', + lock: '', + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, + }, + ] as const)( + 'preserves ordinary manual scans outside the Proxmox guest safety condition %j', + async (patch) => { + render(() => ( + + + + )); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }, + ); }); From 773fd82073b99c82421f2a60f072739fadb082aa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:48:39 +0100 Subject: [PATCH 03/24] Complete typed saved-discovery safety fixtures Keep the browser-verified runtime unchanged while supplying the actual required discovery fields and explicit async fixture return types. The first connected proof passed all 1,932 tests but failed TypeScript; retain it and validate this exact correction. Change-source: pulse-maintainer --- .../__tests__/DiscoveryRun.safety.test.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx index 4fc580c161..258b615192 100644 --- a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.safety.test.tsx @@ -26,7 +26,7 @@ const target = { resourceId: '100', hostname: 'guest', }; -const saved = { +const saved: ResourceDiscovery = { id: 'vm:node-agent:100', resource_type: 'vm', resource_id: '100', @@ -35,6 +35,11 @@ const saved = { service_type: 'home-assistant', service_name: 'Saved service', category: 'home_automation', + service_version: 'fixture', + cli_access: '', + user_secrets: {}, + ai_reasoning: 'Synthetic saved evidence', + scan_duration: 0, facts: [], config_paths: [], data_paths: [], @@ -44,7 +49,7 @@ const saved = { discovered_at: '2026-10-03T12:00:00Z', updated_at: '2026-10-03T12:00:00Z', confidence: 0.9, -} as ResourceDiscovery; +}; beforeEach(() => { resetAIRuntimeState(); @@ -60,10 +65,18 @@ afterEach(() => { describe('manual discovery safety', () => { it.each([ - ['loading', () => new Promise(() => undefined)], - ['absent', async () => null], - ['empty', async () => ({ ...saved, service_type: '', service_name: '', confidence: 0 })], - ['saved', async () => saved], + ['loading', (): Promise => new Promise(() => undefined)], + ['absent', async (): Promise => null], + [ + 'empty', + async (): Promise => ({ + ...saved, + service_type: '', + service_name: '', + confidence: 0, + }), + ], + ['saved', async (): Promise => saved], ] as const)( 'blocks all run affordances with %s discovery while retaining passive reads', async (_name, lookup) => { From bc3f4d81d14873caae6f769dfa670d16fa13d518 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:16:17 +0100 Subject: [PATCH 04/24] Reproduce Discovery outcomes crossing guest and run lifecycles Exercise late HTTP completion, retained background safety errors, pending refreshes, missing-target state, timer ownership and notes saves against the existing production state owner. These controls isolate truthful Discovery use without sending guest commands or claiming native backup acceptance. Change-source: pulse-maintainer --- .../__tests__/DiscoveryRun.lifecycle.test.tsx | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx new file mode 100644 index 0000000000..ab6a4c1014 --- /dev/null +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryRun.lifecycle.test.tsx @@ -0,0 +1,280 @@ +import { createSignal } from 'solid-js'; +import { cleanup, render, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ResourceDiscovery } from '@/types/discovery'; +import { eventBus } from '@/stores/events'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import * as api from '@/api/discovery'; +import { useDiscoveryTabState } from '../useDiscoveryTabState'; + +vi.mock('@/api/discovery', () => ({ + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(), + updateDiscoveryNotes: vi.fn(async () => undefined), +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const saved = (id = '100'): ResourceDiscovery => ({ + id: `vm:node-agent:${id}`, + resource_type: 'vm', + resource_id: id, + target_id: 'node-agent', + hostname: `guest-${id}`, + service_type: 'home-assistant', + service_name: `Saved service ${id}`, + service_version: 'fixture', + category: 'home_automation', + cli_access: '', + user_secrets: {}, + ai_reasoning: 'Synthetic saved evidence', + scan_duration: 0, + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + discovered_at: '2026-10-03T12:00:00Z', + updated_at: '2026-10-03T12:00:00Z', + confidence: 0.9, +}); + +async function mount(initialAgent = 'node-agent') { + const [id, setId] = createSignal('100'); + const [agent, setAgent] = createSignal(initialAgent); + let state!: ReturnType; + const view = render(() => { + state = useDiscoveryTabState({ + resourceType: 'vm', + get agentId() { + return agent(); + }, + get resourceId() { + return id(); + }, + get hostname() { + return `guest-${id()}`; + }, + }); + return Discovery lifecycle; + }); + await waitFor(() => expect(state.discoveryInfo.loading).toBe(false)); + await waitFor(() => expect(state.discovery.loading).toBe(false)); + return { state, setId, setAgent, dispose: view.unmount }; +} + +beforeEach(() => { + vi.resetAllMocks(); + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + vi.mocked(api.getDiscovery).mockImplementation(async (_type, _agent, id) => saved(id)); + vi.mocked(api.getDiscoveryInfo).mockResolvedValue({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + }); + vi.mocked(api.getConnectedAgents).mockResolvedValue({ count: 0, agents: [] }); + vi.mocked(api.triggerDiscovery).mockResolvedValue(saved()); +}); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.useRealTimers(); +}); + +describe('Discovery outcome ownership', () => { + it.each(['success', 'failure'] as const)( + 'does not apply an old target HTTP %s to the new guest or its in-flight run', + async (outcome) => { + const old = deferred(); + const current = deferred(); + vi.mocked(api.triggerDiscovery) + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + const { state, setId } = await mount(); + const first = state.handleTriggerDiscovery(true); + setId('101'); + await waitFor(() => expect(state.discovery()?.resource_id).toBe('101')); + const second = state.handleTriggerDiscovery(true); + if (outcome === 'success') old.resolve(saved()); + else old.reject(new Error('Old guest execution paused')); + await first; + expect(state.discovery()?.resource_id).toBe('101'); + expect(state.isScanning()).toBe(true); + expect(state.scanError()).toBeNull(); + expect(state.scanSuccess()).toBe(false); + current.resolve(saved('101')); + await second; + expect(state.scanSuccess()).toBe(true); + expect(api.triggerDiscovery).toHaveBeenNthCalledWith(2, 'vm', 'node-agent', '101', { + force: true, + hostname: 'guest-101', + }); + }, + ); + + it('invalidates an old run even when the operator leaves and returns to the same target', async () => { + const old = deferred(); + vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise); + const { state, setId } = await mount(); + const first = state.handleTriggerDiscovery(true); + setId('101'); + await waitFor(() => expect(state.discovery()?.resource_id).toBe('101')); + setId('100'); + await waitFor(() => expect(state.discovery()?.resource_id).toBe('100')); + old.resolve({ ...saved(), service_name: 'Old run result' }); + await first; + expect(state.discovery()?.service_name).toBe('Saved service 100'); + expect(state.scanSuccess()).toBe(false); + }); + + it('does not resurrect a run after Discovery was disabled and re-enabled', async () => { + const old = deferred(); + vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise); + const { state } = await mount(); + const first = state.handleTriggerDiscovery(true); + syncAIRuntimeSettings({ discovery_enabled: false } as Parameters[0]); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(false)); + syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]); + await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true)); + old.resolve({ ...saved(), service_name: 'Disabled run result' }); + await first; + expect(state.discovery()?.service_name).toBe('Saved service 100'); + expect(state.scanSuccess()).toBe(false); + }); + + it('ignores HTTP completion after disposal without scheduling another success timer', async () => { + const old = deferred(); + vi.mocked(api.triggerDiscovery).mockReturnValueOnce(old.promise); + const { state, dispose } = await mount(); + vi.useFakeTimers(); + const first = state.handleTriggerDiscovery(true); + dispose(); + old.resolve(saved()); + await first; + expect(state.scanSuccess()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not leave a spinner running when a retained handler has no target agent', async () => { + const { state } = await mount(''); + await state.handleTriggerDiscovery(true); + expect(api.triggerDiscovery).not.toHaveBeenCalled(); + expect(state.scanError()).toBe('Agent identifier unavailable for discovery'); + expect(state.isScanning()).toBe(false); + expect(state.scanSuccess()).toBe(false); + }); + + it('does not let an earlier success timer clear the success of a later explicit run', async () => { + const { state } = await mount(); + vi.useFakeTimers(); + await state.handleTriggerDiscovery(true); + await vi.advanceTimersByTimeAsync(1500); + await state.handleTriggerDiscovery(true); + await vi.advanceTimersByTimeAsync(600); + expect(state.scanSuccess()).toBe(true); + await vi.advanceTimersByTimeAsync(1400); + expect(state.scanSuccess()).toBe(false); + }); + + it.each(['failed', 'completed'] as const)( + 'retains a background %s-with-error outcome and the saved evidence, not silent success', + async (status) => { + const { state } = await mount(); + const before = vi.mocked(api.getDiscovery).mock.calls.length; + vi.useFakeTimers(); + eventBus.emit('ai_discovery_progress', { + resource_id: 'vm:node-agent:100', + status, + error: 'Guest execution paused: backup lock observed', + current_step: 'Guest execution paused', + }); + await vi.advanceTimersByTimeAsync(600); + expect(state.scanError()).toBe('Guest execution paused: backup lock observed'); + expect(state.scanSuccess()).toBe(false); + expect(state.discovery()?.service_name).toBe('Saved service 100'); + expect(api.getDiscovery).toHaveBeenCalledTimes(before); + expect(api.triggerDiscovery).not.toHaveBeenCalled(); + }, + ); + + it('shows background progress without claiming completion of a manual HTTP request', async () => { + const http = deferred(); + vi.mocked(api.triggerDiscovery).mockReturnValueOnce(http.promise); + const { state } = await mount(); + eventBus.emit('ai_discovery_progress', { + resource_id: 'vm:node-agent:100', + status: 'running', + current_step: 'Collecting guest evidence', + }); + expect(state.isScanning()).toBe(true); + eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' }); + expect(state.isScanning()).toBe(false); + const first = state.handleTriggerDiscovery(true); + eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' }); + expect(state.isScanning()).toBe(true); + expect(state.scanSuccess()).toBe(false); + http.resolve(saved()); + await first; + expect(state.scanSuccess()).toBe(true); + }); + + it('drops a delayed background refresh when its target changed before dispatch', async () => { + const { state, setId } = await mount(); + vi.useFakeTimers(); + eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' }); + setId('101'); + await Promise.resolve(); + const before = vi.mocked(api.getDiscovery).mock.calls.length; + await vi.advanceTimersByTimeAsync(600); + expect(api.getDiscovery).toHaveBeenCalledTimes(before); + expect(state.discovery()?.resource_id).toBe('101'); + expect(api.triggerDiscovery).not.toHaveBeenCalled(); + }); + + it('does not apply a delayed background read to a newer manual result on the same guest', async () => { + const refresh = deferred(); + const { state } = await mount(); + vi.mocked(api.getDiscovery).mockReturnValueOnce(refresh.promise); + vi.useFakeTimers(); + eventBus.emit('ai_discovery_progress', { resource_id: 'vm:node-agent:100', status: 'completed' }); + await vi.advanceTimersByTimeAsync(500); + vi.mocked(api.triggerDiscovery).mockResolvedValueOnce({ ...saved(), service_name: 'New manual result' }); + await state.handleTriggerDiscovery(true); + refresh.resolve({ ...saved(), service_name: 'Old background result' }); + await Promise.resolve(); + expect(state.discovery()?.service_name).toBe('New manual result'); + expect(state.scanSuccess()).toBe(true); + }); + + it('does not close another guest’s notes editor when an old save finishes', async () => { + const save = deferred(); + vi.mocked(api.updateDiscoveryNotes).mockReturnValueOnce(save.promise); + const { state, setId } = await mount(); + state.startEditingNotes(); + state.setNotesText('Original guest notes'); + const first = state.handleSaveNotes(); + setId('101'); + await waitFor(() => expect(state.discovery()?.resource_id).toBe('101')); + state.startEditingNotes(); + state.setNotesText('New guest draft'); + save.resolve(); + await first; + expect(state.editingNotes()).toBe(true); + expect(state.notesText()).toBe('New guest draft'); + expect(api.updateDiscoveryNotes).toHaveBeenCalledWith('vm', 'node-agent', '100', { + user_notes: 'Original guest notes', + }); + }); +}); From cadfd1f4e1d9f909a45977f94aadd4f1324e82ef Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:21:27 +0100 Subject: [PATCH 05/24] Keep TrueNAS API setup credentials and responses private Make the existing connection guide fail closed before editing credentials, ignore curl configuration, bound each single POST and retain sensitive responses in new private files. Explain actual test/save status and uncertain-save readback without retrying setup as diagnostics. Contract-Neutral: Documentation-only credential handling and copied-recipe regression tests; no runtime API, TrueNAS polling, authentication or connection-management contract changes. Change-source: pulse-maintainer --- docs/TRUENAS.md | 122 ++++-- frontend-modern/public/docs/TRUENAS.md | 122 ++++-- .../installtests/truenas_api_guidance_test.go | 361 ++++++++++++++++++ 3 files changed, 549 insertions(+), 56 deletions(-) create mode 100644 scripts/installtests/truenas_api_guidance_test.go diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 270d15e838..8578f16dd9 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -101,48 +101,114 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client, -prepare the private Pulse header file as described in the -[API authentication guide](API.md). The Pulse API token authenticates to -Pulse; the separate TrueNAS API key belongs in the connection's JSON file. -Do not put either credential in shell arguments, URLs or a public thread. +Prefer **Settings → Infrastructure → Platform connections** for setup. These +API examples are for an authorised administrator using curl 7.76 or later, +not a way to diagnose stale polling. Testing makes a live system-information +request to TrueNAS; saving creates a connection and starts ordinary polling. +Do not use either during a backup, freeze/thaw or an unresponsive-host incident. -Create or protect the connection file before opening it in a trusted editor: +First prepare a private Pulse header file using the +[API token file procedure](API.md#api-token-recommended), with the admin access +needed for connection management. The Pulse token authenticates to Pulse; +the separate TrueNAS API key belongs in the connection's JSON file. Enter +credentials only in your local editor, never in arguments, URLs or a thread. + +On a trusted machine, as the account running curl, prepare the connection +file below. Stop if either file-preparation step fails. This preserves an +existing file, restricts the directory and refuses symlinked credential paths +or a non-regular connection file before opening the editor: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/truenas-connection.json" -chmod 600 "$HOME/.config/pulse/truenas-connection.json" -vi "$HOME/.config/pulse/truenas-connection.json" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + connection_file="$config_dir/truenas-connection.json" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then + printf 'Connection file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$connection_file" + chmod 600 "$connection_file" + vi "$connection_file" +) ``` -In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), -`username` (the key owner) and `apiKey`. Keep both credential files private; -do not paste their contents into a terminal command or upload them. +In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private and +outside repositories and diagnostics. Do not upload them. -Test the connection before saving it: +The following examples run **on the Pulse host**, using its direct loopback +address. They ignore curl configuration, do not follow redirects or retry, +and retain each response in a new private local file. Responses and errors can +contain identifying details or echoed credentials; review them locally, not in +a public terminal recording or thread. Keep `--disable` first so local curl +configuration cannot enable tracing or change the request. + +Test once without saving: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections/test +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections/test) + printf 'HTTP %s\n' "$status" + [ "$status" = 200 ] +) ``` -After checking the test response, save the connection with: +Require exit 0, HTTP **200** and `success: true` in that private test response. +A redirect, HTTP error or `000` is not a successful test. Test success is not +inventory, metric or guest-thaw acceptance. + +Only after reviewing the test response, create the connection once: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections) + printf 'HTTP %s\n' "$status" + [ "$status" = 201 ] +) ``` -These Pulse URLs are loopback-only examples. For a remote Pulse server, use -its trusted HTTPS origin without disabling certificate verification. +A successful save returns HTTP **201** and the new connection's `id`, not +proof of a complete poll. If saving times out or loses its response, it may +already have created the connection. Check **Platform connections** or +`/api/truenas/connections` in your existing signed-in Pulse browser before +trying again; do not blindly repeat POST and create duplicates. Continue with +the [ordinary polling checks](#stale-truenas-data) for collection acceptance. + +For remote Pulse access, replace the loopback origin in the relevant command +with your trusted **HTTPS** origin and change `--proto '=http'` to +`--proto '=https'`. Keep certificate verification enabled. For a private CA, +add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from +your administrator, not from the failing connection. Do not add `--insecure`, +`--location`, verbose/trace flags or automatic retries. Share only the relevant +HTTP status and a manually redacted error, never the full response or headers. ## Troubleshooting diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index 270d15e838..8578f16dd9 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -101,48 +101,114 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client, -prepare the private Pulse header file as described in the -[API authentication guide](API.md). The Pulse API token authenticates to -Pulse; the separate TrueNAS API key belongs in the connection's JSON file. -Do not put either credential in shell arguments, URLs or a public thread. +Prefer **Settings → Infrastructure → Platform connections** for setup. These +API examples are for an authorised administrator using curl 7.76 or later, +not a way to diagnose stale polling. Testing makes a live system-information +request to TrueNAS; saving creates a connection and starts ordinary polling. +Do not use either during a backup, freeze/thaw or an unresponsive-host incident. -Create or protect the connection file before opening it in a trusted editor: +First prepare a private Pulse header file using the +[API token file procedure](API.md#api-token-recommended), with the admin access +needed for connection management. The Pulse token authenticates to Pulse; +the separate TrueNAS API key belongs in the connection's JSON file. Enter +credentials only in your local editor, never in arguments, URLs or a thread. + +On a trusted machine, as the account running curl, prepare the connection +file below. Stop if either file-preparation step fails. This preserves an +existing file, restricts the directory and refuses symlinked credential paths +or a non-regular connection file before opening the editor: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/truenas-connection.json" -chmod 600 "$HOME/.config/pulse/truenas-connection.json" -vi "$HOME/.config/pulse/truenas-connection.json" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + connection_file="$config_dir/truenas-connection.json" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then + printf 'Connection file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$connection_file" + chmod 600 "$connection_file" + vi "$connection_file" +) ``` -In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), -`username` (the key owner) and `apiKey`. Keep both credential files private; -do not paste their contents into a terminal command or upload them. +In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private and +outside repositories and diagnostics. Do not upload them. -Test the connection before saving it: +The following examples run **on the Pulse host**, using its direct loopback +address. They ignore curl configuration, do not follow redirects or retry, +and retain each response in a new private local file. Responses and errors can +contain identifying details or echoed credentials; review them locally, not in +a public terminal recording or thread. Keep `--disable` first so local curl +configuration cannot enable tracing or change the request. + +Test once without saving: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections/test +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections/test) + printf 'HTTP %s\n' "$status" + [ "$status" = 200 ] +) ``` -After checking the test response, save the connection with: +Require exit 0, HTTP **200** and `success: true` in that private test response. +A redirect, HTTP error or `000` is not a successful test. Test success is not +inventory, metric or guest-thaw acceptance. + +Only after reviewing the test response, create the connection once: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections) + printf 'HTTP %s\n' "$status" + [ "$status" = 201 ] +) ``` -These Pulse URLs are loopback-only examples. For a remote Pulse server, use -its trusted HTTPS origin without disabling certificate verification. +A successful save returns HTTP **201** and the new connection's `id`, not +proof of a complete poll. If saving times out or loses its response, it may +already have created the connection. Check **Platform connections** or +`/api/truenas/connections` in your existing signed-in Pulse browser before +trying again; do not blindly repeat POST and create duplicates. Continue with +the [ordinary polling checks](#stale-truenas-data) for collection acceptance. + +For remote Pulse access, replace the loopback origin in the relevant command +with your trusted **HTTPS** origin and change `--proto '=http'` to +`--proto '=https'`. Keep certificate verification enabled. For a private CA, +add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from +your administrator, not from the failing connection. Do not add `--insecure`, +`--location`, verbose/trace flags or automatic retries. Share only the relevant +HTTP status and a manually redacted error, never the full response or headers. ## Troubleshooting diff --git a/scripts/installtests/truenas_api_guidance_test.go b/scripts/installtests/truenas_api_guidance_test.go new file mode 100644 index 0000000000..9107e4107e --- /dev/null +++ b/scripts/installtests/truenas_api_guidance_test.go @@ -0,0 +1,361 @@ +package installtests + +import ( + "context" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +func trueNASAPIBlocks(doc string) ([]string, error) { + _, section, found := strings.Cut(doc, "### Testing and adding a connection (API)\n") + if !found { + return nil, fmt.Errorf("connection API guidance is missing") + } + section, _, _ = strings.Cut(section, "\n## Troubleshooting") + matches := regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(section, -1) + if len(matches) != 3 { + return nil, fmt.Errorf("want separate preparation/test/save blocks, got %d", len(matches)) + } + blocks := make([]string, 3) + for i, match := range matches { + blocks[i] = match[1] + } + return blocks, nil +} + +func trueNASAPISafety(blocks []string) error { + for _, required := range []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`, + `[ -L "$config_dir" ]`, `[ -L "$connection_file" ]`, `[ ! -f "$connection_file" ]`, + `chmod 700 "$config_dir"`, `chmod 600 "$connection_file"`, `vi "$connection_file"`} { + if !strings.Contains(blocks[0], required) { + return fmt.Errorf("unsafe file preparation: missing %s", required) + } + } + for i, block := range blocks[1:] { + for _, required := range []string{"set -eu", "umask 077", `result_file=$(mktemp "$HOME/.config/pulse/truenas-`, + "status=$(curl --disable --fail-with-body --silent --show-error", "--connect-timeout 5 --max-time 20", + "--proto '=http' --noproxy 127.0.0.1", `--header "@$HOME/.config/pulse/api-header"`, + `--data-binary "@$HOME/.config/pulse/truenas-connection.json"`, + `--output "$result_file" --write-out '%{http_code}'`, + fmt.Sprintf(`[ "$status" = %d ]`, 200+i)} { + if !strings.Contains(block, required) { + return fmt.Errorf("unsafe request %d: missing %s", i+1, required) + } + } + for _, forbidden := range []string{"--insecure", "--location", "--trace", "--verbose", "--config", "--retry", "X-API-Token:", "Authorization:"} { + if strings.Contains(block, forbidden) { + return fmt.Errorf("unsafe request: %s", forbidden) + } + } + if strings.Count(block, "curl ") != 1 || strings.Contains(block, " | ") { + return fmt.Errorf("each copied request must retain its own result and make only one POST") + } + } + return nil +} + +func trueNASAPIGuidance(t *testing.T) []string { + t.Helper() + doc, err := os.ReadFile(repoFile("docs", "TRUENAS.md")) + if err != nil { + t.Fatal(err) + } + blocks, err := trueNASAPIBlocks(string(doc)) + if err != nil { + t.Fatal(err) + } + if err := trueNASAPISafety(blocks); err != nil { + t.Fatal(err) + } + mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "TRUENAS.md")) + if err != nil || string(mirror) != string(doc) { + t.Fatal("shipped TrueNAS guide must be identical to the verified recipe") + } + return blocks +} + +func runTrueNASRecipe(t *testing.T, recipe, home, bin string, extraEnv ...string) (string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", recipe) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"), + "http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=") + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("copied recipe exceeded its bounded test deadline") + } + if err == nil { + return string(output), 0 + } + failure, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + return string(output), failure.ExitCode() +} + +func TestTrueNASAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) { + for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "non-regular", "mkdir failure", "chmod failure"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + bin := filepath.Join(home, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + edited := filepath.Join(home, "editor-started") + if err := os.WriteFile(filepath.Join(bin, "vi"), []byte("#!/bin/sh\nprintf 'started' > \"$EDITOR_STARTED\"\n"), 0700); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".config", "pulse") + file := filepath.Join(dir, "truenas-connection.json") + if mode == "existing" || mode == "file symlink" || mode == "non-regular" { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(home, "untouched") + if strings.Contains(mode, "symlink") { + link := file + if mode == "file symlink" { + if err := os.WriteFile(target, []byte("private-existing-key"), 0644); err != nil { + t.Fatal(err) + } + } else { + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + link = filepath.Join(home, ".config") + if mode == "directory symlink" { + if err := os.Mkdir(link, 0755); err != nil { + t.Fatal(err) + } + link = dir + } + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + } + if mode == "existing" { + if err := os.WriteFile(file, []byte("private-existing-key"), 0644); err != nil { + t.Fatal(err) + } + } + if mode == "non-regular" { + if err := os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + } + if strings.HasSuffix(mode, " failure") { + if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + } + output, exit := runTrueNASRecipe(t, trueNASAPIGuidance(t)[0], home, bin, "EDITOR_STARTED="+edited) + _, editorErr := os.Stat(edited) + if mode != "new" && mode != "existing" { + if exit == 0 || !os.IsNotExist(editorErr) { + t.Fatal("unsafe or failed preparation reached the editor") + } + if strings.Contains(mode, "symlink") { + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != map[bool]os.FileMode{true: 0644, false: 0755}[mode == "file symlink"] { + t.Fatal("refused path modified its target permissions") + } + } + } else { + if exit != 0 || editorErr != nil { + t.Fatalf("safe preparation failed: exit %d, %s", exit, output) + } + for path, permissions := range map[string]os.FileMode{dir: 0700, file: 0600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != permissions { + t.Fatal("credential path has incorrect permissions") + } + } + content, err := os.ReadFile(file) + if err != nil || (mode == "existing" && string(content) != "private-existing-key") { + t.Fatal("preparation did not preserve the existing credential file") + } + } + if strings.Contains(output, "private-existing-key") { + t.Fatal("preparation exposed private contents") + } + }) + } +} + +func TestTrueNASAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) { + blocks := trueNASAPIGuidance(t) + for name, edit := range map[string][2]string{ + "config": {"curl --disable ", "curl "}, "body": {`--output "$result_file" `, ""}, + "deadline": {"--max-time 20 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "}, + "redirect": {"curl --disable ", "curl --disable --location "}, + "inline token": {`"@$HOME/.config/pulse/api-header"`, `'X-API-Token: synthetic-secret'`}, + "fail-open": {"set -eu", ""}, "wrong save status": {`[ "$status" = 201 ]`, `[ "$status" = 200 ]`}, + } { + t.Run(name, func(t *testing.T) { + changed := make([]string, len(blocks)) + for i, block := range blocks { + changed[i] = strings.ReplaceAll(block, edit[0], edit[1]) + } + if trueNASAPISafety(changed) == nil { + t.Fatal("unsafe mutation was accepted") + } + }) + } + if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" { + parent, err := os.ReadFile(filepath.Join(inputs, "truenas-parent.md")) + if err != nil { + t.Fatal(err) + } + old, err := trueNASAPIBlocks(string(parent)) + if err != nil { + t.Fatal(err) + } + if err := trueNASAPISafety(old); err == nil { + t.Fatal("unchanged exact parent guide passed new safety controls") + } else { + t.Logf("exact parent rejected: %s", err) + } + } +} + +// Synthetic peers exercise the actual copied commands and deliberately echo +// both fixture secrets. This is credential/transport safety, not native +// TrueNAS collection, Pulse authorisation or a customer's setup acceptance. +func TestTrueNASAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) { + for _, scenario := range []string{"success", "HTTP error", "redirect", "wrong success status", "trusted HTTPS", "untrusted HTTPS"} { + for index, recipe := range trueNASAPIGuidance(t)[1:] { + t.Run(fmt.Sprintf("%s/%d", scenario, index+1), func(t *testing.T) { + realCurl, err := exec.LookPath("curl") + if err != nil { + t.Fatal("curl is required to verify copied TrueNAS guidance") + } + home := t.TempDir() + bin := filepath.Join(home, "bin") + dir := filepath.Join(home, ".config", "pulse") + for _, path := range []string{bin, dir} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + const token = "synthetic-pulse-secret" + const key = "synthetic-truenas-secret" + const body = `{"host":"https://synthetic.invalid","apiKey":"` + key + `"}` + for file, content := range map[string]string{"api-header": "X-API-Token: " + token, "truenas-connection.json": body} { + if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + capture := filepath.Join(home, "argv") + trace := filepath.Join(home, "must-not-trace") + wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$TN_ARGV\"\nexec \"$TN_CURL\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil { + t.Fatal(err) + } + requests := make(chan string, 4) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload, _ := io.ReadAll(r.Body) + requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("X-API-Token"), r.Header.Get("Content-Type"), string(payload)}, "\n") + code := 200 + index + switch scenario { + case "HTTP error": + code = 400 + case "redirect": + w.Header().Set("Location", "/must-not-follow") + code = 307 + case "wrong success status": + code = 201 - index + } + w.WriteHeader(code) + fmt.Fprint(w, token+" "+key+" private-response") + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + if strings.Contains(scenario, "HTTPS") { + server.StartTLS() + recipe = strings.Replace(recipe, "--proto '=http'", "--proto '=https'", 1) + if scenario == "trusted HTTPS" { + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil { + t.Fatal(err) + } + recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1) + } + } else { + server.Start() + } + t.Cleanup(server.Close) + recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1) + output, exit := runTrueNASRecipe(t, recipe, home, bin, "TN_ARGV="+capture, "TN_CURL="+realCurl) + wantExit := 0 + if scenario == "HTTP error" { + wantExit = 22 + } else if scenario == "untrusted HTTPS" { + wantExit = 60 + } else if scenario == "redirect" || scenario == "wrong success status" { + wantExit = 1 + } + if exit != wantExit { + t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output) + } + argv, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{token, key, "private-response"} { + if strings.Contains(string(argv), secret) || strings.Contains(output, secret) { + t.Fatal("copied request exposed a fixture credential or response") + } + } + if _, err := os.Stat(trace); !os.IsNotExist(err) { + t.Fatal("curl configuration enabled tracing") + } + responses, err := filepath.Glob(filepath.Join(dir, "truenas-*.??????")) + if err != nil || len(responses) != 1 { + t.Fatal("request must retain one new private response file") + } + info, err := os.Stat(responses[0]) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("response file is not private") + } + if scenario == "untrusted HTTPS" { + if len(requests) != 0 { + t.Fatal("untrusted TLS peer received credentials") + } + } else { + if len(requests) != 1 { + t.Fatal("request was missing, redirected or retried") + } + path := "/api/truenas/connections" + if index == 0 { + path += "/test" + } + if got := <-requests; got != strings.Join([]string{"POST", path, token, "application/json", body}, "\n") { + t.Fatal("copied POST differs from the documented endpoint, header or JSON file") + } + response, err := os.ReadFile(responses[0]) + if err != nil || string(response) != token+" "+key+" private-response" { + t.Fatal("complete sensitive response was not retained locally") + } + } + }) + } + } +} From d589407a743a12fe492e93785d9e24c12bbb4fb5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:31:04 +0100 Subject: [PATCH 06/24] Keep optional TrueNAS parent controls independent of other proof inputs Source proofs can supply unrelated input files. Retain recipe mutation controls while reading the exact parent only when its named guide was supplied; unrelated input directories must not become a new full-suite prerequisite. Contract-Neutral: Test-only optional-input handling; no shipped guidance or product contract change. Change-source: pulse-maintainer --- scripts/installtests/truenas_api_guidance_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/installtests/truenas_api_guidance_test.go b/scripts/installtests/truenas_api_guidance_test.go index 9107e4107e..d71f5ebd92 100644 --- a/scripts/installtests/truenas_api_guidance_test.go +++ b/scripts/installtests/truenas_api_guidance_test.go @@ -219,6 +219,11 @@ func TestTrueNASAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) { } if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" { parent, err := os.ReadFile(filepath.Join(inputs, "truenas-parent.md")) + if os.IsNotExist(err) { + // Other source proofs can supply unrelated inputs. The mutation + // controls above still apply without this optional parent receipt. + return + } if err != nil { t.Fatal(err) } From 817834440b3d83056c205eb92920fc4e7c5f28e8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:35:54 +0100 Subject: [PATCH 07/24] Keep Discovery outcomes attached to their guest and run Preserve dispatched work across a same-guest backup precaution, but reject late HTTP, notes, timer and background refresh results after target, feature or run ownership changes. Keep background safety failures visible with saved evidence, distinguish scanner progress from saved HTTP completion, and make error dismissal accessible. The recovered drawer pause and these connected outcomes have current browser proof. Contract-Neutral: Frontend-only behavioural repair of existing precaution, target/run ownership and error presentation. No resource schema, API, route, feature, command admission, permissions or collection contract changes. Change-source: pulse-maintainer --- .../discovery-outcome-ownership.cjs | 301 ++++++++++++++++++ .../discovery-outcome-ownership.html | 12 + .../discovery-outcome-ownership.tsx | 54 ++++ .../guest-discovery-completion.cjs | 19 ++ .../c45de66a92dcfc91.json | 79 +++++ .../src/components/Discovery/DiscoveryTab.tsx | 11 +- .../__tests__/DiscoveryRun.lifecycle.test.tsx | 75 ++++- .../Discovery/useDiscoveryTabState.ts | 184 +++++++---- 8 files changed, 670 insertions(+), 65 deletions(-) create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.cjs create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.html create mode 100644 frontend-modern/browser-tests/discovery-outcome-ownership.tsx create mode 100644 frontend-modern/browser-tests/guest-discovery-completion.cjs create mode 100644 frontend-modern/browser-verification/c45de66a92dcfc91.json diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.cjs b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs new file mode 100644 index 0000000000..3d7238fbab --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs @@ -0,0 +1,301 @@ +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 root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/discovery-ownership-proof/browser'; +const origin = 'http://127.0.0.1:5333'; +const runtime = [ + 'frontend-modern/src/components/Discovery/DiscoveryTab.tsx', + 'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts', + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const saved = (id, service = `Saved Home Assistant ${id}`) => ({ + id: `vm:fixture-node-agent:${id}`, + resource_type: 'vm', + resource_id: id, + target_id: 'fixture-node-agent', + hostname: `guest-${id}`, + service_type: 'home-assistant', + service_name: service, + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-03T12:00:00Z', + updated_at: '2026-10-03T12:00:00Z', + discovery_engine_version: 1, +}); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])), + groups: [], + screenshots: [], + cleanup: {}, + limits: + 'Production DiscoveryTab/state/CSS, synthetic API and production event-bus progress. No native QGA, automatic collector, original freeze cause, thaw, covered filesystem writes, installed, physical-phone or release acceptance.', + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: output + '/cache', + server: { host: '127.0.0.1', port: 5333, strictPort: true, watch: null }, + }); + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop-light', chromium, 1365, false], + ['desktop-dark', chromium, 1365, true], + ['phone-dark', webkit, 390, true], + ['narrow-light', webkit, 320, false], + ]) { + phase = name; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(60000); + const group = { + name, + width, + dark, + browser: browser.version(), + checks: [], + requests: [], + errors: [], + offOrigin: [], + }; + report.groups.push(group); + page.on('pageerror', (error) => group.errors.push(error.message)); + await page.routeWebSocket('**/ws*', () => {}); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + let fail = false; + const releases = new Map(); + const arrivals = new Map(); + const stored = new Map(); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== origin) { + group.offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + group.requests.push({ + path: url.pathname, + method: request.method(), + body: request.postData(), + }); + const target = url.pathname.match(/^\/api\/discovery\/vm\/fixture-node-agent\/(101|102)$/); + if (target) { + const id = target[1]; + if (request.method() === 'GET') + return route.fulfill({ json: stored.get(id) || saved(id) }); + assert.equal(request.method(), 'POST'); + if (fail) + return route.fulfill({ + status: 409, + json: { error: 'Guest execution paused: backup lock observed' }, + }); + await new Promise((resolve) => { + releases.set(id, resolve); + arrivals.get(id)?.(); + }); + const result = saved(id, id === '101' ? 'Old guest result' : 'Current guest result'); + stored.set(id, result); + return route.fulfill({ json: result }); + } + assert.equal(request.method(), 'GET'); + const responses = { + '/api/discovery/info/vm': { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + '/api/ai/agents': { + count: 1, + agents: [{ agent_id: 'fixture-node-agent', hostname: 'pve1' }], + }, + '/api/ai/settings': { discovery_enabled: true, enabled: false }, + '/api/license/runtime-capabilities': { + capabilities: [], + limits: [], + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }; + return route.fulfill({ json: responses[url.pathname] || {} }); + }); + const capture = async (suffix) => { + await page.screenshot({ path: `${output}/${name}-${suffix}.png`, fullPage: true }); + report.screenshots.push(`${name}-${suffix}.png`); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + }; + const emit = (progress) => + page.evaluate((progress) => window.discoveryOwnership.progress(progress), progress); + const run = page.getByRole('button', { name: 'Run Discovery', exact: true }); + const pressRun = async () => { + if (width >= 500) { + await run.focus(); + await page.keyboard.press('Enter'); + } else await run.tap(); + }; + await page.goto(origin + '/browser-tests/discovery-outcome-ownership.html'); + await page.getByText('Saved Home Assistant 101', { exact: true }).waitFor(); + await run.waitFor(); + await page.waitForFunction( + () => + !Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent.trim() === 'Run Discovery', + ).disabled, + ); + const before = group.requests.length; + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'running', + current_step: 'Collecting guest evidence', + percent_complete: 20, + }); + await page.getByText('Collecting guest evidence', { exact: true }).waitFor(); + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'completed', + error: 'Guest execution paused: backup lock observed', + current_step: 'Guest execution paused', + percent_complete: 20, + }); + const alert = page.getByRole('alert'); + await alert.waitFor(); + await page.waitForTimeout(650); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert.equal(group.requests.length, before); + assert(await page.getByText('Saved Home Assistant 101', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await capture('retained-background-pause'); + group.checks.push( + 'Background completed-with-error remains an accessible failure with saved evidence and no retry or refresh', + ); + const dismiss = page.getByRole('button', { name: 'Dismiss Discovery error' }); + await dismiss.focus(); + await page.keyboard.press('Enter'); + assert.equal(await alert.count(), 0); + group.checks.push('The error dismiss action has a keyboard-accessible name'); + const firstPost = new Promise((resolve) => arrivals.set('101', resolve)); + await pressRun(); + await firstPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + await page.evaluate(() => window.discoveryOwnership.target('102')); + await page.getByText('Saved Home Assistant 102', { exact: true }).waitFor(); + const secondPost = new Promise((resolve) => arrivals.set('102', resolve)); + await pressRun(); + await secondPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + assert(releases.has('101')); + assert(releases.has('102')); + releases.get('101')(); + await page.waitForTimeout(150); + assert.equal(await page.getByText('Old guest result', { exact: true }).count(), 0); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + await emit({ + resource_id: 'vm:fixture-node-agent:102', + status: 'completed', + percent_complete: 100, + }); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + releases.get('102')(); + await page.getByText('Current guest result', { exact: true }).waitFor(); + await page.getByText('Discovery complete!', { exact: true }).waitFor(); + group.checks.push( + 'Late old-target HTTP and scanner completion cannot replace the current guest or finish its active HTTP request', + ); + const posts = group.requests.filter((r) => r.method === 'POST'); + assert.equal(posts.length, 2); + assert.deepEqual( + posts.map((p) => JSON.parse(p.body)), + [ + { force: true, hostname: 'guest-101' }, + { force: true, hostname: 'guest-102' }, + ], + ); + await capture('current-guest-result'); + await emit({ resource_id: 'vm:fixture-node-agent:102', status: 'completed' }); + await page.waitForTimeout(650); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 2); + group.checks.push( + 'Later completion cannot strand the old HTTP success banner or issue an automatic scan', + ); + fail = true; + await pressRun(); + await alert.waitFor(); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await page.waitForTimeout(650); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 3); + group.checks.push( + 'A failed explicit run retains prior saved evidence, visible failure and no automatic retry', + ); + assert.deepEqual(group.errors, []); + assert.deepEqual(group.offOrigin, []); + await browser.close(); + browser = null; + } + report.result = 'passed'; + report.verified_at = new Date().toISOString(); + } catch (error) { + report.result = 'failed'; + report.phase = phase; + report.error = String(error.stack || error); + throw error; + } finally { + if (browser) await browser.close(); + report.cleanup.browser_closed = true; + await server.close(); + report.cleanup.server_closed = true; + fs.writeFileSync(output + '/result.json', JSON.stringify(report, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.html b/frontend-modern/browser-tests/discovery-outcome-ownership.html new file mode 100644 index 0000000000..7b3a737737 --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.html @@ -0,0 +1,12 @@ + + + + + + Discovery outcome ownership evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.tsx b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx new file mode 100644 index 0000000000..c9fe99348c --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx @@ -0,0 +1,54 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DiscoveryTab } from '../src/components/Discovery/DiscoveryTab'; +import { eventBus } from '../src/stores/events'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { DiscoveryProgress } from '../src/types/discovery'; +import '../src/index.css'; + +declare global { + interface Window { + discoveryOwnership: { + target: (id: string) => void; + progress: (progress: DiscoveryProgress) => void; + enabled: (enabled: boolean) => void; + }; + } +} + +function Fixture() { + const [id, setId] = createSignal('101'); + const enabled = (discovery_enabled: boolean) => + syncAIRuntimeSettings({ discovery_enabled } as Parameters[0]); + enabled(true); + window.discoveryOwnership = { + target: setId, + progress: (progress) => eventBus.emit('ai_discovery_progress', progress), + enabled, + }; + return ( +
+

Discovery outcome ownership evidence

+

Synthetic guest responses. No guest command is sent.

+

Guest {id()}

+ +
+ ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/guest-discovery-completion.cjs b/frontend-modern/browser-tests/guest-discovery-completion.cjs new file mode 100644 index 0000000000..25bb445ee3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-completion.cjs @@ -0,0 +1,19 @@ +const { spawnSync } = require('node:child_process'); +for (const script of ['guest-discovery-safety.cjs', 'discovery-outcome-ownership.cjs']) { + const result = spawnSync( + process.execPath, + ['/workspace/frontend-modern/browser-tests/' + script], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + console.error( + script, + 'did not complete successfully', + result.status, + result.signal, + result.error?.message, + ); + process.exit(result.status || 1); + } +} +console.log('Combined guest Discovery pause and outcome ownership browser proof passed.'); diff --git a/frontend-modern/browser-verification/c45de66a92dcfc91.json b/frontend-modern/browser-verification/c45de66a92dcfc91.json new file mode 100644 index 0000000000..88b556048b --- /dev/null +++ b/frontend-modern/browser-verification/c45de66a92dcfc91.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "base_sha": "bc3f4d81d14873caae6f769dfa670d16fa13d518", + "verified_at": "2026-10-04T20:32:45.762Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "2e93209a2b3830f77a0ad94d02bd59ea01509932f8d51e594d60f55d1f5895d0", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "e600bb993b80e85b6df48a9e070f1bc2db51d7bb8e5ffd6216e3861d610def14" + }, + "routes": [ + "/browser-tests/guest-discovery-safety.html: production Workloads row, GuestDrawer and DiscoveryTab with synthetic canonical snapshots", + "/browser-tests/discovery-outcome-ownership.html: reused production DiscoveryTab/state, synthetic per-guest APIs and production event-bus progress" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Guest operation lock and all current guest-read deferrals with/without retained data; healthy explicit action", + "Original HTTP result retained after a same-target precaution without cancellation or another scan", + "Background completed-with-error retains an accessible failed outcome and saved evidence", + "Late old-target HTTP result cannot overwrite the current guest or stop its manual request", + "Scanner completion during HTTP does not claim saved success; later terminal event cannot strand earlier success", + "Current explicit failure remains visible without an automatic retry" + ], + "interactions": [ + "Keyboard and touch drawer expansion, Discovery tabs, explicit enabled runs and associated disabled reasons", + "No POST on blocked/unlock/tab/snapshot change and exactly one target/body POST per explicit healthy action", + "Keyboard-labelled error dismissal with saved content preserved", + "Change target during an in-flight request, deliver the old and current responses independently and inspect original ownership", + "Render desktop light/dark and WebKit 390px/320px, inspect wrapping and all 24 captures" + ], + "script": "frontend-modern/browser-tests/guest-discovery-completion.cjs", + "browsers": [ + { + "name": "desktop-light", + "browser": "141.0.7390.37", + "width": 1365, + "dark": false + }, + { + "name": "desktop-dark", + "browser": "141.0.7390.37", + "width": 1365, + "dark": true + }, + { + "name": "phone-dark", + "browser": "26.0", + "width": 390, + "dark": true + }, + { + "name": "narrow-light", + "browser": "26.0", + "width": 320, + "dark": false + } + ], + "limitations": [ + "Synthetic inputs and API responses, not native QGA, backup or automatic poller acceptance.", + "Vite-transformed production components. Exact production build is checked separately in the source-proof VM.", + "No original freeze cause, independent thaw, covered-filesystem writes, installed use, physical phone or containing release is established." + ] +} diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 169fd12981..a95395eed0 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -474,7 +474,10 @@ export const DiscoveryTab: Component = (props) => { {/* Scan Error */} -
+