From 3c249ecacf4ab747ab822ad972527f7799bc00a0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:43:34 +0100 Subject: [PATCH 1/2] Make Proxmox permission help token-aware and backup-safe Remove the user placeholder command that Bash treats as file redirection. Diagnose the configured account and privilege-separated token on the affected scope without broader grants, token rotation or guest probes. Clarify that API-only guest reads still use the backup channel and preserve the monitoring-outage precaution in the existing help. Connected shipped-Markdown controls bind both entry points to the generated token/ACL contract. Contract-Neutral: Existing help and regression controls only; no runtime, schema, collector, token, ACL, navigation or dependency contract change. Change-source: pulse-maintainer --- docs/AGENT_SECURITY.md | 10 +- docs/TROUBLESHOOTING.md | 44 +++++-- docs/VM_DISK_MONITORING.md | 6 + frontend-modern/public/docs/AGENT_SECURITY.md | 10 +- .../public/docs/TROUBLESHOOTING.md | 44 +++++-- .../public/docs/VM_DISK_MONITORING.md | 6 + .../__tests__/proxmoxPermissionsGuide.test.ts | 124 ++++++++++++++++++ 7 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 frontend-modern/src/features/docs/__tests__/proxmoxPermissionsGuide.test.ts diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index eecc89f110..20b5bf4cff 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -5,7 +5,7 @@ Pulse agents incorporate several security mechanisms to ensure that the code run **Start with the least privilege that answers your monitoring question.** For Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring with a read-only token covers inventory, status, and metrics, and the -generated setup script creates a privilege-separated monitoring user for it +generated setup script creates a privilege-separated monitoring token for it (see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a host agent only where you want data the platform API cannot provide, and on Linux consider the supported @@ -89,6 +89,14 @@ You do not need a Pulse agent on every Proxmox-related host just to see basic cluster inventory and utilization. Start with the least-privilege path that answers your monitoring question: +**API-only does not mean guest-agent-free.** VM guest filesystem and memory +collection can still send active requests on the channel used by freeze-enabled +backups. Read permissions and the absence of a host agent do not prove backup +safety. For an affected installation, follow the +[backup safety precaution](VM_DISK_MONITORING.md#backup-safety), including the +monitoring/alert outage while Pulse is stopped; do not add permissions or send +manual probes during a freeze or thaw. + | Goal | Recommended path | Host privilege needed? | |---|---|---| | PVE/PBS/PMG inventory, node status, VM/container status, storage usage, and normal Proxmox API metrics | Add the Proxmox connection with a read-only or narrowly scoped API token | No | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f05100da0a..6981ebc0b5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -630,23 +630,43 @@ remove credentials, cookies, secret URLs and private host, network or personal information. See [Getting Help](#-getting-help) for safe evidence collection. ### Check Permissions (Proxmox) -If Pulse can't see VMs or storage, check the user permissions on Proxmox: -```bash -pveum user permissions @pam -``` -At minimum, ensure the user/token has read access for inventory and metrics: -- `Sys.Audit` -- `Datastore.Audit` +If Pulse cannot see VMs, storage or guest readings, start with the **user, +realm and token ID configured for the affected Pulse connection**, not a +guessed account or your administrator login. Use the existing connection +settings; do not dump the server configuration or expose the token secret. -For VM guest agent features on PVE 9+, prefer: +With privilege separation enabled, the token's effective access is the +**intersection of the user and token permissions**: both must allow the +required privilege on the affected resource path. Ask the Proxmox +administrator to inspect both sides, including inherited ACLs and their +propagation, for that VM, storage or node. An administrator session or +user-only permission listing does not prove the token has access. -- `VM.GuestAgent.Audit` — required for disk usage and guest info -- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache) +Inspect the existing denial and compare it with the relevant read privileges: -For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges. +- Inventory and metrics: `Sys.Audit` and `Datastore.Audit`, on the relevant + node/storage scope. +- VM filesystem usage and guest information on PVE 9+: `VM.GuestAgent.Audit`. +- Guest memory through `/proc/meminfo` on PVE 9+: also + `VM.GuestAgent.FileRead`. +- On PVE 8, `VM.Monitor` is the legacy guest-agent fallback, not an extra + requirement for PVE 9+. -Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role. +The built-in `PVEAuditor` role cannot be modified. Where a read privilege is +actually missing, use the existing narrow custom role and matching scoped +user/token ACLs. **Do not disable privilege separation, grant Administrator or +add guest execution/write privileges** to diagnose missing data. Do not rerun +setup or replace a token as a permissions test: setup can rotate an existing +credential. Make any necessary access repair in your normal maintenance +window, outside backups, then observe normal polling without manual +guest-agent probes. `Sys.Modify` is not a read-only monitoring privilege; see +[pending-update access](#proxmox-pending-update-access) before considering it. + +Keep token secrets and full permission listings private. A report needs only +the relevant missing privilege and a redacted denial, not private identities, +resource paths or the complete ACL tree. Successful inventory does not prove +that guest readings are fresh, or that a guest has thawed. **Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga` can explain missing guest memory; they do not by themselves establish why disk diff --git a/docs/VM_DISK_MONITORING.md b/docs/VM_DISK_MONITORING.md index 0ab7bbd28d..14ba35ea07 100644 --- a/docs/VM_DISK_MONITORING.md +++ b/docs/VM_DISK_MONITORING.md @@ -128,6 +128,12 @@ procedure; a reboot interrupts its workloads. Use the dedicated account and token actually configured for that Pulse connection, not a guessed account name. Review their effective privileges for the affected VM; a host-root diagnostic does not test the API token's access. +With privilege separation enabled, access is the intersection of user and token +permissions: both must allow the required read on the affected VM. Do not disable +privilege separation or recreate the token to diagnose a missing reading. See +[Check permissions](TROUBLESHOOTING.md#check-permissions-proxmox) for the scoped +inspection and repair boundary. A permitted read does not prove disk freshness, +responsiveness or thaw. | Collection | Proxmox VE 9+ | Proxmox VE 8 | | --- | --- | --- | diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index eecc89f110..20b5bf4cff 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -5,7 +5,7 @@ Pulse agents incorporate several security mechanisms to ensure that the code run **Start with the least privilege that answers your monitoring question.** For Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring with a read-only token covers inventory, status, and metrics, and the -generated setup script creates a privilege-separated monitoring user for it +generated setup script creates a privilege-separated monitoring token for it (see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a host agent only where you want data the platform API cannot provide, and on Linux consider the supported @@ -89,6 +89,14 @@ You do not need a Pulse agent on every Proxmox-related host just to see basic cluster inventory and utilization. Start with the least-privilege path that answers your monitoring question: +**API-only does not mean guest-agent-free.** VM guest filesystem and memory +collection can still send active requests on the channel used by freeze-enabled +backups. Read permissions and the absence of a host agent do not prove backup +safety. For an affected installation, follow the +[backup safety precaution](VM_DISK_MONITORING.md#backup-safety), including the +monitoring/alert outage while Pulse is stopped; do not add permissions or send +manual probes during a freeze or thaw. + | Goal | Recommended path | Host privilege needed? | |---|---|---| | PVE/PBS/PMG inventory, node status, VM/container status, storage usage, and normal Proxmox API metrics | Add the Proxmox connection with a read-only or narrowly scoped API token | No | diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index f05100da0a..6981ebc0b5 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -630,23 +630,43 @@ remove credentials, cookies, secret URLs and private host, network or personal information. See [Getting Help](#-getting-help) for safe evidence collection. ### Check Permissions (Proxmox) -If Pulse can't see VMs or storage, check the user permissions on Proxmox: -```bash -pveum user permissions @pam -``` -At minimum, ensure the user/token has read access for inventory and metrics: -- `Sys.Audit` -- `Datastore.Audit` +If Pulse cannot see VMs, storage or guest readings, start with the **user, +realm and token ID configured for the affected Pulse connection**, not a +guessed account or your administrator login. Use the existing connection +settings; do not dump the server configuration or expose the token secret. -For VM guest agent features on PVE 9+, prefer: +With privilege separation enabled, the token's effective access is the +**intersection of the user and token permissions**: both must allow the +required privilege on the affected resource path. Ask the Proxmox +administrator to inspect both sides, including inherited ACLs and their +propagation, for that VM, storage or node. An administrator session or +user-only permission listing does not prove the token has access. -- `VM.GuestAgent.Audit` — required for disk usage and guest info -- `VM.GuestAgent.FileRead` — required for accurate memory monitoring (excludes buff/cache) +Inspect the existing denial and compare it with the relevant read privileges: -For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges. +- Inventory and metrics: `Sys.Audit` and `Datastore.Audit`, on the relevant + node/storage scope. +- VM filesystem usage and guest information on PVE 9+: `VM.GuestAgent.Audit`. +- Guest memory through `/proc/meminfo` on PVE 9+: also + `VM.GuestAgent.FileRead`. +- On PVE 8, `VM.Monitor` is the legacy guest-agent fallback, not an extra + requirement for PVE 9+. -Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role. +The built-in `PVEAuditor` role cannot be modified. Where a read privilege is +actually missing, use the existing narrow custom role and matching scoped +user/token ACLs. **Do not disable privilege separation, grant Administrator or +add guest execution/write privileges** to diagnose missing data. Do not rerun +setup or replace a token as a permissions test: setup can rotate an existing +credential. Make any necessary access repair in your normal maintenance +window, outside backups, then observe normal polling without manual +guest-agent probes. `Sys.Modify` is not a read-only monitoring privilege; see +[pending-update access](#proxmox-pending-update-access) before considering it. + +Keep token secrets and full permission listings private. A report needs only +the relevant missing privilege and a redacted denial, not private identities, +resource paths or the complete ACL tree. Successful inventory does not prove +that guest readings are fresh, or that a guest has thawed. **Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga` can explain missing guest memory; they do not by themselves establish why disk diff --git a/frontend-modern/public/docs/VM_DISK_MONITORING.md b/frontend-modern/public/docs/VM_DISK_MONITORING.md index 0ab7bbd28d..14ba35ea07 100644 --- a/frontend-modern/public/docs/VM_DISK_MONITORING.md +++ b/frontend-modern/public/docs/VM_DISK_MONITORING.md @@ -128,6 +128,12 @@ procedure; a reboot interrupts its workloads. Use the dedicated account and token actually configured for that Pulse connection, not a guessed account name. Review their effective privileges for the affected VM; a host-root diagnostic does not test the API token's access. +With privilege separation enabled, access is the intersection of user and token +permissions: both must allow the required read on the affected VM. Do not disable +privilege separation or recreate the token to diagnose a missing reading. See +[Check permissions](TROUBLESHOOTING.md#check-permissions-proxmox) for the scoped +inspection and repair boundary. A permitted read does not prove disk freshness, +responsiveness or thaw. | Collection | Proxmox VE 9+ | Proxmox VE 8 | | --- | --- | --- | diff --git a/frontend-modern/src/features/docs/__tests__/proxmoxPermissionsGuide.test.ts b/frontend-modern/src/features/docs/__tests__/proxmoxPermissionsGuide.test.ts new file mode 100644 index 0000000000..da97a6db80 --- /dev/null +++ b/frontend-modern/src/features/docs/__tests__/proxmoxPermissionsGuide.test.ts @@ -0,0 +1,124 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { renderDocMarkdown } from '../docMarkdown'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); + +function renderGuide(name: string): HTMLElement { + const article = document.createElement('article'); + article.innerHTML = renderDocMarkdown( + readFileSync(path.join(repoRoot, 'frontend-modern/public/docs', `${name}.md`), 'utf8'), + name, + ); + return article; +} + +function section(article: HTMLElement, id: string): HTMLElement { + const heading = article.querySelector(`#${id}`); + expect(heading, `missing permission guide section ${id}`).not.toBeNull(); + return followingContent(heading!); +} + +function followingContent(heading: Element): HTMLElement { + const content = document.createElement('div'); + for (let element = heading.nextElementSibling; element; element = element.nextElementSibling) { + if (/^H[1-3]$/.test(element.tagName)) break; + content.appendChild(element.cloneNode(true)); + } + return content; +} + +const prose = (element: HTMLElement): string => + (element.textContent ?? '').replace(/\s+/g, ' ').trim(); + +describe('shipped Proxmox permission diagnosis', () => { + it('checks the configured user and token on the affected resource, not an administrator', () => { + const content = prose(section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox')); + expect(content).toContain( + 'user, realm and token ID configured for the affected Pulse connection', + ); + expect(content).toContain('intersection of the user and token permissions'); + expect(content).toContain( + 'both must allow the required privilege on the affected resource path', + ); + expect(content).toContain('inherited ACLs and their propagation'); + expect(content).toContain('administrator session or user-only permission listing'); + expect(content).toContain('does not prove the token has access'); + }); + + it('removes the literal placeholder command that redirects and overwrites local files', () => { + const content = section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox'); + // The old @pam snippet passes bash -n, but redirects from `user` + // into `@pam` instead of passing a user ID. Inspection needs no command. + expect(content.querySelector('pre')).toBeNull(); + expect(prose(content)).not.toContain('pveum user permissions'); + expect(prose(content)).toContain('Inspect the existing denial'); + }); + + it('preserves least privilege and avoids rotating credentials or probing a frozen guest', () => { + const content = prose(section(renderGuide('TROUBLESHOOTING'), 'check-permissions-proxmox')); + expect(content).toContain('Do not disable privilege separation'); + expect(content).toContain('grant Administrator'); + expect(content).toContain('add guest execution/write privileges'); + expect(content).toContain('Do not rerun setup or replace a token as a permissions test'); + expect(content).toContain('normal maintenance window, outside backups'); + expect(content).toContain('normal polling without manual guest-agent probes'); + expect(content).toContain('Keep token secrets and full permission listings private'); + expect(content).toContain('missing privilege and a redacted denial'); + expect(content).toContain('Sys.Modify is not a read-only monitoring privilege'); + }); + + it('distinguishes active guest reads from passive inventory before recommending permissions', () => { + const content = section(renderGuide('AGENT_SECURITY'), 'proxmox-deployment-choices'); + expect(prose(content)).toContain('API-only does not mean guest-agent-free'); + expect(prose(content)).toContain( + 'active requests on the channel used by freeze-enabled backups', + ); + expect(prose(content)).toContain( + 'Read permissions and the absence of a host agent do not prove backup safety', + ); + const warning = Array.from(content.querySelectorAll('p')).find((paragraph) => + paragraph.textContent?.includes('API-only does not mean guest-agent-free'), + ); + const table = content.querySelector('table'); + expect(table).not.toBeNull(); + expect(warning?.compareDocumentPosition(table!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(warning?.querySelector('a')?.getAttribute('href')).toBe( + '/docs/VM_DISK_MONITORING#backup-safety', + ); + }); + + it('keeps the disk-specific entry point token-aware without promising a fresh reading', () => { + const heading = Array.from(renderGuide('VM_DISK_MONITORING').querySelectorAll('h2')).find( + (element) => element.textContent === '⚙️ Permissions', + ); + expect(heading).toBeDefined(); + const content = followingContent(heading!); + const text = prose(content); + expect(text).toContain('intersection of user and token permissions'); + expect(text).toContain('on the affected VM'); + expect(text).toContain('Do not disable privilege separation or recreate the token'); + expect(text).toContain('does not prove disk freshness, responsiveness or thaw'); + expect(content.querySelector('a')?.getAttribute('href')).toBe( + '/docs/TROUBLESHOOTING#check-permissions-proxmox', + ); + }); + + it('matches the existing generated token and ACL contract without running setup', () => { + // Static binding only: neither this test nor the docs create a real token. + const setup = readFileSync( + path.join(repoRoot, 'internal/api/configapi/setup_script_render.go'), + 'utf8', + ); + expect(setup).toContain('--privsep 1'); + expect(setup).toContain('pveum aclmod / -user pulse-monitor@pve -role PVEAuditor'); + expect(setup).toContain('pveum aclmod / -token "$PULSE_TOKEN_ID" -role PVEAuditor'); + for (const name of ['TROUBLESHOOTING', 'VM_DISK_MONITORING', 'AGENT_SECURITY']) { + expect(readFileSync(path.join(repoRoot, 'docs', `${name}.md`))).toEqual( + readFileSync(path.join(repoRoot, 'frontend-modern/public/docs', `${name}.md`)), + ); + } + }); +}); From 899414d8ca2fbc86b2245675bf51bc30d14bfac9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:11:04 +0100 Subject: [PATCH 2/2] Verify composed webhook instructions in both themes Preserve the exact reviewed permission-help commit and the composed light-mode heading plus multiline service instructions. Record a fresh parent-bound browser receipt after inspecting eight Discord and Telegram captures across Chromium desktop and WebKit phone light/dark states. The fixture uses production form/state/CSS with source-exact public templates and no notification operation; earlier harness failures remain retained separately. Contract-Neutral: Browser verification and offline fixture only; no runtime, schema, permission, notification, navigation or dependency contract change. Change-source: pulse-maintainer --- .../browser-tests/webhook-composed.cjs | 350 ++++++++++++++++++ .../browser-tests/webhook-composed.html | 12 + .../browser-tests/webhook-composed.tsx | 27 ++ .../30f309e7b9c98a09.json | 43 +++ 4 files changed, 432 insertions(+) create mode 100644 frontend-modern/browser-tests/webhook-composed.cjs create mode 100644 frontend-modern/browser-tests/webhook-composed.html create mode 100644 frontend-modern/browser-tests/webhook-composed.tsx create mode 100644 frontend-modern/browser-verification/30f309e7b9c98a09.json diff --git a/frontend-modern/browser-tests/webhook-composed.cjs b/frontend-modern/browser-tests/webhook-composed.cjs new file mode 100644 index 0000000000..4e572b6eae --- /dev/null +++ b/frontend-modern/browser-tests/webhook-composed.cjs @@ -0,0 +1,350 @@ +// Fresh proof of the composed heading and multiline instructions, not a replay +// of an old browser operation or installed notification-delivery acceptance. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/webhook-composed-proof-stable'; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const runtimePath = 'frontend-modern/src/components/Alerts/WebhookConfigForm.tsx'; +const expectedHash = '0286a39d8405d214df78d8806f1792991a46e0583ab20d1b1fafb276b3c9160d'; +const integration = '5e967c49f55d0fc2423e9d7ba63c7ed250069a87'; +const sourceFiles = [ + runtimePath, + 'frontend-modern/src/components/Alerts/WebhookConfig.tsx', + 'frontend-modern/src/components/Alerts/useWebhookConfigState.ts', + 'frontend-modern/src/utils/alertWebhookPresentation.ts', + 'frontend-modern/src/index.css', + 'frontend-modern/package-lock.json', + 'tests/integration/package-lock.json', + 'internal/notifications/webhook_templates.go', + 'frontend-modern/browser-tests/webhook-composed.html', + 'frontend-modern/browser-tests/webhook-composed.tsx', + 'frontend-modern/browser-tests/webhook-composed.cjs', +]; + +// Read the committed server catalogue, not a second copy of its instructions. +// This bounded extractor is only for the selected public fixture fields, not +// a Go evaluator or a substitute for a backend response/receiver test. +const templateSource = fs.readFileSync( + '/workspace/internal/notifications/webhook_templates.go', + 'utf8', +); +const starts = [...templateSource.matchAll(/^\s*Service:\s*"([^"]+)"/gm)]; +const templates = ['generic', 'discord', 'telegram'].map((service) => { + const i = starts.findIndex((match) => match[1] === service); + assert.ok(i >= 0, `catalogue service ${service}`); + const block = templateSource.slice( + starts[i].index, + starts[i + 1]?.index ?? templateSource.length, + ); + const value = (field, optional = false) => { + const match = block.match(new RegExp(`^\\s*${field}:\\s*("(?:[^"\\\\]|\\\\.)*")`, 'm')); + assert.ok(optional || match, `catalogue field ${service}.${field}`); + return match ? JSON.parse(match[1]) : ''; + }; + const payload = block.match(/^\s*PayloadTemplate:\s*`([\s\S]*?)`,/m); + assert.ok(payload, `catalogue payload ${service}`); + assert.match(block, /Headers:\s*map\[string\]string\{"Content-Type": "application\/json"\}/); + return { + service, + label: value('Label'), + name: value('Name'), + description: value('Description'), + urlPattern: value('URLPattern'), + method: value('Method'), + headers: { 'Content-Type': 'application/json' }, + mentionPlaceholder: value('MentionPlaceholder', true), + mentionHelp: value('MentionHelp', true), + payloadTemplate: payload[1], + instructions: value('Instructions'), + }; +}); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + const declared = JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')) + .packages['node_modules/@playwright/test'].version; + assert.equal(playwrightVersion, declared); + assert.equal(hash(path.join('/workspace', runtimePath)), expectedHash); + const files = Object.fromEntries( + sourceFiles.map((file) => [file, hash(path.join('/workspace', file))]), + ); + 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'), + optimizeDeps: { noDiscovery: true, include: [] }, + server: { host: '127.0.0.1', port: 5307, strictPort: true, watch: null }, + }); + const origin = 'http://127.0.0.1:5307'; + const results = [], + screenshots = [], + observations = []; + let browser, + phase = 'server', + completed = false; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1440, false], + ['chromium-desktop-dark', chromium, 1440, true], + ['webkit-phone-light', webkit, 390, false], + ['webkit-phone-dark', webkit, 390, true], + ]) { + 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(45000); + const errors = [], + unexpectedRequests = [], + requests = [], + previewSockets = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + // Vite's own HMR client uses this fixed loopback endpoint. Closing it + // creates a real console error even though the form renders correctly. + // Permit that preview-only socket, not application/provider sockets; + // keep the zero-error assertion rather than filtering its diagnostic. + await page.routeWebSocket(/.*/, (socket) => { + const url = new URL(socket.url()); + if (url.origin === 'ws://127.0.0.1:5307' && url.pathname === '/') { + previewSockets.push(url.pathname); + socket.connectToServer(); + } else { + unexpectedRequests.push({ origin: url.origin, path: url.pathname, method: 'WebSocket' }); + socket.close(); + } + }); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== origin || request.method() !== 'GET') { + unexpectedRequests.push({ url: request.url(), method: request.method() }); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push(url.pathname); + if (url.pathname === '/api/notifications/webhook-templates') + return route.fulfill({ json: templates }); + unexpectedRequests.push({ url: request.url(), method: request.method() }); + return route.abort(); + }); + await page.goto(`${origin}/browser-tests/webhook-composed.html`, { + waitUntil: 'networkidle', + }); + await page.getByRole('button', { name: '+ Add Webhook', exact: true }).click(); + await page + .getByRole('button', { name: 'Discord Discord server webhook', exact: true }) + .waitFor(); + for (const service of ['discord', 'telegram']) { + phase = `${name}: ${service}`; + const template = templates.find((candidate) => candidate.service === service); + if (service === 'telegram') { + const toggle = page.getByRole('button', { name: 'Discord →', exact: true }); + await toggle.focus(); + await page.keyboard.press('Enter'); + } + const choice = page.getByRole('button', { + name: `${template.label} ${template.description}`, + exact: true, + }); + if (width < 500) await choice.tap(); + else { + await choice.focus(); + await page.keyboard.press('Enter'); + } + const heading = page.getByRole('heading', { name: 'Setup Instructions', exact: true }); + await heading.waitFor(); + const panel = heading.locator('..'), + instructions = panel.locator('p'); + assert.equal(await instructions.textContent(), template.instructions); + assert.equal( + await page.getByLabel('Webhook URL', { exact: true }).getAttribute('placeholder'), + template.urlPattern, + ); + await heading.evaluate((element) => element.scrollIntoView({ block: 'start' })); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + }); + const layout = await panel.evaluate((element) => { + const heading = element.querySelector('h4'), + p = element.querySelector('p'); + const style = getComputedStyle(heading), + pStyle = getComputedStyle(p); + const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT); + const node = walker.nextNode(); + if (!node || node.textContent !== p.textContent) + throw new Error('Instructions must remain one complete text node'); + let offset = 0; + const linePositions = p.textContent.split('\n').map((line) => { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + 2); + offset += line.length + 1; + const bounds = range.getBoundingClientRect(); + const fragments = [...range.getClientRects()].map((rect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + })); + // A union bound can include a zero-width newline on the preceding + // line. Measure the numbered glyphs, not that whitespace fragment. + const glyphs = fragments.filter((rect) => rect.width > 0 && rect.height > 0); + if (glyphs.length !== 1) + throw new Error('Each numbered prefix needs one painted rectangle'); + return { + number: line.slice(0, 2), + x: glyphs[0].x, + y: glyphs[0].y, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + fragments, + }; + }); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); + context.fillStyle = style.color; + context.fillRect(0, 0, 1, 1); + const ancestorOpacity = []; + for (let current = heading; current; current = current.parentElement) + ancestorOpacity.push(getComputedStyle(current).opacity); + return { + headingClass: heading.className, + color: style.color, + alpha: context.getImageData(0, 0, 1, 1).data[3], + ancestorOpacity, + whiteSpace: pStyle.whiteSpace, + overflowWrap: pStyle.overflowWrap, + linePositions, + panelOverflow: element.scrollWidth - element.clientWidth, + documentOverflow: document.documentElement.scrollWidth - innerWidth, + }; + }); + observations.push({ name, service, layout }); + const file = `${name}-${service}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + screenshots.push({ file, sha256: hash(path.join(output, file)), width, dark, service }); + assert.ok(layout.headingClass.split(' ').includes('text-blue-900')); + assert.ok(layout.headingClass.split(' ').includes('dark:text-blue-100')); + assert.equal(layout.alpha, 255, 'heading colour is not translucent'); + assert.ok(layout.ancestorOpacity.every((opacity) => opacity === '1')); + assert.equal(layout.whiteSpace, 'pre-line'); + assert.equal(layout.overflowWrap, 'break-word'); + assert.equal(layout.linePositions.length, service === 'discord' ? 4 : 5); + layout.linePositions.forEach((line, i, lines) => { + assert.equal(line.number, `${i + 1}.`); + if (i) + assert.ok(line.y > lines[i - 1].y + 5, 'numbered steps need separate rendered lines'); + assert.ok( + Math.abs(line.x - lines[0].x) <= 1, + 'each numbered step starts at the left edge', + ); + }); + assert.ok(layout.panelOverflow <= 1); + assert.ok(layout.documentOverflow <= 1); + assert.equal(await page.getByLabel('Notification operations').textContent(), '0'); + checks.push({ + service, + templateInstructionsExact: true, + layout, + noNotificationOperation: true, + }); + } + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('button', { name: '+ Add Webhook', exact: true }).waitFor(); + assert.equal( + await page.getByRole('heading', { name: 'Setup Instructions', exact: true }).count(), + 0, + ); + assert.equal(await page.getByLabel('Notification operations').textContent(), '0'); + assert.deepEqual(errors, []); + assert.deepEqual(unexpectedRequests, []); + results.push({ + name, + browserVersion: browser.version(), + width, + dark, + phoneEmulated: width < 500, + checks, + cancelReturnsToAdd: true, + requests, + previewSockets, + errors, + unexpectedRequests, + }); + await browser.close(); + browser = undefined; + } + completed = true; + } catch (error) { + fs.writeFileSync( + path.join(output, 'failure.json'), + JSON.stringify( + { phase, error: error.message, files, results, screenshots, observations }, + null, + 2, + ), + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } + if (completed) { + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + integration, + playwrightVersion, + declaredPlaywrightVersion: declared, + files, + results, + screenshots, + observations, + verifiedAt: new Date().toISOString(), + browserAndServerClosed: true, + limits: + 'Production WebhookConfig/form/state/CSS in a standalone offline fixture. Source-exact public template fields, synthetic API. No full app navigation, real credentials, receiver, Test/save/delivery, native phone or installed acceptance.', + }, + null, + 2, + ), + ); + console.log( + `Verified ${results.length} theme/viewport states and ${screenshots.length} source-exact service captures; browser and server closed.`, + ); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/webhook-composed.html b/frontend-modern/browser-tests/webhook-composed.html new file mode 100644 index 0000000000..f8dd556323 --- /dev/null +++ b/frontend-modern/browser-tests/webhook-composed.html @@ -0,0 +1,12 @@ + + + + + + Composed webhook instructions verification + + +
+ + + diff --git a/frontend-modern/browser-tests/webhook-composed.tsx b/frontend-modern/browser-tests/webhook-composed.tsx new file mode 100644 index 0000000000..031b934b28 --- /dev/null +++ b/frontend-modern/browser-tests/webhook-composed.tsx @@ -0,0 +1,27 @@ +// Production form and state, source-exact template text supplied by the runner. +// No real notification receiver, credentials, save or Test operation. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { WebhookConfig } from '../src/components/Alerts/WebhookConfig'; +import '../src/index.css'; + +function Fixture() { + const [operations, setOperations] = createSignal(0); + const unexpectedOperation = () => setOperations((count) => count + 1); + return ( +
+

Webhook setup

+

Offline presentation check. No notification is sent.

+ + {operations()} +
+ ); +} + +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification/30f309e7b9c98a09.json b/frontend-modern/browser-verification/30f309e7b9c98a09.json new file mode 100644 index 0000000000..24533b0f6b --- /dev/null +++ b/frontend-modern/browser-verification/30f309e7b9c98a09.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "base_sha": "5e967c49f55d0fc2423e9d7ba63c7ed250069a87", + "verified_at": "2026-10-05T04:08:20.487Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Alerts/WebhookConfigForm.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Alerts/WebhookConfigForm.tsx": "0286a39d8405d214df78d8806f1792991a46e0583ab20d1b1fafb276b3c9160d" + }, + "routes": [ + "/browser-tests/webhook-composed.html (production Notifications WebhookConfig/form/state/CSS; standalone offline fixture)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Chromium 141.0.7390.37 desktop light and dark; WebKit 26.0 emulated phone light and dark, Playwright 1.56.1 matching integration lock", + "Discord four numbered instructions and Telegram five numbered instructions exactly match the committed server catalogue in each theme/layout", + "Setup Instructions heading is solid blue-900 in light mode and blue-100 in dark mode, alpha 255 and no ancestor opacity; both improvements are composed in the verified hash", + "Each numbered prefix has a distinct painted text rectangle and left edge; pre-line whitespace and break-word wrapping preserve long Telegram URLs without panel/document horizontal overflow", + "All eight full-page captures inspected: headings, step separation, long URLs and phone form layout readable; no page/console errors or unexpected API/provider requests" + ], + "interactions": [ + "Open Add Webhook and select Discord using keyboard on desktop or tap on emulated phone", + "Open the service selector using keyboard Enter and switch to Telegram; source-exact instructions and URL placeholder update, existing typed/default name is preserved", + "Cancel returns to Add Webhook and removes instructions; zero add/update/delete/Test callbacks or non-GET API requests throughout" + ], + "proof": { + "script": "frontend-modern/browser-tests/webhook-composed.cjs", + "script_sha256": "99f74d9ef98188510a68d47cd9508ab7001673523c83d9f3ea54c542fc94313e", + "template_source_sha256": "07b04d7ccf37e15dac8fa333c487122d013f6a364cd8dc5ce5a7ecbbb20b5243", + "limits": "Production WebhookConfig/form/state/CSS in a standalone offline fixture. Source-exact public template fields, synthetic API. No full app navigation, real credentials, receiver, Test/save/delivery, native phone or installed acceptance." + } +}