diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 3b9d1de15..cc946cec2 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -82,10 +82,36 @@ jobs: node -e 'if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(process.argv[1])) process.exit(1)' "$version" echo "version=$version" >> "$GITHUB_OUTPUT" + renderer-axe-boundary: + name: Prove stock Chromium existing-target axe boundary + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install locked dependencies and real Chromium + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Run focused stock Chromium existing-target axe regressions + env: + PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY: '1' + run: node --test --test-name-pattern='packaged acceptance renderer variants' apps/desktop/scripts/packaged-acceptance-renderer.test.mjs + package: name: Validate unsigned ${{ matrix.platform }}-${{ matrix.arch }} package if: github.event_name == 'pull_request' - needs: validation-version + needs: [validation-version, renderer-axe-boundary] runs-on: ${{ matrix.runner }} continue-on-error: ${{ matrix.release_target == false }} timeout-minutes: 60 @@ -209,7 +235,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot gnome-keyring libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip + sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot fonts-liberation gnome-keyring libglib2.0-bin libsecret-1-0 rpm tesseract-ocr unzip xdg-utils xvfb zip - name: Package desktop app from clean checkout shell: bash @@ -294,6 +320,30 @@ jobs: xvfb-run --auto-servernum npm run desktop:smoke ' bash "$keyring_root" + - name: Capture packaged Linux x64 visual and accessibility acceptance evidence + if: matrix.platform == 'linux' && matrix.arch == 'x64' + shell: bash + env: + PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts + run: | + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run desktop:acceptance + ' bash "$keyring_root" + npm run desktop:acceptance:verify + + - name: Publish mandatory packaged desktop acceptance evidence + if: always() && matrix.platform == 'linux' && matrix.arch == 'x64' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-acceptance-linux-x64-${{ github.run_id }}-${{ github.run_attempt }} + path: desktop-acceptance-artifacts + if-no-files-found: error + retention-days: 14 + - name: Launch packaged Windows application and exercise MVP desktop flows if: matrix.platform == 'win32' shell: bash @@ -549,7 +599,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + sudo apt-get install --yes cpio dbus-x11 fakeroot fonts-liberation gnome-keyring libsecret-1-0 rpm tesseract-ocr zip - name: Configure required macOS signing and notarization if: matrix.platform == 'darwin' @@ -651,6 +701,29 @@ jobs: xvfb-run --auto-servernum npm run desktop:smoke ' bash "$keyring_root" + - name: Capture signed packaged Linux x64 visual and accessibility acceptance evidence + if: matrix.platform == 'linux' && matrix.arch == 'x64' + shell: bash + env: + PROPR_DESKTOP_ACCEPTANCE_OUTPUT: ${{ github.workspace }}/desktop-acceptance-artifacts + run: | + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + eval "$(printf "%s\n" "propr-packaged-acceptance" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum npm run desktop:acceptance + ' bash "$keyring_root" + npm run desktop:acceptance:verify + + - name: Publish mandatory signed packaged desktop acceptance evidence + if: always() && matrix.platform == 'linux' && matrix.arch == 'x64' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-acceptance-signed-linux-x64-${{ github.run_id }}-${{ github.run_attempt }} + path: desktop-acceptance-artifacts + if-no-files-found: error + retention-days: 14 - name: Inspect signed and notarized macOS application if: matrix.platform == 'darwin' shell: bash diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ac5bdac0e..9e49314ff 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -13,6 +13,7 @@ npm run desktop:typecheck npm run desktop:test npm run desktop:package npm run desktop:smoke # Run under xvfb-run on a headless Linux host. +npm run desktop:acceptance # Linux x64 package; run under Xvfb in a D-Bus/keyring session. npm run desktop:make npm run desktop:audit # On Linux hosts with the corresponding native packaging tools installed: @@ -44,6 +45,30 @@ executable and fuse inspection without launching a window. Release CI launches b inspects macOS and Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/MSI packages, and validates configured OS signatures. +## Packaged visual and accessibility acceptance + +Linux x64 is the canonical visual runtime. `desktop:acceptance` launches the real packaged executable with a fresh +private Electron profile and drives it over Chromium's debugging protocol with Playwright. A separately authorized +acceptance mode supplies deterministic local API, Socket.IO, browser-pairing, ProPR Connect, and local-setup fixtures +while preserving the production main/preload/renderer boundary, renderer sandbox, context isolation, navigation +policy, and credential service. It requires independent command-line and environment triggers, accepts only a +packaged Linux binary, and refuses the default Electron profile. + +The mandatory artifact contains screenshots for first run, endpoint and Connect confirmation, pairing, local setup +prerequisites/progress/recovery/completion, dashboard profile management, offline, revoked, and incompatible states. +Every state is captured at standard, narrow, high-DPI, 200% zoom, and reduced-motion configurations. The manifest +records fixed locale/time/theme inputs, dimensions, hashes, and native coverage. The 200% variant uses an +acceptance-only, main-authorized preload bridge to Electron `webFrame` zoom and records its read-back alongside raw +CDP/renderer viewports, independently measured geometry, DPR, and physical PNG dimensions. Accessibility evidence +fails for any serious or critical axe finding or missing keyboard order, visible focus, dialog trap/restore, +accessible name, or live announcement proof. Finalization rejects missing, duplicate, unexpected, incorrectly sized, +or secret-bearing output. Sentinel coverage includes renderer DOM, process output, URLs, local/session storage, +persisted profile/config data, screenshots and metadata, and every decompressed Playwright trace entry. + +The existing six-target package matrix is unchanged. Linux x64 produces the visual/accessibility runtime evidence. +Linux arm64, macOS x64/arm64, and Windows x64/arm64 retain native package inspection and platform runtime smoke +coverage; their acceptance classification is structural/runtime-only. + Darwin packaged Connect acceptance first inspects the normal unsigned package, then generates a one-run self-signed CA:false code-signing leaf in an isolated default keychain and signs only that smoke artifact. The signature uses an explicit certificate-bound designated requirement that is verified before the pair process and again after the diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c00932ef4..899187fd2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,6 +31,8 @@ "smoke:package": "node scripts/smoke-packaged.mjs", "smoke:connect-package": "node scripts/smoke-packaged-connect.mjs", "smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only", + "acceptance:package": "node scripts/run-packaged-acceptance.mjs", + "acceptance:verify": "node -e \"import('./scripts/acceptance-artifacts.mjs').then(m => m.verifyAcceptanceArtifacts(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || m.defaultAcceptanceOutputDirectory()))\"", "premake": "npm run prepare:renderer", "make": "electron-forge make", "make:dmg": "node scripts/make-dmg.mjs", @@ -52,7 +54,9 @@ "@electron/fuses": "^2.1.3", "@types/node": "^22.10.0", "@vitejs/plugin-react": "^4.6.0", + "axe-core": "4.10.3", "electron": "^44.0.0", + "playwright": "^1.62.1", "socket.io": "^4.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/apps/desktop/scripts/acceptance-artifacts.mjs b/apps/desktop/scripts/acceptance-artifacts.mjs new file mode 100644 index 000000000..4cbe46cde --- /dev/null +++ b/apps/desktop/scripts/acceptance-artifacts.mjs @@ -0,0 +1,680 @@ +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateRawSync } from 'node:zlib'; +import { PACKAGED_ACCEPTANCE_TIME } from './packaged-acceptance-clock.mjs'; + +export const FIXED_TIME = PACKAGED_ACCEPTANCE_TIME; +export const ACCEPTANCE_ARTIFACT_LEAF = 'desktop-acceptance-artifacts'; +export const ACCEPTANCE_PROFILE_PREFIX = 'propr-desktop-acceptance-'; +export const ACCEPTANCE_SURFACES_PREFIX = 'propr-acceptance-surfaces-'; +export const DESKTOP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const WORKSPACE_ROOT = resolve(DESKTOP_ROOT, '../..'); + +export const ACCEPTANCE_JOURNEYS = Object.freeze([ + 'first-run-chooser', + 'manual-endpoint-confirmation', + 'connect-confirmation', + 'remote-pairing', + 'local-setup-prerequisites', + 'local-setup-progress', + 'local-setup-error', + 'local-setup-completion', + 'dashboard-profile-manager', + 'offline', + 'revoked', + 'incompatible-instance', +]); + +export const ACCEPTANCE_VARIANTS = Object.freeze({ + standard: { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false }, + narrow: { viewport: { width: 880, height: 620 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: false }, + 'high-dpi': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 2, zoom: 1, reducedMotion: false }, + 'zoom-200': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 2, reducedMotion: false }, + 'reduced-motion': { viewport: { width: 1280, height: 820 }, deviceScaleFactor: 1, zoom: 1, reducedMotion: true }, +}); + +export const FIXED_ACCEPTANCE_ORIGINS = Object.freeze({ + // Canonical loopback aliases keep the fixture private while exercising the + // renderer's real remote-pairing path instead of its localhost setup lane. + ready: 'http://127.0.0.2:41731', + revoked: 'http://127.0.0.3:41732', + incompatible: 'http://127.0.0.4:41733', + offline: 'http://127.0.0.1:9', +}); + +export const DETERMINISTIC_INPUTS = Object.freeze({ + originPolicy: 'fixed-loopback-fixtures', + origins: Object.values(FIXED_ACCEPTANCE_ORIGINS), + rendererClock: FIXED_TIME, + timestamps: FIXED_TIME, + visibleData: 'fixed-acceptance-fixtures-v1', + locale: 'en-US', + timezone: 'UTC', + font: 'Liberation Sans', + colorScheme: 'light', + viewports: 'manifest-per-screenshot', + scales: 'manifest-per-screenshot', + zoom: 'authenticated-electron-web-frame-per-screenshot', + reducedMotion: 'manifest-per-screenshot', + animations: 'disabled', + repeatability: 'byte-identical-double-capture', +}); + +export const defaultAcceptanceOutputDirectory = () => join(WORKSPACE_ROOT, ACCEPTANCE_ARTIFACT_LEAF); +export const screenshotName = (journey, variant) => `${journey}--${variant}.png`; +export const expectedScreenshotNames = () => ACCEPTANCE_JOURNEYS.flatMap(journey => + Object.keys(ACCEPTANCE_VARIANTS).map(variant => screenshotName(journey, variant))); + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const canonicalRelative = (root, path) => relative(root, path).split(sep).join('/'); +const isSha256 = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +const isInteger = value => Number.isInteger(value) && value >= 0; +const MAX_VIEWPORT_INSET_CSS_PIXELS = 64; + +const assertExactKeys = (value, expected, description) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${description} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.join('\n') !== wanted.join('\n')) throw new Error(`${description} schema changed`); +}; + +const assertExactJson = (actual, expected, description) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${description} changed`); +}; + +const assertViewportMetricEvidence = (evidence, config, description) => { + const expectedEffective = { + width: config.viewport.width / config.zoom, + height: config.viewport.height / config.zoom, + }; + const expectedPhysical = { + width: config.viewport.width * config.deviceScaleFactor, + height: config.viewport.height * config.deviceScaleFactor, + }; + assertExactKeys(evidence.requestedViewport, ['width', 'height'], `${description} requested viewport`); + assertExactKeys(evidence.playwrightViewport, ['width', 'height'], `${description} Playwright viewport`); + assertExactKeys(evidence.rendererViewport, ['width', 'height'], `${description} renderer viewport`); + assertExactKeys(evidence.documentClientViewport, ['width', 'height'], `${description} document client viewport`); + assertExactKeys(evidence.scrollbarInsets, ['width', 'height'], `${description} scrollbar insets`); + assertExactKeys(evidence.visualViewportInsets, ['width', 'height'], `${description} visual viewport insets`); + assertExactKeys(evidence.layoutViewport, ['width', 'height'], `${description} layout viewport`); + assertExactKeys(evidence.cdpVisualViewport, ['width', 'height', 'scale'], `${description} CDP visual viewport`); + assertExactKeys(evidence.rendererVisualViewport, ['width', 'height', 'scale'], `${description} renderer visual viewport`); + assertExactKeys(evidence.effectiveVisibleCssSpan, ['width', 'height'], `${description} effective visible CSS span`); + assertExactKeys(evidence.geometryZoom, ['width', 'height'], `${description} renderer geometry zoom`); + assertExactKeys(evidence.physicalPngDimensions, ['width', 'height'], `${description} physical PNG dimensions`); + const scrollbarInsetsAreValid = ['width', 'height'].every(axis => ( + Number.isFinite(evidence.scrollbarInsets[axis]) + && evidence.scrollbarInsets[axis] >= 0 + && evidence.scrollbarInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS + && evidence.scrollbarInsets[axis] + === evidence.rendererViewport[axis] - evidence.documentClientViewport[axis] + )); + const visualViewportInsetsAreValid = ['width', 'height'].every(axis => ( + Number.isFinite(evidence.visualViewportInsets[axis]) + && evidence.visualViewportInsets[axis] >= 0 + && evidence.visualViewportInsets[axis] <= MAX_VIEWPORT_INSET_CSS_PIXELS + && evidence.visualViewportInsets[axis] + === evidence.rendererViewport[axis] - evidence.rendererVisualViewport[axis] + )); + if (JSON.stringify(evidence.requestedViewport) !== JSON.stringify(config.viewport) + || JSON.stringify(evidence.playwrightViewport) !== JSON.stringify(config.viewport) + || JSON.stringify(evidence.rendererViewport) !== JSON.stringify(expectedEffective) + || !scrollbarInsetsAreValid + || !visualViewportInsetsAreValid + || JSON.stringify(evidence.layoutViewport) !== JSON.stringify(evidence.documentClientViewport) + || JSON.stringify(evidence.rendererVisualViewport) !== JSON.stringify(evidence.cdpVisualViewport) + || evidence.rendererVisualViewport.scale !== 1 + || JSON.stringify(evidence.effectiveVisibleCssSpan) !== JSON.stringify(expectedEffective) + || evidence.geometryZoom.width !== config.zoom || evidence.geometryZoom.height !== config.zoom + || evidence.requestedDeviceScaleFactor !== config.deviceScaleFactor + || evidence.rendererDevicePixelRatio !== config.deviceScaleFactor * config.zoom + || evidence.requestedZoomFactor !== config.zoom || evidence.appliedZoomFactor !== config.zoom + || evidence.zoomResetFactor !== 1 || evidence.zoomMechanism !== 'electron-web-frame' + || JSON.stringify(evidence.physicalPngDimensions) !== JSON.stringify(expectedPhysical)) { + throw new Error(`${description} viewport metric evidence changed`); + } +}; + +export const readPngDimensions = bytes => { + if (bytes.length < 24 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + throw new Error('Acceptance screenshot is not a PNG'); + } + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +}; + +const walkFiles = async root => { + const files = []; + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const stats = await lstat(path); + if (stats.isSymbolicLink()) throw new Error(`Acceptance artifact contains a symbolic link: ${entry.name}`); + if (stats.isDirectory()) await visit(path); + else if (stats.isFile()) files.push(path); + else throw new Error(`Acceptance artifact is not a regular file: ${entry.name}`); + } + }; + await visit(root); + return files.sort(); +}; + +const genericSecretPatterns = [ + /propr_it_[A-Za-z0-9_-]{20,}/g, + /gh[pousr]_[A-Za-z0-9_]{20,}/g, + /Bearer\s+[A-Za-z0-9._~-]{16,}/gi, + /(?:token|secret|password|private[_-]?key)\s*[=:]\s*[^\s"']{8,}/gi, +]; + +// Keep generic matches inside plaintext runs so binary bytes cannot join otherwise separate metadata. +const printableTextRuns = function* (bytes) { + let start = 0; + for (let index = 0; index < bytes.length; index += 1) { + const byte = bytes[index]; + if (byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)) continue; + if (index > start) yield bytes.subarray(start, index).toString('ascii'); + start = index + 1; + } + if (start < bytes.length) yield bytes.subarray(start).toString('ascii'); +}; + +const scanBytes = (bytes, description, sentinels) => { + for (const sentinel of sentinels) { + if (sentinel && bytes.includes(Buffer.from(sentinel))) throw new Error(`Secret sentinel found in ${description}`); + } + for (const text of printableTextRuns(bytes)) { + for (const pattern of genericSecretPatterns) { + pattern.lastIndex = 0; + if (pattern.test(text)) throw new Error(`Secret-shaped value found in ${description}`); + } + } +}; + +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const defaultOcr = async bytes => { + const result = spawnSync('tesseract', ['stdin', 'stdout', '--dpi', '96', '--psm', '11'], { + input: bytes, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error?.code === 'ENOENT') throw new Error('Acceptance screenshot OCR requires the tesseract executable'); + if (result.error || result.status !== 0) { + throw new Error(`Acceptance screenshot OCR failed: ${result.error?.message || result.stderr || `status ${result.status}`}`); + } + return result.stdout; +}; + +const renderedImageExtension = description => /\.(?:png|jpe?g|webp)$/i.test(description); + +export const scanRenderedScreenshot = async (bytes, description, sentinels = [], ocr = defaultOcr) => { + const recognized = await ocr(bytes, description); + if (typeof recognized !== 'string') throw new Error('Acceptance screenshot OCR returned invalid data'); + scanBytes(Buffer.from(recognized), `${description} (rendered pixels)`, sentinels); +}; + +const readZipEntries = bytes => { + const minimumEndOffset = Math.max(0, bytes.length - 65_557); + let endOffset = -1; + for (let offset = bytes.length - 22; offset >= minimumEndOffset; offset -= 1) { + if (bytes.readUInt32LE(offset) === 0x06054b50) { endOffset = offset; break; } + } + if (endOffset < 0) throw new Error('Acceptance trace ZIP end record is missing'); + const diskNumber = bytes.readUInt16LE(endOffset + 4); + const centralDisk = bytes.readUInt16LE(endOffset + 6); + const entriesOnDisk = bytes.readUInt16LE(endOffset + 8); + const entryCount = bytes.readUInt16LE(endOffset + 10); + const centralSize = bytes.readUInt32LE(endOffset + 12); + const centralOffset = bytes.readUInt32LE(endOffset + 16); + const commentLength = bytes.readUInt16LE(endOffset + 20); + if (diskNumber !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount || entryCount > 10_000 + || centralOffset + centralSize > endOffset || endOffset + 22 + commentLength !== bytes.length) { + throw new Error('Acceptance trace ZIP directory is invalid or unsupported'); + } + const entries = []; + let offset = centralOffset; + let totalUncompressed = 0; + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) throw new Error('Acceptance trace ZIP entry is invalid'); + const flags = bytes.readUInt16LE(offset + 8); + const method = bytes.readUInt16LE(offset + 10); + const checksum = bytes.readUInt32LE(offset + 16); + const compressedSize = bytes.readUInt32LE(offset + 20); + const uncompressedSize = bytes.readUInt32LE(offset + 24); + const filenameLength = bytes.readUInt16LE(offset + 28); + const extraLength = bytes.readUInt16LE(offset + 30); + const commentLength = bytes.readUInt16LE(offset + 32); + const externalAttributes = bytes.readUInt32LE(offset + 38); + const localOffset = bytes.readUInt32LE(offset + 42); + const nextOffset = offset + 46 + filenameLength + extraLength + commentLength; + if (nextOffset > bytes.length || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff + || (flags & 1) !== 0 || ![0, 8].includes(method) || uncompressedSize > 64 * 1024 * 1024) { + throw new Error('Acceptance trace ZIP entry uses an unsafe or unsupported encoding'); + } + const name = bytes.subarray(offset + 46, offset + 46 + filenameLength).toString('utf8'); + if (!name || name.startsWith('/') || name.split('/').includes('..') || ((externalAttributes >>> 16) & 0o170000) === 0o120000) { + throw new Error('Acceptance trace contains an unsafe entry'); + } + if (localOffset + 30 > bytes.length || bytes.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('Acceptance trace ZIP local entry is invalid'); + const localNameLength = bytes.readUInt16LE(localOffset + 26); + const localExtraLength = bytes.readUInt16LE(localOffset + 28); + const dataOffset = localOffset + 30 + localNameLength + localExtraLength; + const localName = bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength).toString('utf8'); + if (localName !== name || bytes.readUInt16LE(localOffset + 6) !== flags + || bytes.readUInt16LE(localOffset + 8) !== method || dataOffset + compressedSize > bytes.length) { + throw new Error('Acceptance trace ZIP entry is truncated or inconsistent'); + } + const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize); + const value = method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed, { maxOutputLength: 64 * 1024 * 1024 }); + if (value.length !== uncompressedSize || crc32(value) !== checksum) throw new Error('Acceptance trace ZIP entry integrity failed'); + totalUncompressed += value.length; + if (totalUncompressed > 256 * 1024 * 1024) throw new Error('Acceptance trace ZIP expands beyond its scan limit'); + entries.push({ name, value }); + offset = nextOffset; + } + if (offset !== centralOffset + centralSize) throw new Error('Acceptance trace ZIP directory size changed'); + return entries; +}; + +const scanZip = async (file, sentinels, ocr) => { + let entries; + try { entries = readZipEntries(await readFile(file)); } catch (error) { + throw new Error(`Acceptance trace is not a readable safe ZIP: ${basename(file)}`, { cause: error }); + } + for (const { name, value } of entries) { + scanBytes(value, `${file}:${name}`, sentinels); + if (renderedImageExtension(name)) await scanRenderedScreenshot(value, `${file}:${name}`, sentinels, ocr); + } +}; + +export const scanAcceptancePaths = async (paths, sentinels = [], { ocr = defaultOcr } = {}) => { + for (const input of paths) { + let stats; + try { stats = await lstat(input); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; } + if (stats.isSymbolicLink()) throw new Error(`Acceptance scan input is a symbolic link: ${input}`); + const files = stats.isDirectory() ? await walkFiles(input) : [input]; + for (const file of files) { + const bytes = await readFile(file); + scanBytes(bytes, file, sentinels); + if (renderedImageExtension(file)) await scanRenderedScreenshot(bytes, file, sentinels, ocr); + if (file.endsWith('.zip')) await scanZip(file, sentinels, ocr); + } + } +}; + +const allowedCleanup = (kind, allowedWorkspaceParents) => { + if (kind === 'artifact') { + return { + parents: new Set((allowedWorkspaceParents ?? [WORKSPACE_ROOT, DESKTOP_ROOT, tmpdir()]).map(path => resolve(path))), + name: value => value === ACCEPTANCE_ARTIFACT_LEAF, + }; + } + if (kind === 'profile') { + return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_PROFILE_PREFIX}[A-Za-z0-9]+$`).test(value) }; + } + if (kind === 'surfaces') { + return { parents: new Set([resolve(tmpdir())]), name: value => new RegExp(`^${ACCEPTANCE_SURFACES_PREFIX}[A-Za-z0-9]+$`).test(value) }; + } + throw new Error('Unknown acceptance cleanup kind'); +}; + +export const safeRemoveAcceptanceLeaf = async (input, { kind = 'artifact', allowedWorkspaceParents } = {}) => { + if (!isAbsolute(input)) throw new Error('Acceptance cleanup requires an absolute path'); + const target = resolve(input); + const policy = allowedCleanup(kind, allowedWorkspaceParents); + const parent = dirname(target); + if (target === parse(target).root || !policy.name(basename(target))) { + throw new Error('Acceptance cleanup target is not a dedicated allowlisted leaf'); + } + const canonicalAllowedParents = new Set(); + for (const allowedParent of policy.parents) { + const allowedStats = await lstat(allowedParent); + if (allowedStats.isSymbolicLink() || !allowedStats.isDirectory()) { + throw new Error('Acceptance cleanup allowed parent must be an existing non-link directory'); + } + canonicalAllowedParents.add(await realpath(allowedParent)); + } + const parentStats = await lstat(parent); + if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) { + throw new Error('Acceptance cleanup parent must be an existing non-link directory'); + } + const canonicalParent = await realpath(parent); + if (!canonicalAllowedParents.has(canonicalParent)) { + throw new Error('Acceptance cleanup target parent is not the canonical allowlisted parent'); + } + let stats; + try { stats = await lstat(target); } catch (error) { if (error?.code === 'ENOENT') return; throw error; } + if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error('Acceptance cleanup target must be a non-link directory'); + await rm(target, { recursive: true, force: false }); +}; + +export const prepareAcceptanceArtifactDirectory = async outputDirectory => { + const root = resolve(outputDirectory); + await safeRemoveAcceptanceLeaf(root, { kind: 'artifact' }); + await mkdir(join(root, 'screenshots'), { recursive: true, mode: 0o700 }); + return root; +}; + +const expectedArtifactFiles = () => new Set([ + ...expectedScreenshotNames().map(name => `screenshots/${name}`), + 'accessibility.json', + 'sanitized-summary.json', + 'sanitized-log.json', + 'sanitized-trace.zip', + 'manifest.json', +]); + +const validateScreenshotEntry = (entry, expectedName) => { + assertExactKeys(entry, [ + 'name', 'journey', 'variant', 'width', 'height', 'physicalPngDimensions', 'reducedMotion', + 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport', + 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport', + 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom', + 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor', + 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism', + 'locale', 'timezone', 'font', 'colorScheme', 'rendererTime', 'originPolicy', 'visibleData', + 'animations', 'bytes', 'sha256', 'repeatabilitySha256', + ], `Acceptance screenshot ${expectedName}`); + const [journey, variantWithExtension] = expectedName.split('--'); + const variant = variantWithExtension.slice(0, -4); + const config = ACCEPTANCE_VARIANTS[variant]; + if (entry.name !== expectedName || entry.journey !== journey || entry.variant !== variant + || entry.width !== config.viewport.width * config.deviceScaleFactor + || entry.height !== config.viewport.height * config.deviceScaleFactor + || entry.reducedMotion !== config.reducedMotion || entry.locale !== DETERMINISTIC_INPUTS.locale + || entry.timezone !== DETERMINISTIC_INPUTS.timezone || entry.font !== DETERMINISTIC_INPUTS.font + || entry.colorScheme !== DETERMINISTIC_INPUTS.colorScheme || entry.rendererTime !== FIXED_TIME + || entry.originPolicy !== DETERMINISTIC_INPUTS.originPolicy || entry.visibleData !== DETERMINISTIC_INPUTS.visibleData + || entry.animations !== DETERMINISTIC_INPUTS.animations || !isInteger(entry.bytes) || entry.bytes === 0 + || !isSha256(entry.sha256) || entry.repeatabilitySha256 !== entry.sha256) { + throw new Error(`Acceptance screenshot metadata changed for ${expectedName}`); + } + assertViewportMetricEvidence(entry, config, `Acceptance screenshot ${expectedName}`); +}; + +const validateAccessibility = accessibility => { + assertExactKeys(accessibility, [ + 'schemaVersion', 'generatedAt', 'serious', 'critical', 'findings', 'checks', 'keyboardOrder', + 'visibleFocus', 'modalFocusTrap', 'modalFocusRestore', 'accessibleNames', 'liveAnnouncements', + ], 'Acceptance accessibility report'); + if (accessibility.schemaVersion !== 6 || accessibility.generatedAt !== FIXED_TIME + || accessibility.serious !== 0 || accessibility.critical !== 0 || accessibility.findings?.length !== 0 + || accessibility.keyboardOrder !== true || accessibility.visibleFocus !== true + || accessibility.modalFocusTrap !== true || accessibility.modalFocusRestore !== true + || accessibility.accessibleNames !== true || accessibility.checks?.length !== expectedScreenshotNames().length) { + throw new Error('Acceptance accessibility thresholds were not met'); + } + assertExactKeys(accessibility.liveAnnouncements, ['status', 'error'], 'Acceptance live announcements'); + for (const [kind, evidence] of Object.entries(accessibility.liveAnnouncements)) { + assertExactKeys(evidence, ['journey', 'beforeHash', 'afterHash', 'mutated'], `Acceptance ${kind} live announcement`); + if (!ACCEPTANCE_JOURNEYS.includes(evidence.journey) || evidence.mutated !== true + || !isSha256(evidence.beforeHash) || !isSha256(evidence.afterHash) || evidence.beforeHash === evidence.afterHash) { + throw new Error(`Acceptance ${kind} live region did not mutate`); + } + } + accessibility.checks.forEach((check, index) => { + const name = expectedScreenshotNames()[index]; + assertExactKeys(check, [ + 'name', 'journey', 'variant', 'serious', 'critical', 'accessibleNames', 'locale', 'timezone', + 'fontLoaded', 'reducedMotion', 'animationsDisabled', 'rendererTime', 'physicalPngDimensions', + 'requestedViewport', 'playwrightViewport', 'rendererViewport', 'documentClientViewport', + 'scrollbarInsets', 'visualViewportInsets', 'layoutViewport', + 'cdpVisualViewport', 'rendererVisualViewport', 'effectiveVisibleCssSpan', 'geometryZoom', + 'requestedDeviceScaleFactor', 'rendererDevicePixelRatio', 'requestedZoomFactor', + 'appliedZoomFactor', 'zoomResetFactor', 'zoomMechanism', + ], `Acceptance accessibility check ${name}`); + const [journey, variantWithExtension] = name.split('--'); + const variant = variantWithExtension.slice(0, -4); + const config = ACCEPTANCE_VARIANTS[variant]; + if (check.name !== name || check.journey !== journey || check.variant !== variant + || check.reducedMotion !== config.reducedMotion || check.locale !== DETERMINISTIC_INPUTS.locale + || check.timezone !== DETERMINISTIC_INPUTS.timezone || check.rendererTime !== FIXED_TIME) { + throw new Error(`Acceptance accessibility metadata changed for ${name}`); + } + assertViewportMetricEvidence(check, config, `Acceptance accessibility check ${name}`); + if (check.serious !== 0 || check.critical !== 0 || check.accessibleNames !== true + || check.fontLoaded !== true || check.animationsDisabled !== true) { + throw new Error(`Acceptance accessibility check failed for ${name}`); + } + }); +}; + +const validateSummary = summary => { + assertExactKeys(summary, ['schemaVersion', 'generatedAt', 'status', 'journeys', 'screenshots', 'boundary', 'console', 'services', 'redaction'], 'Acceptance summary'); + if (summary.schemaVersion !== 5 || summary.generatedAt !== FIXED_TIME || summary.status !== 'passed' + || summary.journeys !== ACCEPTANCE_JOURNEYS.length || summary.screenshots !== expectedScreenshotNames().length + || summary.redaction !== 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.') { + throw new Error('Acceptance sanitized summary is invalid'); + } + assertExactKeys(summary.boundary, ['packagedExecutable', 'rendererOrigin', 'preloadBridge', 'journeys'], 'Acceptance boundary summary'); + if (summary.boundary.packagedExecutable !== true || summary.boundary.rendererOrigin !== 'propr-app://renderer' + || summary.boundary.preloadBridge !== true + || summary.boundary.journeys?.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance executable boundary was not observed'); + assertExactKeys(summary.console, ['records', 'errors'], 'Acceptance console summary'); + if (!isInteger(summary.console.records) || !isInteger(summary.console.errors) || summary.console.errors > summary.console.records) { + throw new Error('Acceptance console summary is invalid'); + } + assertExactKeys(summary.services, ['rest', 'socketIo', 'pairing', 'connect'], 'Acceptance service summary'); + const { rest, socketIo, pairing, connect } = summary.services; + assertExactKeys(rest, ['requestCount', 'authenticatedRequestCount', 'journeys', 'currentUserValidation'], 'Acceptance REST summary'); + assertExactKeys(rest.currentUserValidation, [ + 'correlation', 'rendererRequestIssued', 'rendererScopeGeneration', 'mainProxyObserved', 'mainActiveScopeGeneration', + 'activeScopeAccepted', 'mainOnlyBearerInjection', 'upstreamRequestArrived', 'responseStatus', + 'responseClassification', 'parsedUserSchemaAccepted', 'rendererActiveScopeAccepted', + 'firstFailedPredicate', + ], 'Acceptance current-user validation summary'); + assertExactKeys(socketIo, ['authenticatedConnections', 'events', 'journeys', 'handshake'], 'Acceptance Socket.IO summary'); + assertExactKeys(pairing, ['started', 'polled', 'activated', 'journeys'], 'Acceptance pairing summary'); + assertExactKeys(connect, ['confirmedRequests', 'journeys'], 'Acceptance Connect summary'); + assertExactKeys(socketIo.handshake, [ + 'mainAttempts', 'fixtureAttempts', 'scopeQueryPresent', 'scopeQueryCount', + 'scopeEqualsActivatedBinding', 'activeBindingPresent', 'profileGenerationCurrent', + 'originEqualsActivatedBinding', 'path', 'transport', 'resource', + 'rendererBearerPresent', 'rendererCookiePresent', 'authorizationHeaderPresent', + 'authorizationHeaderExactlyMainInjected', 'rendererObservedApplicationEvent', 'rendererLifecycle', + 'rejectionCategory', + ], 'Acceptance Socket.IO handshake summary'); + const handshake = socketIo.handshake; + assertExactKeys(handshake.rendererLifecycle, [ + 'phase', 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled', + 'disabledByDemoModeLoading', 'disabledByDemoMode', + 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent', + 'desktopRuntime', 'connectionScope', 'socketConstructionInvocations', 'socketConstructions', + 'connectInvocations', + ], 'Acceptance renderer Socket.IO lifecycle summary'); + const currentUserValidation = rest.currentUserValidation; + if (rest.requestCount <= 0 || rest.authenticatedRequestCount <= 0 || !rest.journeys.includes('dashboard-profile-manager') + || currentUserValidation.correlation !== 'current-scope-user-validation' + || currentUserValidation.rendererRequestIssued !== true + || !isInteger(currentUserValidation.rendererScopeGeneration) || currentUserValidation.rendererScopeGeneration <= 0 + || currentUserValidation.mainProxyObserved !== true + || !isInteger(currentUserValidation.mainActiveScopeGeneration) || currentUserValidation.mainActiveScopeGeneration < 0 + || currentUserValidation.activeScopeAccepted !== true + || currentUserValidation.mainOnlyBearerInjection !== true + || currentUserValidation.upstreamRequestArrived !== true + || currentUserValidation.responseStatus !== 200 + || currentUserValidation.responseClassification !== 'success' + || currentUserValidation.parsedUserSchemaAccepted !== true + || currentUserValidation.rendererActiveScopeAccepted !== true + || currentUserValidation.firstFailedPredicate !== 'none' + || socketIo.authenticatedConnections !== 1 || socketIo.events <= 0 || !socketIo.journeys.includes('dashboard-profile-manager') + || handshake.mainAttempts !== 1 || handshake.fixtureAttempts !== 1 + || handshake.scopeQueryPresent !== true || handshake.scopeQueryCount !== 1 + || handshake.scopeEqualsActivatedBinding !== true + || handshake.activeBindingPresent !== true || handshake.profileGenerationCurrent !== true + || handshake.originEqualsActivatedBinding !== true + || handshake.path !== 'socket-io' || handshake.transport !== 'websocket' || handshake.resource !== 'websocket' + || handshake.rendererBearerPresent !== false || handshake.rendererCookiePresent !== false + || handshake.authorizationHeaderPresent !== true || handshake.authorizationHeaderExactlyMainInjected !== true + || handshake.rendererObservedApplicationEvent !== true + || handshake.rendererLifecycle.phase !== 'socket-connect-invoked' + || handshake.rendererLifecycle.profileActivationPublished !== true + || handshake.rendererLifecycle.socketProviderMounted !== true + || handshake.rendererLifecycle.providerDisabled !== false + || handshake.rendererLifecycle.disabledByDemoModeLoading !== false + || handshake.rendererLifecycle.disabledByDemoMode !== false + || handshake.rendererLifecycle.disabledByCurrentUserLoading !== false + || handshake.rendererLifecycle.disabledByCurrentUserAbsent !== false + || handshake.rendererLifecycle.desktopRuntime !== true + || handshake.rendererLifecycle.connectionScope !== 'available' + || handshake.rendererLifecycle.socketConstructionInvocations !== 1 + || handshake.rendererLifecycle.socketConstructions !== 1 + || handshake.rendererLifecycle.connectInvocations !== 1 + || handshake.rejectionCategory !== 'none' + || pairing.started <= 0 || pairing.polled <= 0 || pairing.activated <= 0 || !pairing.journeys.includes('dashboard-profile-manager') + || connect.confirmedRequests <= 0 || connect.journeys.join('\n') !== 'connect-confirmation') { + throw new Error('Acceptance claimed service journey was not observed'); + } +}; + +const validateSanitizedLog = log => { + assertExactKeys(log, ['schemaVersion', 'generatedAt', 'records'], 'Acceptance sanitized log'); + if (log.schemaVersion !== 1 || log.generatedAt !== FIXED_TIME || !Array.isArray(log.records)) throw new Error('Acceptance sanitized log is invalid'); + for (const record of log.records) { + assertExactKeys(record, ['journey', 'source', 'level', 'bytes', 'sha256'], 'Acceptance sanitized log record'); + if (!ACCEPTANCE_JOURNEYS.includes(record.journey) || !['renderer-console', 'renderer-page-error', 'packaged-process'].includes(record.source) + || typeof record.level !== 'string' || !isInteger(record.bytes) || !isSha256(record.sha256)) { + throw new Error('Acceptance sanitized log record is invalid'); + } + } + const processJourneys = log.records.filter(record => record.source === 'packaged-process').map(record => record.journey); + if (processJourneys.join('\n') !== ACCEPTANCE_JOURNEYS.join('\n')) throw new Error('Acceptance sanitized process log coverage is incomplete'); +}; + +export const validateAcceptanceEvidence = (accessibility, manifest, summary, sanitizedLog) => { + assertExactKeys(manifest, [ + 'schemaVersion', 'generatedAt', 'platform', 'arch', 'executableBoundary', 'deterministicInputs', + 'nativePackageCoverage', 'screenshots', 'supporting', + ], 'Acceptance manifest'); + if (manifest.schemaVersion !== 6 || manifest.platform !== 'linux' || manifest.arch !== 'x64' + || manifest.generatedAt !== FIXED_TIME || manifest.executableBoundary !== 'packaged-electron-main-preload-renderer' + || manifest.screenshots?.length !== expectedScreenshotNames().length) { + throw new Error('Acceptance manifest is incomplete or non-deterministic'); + } + assertExactJson(manifest.deterministicInputs, DETERMINISTIC_INPUTS, 'Acceptance deterministic inputs'); + assertExactJson(manifest.nativePackageCoverage, { + 'linux-x64': 'visual-accessibility-runtime', + 'linux-arm64': 'structural-runtime-only', + 'darwin-x64': 'structural-runtime-only', + 'darwin-arm64': 'structural-runtime-only', + 'win32-x64': 'structural-runtime-only', + 'win32-arm64': 'structural-runtime-only', + }, 'Acceptance native package coverage'); + manifest.screenshots.forEach((entry, index) => validateScreenshotEntry(entry, expectedScreenshotNames()[index])); + for (const journey of ACCEPTANCE_JOURNEYS) { + const entries = Object.fromEntries(manifest.screenshots + .filter(entry => entry.journey === journey) + .map(entry => [entry.variant, entry])); + if (entries['zoom-200']?.sha256 === entries.standard?.sha256 + || entries['zoom-200']?.sha256 === entries['high-dpi']?.sha256) { + throw new Error(`Acceptance zoom-200 screenshot is not visually distinct for ${journey}`); + } + } + if (!Array.isArray(manifest.supporting) || manifest.supporting.length !== 4) throw new Error('Acceptance supporting manifest is incomplete'); + const expectedSupporting = ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip']; + manifest.supporting.forEach((entry, index) => { + assertExactKeys(entry, ['name', 'bytes', 'sha256'], `Acceptance supporting artifact ${expectedSupporting[index]}`); + if (entry.name !== expectedSupporting[index] || !isInteger(entry.bytes) || entry.bytes === 0 || !isSha256(entry.sha256)) { + throw new Error('Acceptance supporting artifact metadata changed'); + } + }); + validateAccessibility(accessibility); + validateSummary(summary); + validateSanitizedLog(sanitizedLog); + const rendererLogs = sanitizedLog.records.filter(record => record.source !== 'packaged-process'); + if (rendererLogs.length !== summary.console.records + || rendererLogs.filter(record => record.level === 'error').length !== summary.console.errors) { + throw new Error('Acceptance sanitized log and summary counts disagree'); + } +}; + +export const verifyAcceptanceArtifacts = async (outputDirectory, { sentinels = [], ocr = defaultOcr } = {}) => { + const root = resolve(outputDirectory); + const rootStats = await lstat(root); + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error('Acceptance artifact root must be a non-link directory'); + const files = await walkFiles(root); + const relativeFiles = files.map(path => canonicalRelative(root, path)); + const found = new Set(relativeFiles); + if (found.size !== relativeFiles.length) throw new Error('Duplicate acceptance artifact path'); + const expected = expectedArtifactFiles(); + const missing = [...expected].filter(path => !found.has(path)); + const unexpected = [...found].filter(path => !expected.has(path)); + if (missing.length || unexpected.length) { + throw new Error(`Acceptance artifact set mismatch (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'})`); + } + + const accessibility = JSON.parse(await readFile(join(root, 'accessibility.json'), 'utf8')); + const summary = JSON.parse(await readFile(join(root, 'sanitized-summary.json'), 'utf8')); + const sanitizedLog = JSON.parse(await readFile(join(root, 'sanitized-log.json'), 'utf8')); + const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')); + validateAcceptanceEvidence(accessibility, manifest, summary, sanitizedLog); + + for (const entry of manifest.screenshots) { + const bytes = await readFile(join(root, 'screenshots', entry.name)); + const dimensions = readPngDimensions(bytes); + if (dimensions.width !== entry.width || dimensions.height !== entry.height + || bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) { + throw new Error(`Acceptance screenshot bytes, digest, or dimensions changed for ${entry.name}`); + } + } + for (const entry of manifest.supporting) { + const bytes = await readFile(join(root, entry.name)); + if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) throw new Error(`Acceptance supporting artifact changed for ${entry.name}`); + } + await scanAcceptancePaths([root], sentinels, { ocr }); + return manifest; +}; + +export const writeAcceptanceManifest = async (outputDirectory, screenshotMetadata) => { + const root = resolve(outputDirectory); + await mkdir(root, { recursive: true }); + const screenshotMap = new Map(screenshotMetadata.map(entry => [entry.name, entry])); + if (screenshotMap.size !== screenshotMetadata.length) throw new Error('Duplicate acceptance screenshot metadata'); + const expectedNames = new Set(expectedScreenshotNames()); + const unexpectedMetadata = [...screenshotMap.keys()].filter(name => !expectedNames.has(name)); + if (unexpectedMetadata.length) throw new Error(`Unexpected acceptance screenshot metadata: ${unexpectedMetadata.join(', ')}`); + const screenshots = []; + for (const name of expectedScreenshotNames()) { + if (!screenshotMap.has(name)) throw new Error(`Acceptance screenshot metadata is missing ${name}`); + const path = join(root, 'screenshots', name); + const bytes = await readFile(path); + screenshots.push({ ...screenshotMap.get(name), name, bytes: bytes.length, sha256: sha256(bytes) }); + } + const supporting = []; + for (const name of ['accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip']) { + const bytes = await readFile(join(root, name)); + supporting.push({ name, bytes: bytes.length, sha256: sha256(bytes) }); + } + const manifest = { + schemaVersion: 6, + generatedAt: FIXED_TIME, + platform: 'linux', + arch: 'x64', + executableBoundary: 'packaged-electron-main-preload-renderer', + deterministicInputs: DETERMINISTIC_INPUTS, + nativePackageCoverage: { + 'linux-x64': 'visual-accessibility-runtime', + 'linux-arm64': 'structural-runtime-only', + 'darwin-x64': 'structural-runtime-only', + 'darwin-arm64': 'structural-runtime-only', + 'win32-x64': 'structural-runtime-only', + 'win32-arm64': 'structural-runtime-only', + }, + screenshots, + supporting, + }; + await writeFile(join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + return manifest; +}; + diff --git a/apps/desktop/scripts/acceptance-artifacts.test.mjs b/apps/desktop/scripts/acceptance-artifacts.test.mjs new file mode 100644 index 000000000..4af6aa710 --- /dev/null +++ b/apps/desktop/scripts/acceptance-artifacts.test.mjs @@ -0,0 +1,525 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { + ACCEPTANCE_ARTIFACT_LEAF, + ACCEPTANCE_JOURNEYS, + ACCEPTANCE_VARIANTS, + DETERMINISTIC_INPUTS, + FIXED_TIME, + expectedScreenshotNames, + readPngDimensions, + safeRemoveAcceptanceLeaf, + scanAcceptancePaths, + scanRenderedScreenshot, + screenshotName, + validateAcceptanceEvidence, + verifyAcceptanceArtifacts, + writeAcceptanceManifest, +} from './acceptance-artifacts.mjs'; + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +}; +const zipFixture = (name, contents) => { + const filename = Buffer.from(name); + const data = Buffer.from(contents); + const checksum = crc32(data); + const local = Buffer.alloc(30 + filename.length); + local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(filename.length, 26); + filename.copy(local, 30); + const central = Buffer.alloc(46 + filename.length); + central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); + central.writeUInt32LE(checksum, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(filename.length, 28); filename.copy(central, 46); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10); + end.writeUInt32LE(central.length, 12); end.writeUInt32LE(local.length + data.length, 16); + return Buffer.concat([local, data, central, end]); +}; +const pngHeader = (width, height) => { + const bytes = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); + bytes.write('IHDR', 12, 'ascii'); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +}; + +const viewportMetricEvidenceFor = config => { + const rendererViewport = { + width: config.viewport.width / config.zoom, + height: config.viewport.height / config.zoom, + }; + const scrollbarInsets = config.zoom === 2 ? { width: 8, height: 0 } : { width: 0, height: 0 }; + const visualViewportInsets = config.zoom === 2 ? { width: 7.5, height: 0 } : { width: 0, height: 0 }; + const documentClientViewport = { + width: rendererViewport.width - scrollbarInsets.width, + height: rendererViewport.height - scrollbarInsets.height, + }; + const rendererVisualViewport = { + width: rendererViewport.width - visualViewportInsets.width, + height: rendererViewport.height - visualViewportInsets.height, + scale: 1, + }; + return { + requestedViewport: { ...config.viewport }, + playwrightViewport: { ...config.viewport }, + rendererViewport, + documentClientViewport, + scrollbarInsets, + visualViewportInsets, + layoutViewport: { ...documentClientViewport }, + cdpVisualViewport: { ...rendererVisualViewport }, + rendererVisualViewport, + effectiveVisibleCssSpan: { ...rendererViewport }, + geometryZoom: { width: config.zoom, height: config.zoom }, + requestedDeviceScaleFactor: config.deviceScaleFactor, + rendererDevicePixelRatio: config.deviceScaleFactor * config.zoom, + requestedZoomFactor: config.zoom, + appliedZoomFactor: config.zoom, + zoomResetFactor: 1, + zoomMechanism: 'electron-web-frame', + physicalPngDimensions: { + width: config.viewport.width * config.deviceScaleFactor, + height: config.viewport.height * config.deviceScaleFactor, + }, + }; +}; + +const accessibilityFor = () => ({ + schemaVersion: 6, + generatedAt: FIXED_TIME, + serious: 0, + critical: 0, + findings: [], + checks: ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => ({ + name: screenshotName(journey, variant), journey, variant, serious: 0, critical: 0, accessibleNames: true, + locale: DETERMINISTIC_INPUTS.locale, timezone: DETERMINISTIC_INPUTS.timezone, fontLoaded: true, + reducedMotion: config.reducedMotion, animationsDisabled: true, rendererTime: FIXED_TIME, + ...viewportMetricEvidenceFor(config), + }))), + keyboardOrder: true, + visibleFocus: true, + modalFocusTrap: true, + modalFocusRestore: true, + accessibleNames: true, + liveAnnouncements: { + status: { journey: 'remote-pairing', beforeHash: '1'.repeat(64), afterHash: '2'.repeat(64), mutated: true }, + error: { journey: 'offline', beforeHash: '3'.repeat(64), afterHash: '4'.repeat(64), mutated: true }, + }, +}); + +const summaryFor = () => ({ + schemaVersion: 5, + generatedAt: FIXED_TIME, + status: 'passed', + journeys: ACCEPTANCE_JOURNEYS.length, + screenshots: expectedScreenshotNames().length, + boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS }, + console: { records: 3, errors: 0 }, + services: { + rest: { + requestCount: 12, + authenticatedRequestCount: 3, + journeys: ['dashboard-profile-manager'], + currentUserValidation: { + correlation: 'current-scope-user-validation', + rendererRequestIssued: true, + rendererScopeGeneration: 1, + mainProxyObserved: true, + mainActiveScopeGeneration: 0, + activeScopeAccepted: true, + mainOnlyBearerInjection: true, + upstreamRequestArrived: true, + responseStatus: 200, + responseClassification: 'success', + parsedUserSchemaAccepted: true, + rendererActiveScopeAccepted: true, + firstFailedPredicate: 'none', + }, + }, + socketIo: { + authenticatedConnections: 1, + events: 1, + journeys: ['dashboard-profile-manager'], + handshake: { + mainAttempts: 1, + fixtureAttempts: 1, + scopeQueryPresent: true, + scopeQueryCount: 1, + scopeEqualsActivatedBinding: true, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActivatedBinding: true, + path: 'socket-io', + transport: 'websocket', + resource: 'websocket', + rendererBearerPresent: false, + rendererCookiePresent: false, + authorizationHeaderPresent: true, + authorizationHeaderExactlyMainInjected: true, + rendererObservedApplicationEvent: true, + rendererLifecycle: { + phase: 'socket-connect-invoked', + profileActivationPublished: true, + socketProviderMounted: true, + providerDisabled: false, + disabledByDemoModeLoading: false, + disabledByDemoMode: false, + disabledByCurrentUserLoading: false, + disabledByCurrentUserAbsent: false, + desktopRuntime: true, + connectionScope: 'available', + socketConstructionInvocations: 1, + socketConstructions: 1, + connectInvocations: 1, + }, + rejectionCategory: 'none', + }, + }, + pairing: { started: 1, polled: 1, activated: 1, journeys: ['dashboard-profile-manager'] }, + connect: { confirmedRequests: 1, journeys: ['connect-confirmation'] }, + }, + redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.', +}); + +const sanitizedLogFor = () => ({ + schemaVersion: 1, + generatedAt: FIXED_TIME, + records: [ + ...Array.from({ length: 3 }, (_, index) => ({ + journey: 'first-run-chooser', source: 'renderer-console', level: 'info', bytes: index, sha256: '5'.repeat(64), + })), + ...ACCEPTANCE_JOURNEYS.map(journey => ({ + journey, source: 'packaged-process', level: 'combined', bytes: 0, sha256: '6'.repeat(64), + })), + ], +}); + +const createCompleteArtifactSet = async root => { + await mkdir(join(root, 'screenshots'), { recursive: true }); + const metadata = []; + for (const journey of ACCEPTANCE_JOURNEYS) { + for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) { + const name = screenshotName(journey, variant); + const bytes = Buffer.concat([ + pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor), + Buffer.from(`${journey}:${variant}`), + ]); + await writeFile(join(root, 'screenshots', name), bytes); + metadata.push({ + name, journey, variant, + width: config.viewport.width * config.deviceScaleFactor, + height: config.viewport.height * config.deviceScaleFactor, + reducedMotion: config.reducedMotion, + ...viewportMetricEvidenceFor(config), + locale: DETERMINISTIC_INPUTS.locale, + timezone: DETERMINISTIC_INPUTS.timezone, + font: DETERMINISTIC_INPUTS.font, + colorScheme: DETERMINISTIC_INPUTS.colorScheme, + rendererTime: FIXED_TIME, + originPolicy: DETERMINISTIC_INPUTS.originPolicy, + visibleData: DETERMINISTIC_INPUTS.visibleData, + animations: DETERMINISTIC_INPUTS.animations, + repeatabilitySha256: sha256(bytes), + }); + } + } + const accessibility = accessibilityFor(); + const summary = summaryFor(); + const sanitizedLog = sanitizedLogFor(); + await writeFile(join(root, 'accessibility.json'), `${JSON.stringify(accessibility)}\n`); + await writeFile(join(root, 'sanitized-summary.json'), `${JSON.stringify(summary)}\n`); + await writeFile(join(root, 'sanitized-log.json'), `${JSON.stringify(sanitizedLog)}\n`); + await writeFile(join(root, 'sanitized-trace.zip'), zipFixture('trace-entry.txt', 'sanitized trace')); + const manifest = await writeAcceptanceManifest(root, metadata); + return { accessibility, manifest, summary, sanitizedLog }; +}; + +describe('packaged acceptance artifact contract', () => { + it('has exactly 60 deterministic unique names and physical dimensions', () => { + const names = ACCEPTANCE_JOURNEYS.flatMap(journey => Object.entries(ACCEPTANCE_VARIANTS).map(([variant, config]) => { + assert.deepEqual(readPngDimensions(pngHeader(config.viewport.width * config.deviceScaleFactor, config.viewport.height * config.deviceScaleFactor)), { + width: config.viewport.width * config.deviceScaleFactor, + height: config.viewport.height * config.deviceScaleFactor, + }); + return screenshotName(journey, variant); + })); + assert.equal(names.length, 60); + assert.deepEqual(names, expectedScreenshotNames()); + assert.equal(new Set(names).size, 60); + assert.ok(names.every(name => /^[a-z0-9-]+--[a-z0-9-]+\.png$/.test(name))); + }); + + it('writes and verifies the complete strict 60-entry manifest and supporting set', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-full-')); + const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF); + try { + const evidence = await createCompleteArtifactSet(root); + assert.equal(evidence.manifest.screenshots.length, 60); + assert.equal(evidence.manifest.schemaVersion, 6); + assert.equal(evidence.accessibility.schemaVersion, 6); + const zoomEvidence = evidence.manifest.screenshots.find(entry => entry.variant === 'zoom-200'); + assert.deepEqual(zoomEvidence.rendererViewport, { width: 640, height: 410 }); + assert.deepEqual(zoomEvidence.documentClientViewport, { width: 632, height: 410 }); + assert.deepEqual(zoomEvidence.layoutViewport, { width: 632, height: 410 }); + assert.deepEqual(zoomEvidence.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 }); + assert.deepEqual(zoomEvidence.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 }); + assert.deepEqual(zoomEvidence.scrollbarInsets, { width: 8, height: 0 }); + assert.deepEqual(zoomEvidence.visualViewportInsets, { width: 7.5, height: 0 }); + assert.deepEqual(evidence.manifest.screenshots.map(entry => entry.name), expectedScreenshotNames()); + assert.ok(evidence.manifest.screenshots.every(entry => entry.bytes > 24 && entry.sha256 === entry.repeatabilitySha256)); + assert.deepEqual(evidence.manifest.supporting.map(entry => entry.name), [ + 'accessibility.json', 'sanitized-summary.json', 'sanitized-log.json', 'sanitized-trace.zip', + ]); + assert.doesNotThrow(() => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog)); + const verified = await verifyAcceptanceArtifacts(root, { ocr: async () => '' }); + assert.deepEqual(verified, JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'))); + } finally { await rm(parent, { recursive: true, force: true }); } + }); + + it('scans full persisted surfaces and OCR-recognized rendered pixels', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-scan-')); + try { + await mkdir(join(root, 'storage')); + await writeFile(join(root, 'storage', 'safe.json'), '{"status":"ready"}'); + await scanAcceptancePaths([root], ['acceptance-exact-sentinel']); + await assert.rejects( + scanRenderedScreenshot(Buffer.from('not-used-by-mock'), 'screenshot.png', ['acceptance-exact-sentinel'], async () => 'acceptance-exact-sentinel'), + /rendered pixels/, + ); + await writeFile(join(root, 'storage', 'unsafe.log'), 'acceptance-exact-sentinel'); + await assert.rejects(scanAcceptancePaths([root], ['acceptance-exact-sentinel']), /Secret sentinel/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + it('keeps generic matching within printable binary runs and exact sentinel scanning raw', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-binary-scan-')); + const database = join(root, 'key4.db'); + const binaryAssignmentBridge = Buffer.concat([ + Buffer.from('SQLite format 3\0NSS metadata password=', 'ascii'), + Buffer.from([0x00, 0x80, 0xff, 0x01]), + Buffer.from('binary-record-body', 'ascii'), + Buffer.from([0x00, 0x02]), + ]); + try { + assert.match(binaryAssignmentBridge.toString('utf8'), /password\s*[=:]\s*[^\s"']{8,}/i); + await writeFile(database, binaryAssignmentBridge); + await scanAcceptancePaths([database]); + + for (const credential of [ + `propr_it_${'a'.repeat(20)}`, + `ghp_${'b'.repeat(20)}`, + `Bearer ${'c'.repeat(16)}`, + 'token=printable-value', + 'secret: printable-value', + 'password = printable-value', + 'private-key=printable-value', + ]) { + await writeFile(database, Buffer.concat([ + binaryAssignmentBridge, + Buffer.from([0x00, 0xff]), + Buffer.from(credential, 'ascii'), + Buffer.from([0x00]), + ])); + await assert.rejects(scanAcceptancePaths([database]), /Secret-shaped value/); + } + + const sentinel = 'acceptance-exact-sentinel'; + await writeFile(database, Buffer.concat([ + Buffer.from([0x00, 0x80, 0xff]), + Buffer.from(sentinel, 'ascii'), + Buffer.from([0x01, 0xfe, 0x00]), + ])); + await assert.rejects(scanAcceptancePaths([database], [sentinel]), /Secret sentinel/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + it('fails closed on missing, mutated, and unexpected artifact data', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-invalid-')); + const root = join(parent, ACCEPTANCE_ARTIFACT_LEAF); + try { + await mkdir(join(root, 'screenshots'), { recursive: true }); + await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /artifact set mismatch.*missing/i); + const evidence = await createCompleteArtifactSet(root); + assert.throws( + () => validateAcceptanceEvidence({ ...evidence.accessibility, extra: true }, evidence.manifest, evidence.summary, evidence.sanitizedLog), + /schema changed/, + ); + assert.throws( + () => validateAcceptanceEvidence(evidence.accessibility, evidence.manifest, { + ...evidence.summary, + services: { ...evidence.summary.services, connect: { confirmedRequests: 0, journeys: [] } }, + }, evidence.sanitizedLog), + /not observed/, + ); + for (const mutateHandshake of [ + handshake => { handshake.scopeQueryPresent = false; handshake.scopeQueryCount = 0; }, + handshake => { handshake.scopeQueryCount = 2; }, + handshake => { handshake.scopeEqualsActivatedBinding = false; handshake.rejectionCategory = 'stale-scope'; }, + handshake => { handshake.rejectionCategory = 'wrong-origin'; }, + handshake => { handshake.rendererBearerPresent = true; }, + ]) { + const invalidSummary = structuredClone(evidence.summary); + mutateHandshake(invalidSummary.services.socketIo.handshake); + assert.throws( + () => validateAcceptanceEvidence( + evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog, + ), + /not observed/, + ); + } + for (const mutateLifecycle of [ + lifecycle => { lifecycle.profileActivationPublished = false; }, + lifecycle => { lifecycle.socketProviderMounted = false; }, + lifecycle => { lifecycle.providerDisabled = true; }, + lifecycle => { lifecycle.disabledByDemoModeLoading = true; }, + lifecycle => { lifecycle.disabledByDemoMode = true; }, + lifecycle => { lifecycle.disabledByCurrentUserLoading = true; }, + lifecycle => { lifecycle.disabledByCurrentUserAbsent = true; }, + lifecycle => { lifecycle.connectionScope = 'unavailable'; }, + lifecycle => { lifecycle.socketConstructionInvocations = 0; }, + lifecycle => { lifecycle.socketConstructions = 0; }, + lifecycle => { lifecycle.connectInvocations = 0; }, + lifecycle => { lifecycle.connectInvocations = 2; }, + ]) { + const invalidSummary = structuredClone(evidence.summary); + mutateLifecycle(invalidSummary.services.socketIo.handshake.rendererLifecycle); + assert.throws( + () => validateAcceptanceEvidence( + evidence.accessibility, evidence.manifest, invalidSummary, evidence.sanitizedLog, + ), + /not observed/, + ); + } + const relabeledManifest = structuredClone(evidence.manifest); + const zoomEntry = relabeledManifest.screenshots.find(entry => entry.variant === 'zoom-200'); + zoomEntry.appliedZoomFactor = 1; + zoomEntry.rendererDevicePixelRatio = 1; + zoomEntry.geometryZoom = { width: 1, height: 1 }; + zoomEntry.effectiveVisibleCssSpan = { width: 1280, height: 820 }; + assert.throws( + () => validateAcceptanceEvidence(evidence.accessibility, relabeledManifest, evidence.summary, evidence.sanitizedLog), + /zoom-200.*viewport metric evidence changed/, + ); + for (const mutate of [ + entry => { entry.layoutViewport.width += 1; }, + entry => { entry.scrollbarInsets.width += 1; }, + entry => { entry.visualViewportInsets.width += 1; }, + entry => { entry.rendererVisualViewport.width += 0.5; }, + entry => { entry.cdpVisualViewport.width += 0.5; }, + entry => { + entry.documentClientViewport.width = entry.rendererViewport.width - 65; + entry.layoutViewport.width = entry.documentClientViewport.width; + entry.scrollbarInsets.width = 65; + }, + entry => { + entry.visualViewportInsets.width = 65; + entry.rendererVisualViewport.width = entry.rendererViewport.width - 65; + entry.cdpVisualViewport.width = entry.rendererVisualViewport.width; + }, + entry => { entry.visualViewportInsets.width = Number.NaN; }, + ]) { + const invalidScrollbarManifest = structuredClone(evidence.manifest); + mutate(invalidScrollbarManifest.screenshots.find(entry => entry.variant === 'zoom-200')); + assert.throws( + () => validateAcceptanceEvidence(evidence.accessibility, invalidScrollbarManifest, evidence.summary, evidence.sanitizedLog), + /zoom-200.*viewport metric evidence changed/, + ); + } + const invalidAccessibility = structuredClone(evidence.accessibility); + invalidAccessibility.checks.find(check => check.variant === 'zoom-200').visualViewportInsets.width = 8; + assert.throws( + () => validateAcceptanceEvidence(invalidAccessibility, evidence.manifest, evidence.summary, evidence.sanitizedLog), + /zoom-200.*viewport metric evidence changed/, + ); + const duplicateManifest = structuredClone(evidence.manifest); + const duplicateZoom = duplicateManifest.screenshots.find(entry => entry.variant === 'zoom-200'); + const matchingHighDpi = duplicateManifest.screenshots.find(entry => ( + entry.journey === duplicateZoom.journey && entry.variant === 'high-dpi' + )); + duplicateZoom.sha256 = matchingHighDpi.sha256; + duplicateZoom.repeatabilitySha256 = matchingHighDpi.sha256; + assert.throws( + () => validateAcceptanceEvidence(evidence.accessibility, duplicateManifest, evidence.summary, evidence.sanitizedLog), + /zoom-200 screenshot is not visually distinct/, + ); + const screenshotPath = join(root, 'screenshots', expectedScreenshotNames()[0]); + await writeFile(screenshotPath, pngHeader(1, 1)); + await assert.rejects(verifyAcceptanceArtifacts(root, { ocr: async () => '' }), /bytes, digest, or dimensions/); + } finally { await rm(parent, { recursive: true, force: true }); } + }); + + it('only recursively removes a dedicated non-link allowlisted leaf', async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-cleanup-')); + try { + const leaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF); + await mkdir(join(leaf, 'nested'), { recursive: true }); + await writeFile(join(leaf, 'nested', 'artifact'), 'safe'); + await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] }); + await safeRemoveAcceptanceLeaf(resolve(leaf), { kind: 'artifact', allowedWorkspaceParents: [parent] }); + await assert.rejects(safeRemoveAcceptanceLeaf(resolve(parent), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /dedicated allowlisted leaf/); + const tooDeep = join(parent, 'nested', ACCEPTANCE_ARTIFACT_LEAF); + await mkdir(tooDeep, { recursive: true }); + await assert.rejects(safeRemoveAcceptanceLeaf(resolve(tooDeep), { kind: 'artifact', allowedWorkspaceParents: [parent] }), /canonical allowlisted parent/); + const realParent = join(parent, 'real-parent'); + const linkedParent = join(parent, 'linked-parent'); + await mkdir(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), { recursive: true }); + await symlink(realParent, linkedParent); + await assert.rejects( + safeRemoveAcceptanceLeaf(resolve(linkedParent, ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [linkedParent] }), + /allowed parent must be an existing non-link directory/, + ); + const outside = await mkdtemp(join(tmpdir(), 'propr-acceptance-contract-escape-')); + try { + const escapedParent = join(parent, 'escaped-parent'); + await symlink(outside, escapedParent); + await mkdir(join(outside, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { recursive: true }); + await assert.rejects( + safeRemoveAcceptanceLeaf(join(escapedParent, 'nested', ACCEPTANCE_ARTIFACT_LEAF), { kind: 'artifact', allowedWorkspaceParents: [parent] }), + /canonical allowlisted parent/, + ); + } finally { + await rm(outside, { recursive: true, force: true }); + } + const linkedLeaf = join(parent, ACCEPTANCE_ARTIFACT_LEAF); + await symlink(join(realParent, ACCEPTANCE_ARTIFACT_LEAF), linkedLeaf); + await assert.rejects( + safeRemoveAcceptanceLeaf(resolve(linkedLeaf), { kind: 'artifact', allowedWorkspaceParents: [parent] }), + /non-link directory/, + ); + } finally { await rm(parent, { recursive: true, force: true }); } + }); + + it('authorizes the standard macOS temporary-directory alias by canonical parent identity', { + skip: process.platform !== 'darwin' ? 'macOS-only /var alias regression' : false, + }, async t => { + const aliasedParent = await mkdtemp(join(resolve(tmpdir()), 'propr-acceptance-macos-alias-')); + const canonicalParent = await realpath(aliasedParent); + if (aliasedParent === canonicalParent) { + await rm(aliasedParent, { recursive: true, force: true }); + t.skip('the configured macOS temporary directory does not use an alias'); + return; + } + const leaf = join(aliasedParent, ACCEPTANCE_ARTIFACT_LEAF); + try { + await mkdir(leaf); + await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] }); + await safeRemoveAcceptanceLeaf(leaf, { kind: 'artifact', allowedWorkspaceParents: [canonicalParent] }); + } finally { + await rm(aliasedParent, { recursive: true, force: true }); + } + }); +}); + diff --git a/apps/desktop/scripts/packaged-acceptance-axe.mjs b/apps/desktop/scripts/packaged-acceptance-axe.mjs new file mode 100644 index 000000000..631194f19 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-axe.mjs @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import axeCore from 'axe-core'; + +const require = createRequire(import.meta.url); +const axePackage = require('axe-core/package.json'); + +export const PACKAGED_AXE_VERSION = '4.10.3'; +export const PACKAGED_AXE_SOURCE_SHA256 = '5248a3ce82beae19e2bbd73dc209055747cda599e59a02f3272045206904254f'; +export const PACKAGED_AXE_FRAME_POLICY = 'reject-all-child-frames'; +export const PACKAGED_AXE_CLEANUP_TIMEOUT_MS = 5_000; + +const axeSource = axeCore?.source; +const axeSourceSha256 = typeof axeSource === 'string' + ? createHash('sha256').update(axeSource).digest('hex') + : ''; + +if (axePackage?.name !== 'axe-core' || axePackage?.version !== PACKAGED_AXE_VERSION + || axeCore?.version !== PACKAGED_AXE_VERSION || typeof axeSource !== 'string' + || axeSource.length === 0 || axeSourceSha256 !== PACKAGED_AXE_SOURCE_SHA256) { + throw new Error('Packaged accessibility axe-core source identity or version is invalid'); +} + +const validateProjectedViolations = violations => { + if (!Array.isArray(violations)) { + throw new Error('Packaged accessibility result schema is invalid'); + } + for (const violation of violations) { + if (!violation || typeof violation !== 'object' + || Object.keys(violation).length !== 3 + || typeof violation.id !== 'string' || violation.id.length === 0 + || (violation.impact !== 'serious' && violation.impact !== 'critical') + || !Number.isInteger(violation.nodes) || violation.nodes < 1) { + throw new Error('Packaged accessibility result schema is invalid'); + } + } + return violations; +}; + +/** + * Run the pinned axe-core engine in the already-authenticated renderer target. + * + * The acceptance UI is intentionally single-frame. Child frames are rejected + * instead of being silently skipped or aggregated in a new page. Within that + * document, axe.run(document) performs axe-core's native composed-tree walk, + * including all open shadow roots supported by the engine. + */ +export const analyzeExistingElectronRenderer = async page => { + if (!page || typeof page.evaluate !== 'function' || typeof page.frames !== 'function' + || typeof page.mainFrame !== 'function') { + throw new Error('Packaged accessibility renderer target is invalid'); + } + + const frames = page.frames(); + if (PACKAGED_AXE_FRAME_POLICY !== 'reject-all-child-frames' + || frames.length !== 1 || frames[0] !== page.mainFrame()) { + throw new Error('Packaged accessibility does not permit child frames'); + } + + let injected = false; + let primaryError; + try { + const injectionBoundary = await page.evaluate(() => ({ + childFrames: window.frames.length, + existingAxe: typeof globalThis.axe !== 'undefined', + })); + if (!injectionBoundary || injectionBoundary.childFrames !== 0 || injectionBoundary.existingAxe !== false) { + throw new Error('Packaged accessibility injection boundary is invalid'); + } + + try { + injected = true; + await page.evaluate(axeSource); + } catch (error) { + throw new Error('Packaged accessibility axe-core injection failed', { cause: error }); + } + + const identity = await page.evaluate(() => ({ + version: globalThis.axe?.version, + run: typeof globalThis.axe?.run, + cleanup: typeof globalThis.axe?.cleanup, + })); + if (!identity || identity.version !== PACKAGED_AXE_VERSION + || identity.run !== 'function' || identity.cleanup !== 'function') { + throw new Error('Packaged accessibility injected engine identity or version is invalid'); + } + + let violations; + try { + violations = await page.evaluate(async expectedVersion => { + const engine = globalThis.axe; + const results = await engine.run(document, { resultTypes: ['violations'] }); + const validImpact = new Set([null, 'minor', 'moderate', 'serious', 'critical']); + if (!results || typeof results !== 'object' + || results.testEngine?.name !== 'axe-core' || results.testEngine?.version !== expectedVersion + || !Array.isArray(results.violations)) { + throw new Error('axe-core returned an invalid result envelope'); + } + const projected = []; + for (const violation of results.violations) { + if (!violation || typeof violation !== 'object' + || typeof violation.id !== 'string' || violation.id.length === 0 + || !validImpact.has(violation.impact) || !Array.isArray(violation.nodes) + || violation.nodes.some(node => !node || typeof node !== 'object')) { + throw new Error('axe-core returned an invalid violation'); + } + if (violation.impact === 'serious' || violation.impact === 'critical') { + projected.push({ id: violation.id, impact: violation.impact, nodes: violation.nodes.length }); + } + } + return projected; + }, PACKAGED_AXE_VERSION); + } catch (error) { + throw new Error('Packaged accessibility axe-core execution or result validation failed', { cause: error }); + } + return validateProjectedViolations(violations); + } catch (error) { + primaryError = error; + throw error; + } finally { + if (injected) { + let cleaned = false; + let cleanupError; + try { + cleaned = await page.evaluate(async cleanupTimeoutMs => { + const engine = globalThis.axe; + if (!engine || typeof engine.cleanup !== 'function') { + throw new Error('axe-core cleanup is unavailable'); + } + // axe-core 4.10.3 cleanup completes through these callbacks after + // its plugin/frame queue drains; its direct return value is not completion. + await new Promise((resolve, reject) => { + let settled = false; + let timeout; + const settle = callback => value => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(value); + }; + const resolveCleanup = settle(resolve); + const rejectCleanup = settle(reject); + timeout = setTimeout( + () => rejectCleanup(new Error(`axe-core cleanup timed out after ${cleanupTimeoutMs}ms`)), + cleanupTimeoutMs, + ); + try { + engine.cleanup(resolveCleanup, rejectCleanup); + } catch (error) { + rejectCleanup(error); + } + }); + return Reflect.deleteProperty(globalThis, 'axe') && typeof globalThis.axe === 'undefined'; + }, PACKAGED_AXE_CLEANUP_TIMEOUT_MS); + } catch (error) { + cleanupError = new Error('Packaged accessibility axe-core cleanup failed', { cause: error }); + } + if (!cleaned && !cleanupError) { + cleanupError = new Error('Packaged accessibility axe-core cleanup failed'); + } + if (cleanupError) { + if (!primaryError) throw cleanupError; + throw new AggregateError( + [primaryError, cleanupError], + primaryError instanceof Error ? primaryError.message : String(primaryError), + { cause: primaryError }, + ); + } + } + } +}; + diff --git a/apps/desktop/scripts/packaged-acceptance-clock.d.mts b/apps/desktop/scripts/packaged-acceptance-clock.d.mts new file mode 100644 index 000000000..f0e0c9eb5 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-clock.d.mts @@ -0,0 +1,3 @@ +export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS: number; +export const PACKAGED_ACCEPTANCE_TIME: string; + diff --git a/apps/desktop/scripts/packaged-acceptance-clock.mjs b/apps/desktop/scripts/packaged-acceptance-clock.mjs new file mode 100644 index 000000000..ec284f5c2 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-clock.mjs @@ -0,0 +1,9 @@ +/** + * One epoch owns every deterministic packaged-acceptance clock. Production + * code must consume it only through the dual-authorized acceptance boundary. + */ +export const PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS = 1_767_323_045_000; +export const PACKAGED_ACCEPTANCE_TIME = new Date( + PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS, +).toISOString(); + diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.mjs new file mode 100644 index 000000000..83bdc6c62 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-current-user.mjs @@ -0,0 +1,189 @@ +import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; + +export const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration'; + +export const scopedCurrentUserRequestGeneration = (method, url) => { + if (method !== 'GET' || typeof url !== 'string') return null; + let parsed; + try { + parsed = new URL(url, 'http://fixture.invalid'); + } catch { + return null; + } + if (parsed.pathname !== '/api/auth/user') return null; + const entries = [...parsed.searchParams]; + if (entries.length !== 1 || entries[0][0] !== CURRENT_USER_SCOPE_GENERATION_QUERY) return null; + const value = entries[0][1]; + if (!/^(?:0|[1-9]\d{0,15})$/.test(value) + || parsed.search !== `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${value}`) return null; + const generation = Number(value); + return Number.isSafeInteger(generation) ? generation : null; +}; + +export const isExpectedScopedCurrentUserRequest = (method, url, expectedGeneration) => ( + Number.isSafeInteger(expectedGeneration) + && expectedGeneration >= 0 + && scopedCurrentUserRequestGeneration(method, url) === expectedGeneration +); + +/** Classify only the two authenticated current-user request shapes used by desktop. */ +export const classifyCurrentUserRequestShape = (method, url, origin) => { + if (method !== 'GET' || typeof url !== 'string') return null; + let parsed; + try { + parsed = new URL(url, 'http://fixture.invalid'); + } catch { + return null; + } + if (parsed.pathname !== '/api/auth/user') return null; + + if (origin === DESKTOP_RENDERER_ORIGIN) { + const scopeGeneration = scopedCurrentUserRequestGeneration(method, url); + return scopeGeneration === null ? null : { source: 'renderer', scopeGeneration }; + } + + return url === '/api/auth/user' && parsed.search === '' && [...parsed.searchParams].length === 0 + ? { source: 'main', scopeGeneration: null } + : null; +}; + +const boundedCount = records => Math.min(records.length, 9); + +/** Fixed, bounded, secret-free counts for Local Network Access decisions. */ +export const networkPermissionDecisionSummary = ({ journey, records, invalidCount = 0 }) => { + const selected = records.filter(record => record.journey === journey); + const count = predicate => boundedCount(selected.filter(predicate)); + return { + schemaVersion: 1, + records: boundedCount(selected), + check: count(record => record.decision === 'check'), + request: count(record => record.decision === 'request'), + allowed: count(record => record.allowed === true), + denied: count(record => record.allowed === false), + localNetworkAccess: count(record => record.permissionCategory === 'local-network-access'), + localNetwork: count(record => record.permissionCategory === 'local-network'), + loopbackNetwork: count(record => record.permissionCategory === 'loopback-network'), + activeBindingCurrent: count(record => record.activeBindingCurrent === true), + webContentsPresent: count(record => record.webContentsPresent === true), + webContentsEqualsMainWindow: count(record => record.webContentsEqualsMainWindow === true), + mainWindowPresent: count(record => record.mainWindowPresent === true), + mainFrame: count(record => record.isMainFrame === true), + requestingUrlPresent: count(record => record.requestingUrlPresent === true), + requestingUrlTrusted: count(record => record.requestingUrlTrusted === true), + rendererDocumentUrlTrusted: count(record => record.rendererDocumentUrlTrusted === true), + requestingOriginAuthorityValid: count(record => record.requestingOriginAuthorityValid === true), + requestingOriginAuthorityEqual: count(record => record.requestingOriginAuthorityEqual === true), + invalid: Math.min(invalidCount, 9), + }; +}; + +/** Fixed, bounded, secret-free diagnostics for the strict hosted boundary. */ +export const currentUserValidationPhaseSummary = ({ + journey, + rendererRecords, + mainRecords, + fixtureRecords, + requestRecords, +}) => { + const renderer = rendererRecords.filter(record => record.journey === journey + && record.activeScopePresent && record.scopeGeneration > 0); + const main = mainRecords.filter(record => record.journey === journey); + const fixture = fixtureRecords.filter(record => record.journey === journey + && record.source === 'renderer'); + const currentUserRequests = requestRecords.filter(record => record.journey === journey + && record.origin === DESKTOP_RENDERER_ORIGIN + && scopedCurrentUserRequestGeneration('GET', record.url) !== null); + const phaseCount = phase => boundedCount(renderer.filter(record => record.phase === phase)); + return { + schemaVersion: 1, + options: boundedCount(currentUserRequests.filter(record => record.method === 'OPTIONS')), + get: boundedCount(currentUserRequests.filter(record => record.method === 'GET')), + main: boundedCount(main), + mainAccepted: boundedCount(main.filter(record => record.accepted === true)), + fixture: boundedCount(fixture), + fixture200: boundedCount(fixture.filter(record => record.responseStatus === 200)), + requestIssued: phaseCount('request-issued'), + responseCompleted: phaseCount('response-completed'), + parsedUserAccepted: phaseCount('parsed-user-accepted'), + activeScopeAccepted: phaseCount('active-scope-accepted'), + rejected: boundedCount(renderer.filter(record => record.phase.endsWith('-rejected'))), + }; +}; + +export const currentUserValidationFailureCategory = ({ + journey, + evidenceInvalid, + rendererRecords, + mainRecords, + fixtureRecords, +}) => { + if (evidenceInvalid) return 'current-user-evidence-invalid'; + const renderer = rendererRecords.filter(record => record.journey === journey + && record.activeScopePresent && record.scopeGeneration > 0); + const issued = renderer.filter(record => record.phase === 'request-issued'); + if (issued.length === 0) return 'current-user-renderer-request-not-issued'; + if (issued.length !== 1) return 'current-user-renderer-request-duplicate'; + const generation = issued[0].scopeGeneration; + + // Correlate the exact journey first. Security predicates below must diagnose + // the selected request rather than making rejected evidence unobservable. + const main = mainRecords.filter(record => record.journey === journey); + if (main.length === 0) return 'current-user-main-proxy-not-observed'; + if (main.length !== 1) return 'current-user-main-proxy-duplicate'; + const observed = main[0]; + if (!observed.scopeGenerationQueryValid || observed.scopeGenerationQueryCount !== 1 + || observed.rendererScopeGeneration !== generation) { + return 'current-user-main-generation-correlation-invalid'; + } + if (observed.scopeHeaderCount !== 1) { + if (observed.scopeHeaderCount === 0 && !observed.accepted + && observed.rejectionCategory === 'scope-missing') return 'current-user-main-scope-missing'; + if (observed.scopeHeaderCount === 2 && !observed.accepted + && observed.rejectionCategory === 'scope-duplicate') return 'current-user-main-scope-duplicate'; + return 'current-user-main-scope-header-count-invalid'; + } + if (!observed.activeBindingPresent) { + return !observed.accepted && observed.rejectionCategory === 'no-active-binding' + ? 'current-user-main-no-active-binding' + : 'current-user-main-active-binding-missing'; + } + if (!Number.isSafeInteger(observed.activeScopeGeneration) || observed.activeScopeGeneration < 0) { + return 'current-user-main-active-generation-invalid'; + } + if (!observed.accepted) return `current-user-main-${observed.rejectionCategory}`; + if (observed.rejectionCategory !== 'none') return 'current-user-main-rejection-category-invalid'; + if (!observed.profileGenerationCurrent || !observed.scopeEqualsActive || !observed.originEqualsActive) { + return 'current-user-main-active-scope-rejected'; + } + if (observed.rendererBearerPresent || observed.rendererCookiePresent + || !observed.outboundBearerPresent || !observed.bearerMainInjected) { + return 'current-user-main-bearer-custody-invalid'; + } + // Select the journey's exact renderer-originated upstream observation before + // asserting bearer custody so a malformed request cannot look unobserved. + const fixture = fixtureRecords.filter(record => record.journey === journey + && record.source === 'renderer'); + if (fixture.length === 0) return 'current-user-upstream-request-not-arrived'; + if (fixture.length !== 1) return 'current-user-upstream-request-duplicate'; + if (fixture[0].scopeGeneration !== generation) return 'current-user-upstream-generation-correlation-invalid'; + if (!fixture[0].authorizationPresent || !fixture[0].authorizationMatchesActivatedBearer + || fixture[0].cookiePresent) return 'current-user-upstream-bearer-custody-invalid'; + if (fixture[0].responseStatus !== 200) return `current-user-response-http-${fixture[0].responseStatus}`; + const completed = renderer.filter(record => record.scopeGeneration === generation + && record.phase === 'response-completed'); + if (completed.length === 0) return 'current-user-renderer-response-not-completed'; + if (completed.length !== 1 || completed[0].responseStatus !== 200 + || completed[0].classification !== 'success') return 'current-user-renderer-response-rejected'; + const parsed = renderer.filter(record => record.scopeGeneration === generation + && (record.phase === 'parsed-user-accepted' || record.phase === 'parsed-user-rejected')); + if (parsed.length === 0) return 'current-user-parsed-schema-not-observed'; + if (parsed.length !== 1 || parsed[0].phase !== 'parsed-user-accepted' || !parsed[0].schemaAccepted) { + return 'current-user-parsed-schema-rejected'; + } + const accepted = renderer.filter(record => record.scopeGeneration === generation + && record.phase === 'active-scope-accepted'); + if (accepted.length !== 1) return accepted.length === 0 + ? 'current-user-active-scope-not-accepted' + : 'current-user-active-scope-accepted-duplicate'; + return 'none'; +}; diff --git a/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs new file mode 100644 index 000000000..76b545962 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + classifyCurrentUserRequestShape, + currentUserValidationPhaseSummary, + currentUserValidationFailureCategory, + isExpectedScopedCurrentUserRequest, + networkPermissionDecisionSummary, + scopedCurrentUserRequestGeneration, +} from './packaged-acceptance-current-user.mjs'; + +const journey = 'dashboard-profile-manager'; +const rendererRecords = [ + { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' }, + { + journey, activeScopePresent: true, scopeGeneration: 1, phase: 'response-completed', + responseStatus: 200, classification: 'success', + }, + { + journey, activeScopePresent: true, scopeGeneration: 1, phase: 'parsed-user-accepted', schemaAccepted: true, + }, + { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-accepted' }, +]; +const acceptedMain = { + journey, + rendererScopeGeneration: 1, + scopeGenerationQueryCount: 1, + scopeGenerationQueryValid: true, + scopeHeaderCount: 1, + activeBindingPresent: true, + activeScopeGeneration: 0, + profileGenerationCurrent: true, + scopeEqualsActive: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none', +}; +const fixtureRecords = [{ + journey, + source: 'renderer', + scopeGeneration: 1, + requestArrived: true, + authorizationPresent: true, + authorizationMatchesActivatedBearer: true, + cookiePresent: false, + responseStatus: 200, +}]; +const classify = mainRecord => currentUserValidationFailureCategory({ + journey, + evidenceInvalid: false, + rendererRecords, + mainRecords: [mainRecord], + fixtureRecords, +}); + +describe('packaged scoped current-user request URL', () => { + it('accepts one canonical bounded desktop generation parameter', () => { + assert.equal(scopedCurrentUserRequestGeneration( + 'GET', '/api/auth/user?proprDesktopScopeGeneration=0', + ), 0); + assert.equal(scopedCurrentUserRequestGeneration( + 'GET', `/api/auth/user?proprDesktopScopeGeneration=${Number.MAX_SAFE_INTEGER}`, + ), Number.MAX_SAFE_INTEGER); + }); + + it('rejects hosted, non-GET, duplicate, unrelated, and unbounded query forms', () => { + for (const [method, url] of [ + ['GET', '/api/auth/user'], + ['POST', '/api/auth/user?proprDesktopScopeGeneration=1'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&proprDesktopScopeGeneration=2'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=1&credential=secret'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=01'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=9007199254740992'], + ['GET', '/api/smoke/rest?proprDesktopScopeGeneration=1'], + ]) { + assert.equal(scopedCurrentUserRequestGeneration(method, url), null, `${method} ${url}`); + } + }); + + it('matches only the canonical bounded generation expected by an ordinary packaged launch', () => { + assert.equal(isExpectedScopedCurrentUserRequest( + 'GET', '/api/auth/user?proprDesktopScopeGeneration=1', 1, + ), true); + for (const [url, expectedGeneration] of [ + ['/api/auth/user?proprDesktopScopeGeneration=0', 1], + ['/api/auth/user?proprDesktopScopeGeneration=2', 1], + ['/api/auth/user?proprDesktopScopeGeneration=1&extra=1', 1], + ['/api/auth/user?proprDesktopScopeGeneration=1', -1], + ]) { + assert.equal(isExpectedScopedCurrentUserRequest('GET', url, expectedGeneration), false); + } + }); +}); + +describe('packaged current-user fixture request shapes', () => { + it('distinguishes the exact main probe from renderer-scoped validation', () => { + assert.deepEqual(classifyCurrentUserRequestShape( + 'GET', '/api/auth/user', undefined, + ), { source: 'main', scopeGeneration: null }); + assert.deepEqual(classifyCurrentUserRequestShape( + 'GET', '/api/auth/user', 'https://non-renderer.example.test', + ), { source: 'main', scopeGeneration: null }); + assert.deepEqual(classifyCurrentUserRequestShape( + 'GET', '/api/auth/user?proprDesktopScopeGeneration=7', 'propr-app://renderer', + ), { source: 'renderer', scopeGeneration: 7 }); + }); + + it('rejects cross-assigned, duplicate, extra, and noncanonical encoded forms', () => { + for (const [method, url, origin] of [ + ['GET', '/api/auth/user?proprDesktopScopeGeneration=7', undefined], + ['GET', '/api/auth/user', 'propr-app://renderer'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&proprDesktopScopeGeneration=8', 'propr-app://renderer'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=7&extra=1', 'propr-app://renderer'], + ['GET', '/api/auth/user?%70roprDesktopScopeGeneration=7', 'propr-app://renderer'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=%37', 'propr-app://renderer'], + ['GET', '/api/auth/user?proprDesktopScopeGeneration=07', 'propr-app://renderer'], + ['GET', '/api/auth/user?', undefined], + ['POST', '/api/auth/user', undefined], + ]) { + assert.equal(classifyCurrentUserRequestShape(method, url, origin), null, `${method} ${url}`); + } + }); +}); + +describe('packaged current-user acceptance correlation', () => { + it('diagnoses a correlated zero-scope record', () => { + assert.equal(classify({ + ...acceptedMain, scopeHeaderCount: 0, accepted: false, rejectionCategory: 'scope-missing', + outboundBearerPresent: false, bearerMainInjected: false, + }), 'current-user-main-scope-missing'); + }); + + it('diagnoses a correlated duplicate-scope record', () => { + assert.equal(classify({ + ...acceptedMain, scopeHeaderCount: 2, accepted: false, rejectionCategory: 'scope-duplicate', + outboundBearerPresent: false, bearerMainInjected: false, + }), 'current-user-main-scope-duplicate'); + }); + + it('diagnoses a correlated no-binding record', () => { + assert.equal(classify({ + ...acceptedMain, activeBindingPresent: false, accepted: false, rejectionCategory: 'no-active-binding', + profileGenerationCurrent: false, scopeEqualsActive: false, originEqualsActive: false, + outboundBearerPresent: false, bearerMainInjected: false, + }), 'current-user-main-no-active-binding'); + }); + + it('diagnoses a correlated stale-scope record', () => { + assert.equal(classify({ + ...acceptedMain, accepted: false, rejectionCategory: 'stale-scope', scopeEqualsActive: false, + outboundBearerPresent: false, bearerMainInjected: false, + }), 'current-user-main-stale-scope'); + }); + + it('accepts the current scope with main-only bearer custody', () => { + assert.equal(classify(acceptedMain), 'none'); + }); + + it('rejects a main or upstream record from another renderer generation', () => { + assert.equal(classify({ + ...acceptedMain, rendererScopeGeneration: 2, + }), 'current-user-main-generation-correlation-invalid'); + assert.equal(currentUserValidationFailureCategory({ + journey, + evidenceInvalid: false, + rendererRecords, + mainRecords: [acceptedMain], + fixtureRecords: [{ ...fixtureRecords[0], scopeGeneration: 2 }], + }), 'current-user-upstream-generation-correlation-invalid'); + }); + + it('correlates main evidence by journey and upstream evidence by renderer request origin', () => { + assert.equal(currentUserValidationFailureCategory({ + journey, + evidenceInvalid: false, + rendererRecords, + mainRecords: [ + { ...acceptedMain, journey: 'another-journey', accepted: false, rejectionCategory: 'stale-scope' }, + acceptedMain, + ], + fixtureRecords: [ + { ...fixtureRecords[0], source: 'main', authorizationMatchesActivatedBearer: false }, + fixtureRecords[0], + ], + }), 'none'); + }); + + it('diagnoses upstream bearer custody after correlating the fixture record', () => { + assert.equal(currentUserValidationFailureCategory({ + journey, + evidenceInvalid: false, + rendererRecords, + mainRecords: [acceptedMain], + fixtureRecords: [{ ...fixtureRecords[0], authorizationMatchesActivatedBearer: false }], + }), 'current-user-upstream-bearer-custody-invalid'); + }); +}); + +describe('packaged current-user strict failure diagnostics', () => { + it('reports fixed bounded Local Network Access decision counts', () => { + const base = { + journey, + permissionCategory: 'loopback-network', + decision: 'check', + allowed: true, + activeBindingCurrent: true, + webContentsPresent: false, + webContentsEqualsMainWindow: false, + mainWindowPresent: true, + isMainFrame: true, + requestingUrlPresent: false, + requestingUrlTrusted: false, + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityValid: true, + requestingOriginAuthorityEqual: true, + }; + const summary = networkPermissionDecisionSummary({ + journey, + records: [ + base, + { + ...base, + permissionCategory: 'local-network-access', + decision: 'request', + allowed: false, + webContentsPresent: true, + webContentsEqualsMainWindow: true, + requestingUrlPresent: true, + requestingUrlTrusted: true, + }, + ...Array.from({ length: 12 }, () => ({ ...base, journey: 'another-journey' })), + ], + invalidCount: 12, + }); + + assert.deepEqual(summary, { + schemaVersion: 1, + records: 2, + check: 1, + request: 1, + allowed: 1, + denied: 1, + localNetworkAccess: 1, + localNetwork: 0, + loopbackNetwork: 1, + activeBindingCurrent: 2, + webContentsPresent: 1, + webContentsEqualsMainWindow: 1, + mainWindowPresent: 2, + mainFrame: 2, + requestingUrlPresent: 1, + requestingUrlTrusted: 1, + rendererDocumentUrlTrusted: 2, + requestingOriginAuthorityValid: 2, + requestingOriginAuthorityEqual: 2, + invalid: 9, + }); + assert.doesNotMatch(JSON.stringify(summary), /example\.test|propr_it_|Bearer|renderer\.html/); + }); + + it('reports fixed bounded phase counts without retaining request secrets', () => { + const summary = currentUserValidationPhaseSummary({ + journey, + rendererRecords: [ + { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'request-issued' }, + { journey, activeScopePresent: true, scopeGeneration: 1, phase: 'active-scope-rejected' }, + ], + mainRecords: [], + fixtureRecords: [], + requestRecords: Array.from({ length: 12 }, () => ({ + journey, + method: 'OPTIONS', + origin: 'propr-app://renderer', + url: '/api/auth/user?proprDesktopScopeGeneration=1', + authorization: 'Bearer must-not-appear', + })), + }); + + assert.deepEqual(summary, { + schemaVersion: 1, + options: 9, + get: 0, + main: 0, + mainAccepted: 0, + fixture: 0, + fixture200: 0, + requestIssued: 1, + responseCompleted: 0, + parsedUserAccepted: 0, + activeScopeAccepted: 0, + rejected: 1, + }); + assert.equal(JSON.stringify(summary).includes('must-not-appear'), false); + }); +}); diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.mjs new file mode 100644 index 000000000..841212faa --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-renderer.mjs @@ -0,0 +1,341 @@ +const defaultSleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + +const closeEnough = (actual, expected) => Number.isFinite(actual) && Math.abs(actual - expected) < 0.01; +const ELECTRON_ZOOM_MECHANISM = 'electron-web-frame'; +const MAX_VIEWPORT_INSET_CSS_PIXELS = 64; + +const diagnosticSuffix = diagnostics => `; measurements=${JSON.stringify(diagnostics)}`; + +const assertRendererMetrics = (actual, expected, description, diagnostics) => { + if (!closeEnough(actual, expected)) { + throw new Error(`Packaged Electron ${description} changed: expected ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`); + } +}; + +const assertExactRendererMetric = (actual, expected, description, diagnostics) => { + if (actual !== expected) { + throw new Error(`Packaged Electron ${description} changed: expected exactly ${expected}, received ${actual}${diagnosticSuffix(diagnostics)}`); + } +}; + +const assertExactKeys = (value, keys, description) => { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).sort().join('\n') !== [...keys].sort().join('\n')) { + throw new Error(`Packaged Electron ${description} acknowledgement is malformed`); + } +}; + +const measureRenderer = async (page, cdp) => { + const [layout, renderer] = await Promise.all([ + cdp.send('Page.getLayoutMetrics'), + page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + documentClientViewport: { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + }, + devicePixelRatio: window.devicePixelRatio, + reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + visualViewport: window.visualViewport && { + width: window.visualViewport.width, + height: window.visualViewport.height, + scale: window.visualViewport.scale, + }, + })), + ]); + return { layout, renderer }; +}; + +const waitForStableRendererMeasurement = async ( + page, + cdp, + { settle, settleMs, measurementTimeoutMs, stableMeasurements }, +) => { + const deadline = Date.now() + measurementTimeoutMs; + let previous; + let stable = 0; + let measurement; + do { + measurement = await measureRenderer(page, cdp); + const serialized = JSON.stringify(measurement); + stable = serialized === previous ? stable + 1 : 1; + previous = serialized; + if (stable >= stableMeasurements) return measurement; + await settle(settleMs); + } while (Date.now() <= deadline); + throw new Error(`Packaged Electron renderer zoom metrics did not stabilize within ${measurementTimeoutMs}ms`); +}; + +/** + * Configure the visible Electron renderer without relying on Browser-domain + * commands on a target-scoped CDP session. Playwright owns the viewport/window + * resize and target DPR. The authenticated context-isolated preload bridge is + * the sole zoom authority; CDP page scale is intentionally not used. + */ +export const configureElectronRendererVariant = async ( + page, + cdp, + config, + { + colorScheme = 'light', + settle = defaultSleep, + settleMs = 25, + measurementTimeoutMs = 3_000, + stableMeasurements = 3, + } = {}, +) => { + const { viewport, deviceScaleFactor, zoom, reducedMotion } = config || {}; + if (!Number.isInteger(viewport?.width) || viewport.width <= 0 + || !Number.isInteger(viewport?.height) || viewport.height <= 0 + || !Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0 + || (zoom !== 1 && zoom !== 2) || typeof reducedMotion !== 'boolean' + || !Number.isInteger(measurementTimeoutMs) || measurementTimeoutMs <= 0 + || !Number.isInteger(stableMeasurements) || stableMeasurements < 2) { + throw new Error('Packaged Electron renderer variant configuration is invalid'); + } + + await page.emulateMedia({ + reducedMotion: reducedMotion ? 'reduce' : 'no-preference', + colorScheme, + }); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: viewport.width, + height: viewport.height, + deviceScaleFactor, + mobile: false, + screenWidth: viewport.width, + screenHeight: viewport.height, + }); + const zoomAcknowledgement = await page.evaluate(async requestedFactor => { + const bridge = globalThis.__PROPR_PACKAGED_ACCEPTANCE__; + if (!bridge || typeof bridge !== 'object' || typeof bridge.setZoomFactor !== 'function') { + throw new Error('Packaged acceptance Electron zoom bridge is unavailable'); + } + return bridge.setZoomFactor(requestedFactor); + }, zoom); + assertExactKeys( + zoomAcknowledgement, + ['requestedFactor', 'resetFactor', 'appliedFactor', 'mechanism'], + 'zoom bridge', + ); + if (zoomAcknowledgement.requestedFactor !== zoom || zoomAcknowledgement.resetFactor !== 1 + || zoomAcknowledgement.appliedFactor !== zoom || zoomAcknowledgement.mechanism !== ELECTRON_ZOOM_MECHANISM) { + throw new Error(`Packaged Electron zoom bridge acknowledgement changed: ${JSON.stringify(zoomAcknowledgement)}`); + } + + const playwrightViewport = page.viewportSize(); + const { layout, renderer } = await waitForStableRendererMeasurement(page, cdp, { + settle, settleMs, measurementTimeoutMs, stableMeasurements, + }); + const effectiveVisibleCssSpan = { + width: renderer?.width, + height: renderer?.height, + }; + const geometryZoom = { + width: playwrightViewport?.width / effectiveVisibleCssSpan.width, + height: playwrightViewport?.height / effectiveVisibleCssSpan.height, + }; + const expectedEffective = { + width: viewport.width / zoom, + height: viewport.height / zoom, + }; + const layoutViewport = { + width: layout?.cssLayoutViewport?.clientWidth, + height: layout?.cssLayoutViewport?.clientHeight, + }; + const cdpVisualViewport = { + width: layout?.cssVisualViewport?.clientWidth, + height: layout?.cssVisualViewport?.clientHeight, + scale: layout?.cssVisualViewport?.scale, + }; + const rendererVisualViewport = { + width: renderer?.visualViewport?.width, + height: renderer?.visualViewport?.height, + scale: renderer?.visualViewport?.scale, + }; + const documentClientViewport = { + width: renderer?.documentClientViewport?.width, + height: renderer?.documentClientViewport?.height, + }; + const scrollbarInsets = { + width: renderer?.width - documentClientViewport.width, + height: renderer?.height - documentClientViewport.height, + }; + const visualViewportInsets = { + width: renderer?.width - rendererVisualViewport.width, + height: renderer?.height - rendererVisualViewport.height, + }; + // Keep failure output limited to explicitly selected numeric geometry. This + // makes a native CDP mismatch actionable without serializing page content. + const measurementDiagnostics = { + requestedViewport: viewport, + playwrightViewport, + rendererViewport: effectiveVisibleCssSpan, + documentClientViewport, + layoutViewport, + cdpVisualViewport, + rendererVisualViewport, + scrollbarInsets, + visualViewportInsets, + geometryZoom, + }; + + assertExactRendererMetric(playwrightViewport?.width, viewport.width, 'Playwright viewport width', measurementDiagnostics); + assertExactRendererMetric(playwrightViewport?.height, viewport.height, 'Playwright viewport height', measurementDiagnostics); + assertExactRendererMetric(renderer?.width, expectedEffective.width, 'renderer viewport width', measurementDiagnostics); + assertExactRendererMetric(renderer?.height, expectedEffective.height, 'renderer viewport height', measurementDiagnostics); + assertRendererMetrics(renderer?.devicePixelRatio, deviceScaleFactor * zoom, 'renderer device pixel ratio', measurementDiagnostics); + assertRendererMetrics(geometryZoom.width, zoom, 'measured renderer geometry width ratio', measurementDiagnostics); + assertRendererMetrics(geometryZoom.height, zoom, 'measured renderer geometry height ratio', measurementDiagnostics); + for (const [kind, insets] of Object.entries({ scrollbar: scrollbarInsets, 'visual viewport': visualViewportInsets })) { + for (const [axis, inset] of Object.entries(insets)) { + if (!Number.isFinite(inset) || inset < 0 || inset > MAX_VIEWPORT_INSET_CSS_PIXELS) { + throw new Error(`Packaged Electron ${axis} ${kind} inset is invalid: expected 0-${MAX_VIEWPORT_INSET_CSS_PIXELS}, received ${inset}${diagnosticSuffix(measurementDiagnostics)}`); + } + } + } + assertExactRendererMetric(layoutViewport.width, documentClientViewport.width, 'layout viewport width versus document client width', measurementDiagnostics); + assertExactRendererMetric(layoutViewport.height, documentClientViewport.height, 'layout viewport height versus document client height', measurementDiagnostics); + assertExactRendererMetric(rendererVisualViewport.width, cdpVisualViewport.width, 'renderer visual viewport width versus raw CDP visual width', measurementDiagnostics); + assertExactRendererMetric(rendererVisualViewport.height, cdpVisualViewport.height, 'renderer visual viewport height versus raw CDP visual height', measurementDiagnostics); + assertExactRendererMetric(rendererVisualViewport.scale, cdpVisualViewport.scale, 'renderer visual viewport scale versus raw CDP visual scale', measurementDiagnostics); + assertExactRendererMetric(rendererVisualViewport.scale, 1, 'raw renderer page scale', measurementDiagnostics); + if (renderer?.reducedMotion !== reducedMotion) { + throw new Error(`Packaged Electron reduced-motion emulation changed: expected ${reducedMotion}, received ${renderer?.reducedMotion}${diagnosticSuffix(measurementDiagnostics)}`); + } + + return { + requestedViewport: { ...viewport }, + playwrightViewport: { ...playwrightViewport }, + rendererViewport: { width: renderer.width, height: renderer.height }, + documentClientViewport, + scrollbarInsets, + visualViewportInsets, + layoutViewport, + cdpVisualViewport, + rendererVisualViewport, + effectiveVisibleCssSpan, + geometryZoom, + requestedDeviceScaleFactor: deviceScaleFactor, + rendererDevicePixelRatio: renderer.devicePixelRatio, + requestedZoomFactor: zoom, + appliedZoomFactor: zoomAcknowledgement.appliedFactor, + zoomResetFactor: zoomAcknowledgement.resetFactor, + zoomMechanism: zoomAcknowledgement.mechanism, + reducedMotion: renderer.reducedMotion, + }; +}; + +export const forEachElectronRendererVariant = async ( + page, + cdp, + variants, + capture, + options, +) => { + if (!variants || typeof variants !== 'object' || typeof capture !== 'function') { + throw new Error('Packaged Electron renderer variant capture configuration is invalid'); + } + for (const [variant, config] of Object.entries(variants)) { + const metrics = await configureElectronRendererVariant(page, cdp, config, options); + await capture({ variant, config, metrics }); + } +}; + +export const captureElectronRendererScreenshot = async cdp => { + const result = await cdp.send('Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: false, + }); + if (typeof result?.data !== 'string' || result.data.length === 0) { + throw new Error('Packaged Electron renderer screenshot capture returned invalid data'); + } + return Buffer.from(result.data, 'base64'); +}; + +const processExitError = (code, signal) => new Error( + `Packaged Electron exited before its renderer target was usable (${code ?? signal ?? 'unknown'})`, +); + +/** + * CDP can become reachable before Electron publishes its renderer target. + * Keep this wait bounded independently from later renderer initialization and + * reject immediately if either side of the CDP connection goes away. + */ +export const waitForUsableElectronRenderer = async ( + browser, + child, + { + expectedUrlPrefix, + timeoutMs = 15_000, + pollIntervalMs = 25, + sleep = defaultSleep, + } = {}, +) => { + if (!expectedUrlPrefix || timeoutMs < 0 || pollIntervalMs < 0) { + throw new Error('Packaged Electron renderer wait configuration is invalid'); + } + + let resolveTermination; + const termination = new Promise(resolve => { resolveTermination = resolve; }); + let terminated = false; + let terminationError; + const terminate = error => { + if (terminated) return; + terminated = true; + terminationError = error; + resolveTermination(error); + }; + const onClose = (code, signal) => terminate(processExitError(code, signal)); + const onError = error => terminate(new Error('Packaged Electron failed before its renderer target was usable', { cause: error })); + const onDisconnected = () => terminate(new Error('Packaged Electron CDP disconnected before its renderer target was usable')); + + child.once('close', onClose); + child.once('error', onError); + browser.once('disconnected', onDisconnected); + const timeout = setTimeout( + () => terminate(new Error(`Packaged Electron renderer target did not become usable within ${timeoutMs}ms`)), + timeoutMs, + ); + + if (child.exitCode !== null || child.signalCode !== null) onClose(child.exitCode, child.signalCode); + else if (!browser.isConnected()) onDisconnected(); + + const bounded = async promise => { + if (terminationError) throw terminationError; + const result = await Promise.race([ + Promise.resolve(promise).then( + value => ({ value }), + error => ({ probeError: error }), + ), + termination.then(error => ({ terminationError: error })), + ]); + if (terminationError) throw terminationError; + if (result.terminationError) throw result.terminationError; + return result; + }; + + try { + while (true) { + for (const context of browser.contexts()) { + for (const page of context.pages()) { + const pageUrl = page.url(); + if (page.isClosed() || (pageUrl !== expectedUrlPrefix && !pageUrl.startsWith(`${expectedUrlPrefix}/`))) continue; + const probe = await bounded(page.evaluate(() => ({ rendererTargetUsable: true }))); + if (probe.probeError || probe.value?.rendererTargetUsable !== true || page.isClosed()) continue; + return { context, page }; + } + } + await bounded(sleep(pollIntervalMs)); + } + } finally { + clearTimeout(timeout); + child.off('close', onClose); + child.off('error', onError); + browser.off('disconnected', onDisconnected); + } +}; + diff --git a/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs new file mode 100644 index 000000000..9ccc6dbfe --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs @@ -0,0 +1,520 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import { chromium } from 'playwright'; +import { ACCEPTANCE_VARIANTS } from './acceptance-artifacts.mjs'; +import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs'; +import { + captureElectronRendererScreenshot, + configureElectronRendererVariant, + forEachElectronRendererVariant, + waitForUsableElectronRenderer, +} from './packaged-acceptance-renderer.mjs'; + +const processFixture = () => Object.assign(new EventEmitter(), { exitCode: null, signalCode: null }); +const browserFixture = context => Object.assign(new EventEmitter(), { + contexts: () => [context], + isConnected: () => true, +}); +const REAL_CHROMIUM_BOUNDARY_ENVIRONMENT = 'PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY'; +const REAL_CHROMIUM_BOUNDARY_OPT_IN = '1'; +const realChromiumBoundaryEnabled = value => { + if (value === undefined) return false; + if (value !== REAL_CHROMIUM_BOUNDARY_OPT_IN) { + throw new Error(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly "${REAL_CHROMIUM_BOUNDARY_OPT_IN}"`); + } + return true; +}; +const runRealChromiumBoundary = realChromiumBoundaryEnabled( + process.env[REAL_CHROMIUM_BOUNDARY_ENVIRONMENT], +); +const installedChromium = () => [ + chromium.executablePath(), + '/usr/bin/chromium', + '/usr/bin/google-chrome', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', +].find(candidate => existsSync(candidate)); + +const rendererVariantFixture = ({ + acknowledgement, + appliedFactor, + cdpVisualWidthAdjustment = 0, + layoutWidthAdjustment = 0, + rendererVisualWidthAdjustment = 0, + rendererSpan = 'effective', + scrollbarInsets = { width: 0, height: 0 }, + visualViewportInsets = { width: 0, height: 0 }, + unstable = false, +} = {}) => { + let viewport; + let metrics; + let reducedMotion = false; + let measurement = 0; + const commands = []; + const dimensionsFor = span => { + if (span === 'invalid') return { width: viewport.width - 17, height: viewport.height - 11 }; + if (unstable) measurement += 1; + const adjustment = unstable ? measurement % 2 : 0; + const effective = { width: viewport.width / metrics.zoom + adjustment, height: viewport.height / metrics.zoom }; + if (span === 'document') { + return { width: effective.width - scrollbarInsets.width, height: effective.height - scrollbarInsets.height }; + } + if (span === 'visual') { + return { width: effective.width - visualViewportInsets.width, height: effective.height - visualViewportInsets.height }; + } + return effective; + }; + const page = { + emulateMedia: async media => { reducedMotion = media.reducedMotion === 'reduce'; }, + setViewportSize: async value => { viewport = { ...value }; }, + viewportSize: () => ({ ...viewport }), + evaluate: async (_fn, argument) => { + if (typeof argument === 'number') { + metrics.zoom = appliedFactor ?? argument; + return acknowledgement ?? { + requestedFactor: argument, + resetFactor: 1, + appliedFactor: metrics.zoom, + mechanism: 'electron-web-frame', + }; + } + const dimensions = dimensionsFor(rendererSpan); + const visualDimensions = dimensionsFor('visual'); + return { + ...dimensions, + documentClientViewport: dimensionsFor('document'), + devicePixelRatio: metrics.deviceScaleFactor * metrics.zoom + (unstable ? measurement / 1_000 : 0), + reducedMotion, + visualViewport: { + ...visualDimensions, + width: visualDimensions.width + rendererVisualWidthAdjustment, + scale: 1, + }, + }; + }, + }; + const cdp = { + send: async (method, params) => { + commands.push({ method, params }); + if (method === 'Browser.getWindowForTarget') throw new Error('Browser.getWindowForTarget is unavailable'); + if (method === 'Emulation.setDeviceMetricsOverride') { + metrics = { deviceScaleFactor: params.deviceScaleFactor, zoom: metrics?.zoom || 1 }; + return {}; + } + if (method === 'Page.getLayoutMetrics') { + const documentClientViewport = dimensionsFor('document'); + const rawVisualViewport = dimensionsFor('visual'); + return { + cssLayoutViewport: { + clientWidth: documentClientViewport.width + layoutWidthAdjustment, + clientHeight: documentClientViewport.height, + }, + cssVisualViewport: { + clientWidth: rawVisualViewport.width + cdpVisualWidthAdjustment, + clientHeight: rawVisualViewport.height, + scale: 1, + }, + }; + } + if (method === 'Page.captureScreenshot') { + return { data: Buffer.from(`${viewport.width}x${viewport.height}@${metrics.deviceScaleFactor}/${metrics.zoom}/${reducedMotion}`).toString('base64') }; + } + throw new Error(`Unexpected CDP command: ${method}`); + }, + }; + return { commands, cdp, page }; +}; + +describe('packaged acceptance renderer discovery', () => { + it('waits deterministically when CDP precedes the first Electron renderer page', async () => { + const pages = []; + const context = { pages: () => pages }; + const child = processFixture(); + const browser = browserFixture(context); + let polls = 0; + const page = { + evaluate: async () => ({ rendererTargetUsable: true }), + isClosed: () => false, + url: () => 'propr-app://renderer/index.html', + }; + + const renderer = await waitForUsableElectronRenderer(browser, child, { + expectedUrlPrefix: 'propr-app://renderer', + timeoutMs: 1_000, + sleep: async () => { + polls += 1; + pages.push(page); + }, + }); + + assert.equal(polls, 1); + assert.deepEqual(renderer, { context, page }); + assert.equal(child.listenerCount('close'), 0); + assert.equal(child.listenerCount('error'), 0); + assert.equal(browser.listenerCount('disconnected'), 0); + }); + + it('rejects process exit while waiting and removes its listeners', async () => { + const context = { pages: () => [] }; + const child = processFixture(); + const browser = browserFixture(context); + await assert.rejects(waitForUsableElectronRenderer(browser, child, { + expectedUrlPrefix: 'propr-app://renderer', + timeoutMs: 1_000, + sleep: async () => child.emit('close', 17, null), + }), /exited.*17/); + assert.equal(child.listenerCount('close'), 0); + assert.equal(browser.listenerCount('disconnected'), 0); + }); + + it('rejects a strictly bounded timeout when no renderer target appears', async () => { + const context = { pages: () => [] }; + const child = processFixture(); + const browser = browserFixture(context); + await assert.rejects(waitForUsableElectronRenderer(browser, child, { + expectedUrlPrefix: 'propr-app://renderer', + timeoutMs: 1, + sleep: async () => new Promise(() => undefined), + }), /within 1ms/); + assert.equal(child.listenerCount('close'), 0); + assert.equal(browser.listenerCount('disconnected'), 0); + }); +}); + +describe('packaged acceptance renderer variants', () => { + it('fails closed unless the real Chromium boundary has its exact explicit opt-in', () => { + assert.equal(realChromiumBoundaryEnabled(undefined), false); + assert.equal(realChromiumBoundaryEnabled(REAL_CHROMIUM_BOUNDARY_OPT_IN), true); + for (const malformed of ['', '0', 'true', ' 1', '1 ']) { + assert.throws( + () => realChromiumBoundaryEnabled(malformed), + new RegExp(`${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT} must be unset or exactly`), + ); + } + }); + + it('configures and captures all five variants when Browser.getWindowForTarget is unavailable', async () => { + const { commands, cdp, page } = rendererVariantFixture(); + const captures = []; + + await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics: actual }) => { + captures.push({ variant, bytes: await captureElectronRendererScreenshot(cdp), actual }); + const effective = { width: config.viewport.width / config.zoom, height: config.viewport.height / config.zoom }; + assert.deepEqual(actual.requestedViewport, config.viewport); + assert.deepEqual(actual.playwrightViewport, config.viewport); + assert.deepEqual(actual.rendererViewport, effective); + assert.deepEqual(actual.documentClientViewport, effective); + assert.deepEqual(actual.scrollbarInsets, { width: 0, height: 0 }); + assert.deepEqual(actual.visualViewportInsets, { width: 0, height: 0 }); + assert.deepEqual(actual.layoutViewport, effective); + assert.deepEqual(actual.cdpVisualViewport, { + width: config.viewport.width / config.zoom, + height: config.viewport.height / config.zoom, + scale: 1, + }); + assert.deepEqual(actual.rendererVisualViewport, actual.cdpVisualViewport); + assert.deepEqual(actual.effectiveVisibleCssSpan, effective); + assert.deepEqual(actual.geometryZoom, { width: config.zoom, height: config.zoom }); + assert.equal(actual.requestedDeviceScaleFactor, config.deviceScaleFactor); + assert.equal(actual.rendererDevicePixelRatio, config.deviceScaleFactor * config.zoom); + assert.equal(actual.requestedZoomFactor, config.zoom); + assert.equal(actual.appliedZoomFactor, config.zoom); + assert.equal(actual.zoomResetFactor, 1); + assert.equal(actual.zoomMechanism, 'electron-web-frame'); + assert.equal(actual.reducedMotion, config.reducedMotion); + }, { settle: async () => undefined }); + + assert.deepEqual(captures.map(capture => capture.variant), Object.keys(ACCEPTANCE_VARIANTS)); + assert.equal(captures.length, 5); + assert.equal(new Set(captures.map(capture => capture.bytes.toString())).size, 5); + assert.equal(commands.some(command => command.method.startsWith('Browser.')), false); + assert.equal(commands.filter(command => command.method === 'Emulation.setDeviceMetricsOverride').length, 5); + assert.equal(commands.filter(command => command.method === 'Emulation.setPageScaleFactor').length, 0); + assert.equal(commands.filter(command => command.method === 'Page.getLayoutMetrics').length, 15); + assert.equal(commands.filter(command => command.method === 'Page.captureScreenshot').length, 5); + }); + + it('proves packaged Electron zoom-200 from webFrame acknowledgement and independent renderer geometry', async () => { + const { commands, cdp, page } = rendererVariantFixture({ + scrollbarInsets: { width: 8, height: 0 }, + visualViewportInsets: { width: 7.5, height: 0 }, + }); + const config = ACCEPTANCE_VARIANTS['zoom-200']; + + const actual = await configureElectronRendererVariant(page, cdp, config, { settle: async () => undefined }); + + assert.deepEqual(actual.requestedViewport, { width: 1280, height: 820 }); + assert.deepEqual(actual.rendererViewport, { width: 640, height: 410 }); + assert.deepEqual(actual.documentClientViewport, { width: 632, height: 410 }); + assert.deepEqual(actual.scrollbarInsets, { width: 8, height: 0 }); + assert.deepEqual(actual.visualViewportInsets, { width: 7.5, height: 0 }); + assert.deepEqual(actual.layoutViewport, { width: 632, height: 410 }); + assert.deepEqual(actual.cdpVisualViewport, { width: 632.5, height: 410, scale: 1 }); + assert.deepEqual(actual.rendererVisualViewport, { width: 632.5, height: 410, scale: 1 }); + assert.deepEqual(actual.effectiveVisibleCssSpan, { width: 640, height: 410 }); + assert.deepEqual(actual.geometryZoom, { width: 2, height: 2 }); + assert.equal(actual.rendererDevicePixelRatio, 2); + assert.equal(actual.appliedZoomFactor, 2); + assert.equal(actual.zoomMechanism, 'electron-web-frame'); + assert.equal(commands.some(command => command.method === 'Emulation.setPageScaleFactor'), false); + }); + + it('fails closed on a wrong or malformed Electron zoom acknowledgement', async () => { + const unavailable = rendererVariantFixture(); + unavailable.page.evaluate = async () => { throw new Error('Packaged acceptance Electron zoom bridge is unavailable'); }; + await assert.rejects( + configureElectronRendererVariant(unavailable.page, unavailable.cdp, ACCEPTANCE_VARIANTS.standard, { settle: async () => undefined }), + /zoom bridge is unavailable/, + ); + const wrong = rendererVariantFixture({ appliedFactor: 1 }); + await assert.rejects( + configureElectronRendererVariant(wrong.page, wrong.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /zoom bridge acknowledgement changed/, + ); + const malformed = rendererVariantFixture({ acknowledgement: { appliedFactor: 2 } }); + await assert.rejects( + configureElectronRendererVariant(malformed.page, malformed.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /acknowledgement is malformed/, + ); + }); + + it('fails closed on renderer geometry mismatch and an unstable measurement timeout', async () => { + const mismatch = rendererVariantFixture({ rendererSpan: 'invalid' }); + await assert.rejects( + configureElectronRendererVariant(mismatch.page, mismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /renderer viewport width changed/, + ); + const unstable = rendererVariantFixture({ unstable: true }); + await assert.rejects( + configureElectronRendererVariant(unstable.page, unstable.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { + settle: async () => undefined, measurementTimeoutMs: 1, + }), + /did not stabilize within 1ms/, + ); + }); + + it('fails closed on arbitrary layout spans, invalid insets, and exact renderer/CDP visual mismatches', async () => { + const arbitraryLayout = rendererVariantFixture({ + layoutWidthAdjustment: 1, + scrollbarInsets: { width: 8, height: 0 }, + }); + await assert.rejects( + configureElectronRendererVariant(arbitraryLayout.page, arbitraryLayout.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /layout viewport width versus document client width changed: expected exactly 632, received 633/, + ); + + for (const width of [-1, 65]) { + const invalidScrollbar = rendererVariantFixture({ scrollbarInsets: { width, height: 0 } }); + await assert.rejects( + configureElectronRendererVariant(invalidScrollbar.page, invalidScrollbar.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /width scrollbar inset is invalid/, + ); + } + + for (const width of [-1, 65, Number.NaN]) { + const invalidVisualInset = rendererVariantFixture({ visualViewportInsets: { width, height: 0 } }); + await assert.rejects( + configureElectronRendererVariant(invalidVisualInset.page, invalidVisualInset.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /width visual viewport inset is invalid/, + ); + } + + const visualMismatch = rendererVariantFixture({ + cdpVisualWidthAdjustment: -0.5, + scrollbarInsets: { width: 8, height: 0 }, + visualViewportInsets: { width: 7.5, height: 0 }, + }); + await assert.rejects( + configureElectronRendererVariant(visualMismatch.page, visualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + error => /renderer visual viewport width versus raw CDP visual width changed: expected exactly 632, received 632.5/.test(error?.message) + && /"documentClientViewport":\{"width":632,"height":410\}/.test(error.message) + && /"cdpVisualViewport":\{"width":632,"height":410,"scale":1\}/.test(error.message) + && /"rendererVisualViewport":\{"width":632.5,"height":410,"scale":1\}/.test(error.message) + && !error.message.includes('propr-app://'), + ); + + const rendererVisualMismatch = rendererVariantFixture({ rendererVisualWidthAdjustment: -0.5 }); + await assert.rejects( + configureElectronRendererVariant(rendererVisualMismatch.page, rendererVisualMismatch.cdp, ACCEPTANCE_VARIANTS['zoom-200'], { settle: async () => undefined }), + /renderer visual viewport width versus raw CDP visual width changed/, + ); + }); + + describe('real Chromium existing-target axe boundary', { + skip: !runRealChromiumBoundary + ? `Real Chromium is owned by the required predecessor (${REAL_CHROMIUM_BOUNDARY_ENVIRONMENT}=1)` + : process.platform !== 'linux' || process.arch !== 'x64' + ? 'Linux x64 packaged acceptance boundary' + : false, + }, () => { + let browser; + let cdp; + let context; + let createTargetAttempts = 0; + let evaluateRenderer; + let injectionMode = 'success'; + let newPageAttempts = 0; + let page; + let session; + + before(async () => { + const executablePath = installedChromium(); + assert.ok(executablePath, 'A Chromium executable is required for the in-renderer axe boundary regression'); + browser = await chromium.launch({ executablePath, headless: true }); + context = await browser.newContext(); + page = await context.newPage(); + await page.setContent(` + Acceptance boundary +

Acceptance boundary

+ `); + + Object.defineProperty(context, 'newPage', { + configurable: true, + value: async () => { + newPageAttempts += 1; + throw new Error('Target.createTarget is unsupported'); + }, + }); + session = await context.newCDPSession(page); + cdp = { + send: async (method, params) => { + if (method === 'Target.createTarget') { + createTargetAttempts += 1; + throw new Error('Target.createTarget is unsupported'); + } + return session.send(method, params); + }, + }; + + evaluateRenderer = page.evaluate.bind(page); + await evaluateRenderer(() => { + globalThis.__acceptanceAxeCleanup = { completed: 0, started: 0, callbackBased: 0 }; + }); + page.evaluate = async (pageFunction, argument) => { + const result = await evaluateRenderer(pageFunction, argument); + if (typeof pageFunction === 'string') { + await evaluateRenderer(mode => { + if (mode === 'timeout') { + globalThis.axe.cleanup = () => undefined; + return; + } + if (mode === 'combined-failure') { + globalThis.axe.run = async () => { throw new Error('specific axe execution failure'); }; + globalThis.axe.cleanup = (_resolve, reject) => { + setTimeout(() => reject(new Error('asynchronous axe cleanup failure')), 25); + }; + return; + } + const cleanupState = globalThis.__acceptanceAxeCleanup; + const cleanup = globalThis.axe.cleanup.bind(globalThis.axe); + globalThis.axe.cleanup = (resolve, reject) => { + cleanupState.started += 1; + if (typeof resolve === 'function' && typeof reject === 'function') { + cleanupState.callbackBased += 1; + } + cleanup( + value => setTimeout(() => { + cleanupState.completed += 1; + if (typeof resolve === 'function') resolve(value); + }, 25), + error => setTimeout(() => { + cleanupState.completed += 1; + if (typeof reject === 'function') reject(error); + }, 25), + ); + }; + }, injectionMode); + } + return result; + }; + }, { timeout: 30_000 }); + + after(async () => { + try { + if (session) await session.detach(); + } finally { + if (browser) await browser.close(); + } + }, { timeout: 15_000 }); + + it('proves five-variant axe success and reinjection without claiming stock Chromium proves Electron zoom', { + timeout: 45_000, + }, async () => { + await assert.rejects(context.newPage(), /Target\.createTarget is unsupported/); + await assert.rejects(cdp.send('Target.createTarget', { url: 'about:blank' }), /Target\.createTarget is unsupported/); + + const checks = []; + assert.equal(await page.evaluate(() => typeof globalThis.__PROPR_PACKAGED_ACCEPTANCE__), 'undefined'); + for (const [variant, config] of Object.entries(ACCEPTANCE_VARIANTS)) { + await page.setViewportSize(config.viewport); + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: config.viewport.width, + height: config.viewport.height, + deviceScaleFactor: config.deviceScaleFactor, + mobile: false, + screenWidth: config.viewport.width, + screenHeight: config.viewport.height, + }); + const violations = await analyzeExistingElectronRenderer(page); + const cleanupState = await evaluateRenderer(() => ({ ...globalThis.__acceptanceAxeCleanup })); + checks.push({ variant, violations, cleanupState }); + assert.equal(cleanupState.started, checks.length); + assert.equal(cleanupState.callbackBased, checks.length); + assert.equal(cleanupState.completed, checks.length); + assert.equal(await page.evaluate(() => typeof globalThis.axe), 'undefined'); + } + + assert.deepEqual(checks.map(check => check.variant), Object.keys(ACCEPTANCE_VARIANTS)); + assert.equal(checks.length, 5); + for (const check of checks) { + assert.ok(check.violations.some(violation => violation.id === 'button-name' + && violation.impact === 'critical' && violation.nodes === 1)); + } + assert.equal(newPageAttempts, 1); + assert.equal(createTargetAttempts, 1); + assert.equal(page.isClosed(), false); + }); + + it('fails closed at the production axe cleanup timeout', { + timeout: 10_000, + }, async () => { + injectionMode = 'timeout'; + await assert.rejects( + analyzeExistingElectronRenderer(page), + error => error?.message === 'Packaged accessibility axe-core cleanup failed' + && /cleanup timed out after 5000ms/.test(error.cause?.message), + ); + assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object'); + await evaluateRenderer(() => Reflect.deleteProperty(globalThis, 'axe')); + assert.equal(page.isClosed(), false); + }); + + it('preserves the primary axe error alongside asynchronous cleanup failure', { + timeout: 10_000, + }, async () => { + injectionMode = 'combined-failure'; + let combinedFailure; + try { + await analyzeExistingElectronRenderer(page); + } catch (error) { + combinedFailure = error; + } + assert.ok(combinedFailure instanceof AggregateError); + assert.equal(combinedFailure.message, 'Packaged accessibility axe-core execution or result validation failed'); + assert.equal(combinedFailure.errors.length, 2); + assert.match(combinedFailure.errors[0].message, /execution or result validation failed/); + assert.match(combinedFailure.errors[1].message, /axe-core cleanup failed/); + assert.match(combinedFailure.errors[1].cause?.message, /asynchronous axe cleanup failure/); + assert.equal(await evaluateRenderer(() => typeof globalThis.axe), 'object'); + assert.equal(newPageAttempts, 1); + assert.equal(createTargetAttempts, 1); + assert.equal(page.isClosed(), false); + }); + }); +}); + diff --git a/apps/desktop/scripts/packaged-acceptance-socket.test.mjs b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs new file mode 100644 index 000000000..5103d287b --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const readSource = relativePath => readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', +); + +const runnerSource = readSource('./run-packaged-acceptance.mjs'); +const socketProviderSource = readSource('../../../propr-ui/src/contexts/SocketProvider.tsx'); + +const sourceBetween = (source, startMarker, endMarker) => { + const start = source.indexOf(startMarker); + assert.notEqual(start, -1, `Missing source marker: ${startMarker}`); + const end = source.indexOf(endMarker, start); + assert.notEqual(end, -1, `Missing source marker: ${endMarker}`); + return source.slice(start, end); +}; + +const occurrences = (source, value) => source.split(value).length - 1; + +describe('packaged acceptance Socket.IO application synchronization', () => { + it('uses the exact production subscription event and emits only from its one-shot handler', () => { + assert.match(socketProviderSource, /socket\.emit\('subscribe:queue:stats'\)/); + assert.match(runnerSource, /const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats';/); + + const connectionCallback = sourceBetween( + runnerSource, + "io.on('connection', socket => {", + ' server.listen(', + ); + const connectionRecord = connectionCallback.indexOf( + "socketRecords.push({ journey, mode, event: 'connection', authenticated });", + ); + const subscriptionHandler = connectionCallback.indexOf( + 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => {', + ); + const subscriptionReceived = connectionCallback.indexOf( + 'recordApplicationSubscription();', + subscriptionHandler, + ); + const serverEmit = connectionCallback.indexOf( + 'socket.emit(QUEUE_STATS_UPDATE, {', + subscriptionReceived, + ); + const fixtureEventRecord = connectionCallback.indexOf( + "direction: 'fixture-to-renderer',", + serverEmit, + ); + + assert.ok(connectionRecord < subscriptionHandler); + assert.ok(subscriptionHandler < subscriptionReceived); + assert.ok(subscriptionReceived < serverEmit); + assert.ok(serverEmit < fixtureEventRecord); + assert.equal(occurrences(connectionCallback, 'socket.emit(QUEUE_STATS_UPDATE, {'), 1); + assert.doesNotMatch(connectionCallback.slice(0, subscriptionHandler), /socket\.emit\(QUEUE_STATS_UPDATE/); + assert.doesNotMatch(connectionCallback, /setTimeout|sleep\(/); + assert.match(connectionCallback, /timestamp: FIXED_TIME/); + assert.match(connectionCallback, /completed: 12, failed: 0, delayed: 0, total: 12/); + }); + + it('records a bounded duplicate subscription without producing another fixture event', () => { + const connectionCallback = sourceBetween( + runnerSource, + "io.on('connection', socket => {", + ' server.listen(', + ); + + assert.equal( + occurrences(connectionCallback, 'socket.once(QUEUE_STATS_SUBSCRIBE_EVENT'), + 2, + ); + assert.match( + connectionCallback, + /recordApplicationSubscription\(\);\s+socket\.once\(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription\);\s+socket\.emit\(QUEUE_STATS_UPDATE/, + ); + assert.equal(occurrences(connectionCallback, 'genuineApplicationSubscription: true'), 1); + assert.equal(occurrences(connectionCallback, 'genuineApplicationEvent: true'), 1); + }); + + it('requires exact connection, subscription, emit, and renderer-observation counts', () => { + const waitSource = sourceBetween( + runnerSource, + 'const waitForAuthenticatedSocket = async journey => {', + '\nconst observedServiceSummary = () => {', + ); + const summarySource = sourceBetween( + runnerSource, + 'const observedServiceSummary = () => {', + '\ntry {\n readyOrigin = await createFixture', + ); + + assert.match(waitSource, /connected\.length === 1 && applicationSubscriptions\.length === 1/); + assert.match(waitSource, /applicationEvents\.length === 1 && rendererObservedApplicationEvents\.length === 1/); + assert.match(waitSource, /record\.event === QUEUE_STATS_SUBSCRIBE_EVENT && record\.authenticated/); + assert.match(waitSource, /record\.event === QUEUE_STATS_UPDATE && record\.authenticated/); + assert.match(runnerSource, /if \(subscriptions\.length > 1\) return 'duplicate-application-subscription';/); + assert.match(runnerSource, /if \(applicationEvents\.length > 1\) return 'duplicate-application-event';/); + assert.match(runnerSource, /return 'application-subscription-not-observed';/); + assert.match(runnerSource, /return 'renderer-application-event-not-observed';/); + assert.match(summarySource, /socketSubscriptions\.length !== 1/); + assert.match(summarySource, /services\.socketIo\.events !== 1/); + assert.match(summarySource, /rendererObservedApplicationEvents\.length !== 1/); + assert.doesNotMatch(waitSource, /applicationEvents\.length >= 1/); + }); +}); + diff --git a/apps/desktop/scripts/packaged-acceptance-stats.test.mjs b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs new file mode 100644 index 000000000..c4f1d2eb0 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { describe, it } from 'node:test'; + +const readSource = relativePath => readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', +); + +const runnerSource = readSource('./run-packaged-acceptance.mjs'); +const taskStatsApiSource = readSource('../../../propr-ui/src/api/taskStatsApi.ts'); +const dashboardSource = readSource('../../../propr-ui/src/components/Dashboard.tsx'); +const taskStatsChartSource = readSource('../../../propr-ui/src/components/TaskStatsChart.tsx'); +const topModelsSource = readSource('../../../propr-ui/src/components/TopModels.tsx'); +const repositoryBreakdownSource = readSource('../../../propr-ui/src/components/RepositoryBreakdown.tsx'); + +const fixturePayload = pathname => { + const route = `if (requestUrl.pathname === '${pathname}')`; + const routeStart = runnerSource.indexOf(route); + assert.notEqual(routeStart, -1, `${pathname} exact route is missing`); + const responseStart = runnerSource.indexOf('return json(response, 200, ', routeStart); + assert.notEqual(responseStart, -1, `${pathname} response is missing`); + const valueStart = responseStart + 'return json(response, 200, '.length; + const valueEnd = runnerSource.indexOf(');', valueStart); + assert.notEqual(valueEnd, -1, `${pathname} response terminator is missing`); + const value = runInNewContext(`(${runnerSource.slice(valueStart, valueEnd)})`); + return JSON.parse(JSON.stringify(value)); +}; + +const interfaceFields = name => { + const match = taskStatsApiSource.match(new RegExp(`export interface ${name} \\{([\\s\\S]*?)\\n\\}`)); + assert.ok(match, `${name} interface is missing`); + return [...match[1].matchAll(/^\s{2}([A-Za-z_][A-Za-z0-9_]*):/gm)].map(field => field[1]); +}; + +describe('packaged acceptance stats fixtures', () => { + it('dispatches exact stats pathnames before the unchanged generic fallback', () => { + const generatingPlans = runnerSource.indexOf("requestUrl.pathname === '/api/stats/generating-plans'"); + const tasks = runnerSource.indexOf("requestUrl.pathname === '/api/stats/tasks'"); + const overview = runnerSource.indexOf("requestUrl.pathname === '/api/stats/overview'"); + const repositories = runnerSource.indexOf("requestUrl.pathname === '/api/stats/repositories'"); + const genericFallback = runnerSource.indexOf("request.url?.startsWith('/api/stats/')"); + + assert.ok(generatingPlans < tasks && tasks < overview && overview < repositories); + assert.ok(repositories < genericFallback); + assert.deepEqual(fixturePayload('/api/stats/generating-plans'), { count: 0 }); + }); + + it('matches TaskStatsResponse and the fields synchronously mapped by Dashboard and TaskStatsChart', () => { + const payload = fixturePayload('/api/stats/tasks'); + + assert.deepEqual(Object.keys(payload), interfaceFields('TaskStatsResponse')); + assert.deepEqual(payload, { + dailyCounts: [], + statusDistribution: [ + { status: 'completed', count: 12 }, + { status: 'failed', count: 0 }, + ], + avgProcessingTime: [], + summary: { total: 12, completed: 12, failed: 0 }, + }); + assert.match(dashboardSource, /taskStats\?\.dailyCounts\?\.map/); + assert.match(dashboardSource, /const \{ completed, total \} = taskStats\.summary/); + assert.match(dashboardSource, /taskStats\?\.summary\?\.total\?\.toLocaleString/); + assert.match(dashboardSource, /taskStats\?\.summary\?\.failed/); + for (const field of ['dailyCounts', 'statusDistribution', 'avgProcessingTime']) { + assert.match(taskStatsChartSource, new RegExp(`stats\\.${field}\\.map`)); + assert.ok(Array.isArray(payload[field]), `${field} must be synchronously mappable`); + } + assert.equal(payload.summary.total, 12); + assert.equal(payload.summary.completed, 12); + assert.equal(payload.summary.failed, 0); + }); + + it('matches StatsOverviewResponse and RepositoryStatsResponse consumer contracts', () => { + const overview = fixturePayload('/api/stats/overview'); + const repositories = fixturePayload('/api/stats/repositories'); + + assert.deepEqual(Object.keys(overview), interfaceFields('StatsOverviewResponse')); + assert.deepEqual(Object.keys(overview.tasks), interfaceFields('StatsOverviewTasks')); + assert.deepEqual(Object.keys(overview.usage), interfaceFields('StatsOverviewUsage')); + assert.deepEqual(Object.keys(overview.system), interfaceFields('StatsOverviewSystem')); + assert.deepEqual(overview, { + tasks: { + completed: 12, + planned: 0, + pr_iterations_avg: 0, + merged_prs: 0, + total_followups: 0, + }, + usage: { total_tokens: 0, total_cost_usd: 0, models: {} }, + system: { repos_indexed: 0 }, + }); + assert.deepEqual(Object.keys(repositories), interfaceFields('RepositoryStatsResponse')); + assert.deepEqual(repositories, { repositories: [] }); + assert.match(topModelsSource, /Object\.keys\(metrics\.usage\.models\)/); + assert.match(topModelsSource, /Object\.entries\(metrics\.usage\.models\)/); + assert.match(dashboardSource, /overviewStats\?\.usage\?\.total_cost_usd/); + assert.equal(typeof overview.usage.models, 'object'); + assert.equal(Array.isArray(overview.usage.models), false); + assert.match(repositoryBreakdownSource, /setRepositories\(data\.repositories \|\| \[\]\)/); + assert.ok(Array.isArray(repositories.repositories)); + }); +}); + diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index 8754d7261..bae4a597c 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -727,6 +727,8 @@ describe('packaged Connect fixture cleanup', () => { let attempts = 0; const result = await removeAuthorizedConnectFixture({ ...identityOptions, + // This test proves retry semantics; timeout-specific tests retain their tight wall-clock bounds. + retryBoundMs: 250, rmImpl: async removed => { assert.equal(removed, fixture); attempts += 1; diff --git a/apps/desktop/scripts/run-packaged-acceptance.mjs b/apps/desktop/scripts/run-packaged-acceptance.mjs new file mode 100644 index 000000000..85017fd49 --- /dev/null +++ b/apps/desktop/scripts/run-packaged-acceptance.mjs @@ -0,0 +1,1633 @@ +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { constants as fsConstants } from 'node:fs'; +import { access, lstat, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium } from 'playwright'; +import { Server as SocketIOServer } from 'socket.io'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_QUERY, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, + QUEUE_STATS_UPDATE, +} from '@propr/shared'; +import { + ACCEPTANCE_JOURNEYS, + ACCEPTANCE_PROFILE_PREFIX, + ACCEPTANCE_SURFACES_PREFIX, + ACCEPTANCE_VARIANTS, + DETERMINISTIC_INPUTS, + FIXED_ACCEPTANCE_ORIGINS, + FIXED_TIME, + defaultAcceptanceOutputDirectory, + prepareAcceptanceArtifactDirectory, + readPngDimensions, + safeRemoveAcceptanceLeaf, + scanAcceptancePaths, + screenshotName, + verifyAcceptanceArtifacts, + writeAcceptanceManifest, +} from './acceptance-artifacts.mjs'; +import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from './packaged-acceptance-clock.mjs'; +import { analyzeExistingElectronRenderer } from './packaged-acceptance-axe.mjs'; +import { + classifyCurrentUserRequestShape, + currentUserValidationPhaseSummary, + currentUserValidationFailureCategory as classifyCurrentUserValidation, + networkPermissionDecisionSummary, +} from './packaged-acceptance-current-user.mjs'; +import { + captureElectronRendererScreenshot, + forEachElectronRendererVariant, + waitForUsableElectronRenderer, +} from './packaged-acceptance-renderer.mjs'; + +if (process.platform !== 'linux' || process.arch !== 'x64') { + throw new Error('Packaged desktop visual acceptance must run on Linux x64'); +} + +const outputDirectory = resolve(process.env.PROPR_DESKTOP_ACCEPTANCE_OUTPUT || defaultAcceptanceOutputDirectory()); +const binaryPath = resolve('out', 'propr-desktop-linux-x64', 'propr-desktop'); +const DEVICE_SECRET = 'D'.repeat(43); +const INSTANCE_TOKEN = `propr_it_${'T'.repeat(43)}`; +const ACTIVATION_TICKET = 'A'.repeat(43); +const PAIRING_ID = `dpr_${'P'.repeat(22)}`; +const RECEIPT = 'R'.repeat(22); +const SENTINELS = [DEVICE_SECRET, INSTANCE_TOKEN, ACTIVATION_TICKET]; +const FIXED_MILLIS = PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS; +const consoleRecords = []; +const pendingConsoleRecords = []; +const pageErrorRecords = []; +const processRecords = []; +const requestRecords = []; +const socketRecords = []; +const fixtureHandshakeRecords = []; +const mainHandshakeRecords = []; +const rendererLifecycleRecords = []; +const rendererCurrentUserRecords = []; +const mainCurrentUserRecords = []; +const fixtureCurrentUserRecords = []; +const networkPermissionRecords = []; +const screenshotMetadata = []; +const accessibilityChecks = []; +const axeFindings = []; +const liveAnnouncements = {}; +const surfaces = []; +const boundaryJourneys = new Set(); +let activeJourney = 'startup'; +let keyboardOrder = false; +let visibleFocus = false; +let modalFocusTrap = false; +let modalFocusRestore = false; +let traceWritten = false; +const rendererLifecycleInvalidStates = new Map(); +let currentUserEvidenceInvalid = false; +let networkPermissionEvidenceInvalidCount = 0; + +const digest = value => createHash('sha256').update(value).digest('hex'); +const unique = values => [...new Set(values)].sort((a, b) => ACCEPTANCE_JOURNEYS.indexOf(a) - ACCEPTANCE_JOURNEYS.indexOf(b)); +const sleep = milliseconds => new Promise(resolveValue => setTimeout(resolveValue, milliseconds)); +const MAIN_HANDSHAKE_EVENT = 'desktop.acceptance.websocket_handshake'; +const MAIN_CURRENT_USER_EVENT = 'desktop.acceptance.current_user_proxy'; +const NETWORK_PERMISSION_EVENT = 'desktop.acceptance.network_permission'; +const RENDERER_LIFECYCLE_PREFIX = '[ProPR Acceptance Renderer Lifecycle]'; +const RENDERER_CURRENT_USER_PREFIX = '[ProPR Acceptance Current User]'; +const QUEUE_STATS_SUBSCRIBE_EVENT = 'subscribe:queue:stats'; +const RENDERER_LIFECYCLE_PHASES = new Set([ + 'profile-activation-published', 'socket-provider-mounted', 'socket-effect-disabled', + 'socket-effect-scope-unavailable', 'socket-effect-ready', 'socket-construction-invoked', 'socket-constructed', + 'socket-connect-invoked', +]); +const RENDERER_LIFECYCLE_KEYS = [ + 'schemaVersion', 'phase', 'profileActivationPublished', 'socketProviderMounted', + 'providerDisabled', 'disabledByDemoModeLoading', 'disabledByDemoMode', + 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent', 'desktopRuntime', + 'connectionScope', 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations', +].sort(); +const MAIN_HANDSHAKE_CATEGORIES = new Set([ + 'none', 'untrusted-http-origin', 'wrong-path', 'wrong-transport', 'wrong-resource-type', + 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding', + 'stale-generation', 'wrong-origin', 'stale-scope', +]); +const CURRENT_USER_PROXY_CATEGORIES = new Set([ + 'none', 'scope-missing', 'scope-duplicate', 'scope-malformed', 'no-active-binding', + 'stale-generation', 'wrong-origin', 'stale-scope', +]); +const CURRENT_USER_RENDERER_PHASES = new Set([ + 'request-issued', 'response-completed', 'parsed-user-accepted', 'parsed-user-rejected', + 'active-scope-accepted', 'stale-scope-rejected', 'revoked-scope-rejected', 'active-scope-rejected', +]); +const CURRENT_USER_CLASSIFICATIONS = new Set([ + 'pending', 'success', 'unauthenticated', 'revoked', 'forbidden', 'server-error', + 'network-error', 'invalid-schema', +]); +const NETWORK_PERMISSION_CATEGORIES = new Set([ + 'local-network-access', 'local-network', 'loopback-network', +]); + +const recordRendererLifecycleInvalid = (journey, categories) => { + const current = rendererLifecycleInvalidStates.get(journey); + if (current) { + current.count = Math.min(current.count + 1, 9); + categories.forEach(category => current.categories.add(category)); + return; + } + rendererLifecycleInvalidStates.set(journey, { + count: 1, + firstCategory: categories[0], + categories: new Set(categories), + }); +}; + +const captureMainHandshakeEvidence = (line, journey) => { + if (!line.includes(MAIN_HANDSHAKE_EVENT)) return; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + const evidence = { + schemaVersion: record.schemaVersion, + path: record.path, + transport: record.transport, + resource: record.resource, + scopeQueryPresent: record.scopeQueryPresent, + scopeQueryCount: record.scopeQueryCount, + scopeEqualsActive: record.scopeEqualsActive, + activeBindingPresent: record.activeBindingPresent, + profileGenerationCurrent: record.profileGenerationCurrent, + originEqualsActive: record.originEqualsActive, + rendererBearerPresent: record.rendererBearerPresent, + rendererCookiePresent: record.rendererCookiePresent, + outboundBearerPresent: record.outboundBearerPresent, + bearerMainInjected: record.bearerMainInjected, + accepted: record.accepted, + rejectionCategory: record.rejectionCategory, + }; + const valid = record.event === MAIN_HANDSHAKE_EVENT + && evidence.schemaVersion === 1 + && ['socket-io', 'other'].includes(evidence.path) + && ['websocket', 'other'].includes(evidence.transport) + && ['websocket', 'other'].includes(evidence.resource) + && Number.isSafeInteger(evidence.scopeQueryCount) && evidence.scopeQueryCount >= 0 + && MAIN_HANDSHAKE_CATEGORIES.has(evidence.rejectionCategory) + && [ + evidence.scopeQueryPresent, + evidence.scopeEqualsActive, + evidence.activeBindingPresent, + evidence.profileGenerationCurrent, + evidence.originEqualsActive, + evidence.rendererBearerPresent, + evidence.rendererCookiePresent, + evidence.outboundBearerPresent, + evidence.bearerMainInjected, + evidence.accepted, + ].every(value => typeof value === 'boolean'); + mainHandshakeRecords.push(valid + ? { journey, ...evidence } + : { journey, accepted: false, rejectionCategory: 'invalid-main-evidence' }); + } catch { + mainHandshakeRecords.push({ journey, accepted: false, rejectionCategory: 'invalid-main-evidence' }); + } +}; + +const captureMainCurrentUserEvidence = (line, journey) => { + if (!line.includes(MAIN_CURRENT_USER_EVENT)) return; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + const evidence = { + schemaVersion: record.schemaVersion, + correlation: record.correlation, + requestObserved: record.requestObserved, + method: record.method, + rendererScopeGeneration: record.rendererScopeGeneration, + scopeGenerationQueryCount: record.scopeGenerationQueryCount, + scopeGenerationQueryValid: record.scopeGenerationQueryValid, + scopeHeaderCount: record.scopeHeaderCount, + activeBindingPresent: record.activeBindingPresent, + activeScopeGeneration: record.activeScopeGeneration, + profileGenerationCurrent: record.profileGenerationCurrent, + scopeEqualsActive: record.scopeEqualsActive, + originEqualsActive: record.originEqualsActive, + rendererBearerPresent: record.rendererBearerPresent, + rendererCookiePresent: record.rendererCookiePresent, + outboundBearerPresent: record.outboundBearerPresent, + bearerMainInjected: record.bearerMainInjected, + accepted: record.accepted, + rejectionCategory: record.rejectionCategory, + }; + const valid = record.event === MAIN_CURRENT_USER_EVENT + && evidence.schemaVersion === 2 && evidence.correlation === 'current-scope-user-validation' + && evidence.requestObserved === true && evidence.method === 'get' + && (evidence.rendererScopeGeneration === null + || (Number.isSafeInteger(evidence.rendererScopeGeneration) && evidence.rendererScopeGeneration >= 0)) + && Number.isSafeInteger(evidence.scopeGenerationQueryCount) + && evidence.scopeGenerationQueryCount >= 0 && evidence.scopeGenerationQueryCount <= 2 + && typeof evidence.scopeGenerationQueryValid === 'boolean' + && Number.isSafeInteger(evidence.scopeHeaderCount) && evidence.scopeHeaderCount >= 0 && evidence.scopeHeaderCount <= 2 + && Number.isSafeInteger(evidence.activeScopeGeneration) && evidence.activeScopeGeneration >= 0 + && CURRENT_USER_PROXY_CATEGORIES.has(evidence.rejectionCategory) + && [ + evidence.activeBindingPresent, evidence.profileGenerationCurrent, evidence.scopeEqualsActive, + evidence.originEqualsActive, evidence.rendererBearerPresent, evidence.rendererCookiePresent, + evidence.outboundBearerPresent, evidence.bearerMainInjected, evidence.accepted, + ].every(value => typeof value === 'boolean') + && mainCurrentUserRecords.filter(item => item.journey === journey).length < 4; + if (!valid) currentUserEvidenceInvalid = true; + else mainCurrentUserRecords.push({ journey, ...evidence }); + } catch { + currentUserEvidenceInvalid = true; + } +}; + +const captureNetworkPermissionEvidence = (line, journey) => { + if (!line.includes(NETWORK_PERMISSION_EVENT)) return; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + const evidence = { + schemaVersion: record.schemaVersion, + permissionCategory: record.permissionCategory, + decision: record.decision, + allowed: record.allowed, + activeBindingCurrent: record.activeBindingCurrent, + webContentsPresent: record.webContentsPresent, + webContentsEqualsMainWindow: record.webContentsEqualsMainWindow, + mainWindowPresent: record.mainWindowPresent, + isMainFrame: record.isMainFrame, + requestingUrlPresent: record.requestingUrlPresent, + requestingUrlTrusted: record.requestingUrlTrusted, + rendererDocumentUrlTrusted: record.rendererDocumentUrlTrusted, + requestingOriginAuthorityValid: record.requestingOriginAuthorityValid, + requestingOriginAuthorityEqual: record.requestingOriginAuthorityEqual, + }; + const valid = record.event === NETWORK_PERMISSION_EVENT + && evidence.schemaVersion === 1 + && NETWORK_PERMISSION_CATEGORIES.has(evidence.permissionCategory) + && ['check', 'request'].includes(evidence.decision) + && [ + evidence.allowed, + evidence.activeBindingCurrent, + evidence.webContentsPresent, + evidence.webContentsEqualsMainWindow, + evidence.mainWindowPresent, + evidence.isMainFrame, + evidence.requestingUrlPresent, + evidence.requestingUrlTrusted, + evidence.rendererDocumentUrlTrusted, + evidence.requestingOriginAuthorityValid, + evidence.requestingOriginAuthorityEqual, + ].every(value => typeof value === 'boolean'); + if (!valid || networkPermissionRecords.filter(item => item.journey === journey).length >= 24) { + networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9); + return; + } + networkPermissionRecords.push({ journey, ...evidence }); + } catch { + networkPermissionEvidenceInvalidCount = Math.min(networkPermissionEvidenceInvalidCount + 1, 9); + } +}; + +const captureRendererLifecycleEvidence = (text, journey) => { + if (!text.startsWith(RENDERER_LIFECYCLE_PREFIX)) return; + const serializedPrefix = `${RENDERER_LIFECYCLE_PREFIX} `; + let evidence; + try { + if (!text.startsWith(serializedPrefix)) { + recordRendererLifecycleInvalid(journey, ['serialization-prefix']); + return; + } + evidence = JSON.parse(text.slice(serializedPrefix.length)); + } catch { + recordRendererLifecycleInvalid(journey, ['serialization-json']); + return; + } + const exactKeys = evidence && typeof evidence === 'object' && !Array.isArray(evidence) + && Object.keys(evidence).sort().join('\n') === RENDERER_LIFECYCLE_KEYS.join('\n'); + const booleans = exactKeys && [ + 'profileActivationPublished', 'socketProviderMounted', 'providerDisabled', 'desktopRuntime', + 'disabledByDemoModeLoading', 'disabledByDemoMode', + 'disabledByCurrentUserLoading', 'disabledByCurrentUserAbsent', + ].every(key => typeof evidence[key] === 'boolean'); + const counts = exactKeys && [ + 'socketConstructionInvocations', 'socketConstructions', 'connectInvocations', + ].every(key => Number.isInteger(evidence[key]) && evidence[key] >= 0 && evidence[key] <= 2); + const disableReasonsConsistent = booleans && evidence.providerDisabled === [ + evidence.disabledByDemoModeLoading, + evidence.disabledByDemoMode, + evidence.disabledByCurrentUserLoading, + evidence.disabledByCurrentUserAbsent, + ].some(Boolean); + const missingKey = evidence && typeof evidence === 'object' && !Array.isArray(evidence) + ? RENDERER_LIFECYCLE_KEYS.find(key => !Object.hasOwn(evidence, key)) + : null; + const invalidCategory = !evidence || typeof evidence !== 'object' || Array.isArray(evidence) ? 'evidence-kind' + : missingKey ? `missing-${missingKey}` + : !exactKeys ? 'unexpected-keys' + : evidence.schemaVersion !== 2 ? 'schema-version' + : !RENDERER_LIFECYCLE_PHASES.has(evidence.phase) ? 'phase' + : !['unknown', 'available', 'unavailable'].includes(evidence.connectionScope) ? 'connection-scope' + : !booleans ? 'booleans' + : !counts ? 'counts' + : !disableReasonsConsistent ? 'disable-reasons' + : null; + const overflow = rendererLifecycleRecords.filter(record => record.journey === journey).length >= 12; + if (invalidCategory || overflow) { + recordRendererLifecycleInvalid(journey, [ + ...(invalidCategory ? [invalidCategory] : []), + ...(overflow ? ['overflow'] : []), + ]); + return; + } + rendererLifecycleRecords.push({ journey, ...evidence }); +}; + +const captureRendererCurrentUserEvidence = (argumentsValue, journey) => { + if (argumentsValue[0] !== RENDERER_CURRENT_USER_PREFIX) return; + const evidence = argumentsValue[1]; + const expectedKeys = [ + 'activeScopePresent', 'classification', 'correlation', 'phase', 'responseStatus', 'schemaAccepted', + 'schemaVersion', 'scopeGeneration', + ].sort(); + const valid = evidence && typeof evidence === 'object' && !Array.isArray(evidence) + && Object.keys(evidence).sort().join('\n') === expectedKeys.join('\n') + && evidence.schemaVersion === 1 + && evidence.correlation === 'current-scope-user-validation' + && CURRENT_USER_RENDERER_PHASES.has(evidence.phase) + && CURRENT_USER_CLASSIFICATIONS.has(evidence.classification) + && Number.isSafeInteger(evidence.scopeGeneration) && evidence.scopeGeneration >= 0 + && Number.isSafeInteger(evidence.responseStatus) && evidence.responseStatus >= 0 && evidence.responseStatus <= 599 + && typeof evidence.activeScopePresent === 'boolean' + && typeof evidence.schemaAccepted === 'boolean' + && rendererCurrentUserRecords.filter(item => item.journey === journey).length < 10; + if (!valid) currentUserEvidenceInvalid = true; + else rendererCurrentUserRecords.push({ journey, ...evidence }); +}; + +await access(binaryPath, fsConstants.X_OK); +const binaryStats = await lstat(binaryPath); +if (!binaryStats.isFile() || binaryStats.isSymbolicLink()) throw new Error('Packaged acceptance binary must be an executable non-link file'); +await prepareAcceptanceArtifactDirectory(outputDirectory); + +const json = (response, status, value, extra = {}) => { + response.writeHead(status, { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'Content-Type, X-ProPR-Desktop-Transport-Scope', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Allow-Private-Network': 'true', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + ...extra, + }); + response.end(JSON.stringify(value)); +}; + +const fixtures = []; +const fixtureHandshakeCategory = evidence => { + if (evidence.path !== 'socket-io') return 'wrong-path'; + if (evidence.transport !== 'websocket') return 'wrong-transport'; + if (evidence.resource !== 'websocket-upgrade') return 'wrong-resource-type'; + if (evidence.scopeQueryCount === 0) return 'scope-missing'; + if (evidence.scopeQueryCount !== 1) return 'scope-duplicate'; + if (!evidence.scopeQueryWellFormed) return 'scope-malformed'; + if (evidence.cookiePresent) return 'cookie-present'; + if (!evidence.authorizationPresent) return 'authorization-missing'; + if (!evidence.authorizationMatchesActivatedBearer) return 'authorization-mismatch'; + return 'none'; +}; + +const createFixture = async (mode, fixedOrigin) => { + const expectedUrl = new URL(fixedOrigin); + let binding = null; + let authChecks = 0; + const recordCurrentUserFixture = record => { + if (fixtureCurrentUserRecords.filter(item => item.journey === activeJourney).length >= 4) { + currentUserEvidenceInvalid = true; + return; + } + fixtureCurrentUserRecords.push(record); + }; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const bodyText = Buffer.concat(chunks).toString('utf8'); + const requestUrl = new URL(request.url || '/', fixedOrigin); + let body = {}; + try { body = bodyText ? JSON.parse(bodyText) : {}; } catch { body = {}; } + requestRecords.push({ + journey: activeJourney, + mode, + method: request.method || '', + url: request.url || '', + fullUrl: `${fixedOrigin}${request.url || ''}`, + origin: request.headers.origin || '', + hasAuthorization: Boolean(request.headers.authorization), + bodyBytes: Buffer.byteLength(bodyText), + }); + if (request.method === 'OPTIONS') return json(response, 204, {}); + if (request.url === '/api/desktop/discovery') { + return json(response, 200, { + schemaVersion: 1, + product: 'ProPR', version: mode === 'incompatible' ? '99.0.0' : '0.8.15', + apiCompatibility: mode === 'incompatible' ? '2099-01-01' : PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: mode === 'ready' + ? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + : mode === 'revoked' + ? 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + : 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + desktopAuthentication: { protocolVersion: 2, browserPairing: true, instanceBearerTokens: true, socketIoBearerAuthentication: true }, + }); + } + if (request.method === 'POST' && request.url === '/api/desktop/pairings') { + binding = body; + return json(response, 200, { + pairingId: PAIRING_ID, + deviceSecret: DEVICE_SECRET, + approvalUrl: `${fixedOrigin}/api/desktop/pairings/${PAIRING_ID}/browser`, + expiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(), + interval: 1, + }); + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/poll`) { + return json(response, 200, { + status: 'provisional', token: INSTANCE_TOKEN, tokenType: 'Bearer', activationTicket: ACTIVATION_TICKET, + activationExpiresAt: new Date(FIXED_MILLIS + 60_000).toISOString(), instanceId: binding?.instanceId, + origin: binding?.origin, scope: binding?.scope, credentialGeneration: binding?.credentialGeneration, + }); + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/activate`) { + return json(response, 200, { status: 'active', receipt: RECEIPT, activatedAt: FIXED_TIME, expiresAt: null }); + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${PAIRING_ID}/cancel`) { + return json(response, 200, { status: 'cancelled', cancelledAt: FIXED_TIME }); + } + if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') return json(response, 204, {}); + const currentUserShape = classifyCurrentUserRequestShape( + request.method, + request.url, + request.headers.origin, + ); + if (currentUserShape !== null) { + if (currentUserShape.source === 'renderer') authChecks += 1; + const source = currentUserShape.source; + const record = { + journey: activeJourney, + correlation: 'current-scope-user-validation', + source, + scopeGeneration: currentUserShape.scopeGeneration, + requestArrived: true, + authorizationPresent: typeof request.headers.authorization === 'string', + authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`, + cookiePresent: typeof request.headers.cookie === 'string', + responseStatus: 200, + classification: 'success', + }; + if (source === 'renderer' && mode === 'revoked' && authChecks >= 1) { + record.responseStatus = 401; + record.classification = 'revoked'; + recordCurrentUserFixture(record); + return json(response, 401, { code: 'INSTANCE_TOKEN_REVOKED' }); + } + if (request.headers.authorization !== `Bearer ${INSTANCE_TOKEN}`) { + record.responseStatus = 401; + record.classification = 'unauthenticated'; + recordCurrentUserFixture(record); + return json(response, 401, { code: 'INVALID_INSTANCE_TOKEN' }); + } + recordCurrentUserFixture(record); + return json(response, 200, { + id: 'acceptance-user', login: 'acceptance-admin', username: 'acceptance-admin', displayName: 'Acceptance Admin', + email: null, avatarUrl: null, role: 'admin', + permissions: ['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings'], + authorizationSource: 'local', + }); + } + if (request.url === '/api/compatibility') return json(response, 200, { apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY }); + // Socket lifecycle evidence requires the same boolean consumed from the + // production endpoint; the generic API fixture shape is not compatible. + if (request.url === '/api/auth/demo-mode') return json(response, 200, { demoMode: false }); + if (request.url?.startsWith('/api/status')) return json(response, 200, { daemon: 'Running', redis: 'Connected', githubAuth: 'Authenticated', claudeAuth: 'Ready', agents: [], githubEventIntake: 'ProPR Connect', githubEventIntakeStatus: 'Connected' }); + if (request.url?.startsWith('/api/queue/stats')) return json(response, 200, { active: 0, waiting: 0, completed: 12, failed: 0, delayed: 0, paused: 0 }); + if (requestUrl.pathname === '/api/stats/generating-plans') return json(response, 200, { count: 0 }); + if (requestUrl.pathname === '/api/stats/tasks') { + return json(response, 200, { + dailyCounts: [], + statusDistribution: [ + { status: 'completed', count: 12 }, + { status: 'failed', count: 0 }, + ], + avgProcessingTime: [], + summary: { total: 12, completed: 12, failed: 0 }, + }); + } + if (requestUrl.pathname === '/api/stats/overview') { + return json(response, 200, { + tasks: { + completed: 12, + planned: 0, + pr_iterations_avg: 0, + merged_prs: 0, + total_followups: 0, + }, + usage: { total_tokens: 0, total_cost_usd: 0, models: {} }, + system: { repos_indexed: 0 }, + }); + } + if (requestUrl.pathname === '/api/stats/repositories') { + return json(response, 200, { repositories: [] }); + } + if (request.url?.startsWith('/api/stats/')) return json(response, 200, { total: 12, pending: 0, inProgress: 0, completed: 12, failed: 0, dailyCounts: [] }); + if (request.url?.startsWith('/api/tasks')) return json(response, 200, { tasks: [], total: 0 }); + if (request.url?.startsWith('/api/')) return json(response, 200, { agents: [], repositories: [], items: [], count: 0 }); + return json(response, 404, { code: 'NOT_FOUND' }); + }); + const io = new SocketIOServer(server, { + path: '/socket.io/', + transports: ['websocket'], + cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, + }); + io.engine.use((request, _response, next) => { + const requestUrl = new URL(request.url || '/', 'http://fixture.invalid'); + const queryScopes = requestUrl.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + const evidence = { + journey: activeJourney, + mode, + path: requestUrl.pathname === '/socket.io/' ? 'socket-io' : 'other', + transport: requestUrl.searchParams.getAll('transport').length === 1 + && requestUrl.searchParams.get('transport') === 'websocket' ? 'websocket' : 'other', + resource: request.method === 'GET' && request.headers.upgrade?.toLowerCase() === 'websocket' + ? 'websocket-upgrade' : 'other', + scopeQueryPresent: queryScopes.length > 0, + scopeQueryCount: queryScopes.length, + scopeQueryWellFormed: queryScopes.length === 1 && /^[A-Za-z0-9_-]{22}$/.test(queryScopes[0]), + authorizationPresent: typeof request.headers.authorization === 'string', + authorizationMatchesActivatedBearer: request.headers.authorization === `Bearer ${INSTANCE_TOKEN}`, + cookiePresent: typeof request.headers.cookie === 'string', + accepted: false, + rejectionCategory: 'none', + }; + evidence.rejectionCategory = fixtureHandshakeCategory(evidence); + evidence.accepted = evidence.rejectionCategory === 'none'; + fixtureHandshakeRecords.push(evidence); + if (!evidence.accepted) { + next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED')); + return; + } + next(); + }); + io.use((socket, next) => { + const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid').searchParams + .getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + if (socket.handshake.headers.authorization !== `Bearer ${INSTANCE_TOKEN}` || queryScopes.length !== 1) { + next(new Error('PACKAGED_ACCEPTANCE_SOCKET_REJECTED')); + return; + } + next(); + }); + io.on('connection', socket => { + const journey = activeJourney; + const authenticated = socket.handshake.headers.authorization === `Bearer ${INSTANCE_TOKEN}`; + const recordApplicationSubscription = () => socketRecords.push({ + journey, + mode, + event: QUEUE_STATS_SUBSCRIBE_EVENT, + authenticated, + direction: 'client-to-fixture', + genuineApplicationSubscription: true, + }); + socketRecords.push({ journey, mode, event: 'connection', authenticated }); + socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, () => { + recordApplicationSubscription(); + socket.once(QUEUE_STATS_SUBSCRIBE_EVENT, recordApplicationSubscription); + socket.emit(QUEUE_STATS_UPDATE, { + eventType: QUEUE_STATS_UPDATE, + stats: { waiting: 0, active: 0, completed: 12, failed: 0, delayed: 0, total: 12 }, + timestamp: FIXED_TIME, + }); + socketRecords.push({ + journey, + mode, + event: QUEUE_STATS_UPDATE, + authenticated, + direction: 'fixture-to-renderer', + genuineApplicationEvent: true, + }); + }); + }); + server.listen(Number(expectedUrl.port), expectedUrl.hostname); + await Promise.race([ + once(server, 'listening'), + once(server, 'error').then(([error]) => { throw error; }), + ]); + const address = server.address(); + fixtures.push({ server, io }); + const observedOrigin = `http://${address.address}:${address.port}`; + if (observedOrigin !== fixedOrigin) throw new Error(`Acceptance fixture origin changed: ${observedOrigin}`); + return fixedOrigin; +}; + +let readyOrigin; +let revokedOrigin; +let incompatibleOrigin; + +const isolatedEnvironment = (userData, scenario) => { + const allowed = {}; + for (const name of ['DBUS_SESSION_BUS_ADDRESS', 'GNOME_KEYRING_CONTROL', 'DISPLAY', 'XAUTHORITY', 'PATH']) { + if (process.env[name]) allowed[name] = process.env[name]; + } + return { + ...allowed, + HOME: userData, + LANG: 'C.UTF-8', + LANGUAGE: 'en_US:en', + LC_ALL: 'C.UTF-8', + TZ: 'UTC', + SOURCE_DATE_EPOCH: String(Math.floor(FIXED_MILLIS / 1000)), + XDG_CONFIG_HOME: join(userData, 'xdg-config'), + XDG_DATA_HOME: join(userData, 'xdg-data'), + XDG_CACHE_HOME: join(userData, 'xdg-cache'), + PROPR_DESKTOP_ACCEPTANCE_TEST: '1', + PROPR_DESKTOP_ACCEPTANCE_SCENARIO: scenario, + }; +}; + +const terminateProcessTree = async (child, childClosed) => { + if (child.exitCode !== null || child.signalCode !== null) return; + if (!Number.isInteger(child.pid)) { await childClosed.catch(() => undefined); return; } + try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } + await Promise.race([childClosed, sleep(5_000)]).catch(() => undefined); + if (child.exitCode === null && child.signalCode === null) { + try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } + await childClosed.catch(() => undefined); + } +}; + +const launchApplication = async (scenario, initialDeepLink) => { + const userData = await mkdtemp(join(tmpdir(), ACCEPTANCE_PROFILE_PREFIX)); + let child; + let childClosed; + let browser; + let cleaned = false; + let output = ''; + const cleanup = async () => { + if (cleaned) return; + cleaned = true; + const failures = []; + if (browser) await browser.close().catch(error => failures.push(error)); + if (child && childClosed) await terminateProcessTree(child, childClosed).catch(error => failures.push(error)); + await scanAcceptancePaths([userData], SENTINELS).catch(error => failures.push(error)); + surfaces.push({ kind: 'packaged-process-output', journey: activeJourney, value: output }); + processRecords.push({ journey: activeJourney, source: 'packaged-process', level: 'combined', bytes: Buffer.byteLength(output), sha256: digest(output) }); + await safeRemoveAcceptanceLeaf(userData, { kind: 'profile' }).catch(error => failures.push(error)); + if (failures.length) throw new AggregateError(failures, 'Packaged acceptance cleanup failed'); + }; + + try { + const args = [ + '--disable-background-networking', '--disable-gpu', '--force-device-scale-factor=1', '--lang=en-US', + '--remote-debugging-port=0', '--propr-acceptance-test', `--user-data-dir=${userData}`, + '--password-store=gnome-libsecret', ...(initialDeepLink ? [initialDeepLink] : []), + ]; + child = spawn(binaryPath, args, { + cwd: userData, + detached: true, + env: isolatedEnvironment(userData, scenario), + stdio: ['ignore', 'pipe', 'pipe'], + }); + childClosed = once(child, 'close'); + let resolveEndpoint; + let rejectEndpoint; + const endpoint = new Promise((resolveValue, rejectValue) => { resolveEndpoint = resolveValue; rejectEndpoint = rejectValue; }); + const capture = () => { + let remainder = ''; + return chunk => { + const text = chunk.toString(); + output += text; + const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) resolveEndpoint(match[1]); + const lines = `${remainder}${text}`.split(/\r?\n/); + remainder = lines.pop() || ''; + lines.forEach(line => { + captureMainHandshakeEvidence(line, activeJourney); + captureMainCurrentUserEvidence(line, activeJourney); + captureNetworkPermissionEvidence(line, activeJourney); + }); + }; + }; + child.stdout.on('data', capture()); + child.stderr.on('data', capture()); + child.once('error', rejectEndpoint); + child.once('close', code => rejectEndpoint(new Error(`Packaged Electron exited before CDP startup (${code ?? 'signal'})`))); + const timeout = setTimeout(() => rejectEndpoint(new Error('Packaged Electron CDP endpoint did not start')), 20_000); + const wsEndpoint = await endpoint.finally(() => clearTimeout(timeout)); + browser = await chromium.connectOverCDP(wsEndpoint); + const { context, page } = await waitForUsableElectronRenderer(browser, child, { + expectedUrlPrefix: DESKTOP_RENDERER_ORIGIN, + timeoutMs: 15_000, + }); + page.on('console', message => { + const journey = activeJourney; + // Lifecycle evidence is a single serialized renderer snapshot. Capture it + // synchronously so its strict transition order does not depend on CDP + // JSHandle resolution or page lifetime. + captureRendererLifecycleEvidence(message.text(), journey); + const pending = Promise.all(message.args().map(async argument => { + try { return await argument.jsonValue(); } catch { return { unserializable: argument.toString() }; } + })).then(argumentsValue => { + const record = { + journey, + type: message.type(), + text: message.text(), + arguments: argumentsValue, + location: message.location(), + }; + consoleRecords.push(record); + captureRendererCurrentUserEvidence(record.arguments, journey); + }); + pendingConsoleRecords.push(pending); + }); + page.on('pageerror', error => pageErrorRecords.push({ journey: activeJourney, name: error.name, message: error.message, stack: error.stack || '' })); + await page.evaluate(({ fixedTime, fixedMillis }) => { + const NativeDate = Date; + class AcceptanceDate extends NativeDate { + constructor(...args) { super(...(args.length ? args : [fixedMillis])); } + static now() { return fixedMillis; } + } + Object.defineProperty(window, 'Date', { configurable: false, value: AcceptanceDate }); + let uuidCounter = 0; + Object.defineProperty(Crypto.prototype, 'randomUUID', { + configurable: false, + value: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`, + }); + document.documentElement.dataset.acceptanceFixedTime = fixedTime; + }, { fixedTime: FIXED_TIME, fixedMillis: FIXED_MILLIS }); + await page.waitForFunction(() => Boolean(document.querySelector('.desktop-entry, .desktop-app')), null, { timeout: 15_000 }); + await page.addStyleTag({ content: ` + *, *::before, *::after { animation: none !important; caret-color: transparent !important; transition: none !important; scroll-behavior: auto !important; } + html body, html body button, html body input, html body select, html body textarea { font-family: "Liberation Sans", sans-serif !important; } + ` }); + const initialization = await page.evaluate(fixedTime => ({ + rendererOrigin: `${location.protocol}//${location.host}`, + preloadBridge: typeof window.proprDesktop === 'object' && window.proprDesktop !== null, + acceptanceZoomBridge: typeof window.__PROPR_PACKAGED_ACCEPTANCE__ === 'object' + && window.__PROPR_PACKAGED_ACCEPTANCE__ !== null + && Object.keys(window.__PROPR_PACKAGED_ACCEPTANCE__).join('\n') === 'setZoomFactor' + && typeof window.__PROPR_PACKAGED_ACCEPTANCE__.setZoomFactor === 'function', + fontLoaded: document.fonts.check('12px "Liberation Sans"'), + locale: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + rendererTime: new Date().toISOString(), + fixedMarker: document.documentElement.dataset.acceptanceFixedTime === fixedTime, + }), FIXED_TIME); + if (initialization.rendererOrigin !== 'propr-app://renderer' || !initialization.preloadBridge + || !initialization.acceptanceZoomBridge + || !initialization.fontLoaded || initialization.locale !== DETERMINISTIC_INPUTS.locale + || initialization.timezone !== DETERMINISTIC_INPUTS.timezone || initialization.rendererTime !== FIXED_TIME + || !initialization.fixedMarker) { + throw new Error(`Packaged acceptance deterministic boundary initialization failed: ${JSON.stringify(initialization)}`); + } + boundaryJourneys.add(activeJourney); + return { context, page, close: cleanup }; + } catch (error) { + try { await cleanup(); } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Packaged acceptance launch and cleanup failed'); + } + throw error; + } +}; + +const fillEndpoint = async (page, origin, name = 'Acceptance ProPR') => { + await page.getByRole('button', { name: /Connect to an existing instance/ }).click(); + await page.getByLabel('Display name').fill(name); + await page.getByLabel('Instance URL').fill(origin); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); +}; + +const pair = async (page, origin, name = 'Acceptance ProPR') => { + await fillEndpoint(page, origin, name); + await page.getByRole('button', { name: 'Sign in in browser' }).waitFor(); + await page.getByRole('button', { name: 'Sign in in browser' }).click(); +}; + +const liveRegionSnapshot = () => [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({ + ariaLive: element.getAttribute('aria-live') || '', + role: element.getAttribute('role') || '', + text: element.textContent || '', +})); + +const observeLiveMutation = async (page, kind, journey, action) => { + const before = await page.evaluate(liveRegionSnapshot); + await action(); + await page.waitForFunction(({ beforeJson, kindName }) => { + const snapshot = [...document.querySelectorAll('[aria-live], [role="status"], [role="alert"]')].map(element => ({ + ariaLive: element.getAttribute('aria-live') || '', role: element.getAttribute('role') || '', text: element.textContent || '', + })); + const changed = JSON.stringify(snapshot) !== beforeJson && snapshot.some(entry => entry.text.trim()); + if (!changed) return false; + return kindName === 'status' || snapshot.some(entry => entry.role === 'alert' || /could not|unavailable|error|attention/i.test(entry.text)); + }, { beforeJson: JSON.stringify(before), kindName: kind }, { timeout: 15_000 }); + const after = await page.evaluate(liveRegionSnapshot); + const beforeJson = JSON.stringify(before); + const afterJson = JSON.stringify(after); + if (beforeJson === afterJson) throw new Error(`Acceptance ${kind} live region did not mutate`); + liveAnnouncements[kind] = { journey, beforeHash: digest(beforeJson), afterHash: digest(afterJson), mutated: true }; +}; + +const collectRendererSurface = async (page, journey, name) => { + const renderer = await page.evaluate(async ({ journeyName, screenshot }) => { + const attributes = element => Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value])); + const forms = [...document.querySelectorAll('input, select, textarea, button')].map(element => ({ + tagName: element.tagName, + attributes: attributes(element), + value: 'value' in element ? element.value : '', + checked: 'checked' in element ? element.checked : false, + selectedValues: element instanceof HTMLSelectElement ? [...element.selectedOptions].map(option => option.value) : [], + })); + const databases = []; + if (typeof indexedDB.databases === 'function') { + for (const databaseInfo of await indexedDB.databases()) { + if (!databaseInfo.name) continue; + const contents = await new Promise((resolveValue, rejectValue) => { + const request = indexedDB.open(databaseInfo.name); + request.onerror = () => rejectValue(request.error); + request.onsuccess = () => { + const database = request.result; + const stores = [...database.objectStoreNames]; + if (stores.length === 0) { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: {} }); return; } + const transaction = database.transaction(stores, 'readonly'); + const values = {}; + transaction.onerror = () => rejectValue(transaction.error); + transaction.oncomplete = () => { database.close(); resolveValue({ name: databaseInfo.name, version: database.version, stores: values }); }; + stores.forEach(store => { + const all = transaction.objectStore(store).getAll(); + all.onsuccess = () => { values[store] = all.result; }; + }); + }; + }); + databases.push(contents); + } + } + const cachesData = []; + if ('caches' in window) { + for (const cacheName of await caches.keys()) { + const cache = await caches.open(cacheName); + const entries = []; + for (const request of await cache.keys()) { + const response = await cache.match(request); + entries.push({ requestUrl: request.url, responseUrl: response?.url || '', body: response ? await response.clone().text() : '' }); + } + cachesData.push({ name: cacheName, entries }); + } + } + return { + kind: 'renderer', journey: journeyName, screenshot, url: location.href, title: document.title, + html: document.documentElement.outerHTML, forms, + localStorage: Object.fromEntries(Object.entries(localStorage)), + sessionStorage: Object.fromEntries(Object.entries(sessionStorage)), + indexedDB: databases, caches: cachesData, + }; + }, { journeyName: journey, screenshot: name }); + renderer.cookies = await page.context().cookies(); + surfaces.push(renderer); +}; + +const inspectAccessibility = async (page, journey, variant, config, metrics, name) => { + const seriousFindings = await analyzeExistingElectronRenderer(page); + for (const violation of seriousFindings) { + axeFindings.push({ name, journey, variant, id: violation.id, impact: violation.impact, nodes: violation.nodes }); + } + const deterministic = await page.evaluate(({ expectedReducedMotion, fixedTime }) => { + const visibleControls = [...document.querySelectorAll('button, a[href], input, select, textarea')].filter(element => { + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0; + }); + const unnamed = visibleControls.filter(element => { + const label = element.getAttribute('aria-label') || element.getAttribute('title') + || (element instanceof HTMLInputElement ? element.labels?.[0]?.textContent : '') || element.textContent; + return !label?.trim(); + }).length; + return { + accessibleNames: unnamed === 0, + locale: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + fontLoaded: document.fonts.check('12px "Liberation Sans"'), + reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, + rendererTime: new Date().toISOString(), + animationsDisabled: document.getAnimations({ subtree: true }).every(animation => animation.playState !== 'running'), + expectedReducedMotion, + }; + }, { expectedReducedMotion: config.reducedMotion, fixedTime: FIXED_TIME }); + if (deterministic.locale !== DETERMINISTIC_INPUTS.locale || deterministic.timezone !== DETERMINISTIC_INPUTS.timezone + || deterministic.rendererTime !== FIXED_TIME || deterministic.reducedMotion !== config.reducedMotion + || deterministic.reducedMotion !== deterministic.expectedReducedMotion || !deterministic.fontLoaded + || !deterministic.animationsDisabled) { + throw new Error(`Acceptance deterministic renderer inputs changed for ${name}: ${JSON.stringify(deterministic)}`); + } + return { + name, journey, variant, + serious: seriousFindings.filter(finding => finding.impact === 'serious').length, + critical: seriousFindings.filter(finding => finding.impact === 'critical').length, + accessibleNames: deterministic.accessibleNames, + locale: deterministic.locale, + timezone: deterministic.timezone, + fontLoaded: deterministic.fontLoaded, + reducedMotion: deterministic.reducedMotion, + requestedViewport: metrics.requestedViewport, + playwrightViewport: metrics.playwrightViewport, + rendererViewport: metrics.rendererViewport, + documentClientViewport: metrics.documentClientViewport, + scrollbarInsets: metrics.scrollbarInsets, + visualViewportInsets: metrics.visualViewportInsets, + layoutViewport: metrics.layoutViewport, + cdpVisualViewport: metrics.cdpVisualViewport, + rendererVisualViewport: metrics.rendererVisualViewport, + effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan, + geometryZoom: metrics.geometryZoom, + requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor, + rendererDevicePixelRatio: metrics.rendererDevicePixelRatio, + requestedZoomFactor: metrics.requestedZoomFactor, + appliedZoomFactor: metrics.appliedZoomFactor, + zoomResetFactor: metrics.zoomResetFactor, + zoomMechanism: metrics.zoomMechanism, + animationsDisabled: deterministic.animationsDisabled, + rendererTime: deterministic.rendererTime, + }; +}; + +const captureVariants = async (page, journey) => { + const cdp = await page.context().newCDPSession(page); + try { + await forEachElectronRendererVariant(page, cdp, ACCEPTANCE_VARIANTS, async ({ variant, config, metrics }) => { + const name = screenshotName(journey, variant); + const accessibilityCheck = await inspectAccessibility(page, journey, variant, config, metrics, name); + await collectRendererSurface(page, journey, name); + const first = await captureElectronRendererScreenshot(cdp); + const second = await captureElectronRendererScreenshot(cdp); + const firstDigest = digest(first); + if (!first.equals(second)) throw new Error(`Acceptance screenshot was not repeatable for ${name}`); + const dimensions = readPngDimensions(first); + const expectedDimensions = { + width: metrics.requestedViewport.width * metrics.requestedDeviceScaleFactor, + height: metrics.requestedViewport.height * metrics.requestedDeviceScaleFactor, + }; + if (dimensions.width !== expectedDimensions.width || dimensions.height !== expectedDimensions.height) { + throw new Error(`Acceptance screenshot dimensions changed for ${name}: ${JSON.stringify(dimensions)}`); + } + const physicalPngDimensions = { ...dimensions }; + accessibilityChecks.push({ ...accessibilityCheck, physicalPngDimensions }); + await writeFile(join(outputDirectory, 'screenshots', name), first, { mode: 0o600 }); + screenshotMetadata.push({ + name, journey, variant, + width: dimensions.width, + height: dimensions.height, + physicalPngDimensions, + requestedDeviceScaleFactor: metrics.requestedDeviceScaleFactor, + rendererDevicePixelRatio: metrics.rendererDevicePixelRatio, + requestedZoomFactor: metrics.requestedZoomFactor, + appliedZoomFactor: metrics.appliedZoomFactor, + zoomResetFactor: metrics.zoomResetFactor, + zoomMechanism: metrics.zoomMechanism, + reducedMotion: metrics.reducedMotion, + requestedViewport: metrics.requestedViewport, + playwrightViewport: metrics.playwrightViewport, + rendererViewport: metrics.rendererViewport, + documentClientViewport: metrics.documentClientViewport, + scrollbarInsets: metrics.scrollbarInsets, + visualViewportInsets: metrics.visualViewportInsets, + layoutViewport: metrics.layoutViewport, + cdpVisualViewport: metrics.cdpVisualViewport, + rendererVisualViewport: metrics.rendererVisualViewport, + effectiveVisibleCssSpan: metrics.effectiveVisibleCssSpan, + geometryZoom: metrics.geometryZoom, + locale: DETERMINISTIC_INPUTS.locale, + timezone: DETERMINISTIC_INPUTS.timezone, + font: DETERMINISTIC_INPUTS.font, + colorScheme: DETERMINISTIC_INPUTS.colorScheme, + rendererTime: FIXED_TIME, + originPolicy: DETERMINISTIC_INPUTS.originPolicy, + visibleData: DETERMINISTIC_INPUTS.visibleData, + animations: DETERMINISTIC_INPUTS.animations, + repeatabilitySha256: firstDigest, + }); + }, { colorScheme: DETERMINISTIC_INPUTS.colorScheme }); + } finally { + await cdp.detach(); + } +}; + +const runJourney = async (journey, scenario, initialDeepLink, prepare) => { + activeJourney = journey; + const application = await launchApplication(scenario, initialDeepLink); + try { + const afterCapture = await prepare(application.page, application); + await captureVariants(application.page, journey); + if (typeof afterCapture === 'function') await afterCapture(); + } finally { + await application.close(); + } +}; + +const waitForObserved = async (predicate, description) => { + const deadline = Date.now() + 15_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Acceptance did not observe ${description}`); + await sleep(50); + } +}; + +const currentUserValidationFailureCategory = journey => { + return classifyCurrentUserValidation({ + journey, + evidenceInvalid: currentUserEvidenceInvalid, + rendererRecords: rendererCurrentUserRecords, + mainRecords: mainCurrentUserRecords, + fixtureRecords: fixtureCurrentUserRecords, + }); +}; + +const currentUserPhaseSummary = journey => currentUserValidationPhaseSummary({ + journey, + rendererRecords: rendererCurrentUserRecords, + mainRecords: mainCurrentUserRecords, + fixtureRecords: fixtureCurrentUserRecords, + requestRecords, +}); + +const networkPermissionSummary = journey => networkPermissionDecisionSummary({ + journey, + records: networkPermissionRecords, + invalidCount: networkPermissionEvidenceInvalidCount, +}); + +const settlePendingRendererConsoleCaptures = async () => { + let settledCount = 0; + while (settledCount < pendingConsoleRecords.length) { + const unsettled = pendingConsoleRecords.slice(settledCount); + settledCount += unsettled.length; + await Promise.allSettled(unsettled); + } +}; + +const boundedAcceptanceDiagnosticCount = records => Math.min(records.length, 9); + +const rendererConsoleErrorCategory = record => { + if (record.text.startsWith('Failed to refresh instance authorization:') + || record.text.startsWith('Failed to synchronize instance authorization:')) return 'currentUserSync'; + if (record.text.startsWith('[SocketContext]')) return 'socketContext'; + if (record.text.startsWith('Failed to load or render a route:') + || record.text.startsWith('Root container missing in index.html') + || record.text.startsWith('Uncaught ') + || record.text.startsWith('Error: Minified React error') + || record.text.startsWith('Warning: React')) return 'reactRuntime'; + if (record.text.startsWith('Failed to fetch ') + || record.text.startsWith('Failed to load ') + || record.text.startsWith('Error fetching ') + || record.text.startsWith('Silent refresh failed:') + || record.text.startsWith('[useGenerationPolling] Poll error:')) return 'apiLoad'; + return 'other'; +}; + +const rendererConsoleErrorCategoryCounts = records => { + const counts = { currentUserSync: 0, apiLoad: 0, socketContext: 0, reactRuntime: 0, other: 0 }; + for (const record of records) { + const category = rendererConsoleErrorCategory(record); + counts[category] = Math.min(counts[category] + 1, 9); + } + return counts; +}; + +const rendererErrorCountSummary = journey => { + const rendererConsole = consoleRecords.filter(record => record.journey === journey); + const rendererPageErrors = pageErrorRecords.filter(record => record.journey === journey); + const rendererConsoleErrors = rendererConsole.filter(record => record.type === 'error'); + return { + schemaVersion: 2, + console: boundedAcceptanceDiagnosticCount(rendererConsole), + consoleErrors: boundedAcceptanceDiagnosticCount(rendererConsoleErrors), + pageErrors: boundedAcceptanceDiagnosticCount(rendererPageErrors), + consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts(rendererConsoleErrors), + }; +}; + +const rendererLifecycleDiagnosticSummary = journey => { + const invalid = rendererLifecycleInvalidStates.get(journey); + return { + schemaVersion: 2, + recordCount: rendererLifecycleRecords.filter(record => record.journey === journey).length, + invalidCount: invalid?.count ?? 0, + firstInvalidCategory: invalid?.firstCategory ?? 'none', + invalidCategories: invalid ? [...invalid.categories].sort() : [], + }; +}; + +const rendererLifecycleInvalidSummary = () => { + const [firstJourney, firstInvalid] = rendererLifecycleInvalidStates.entries().next().value ?? []; + return { + schemaVersion: 1, + journeyCount: Math.min(rendererLifecycleInvalidStates.size, ACCEPTANCE_JOURNEYS.length), + invalidCount: Math.min([...rendererLifecycleInvalidStates.values()] + .reduce((total, invalid) => total + invalid.count, 0), 9), + firstJourney: ACCEPTANCE_JOURNEYS.includes(firstJourney) ? firstJourney : 'unknown', + firstInvalidCategory: firstInvalid?.firstCategory ?? 'none', + }; +}; + +const rendererUiStateSummary = async page => { + const absent = { + schemaVersion: 1, + connectionPillPresent: false, + connectionStatus: 'absent', + accessibleLabelCategory: 'other', + navigatorOnline: false, + desktopTitleBarPresent: false, + routeLayoutPresent: false, + loadingSpinnerPresent: false, + validatedCurrentUserMarkerPresent: false, + dashboardMarkerPresent: false, + }; + try { + return await page.evaluate(() => { + const pill = document.querySelector('.desktop-connection-pill'); + const classStatuses = ['ready', 'offline', 'incompatible'].filter(status => + pill?.classList.contains(`desktop-connection-${status}`)); + const connectionStatus = pill === null + ? 'absent' + : classStatuses.length === 1 ? classStatuses[0] : 'unknown'; + const label = pill?.getAttribute('aria-label'); + const accessibleLabelCategory = label === 'Connected: Operations' + ? 'Connected: Operations' + : label === 'Offline: Operations' + ? 'Offline: Operations' + : label === 'Update required: Operations' ? 'Update required: Operations' : 'other'; + const routeLayout = document.querySelector( + '.desktop-shell .desktop-shell-content main.mobile-content-clearance', + ); + const loadingSpinner = document.querySelector( + '[class~="h-screen"][class~="w-full"] [class~="h-12"][class~="w-12"][class~="animate-spin"]', + ); + const validatedCurrentUserMarker = document.querySelector('a[href="/admin/members"]'); + const dashboardMarker = [...document.querySelectorAll('main h3')] + .some(element => element.textContent?.trim() === 'Recent Activity'); + return { + schemaVersion: 1, + connectionPillPresent: pill !== null, + connectionStatus, + accessibleLabelCategory, + navigatorOnline: navigator.onLine, + desktopTitleBarPresent: document.querySelector('.desktop-titlebar') !== null, + routeLayoutPresent: routeLayout !== null, + loadingSpinnerPresent: loadingSpinner !== null, + validatedCurrentUserMarkerPresent: validatedCurrentUserMarker !== null, + dashboardMarkerPresent: dashboardMarker, + }; + }); + } catch { + return absent; + } +}; + +const rendererSurfacePhase = async page => { + try { + return await page.evaluate(() => { + if (document.querySelector('.desktop-app')) return 'app'; + if (document.querySelector('.desktop-entry')) return 'entry'; + return 'loading'; + }); + } catch { + return 'loading'; + } +}; + +const socketHandshakeFailureCategory = journey => { + const currentUserCategory = currentUserValidationFailureCategory(journey); + if (currentUserCategory !== 'none') return currentUserCategory; + const rejectedMain = mainHandshakeRecords.find(record => record.journey === journey && !record.accepted); + if (rejectedMain) return `main-${rejectedMain.rejectionCategory}`; + const rejectedFixture = fixtureHandshakeRecords.find(record => record.journey === journey && !record.accepted); + if (rejectedFixture) return `fixture-${rejectedFixture.rejectionCategory}`; + const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted); + const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted); + const connected = socketRecords.filter(record => record.journey === journey + && record.event === 'connection' && record.authenticated); + const subscriptions = socketRecords.filter(record => record.journey === journey + && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated + && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture'); + const applicationEvents = socketRecords.filter(record => record.journey === journey + && record.event === QUEUE_STATS_UPDATE && record.authenticated + && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer'); + const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey + && record.text.startsWith('[SocketContext] Received queue stats update:')); + if (mainAccepted.length > 1) return 'duplicate-main-upgrade'; + if (fixtureAccepted.length > 1) return 'duplicate-fixture-upgrade'; + if (connected.length > 1) return 'duplicate-authenticated-connection'; + if (subscriptions.length > 1) return 'duplicate-application-subscription'; + if (applicationEvents.length > 1) return 'duplicate-application-event'; + if (rendererObservedApplicationEvents.length > 1) return 'duplicate-renderer-application-event'; + if (mainAccepted.length === 1 && fixtureAccepted.length === 0) return 'fixture-not-reached'; + if (fixtureAccepted.length === 1 && connected.length === 0) return 'namespace-not-connected'; + if (connected.length === 1 && subscriptions.length === 0) return 'application-subscription-not-observed'; + if (subscriptions.length === 1 && applicationEvents.length === 0) return 'application-event-not-produced'; + if (applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 0) { + return 'renderer-application-event-not-observed'; + } + const lifecycleCategory = rendererLifecycleCategory(journey); + return lifecycleCategory === 'none' + ? 'renderer-connect-invoked-upgrade-not-produced' + : lifecycleCategory; +}; + +const rendererLifecycleCategory = journey => { + if (rendererLifecycleInvalidStates.has(journey)) return 'renderer-lifecycle-evidence-invalid'; + const records = rendererLifecycleRecords.filter(record => record.journey === journey); + const latest = records.at(-1); + if (!latest?.profileActivationPublished) return 'renderer-profile-activation-not-propagated'; + if (!latest.socketProviderMounted) return 'renderer-socket-provider-not-mounted'; + if (latest.providerDisabled) { + if (latest.disabledByDemoModeLoading) return 'renderer-socket-provider-disabled-demo-mode-loading'; + if (latest.disabledByDemoMode) return 'renderer-socket-provider-disabled-demo-mode'; + if (latest.disabledByCurrentUserLoading) return 'renderer-socket-provider-disabled-current-user-loading'; + if (latest.disabledByCurrentUserAbsent) return 'renderer-socket-provider-disabled-current-user-absent'; + return 'renderer-socket-provider-disabled-unattributed'; + } + if (!latest.desktopRuntime) return 'renderer-desktop-runtime-unavailable'; + if (latest.connectionScope !== 'available') return 'renderer-connection-scope-unavailable'; + if (latest.socketConstructionInvocations === 0) return 'renderer-socket-construction-not-invoked'; + if (latest.socketConstructions === 0) return 'renderer-socket-construction-not-produced'; + if (latest.connectInvocations === 0) return 'renderer-socket-connect-not-invoked'; + if (latest.socketConstructionInvocations !== 1 || latest.socketConstructions !== 1 + || latest.connectInvocations !== 1) return 'renderer-socket-lifecycle-duplicate'; + return 'none'; +}; + +const waitForAuthenticatedSocket = async journey => { + const deadline = Date.now() + 15_000; + while (true) { + const mainAccepted = mainHandshakeRecords.filter(record => record.journey === journey && record.accepted); + const fixtureAccepted = fixtureHandshakeRecords.filter(record => record.journey === journey && record.accepted); + const connected = socketRecords.filter(record => record.journey === journey + && record.event === 'connection' && record.authenticated); + const applicationSubscriptions = socketRecords.filter(record => record.journey === journey + && record.event === QUEUE_STATS_SUBSCRIBE_EVENT && record.authenticated + && record.genuineApplicationSubscription === true && record.direction === 'client-to-fixture'); + const applicationEvents = socketRecords.filter(record => record.journey === journey + && record.event === QUEUE_STATS_UPDATE && record.authenticated + && record.genuineApplicationEvent === true && record.direction === 'fixture-to-renderer'); + const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === journey + && record.text.startsWith('[SocketContext] Received queue stats update:')); + const rendererLifecycle = rendererLifecycleCategory(journey); + if (mainAccepted.length === 1 && fixtureAccepted.length === 1 + && connected.length === 1 && applicationSubscriptions.length === 1 + && applicationEvents.length === 1 && rendererObservedApplicationEvents.length === 1 + && rendererLifecycle === 'none') return; + const category = socketHandshakeFailureCategory(journey); + if (category.startsWith('main-') || category.startsWith('fixture-') || category.startsWith('duplicate-')) { + throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; renderer-lifecycle=${JSON.stringify(rendererLifecycleDiagnosticSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`); + } + if (Date.now() >= deadline) { + throw new Error(`Acceptance Socket.IO handshake failed: ${category}; current-user-phases=${JSON.stringify(currentUserPhaseSummary(journey))}; renderer-lifecycle=${JSON.stringify(rendererLifecycleDiagnosticSummary(journey))}; network-permissions=${JSON.stringify(networkPermissionSummary(journey))}`); + } + await sleep(50); + } +}; + +const observedServiceSummary = () => { + const restRequests = requestRecords.filter(record => record.method !== 'OPTIONS' && record.url.startsWith('/api/')); + const authenticatedRequests = restRequests.filter(record => record.hasAuthorization); + const socketConnections = socketRecords.filter(record => record.event === 'connection' && record.authenticated); + const socketSubscriptions = socketRecords.filter(record => record.event === QUEUE_STATS_SUBSCRIBE_EVENT + && record.authenticated && record.genuineApplicationSubscription === true + && record.direction === 'client-to-fixture'); + const socketEvents = socketRecords.filter(record => record.event === QUEUE_STATS_UPDATE + && record.authenticated && record.genuineApplicationEvent === true + && record.direction === 'fixture-to-renderer'); + const mainHandshakes = mainHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted); + const fixtureHandshakes = fixtureHandshakeRecords.filter(record => record.journey === 'dashboard-profile-manager' && record.accepted); + const mainHandshake = mainHandshakes[0]; + const fixtureHandshake = fixtureHandshakes[0]; + const rendererObservedApplicationEvents = consoleRecords.filter(record => record.journey === 'dashboard-profile-manager' + && record.text.startsWith('[SocketContext] Received queue stats update:')); + const rendererLifecycle = rendererLifecycleRecords + .filter(record => record.journey === 'dashboard-profile-manager').at(-1); + const pairingStarted = requestRecords.filter(record => record.method === 'POST' && record.url === '/api/desktop/pairings'); + const pairingPolled = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`); + const pairingActivated = requestRecords.filter(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`); + const connectConfirmed = requestRecords.filter(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery'); + const currentUserCategory = currentUserValidationFailureCategory('dashboard-profile-manager'); + const currentUserIssued = rendererCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager' + && record.activeScopePresent && record.phase === 'request-issued'); + const currentUserMain = mainCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager'); + const currentUserFixture = fixtureCurrentUserRecords.find(record => record.journey === 'dashboard-profile-manager' + && record.source === 'renderer'); + const services = { + rest: { + requestCount: restRequests.length, + authenticatedRequestCount: authenticatedRequests.length, + journeys: unique(restRequests.map(record => record.journey)), + currentUserValidation: { + correlation: 'current-scope-user-validation', + rendererRequestIssued: Boolean(currentUserIssued), + rendererScopeGeneration: currentUserIssued?.scopeGeneration ?? 0, + mainProxyObserved: Boolean(currentUserMain), + mainActiveScopeGeneration: currentUserMain?.activeScopeGeneration ?? 0, + activeScopeAccepted: currentUserMain?.accepted === true, + mainOnlyBearerInjection: currentUserMain?.rendererBearerPresent === false + && currentUserMain?.rendererCookiePresent === false + && currentUserMain?.bearerMainInjected === true, + upstreamRequestArrived: currentUserFixture?.requestArrived === true, + responseStatus: currentUserFixture?.responseStatus ?? 0, + responseClassification: currentUserFixture?.classification ?? 'not-observed', + parsedUserSchemaAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager' + && record.activeScopePresent && record.phase === 'parsed-user-accepted' && record.schemaAccepted), + rendererActiveScopeAccepted: rendererCurrentUserRecords.some(record => record.journey === 'dashboard-profile-manager' + && record.activeScopePresent && record.phase === 'active-scope-accepted'), + firstFailedPredicate: currentUserCategory, + }, + }, + socketIo: { + authenticatedConnections: socketConnections.length, + events: socketEvents.length, + journeys: unique(socketRecords.map(record => record.journey)), + handshake: { + mainAttempts: mainHandshakes.length, + fixtureAttempts: fixtureHandshakes.length, + scopeQueryPresent: mainHandshake?.scopeQueryPresent === true && fixtureHandshake?.scopeQueryPresent === true, + scopeQueryCount: mainHandshake?.scopeQueryCount ?? 0, + scopeEqualsActivatedBinding: mainHandshake?.scopeEqualsActive === true, + activeBindingPresent: mainHandshake?.activeBindingPresent === true, + profileGenerationCurrent: mainHandshake?.profileGenerationCurrent === true, + originEqualsActivatedBinding: mainHandshake?.originEqualsActive === true, + path: mainHandshake?.path ?? 'other', + transport: mainHandshake?.transport ?? 'other', + resource: mainHandshake?.resource ?? 'other', + rendererBearerPresent: mainHandshake?.rendererBearerPresent ?? false, + rendererCookiePresent: mainHandshake?.rendererCookiePresent ?? false, + authorizationHeaderPresent: fixtureHandshake?.authorizationPresent ?? false, + authorizationHeaderExactlyMainInjected: mainHandshake?.bearerMainInjected === true + && fixtureHandshake?.authorizationMatchesActivatedBearer === true, + rendererObservedApplicationEvent: rendererObservedApplicationEvents.length === 1, + rendererLifecycle: rendererLifecycle ? { + phase: rendererLifecycle.phase, + profileActivationPublished: rendererLifecycle.profileActivationPublished, + socketProviderMounted: rendererLifecycle.socketProviderMounted, + providerDisabled: rendererLifecycle.providerDisabled, + disabledByDemoModeLoading: rendererLifecycle.disabledByDemoModeLoading, + disabledByDemoMode: rendererLifecycle.disabledByDemoMode, + disabledByCurrentUserLoading: rendererLifecycle.disabledByCurrentUserLoading, + disabledByCurrentUserAbsent: rendererLifecycle.disabledByCurrentUserAbsent, + desktopRuntime: rendererLifecycle.desktopRuntime, + connectionScope: rendererLifecycle.connectionScope, + socketConstructionInvocations: rendererLifecycle.socketConstructionInvocations, + socketConstructions: rendererLifecycle.socketConstructions, + connectInvocations: rendererLifecycle.connectInvocations, + } : null, + rejectionCategory: mainHandshake?.rejectionCategory ?? socketHandshakeFailureCategory('dashboard-profile-manager'), + }, + }, + pairing: { started: pairingStarted.length, polled: pairingPolled.length, activated: pairingActivated.length, journeys: unique([...pairingStarted, ...pairingPolled, ...pairingActivated].map(record => record.journey)) }, + connect: { confirmedRequests: connectConfirmed.length, journeys: unique(connectConfirmed.map(record => record.journey)) }, + }; + if (services.rest.requestCount <= 0 || services.rest.authenticatedRequestCount <= 0 + || currentUserCategory !== 'none' + || services.socketIo.authenticatedConnections !== 1 || socketSubscriptions.length !== 1 + || services.socketIo.events !== 1 || rendererObservedApplicationEvents.length !== 1 + || services.socketIo.handshake.mainAttempts !== 1 || services.socketIo.handshake.fixtureAttempts !== 1 + || !services.socketIo.handshake.scopeQueryPresent || services.socketIo.handshake.scopeQueryCount !== 1 + || !services.socketIo.handshake.scopeEqualsActivatedBinding + || !services.socketIo.handshake.activeBindingPresent + || !services.socketIo.handshake.profileGenerationCurrent + || !services.socketIo.handshake.originEqualsActivatedBinding + || services.socketIo.handshake.path !== 'socket-io' + || services.socketIo.handshake.transport !== 'websocket' + || services.socketIo.handshake.resource !== 'websocket' + || services.socketIo.handshake.rendererBearerPresent + || services.socketIo.handshake.rendererCookiePresent + || !services.socketIo.handshake.authorizationHeaderPresent + || !services.socketIo.handshake.authorizationHeaderExactlyMainInjected + || !services.socketIo.handshake.rendererObservedApplicationEvent + || services.socketIo.handshake.rendererLifecycle?.phase !== 'socket-connect-invoked' + || services.socketIo.handshake.rendererLifecycle?.profileActivationPublished !== true + || services.socketIo.handshake.rendererLifecycle?.socketProviderMounted !== true + || services.socketIo.handshake.rendererLifecycle?.providerDisabled !== false + || services.socketIo.handshake.rendererLifecycle?.disabledByDemoModeLoading !== false + || services.socketIo.handshake.rendererLifecycle?.disabledByDemoMode !== false + || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserLoading !== false + || services.socketIo.handshake.rendererLifecycle?.disabledByCurrentUserAbsent !== false + || services.socketIo.handshake.rendererLifecycle?.desktopRuntime !== true + || services.socketIo.handshake.rendererLifecycle?.connectionScope !== 'available' + || services.socketIo.handshake.rendererLifecycle?.socketConstructionInvocations !== 1 + || services.socketIo.handshake.rendererLifecycle?.socketConstructions !== 1 + || services.socketIo.handshake.rendererLifecycle?.connectInvocations !== 1 + || services.socketIo.handshake.rejectionCategory !== 'none' + || services.pairing.started <= 0 || services.pairing.polled <= 0 || services.pairing.activated <= 0 + || services.connect.confirmedRequests <= 0) { + throw new Error(`Acceptance packaged-renderer service journeys were incomplete: ${JSON.stringify(services)}`); + } + return services; +}; + +try { + readyOrigin = await createFixture('ready', FIXED_ACCEPTANCE_ORIGINS.ready); + revokedOrigin = await createFixture('revoked', FIXED_ACCEPTANCE_ORIGINS.revoked); + incompatibleOrigin = await createFixture('incompatible', FIXED_ACCEPTANCE_ORIGINS.incompatible); + await runJourney('first-run-chooser', 'default', null, async page => { + await page.getByRole('heading', { name: 'Let’s set up this computer' }).waitFor(); + const seen = []; + for (let index = 0; index < 3; index += 1) { + await page.keyboard.press('Tab'); + seen.push(await page.evaluate(() => document.activeElement?.textContent?.trim() || document.activeElement?.getAttribute('aria-label'))); + } + keyboardOrder = seen.every(Boolean) && new Set(seen).size === seen.length + && seen[0].includes('Set up this computer') + && seen[1].includes('Connect to an existing instance') + && seen[2].includes('Search for instances on this network'); + visibleFocus = await page.evaluate(() => { + const element = document.activeElement; + if (!(element instanceof HTMLElement)) return false; + const style = getComputedStyle(element); + return style.outlineStyle !== 'none' || style.boxShadow !== 'none'; + }); + }); + await runJourney('manual-endpoint-confirmation', 'default', null, async page => { + await page.getByRole('button', { name: /Connect to an existing instance/ }).click(); + await page.getByLabel('Display name').fill('Design Review'); + await page.getByLabel('Instance URL').fill(readyOrigin); + }); + await runJourney('connect-confirmation', 'default', `propr://connect?api=${encodeURIComponent(readyOrigin)}`, async page => { + await page.getByText('Review this untrusted instance address').waitFor(); + return async () => { + await page.getByRole('button', { name: 'Connect', exact: true }).click(); + await page.getByRole('button', { name: 'Sign in in browser' }).waitFor(); + await waitForObserved(() => requestRecords.some(record => record.journey === 'connect-confirmation' && record.url === '/api/desktop/discovery'), 'the confirmed Connect request'); + }; + }); + await runJourney('remote-pairing', 'default', null, async page => { + await observeLiveMutation(page, 'status', 'remote-pairing', () => fillEndpoint(page, readyOrigin, 'Remote Team')); + await page.getByRole('button', { name: 'Sign in in browser' }).waitFor(); + }); + await runJourney('local-setup-prerequisites', 'default', null, async page => { + await page.getByRole('button', { name: /Set up this computer/ }).click(); + await page.getByRole('heading', { name: 'Check the essentials' }).waitFor(); + }); + await runJourney('local-setup-progress', 'default', null, async page => { + await page.getByRole('button', { name: /Set up this computer/ }).click(); + for (let index = 0; index < 5; index += 1) await page.getByRole('button', { name: /Continue/ }).click(); + await page.getByRole('button', { name: /Install ProPR/ }).click(); + await page.getByRole('heading', { name: 'Setting up ProPR' }).waitFor(); + }); + await runJourney('local-setup-error', 'setup-error', null, async page => { + await page.getByRole('button', { name: /Set up this computer/ }).click(); + await page.getByRole('heading', { name: 'Setup needs attention' }).waitFor(); + }); + await runJourney('local-setup-completion', 'setup-complete', null, async page => { + await page.getByRole('button', { name: /Set up this computer/ }).click(); + await page.getByRole('heading', { name: 'ProPR is ready' }).waitFor(); + }); + await runJourney('dashboard-profile-manager', 'default', null, async (page, application) => { + await pair(page, readyOrigin, 'Operations'); + const opener = page.getByRole('button', { name: /Connected: Operations/ }); + try { + await opener.waitFor({ timeout: 15_000 }); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'TimeoutError') throw error; + await settlePendingRendererConsoleCaptures(); + const journey = 'dashboard-profile-manager'; + const diagnostic = { + schemaVersion: 2, + currentUserPhases: currentUserPhaseSummary(journey), + networkPermissions: networkPermissionSummary(journey), + currentUserCategory: currentUserValidationFailureCategory(journey), + rendererLifecycleCategory: rendererLifecycleCategory(journey), + rendererLifecycle: rendererLifecycleDiagnosticSummary(journey), + surfacePhase: await rendererSurfacePhase(page), + uiState: await rendererUiStateSummary(page), + rendererErrors: rendererErrorCountSummary(journey), + }; + throw new Error(`Acceptance dashboard Connected control timed out: ${JSON.stringify(diagnostic)}`); + } + await waitForObserved(() => { + const journeyRequests = requestRecords.filter(record => record.journey === 'dashboard-profile-manager'); + return journeyRequests.some(record => record.method === 'POST' && record.url === '/api/desktop/pairings') + && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/poll`) + && journeyRequests.some(record => record.method === 'POST' && record.url === `/api/desktop/pairings/${PAIRING_ID}/activate`); + }, `the complete pairing flow for ${PAIRING_ID}`); + await waitForAuthenticatedSocket('dashboard-profile-manager'); + await application.context.tracing.start({ screenshots: true, snapshots: true, sources: false }); + await opener.focus(); + await opener.click(); + const dialog = page.getByRole('dialog', { name: 'Manage instances' }); + await dialog.waitFor(); + const focusables = dialog.locator('button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'); + const focusableCount = await focusables.count(); + if (focusableCount < 2) throw new Error('Acceptance modal has insufficient focus targets'); + await focusables.nth(focusableCount - 1).focus(); + await page.keyboard.press('Tab'); + const wrappedForward = await focusables.first().evaluate(element => element === document.activeElement); + await focusables.first().focus(); + await page.keyboard.press('Shift+Tab'); + const wrappedBackward = await focusables.nth(focusableCount - 1).evaluate(element => element === document.activeElement); + modalFocusTrap = wrappedForward && wrappedBackward; + await page.keyboard.press('Escape'); + modalFocusRestore = await opener.evaluate(element => element === document.activeElement); + await opener.click(); + await application.context.tracing.stop({ path: join(outputDirectory, 'sanitized-trace.zip') }); + traceWritten = true; + }); + await runJourney('offline', 'default', null, async page => { + await observeLiveMutation(page, 'error', 'offline', () => fillEndpoint(page, FIXED_ACCEPTANCE_ORIGINS.offline, 'Offline Lab')); + await page.getByText(/could not discover|could not check|unavailable/i).first().waitFor({ timeout: 15_000 }); + }); + await runJourney('revoked', 'default', null, async page => { + await pair(page, revokedOrigin, 'Revoked Lab'); + await page.getByText(/revoked or expired/i).waitFor({ timeout: 15_000 }); + }); + await runJourney('incompatible-instance', 'default', null, async page => { + await fillEndpoint(page, incompatibleOrigin, 'Future Instance'); + await page.getByText(/Update required/i).waitFor({ timeout: 15_000 }); + }); + + if (!traceWritten) throw new Error('Playwright trace was not produced'); + await Promise.all(pendingConsoleRecords); + if (rendererLifecycleInvalidStates.size > 0) { + throw new Error(`Acceptance renderer lifecycle evidence was invalid: ${JSON.stringify(rendererLifecycleInvalidSummary())}`); + } + if (boundaryJourneys.size !== ACCEPTANCE_JOURNEYS.length + || ACCEPTANCE_JOURNEYS.some(journey => !boundaryJourneys.has(journey))) throw new Error('Packaged main/preload/renderer boundary was not observed for every journey'); + const serious = axeFindings.filter(finding => finding.impact === 'serious').length; + const critical = axeFindings.filter(finding => finding.impact === 'critical').length; + const accessibility = { + schemaVersion: 6, + generatedAt: FIXED_TIME, + serious, + critical, + findings: axeFindings, + checks: accessibilityChecks, + keyboardOrder, + visibleFocus, + modalFocusTrap, + modalFocusRestore, + accessibleNames: accessibilityChecks.every(check => check.accessibleNames), + liveAnnouncements: { status: liveAnnouncements.status, error: liveAnnouncements.error }, + }; + await writeFile(join(outputDirectory, 'accessibility.json'), `${JSON.stringify(accessibility, null, 2)}\n`, { mode: 0o600 }); + + const services = observedServiceSummary(); + const sanitizedSummary = { + schemaVersion: 5, + generatedAt: FIXED_TIME, + status: 'passed', + journeys: ACCEPTANCE_JOURNEYS.length, + screenshots: screenshotMetadata.length, + boundary: { packagedExecutable: true, rendererOrigin: 'propr-app://renderer', preloadBridge: true, journeys: ACCEPTANCE_JOURNEYS }, + console: { records: consoleRecords.length + pageErrorRecords.length, errors: consoleRecords.filter(record => record.type === 'error').length + pageErrorRecords.length }, + services, + redaction: 'Full raw surfaces were scanned; published logs retain only source, level, byte count, and digest.', + }; + await writeFile(join(outputDirectory, 'sanitized-summary.json'), `${JSON.stringify(sanitizedSummary, null, 2)}\n`, { mode: 0o600 }); + + const sanitizedLogRecords = [ + ...consoleRecords.map(record => ({ + journey: record.journey, source: 'renderer-console', level: record.type, + bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)), + })), + ...pageErrorRecords.map(record => ({ + journey: record.journey, source: 'renderer-page-error', level: 'error', + bytes: Buffer.byteLength(JSON.stringify(record)), sha256: digest(JSON.stringify(record)), + })), + ...processRecords, + ]; + await writeFile(join(outputDirectory, 'sanitized-log.json'), `${JSON.stringify({ + schemaVersion: 1, generatedAt: FIXED_TIME, records: sanitizedLogRecords, + }, null, 2)}\n`, { mode: 0o600 }); + + const scanRoot = await mkdtemp(join(tmpdir(), ACCEPTANCE_SURFACES_PREFIX)); + try { + surfaces.push({ kind: 'renderer-console', records: consoleRecords }); + surfaces.push({ kind: 'renderer-page-errors', records: pageErrorRecords }); + surfaces.push({ kind: 'service-urls', records: requestRecords }); + surfaces.push({ kind: 'socket-events', records: socketRecords }); + surfaces.push({ kind: 'socket-main-handshakes', records: mainHandshakeRecords }); + surfaces.push({ kind: 'socket-fixture-handshakes', records: fixtureHandshakeRecords }); + surfaces.push({ kind: 'current-user-renderer-validation', records: rendererCurrentUserRecords }); + surfaces.push({ kind: 'current-user-main-proxy', records: mainCurrentUserRecords }); + surfaces.push({ kind: 'current-user-fixture-responses', records: fixtureCurrentUserRecords }); + surfaces.push({ kind: 'screenshot-metadata', records: screenshotMetadata }); + surfaces.push({ kind: 'accessibility-metadata', value: accessibility }); + await writeFile(join(scanRoot, 'complete-renderer-process-network-and-metadata-surfaces.json'), JSON.stringify(surfaces), { mode: 0o600 }); + await scanAcceptancePaths([scanRoot], SENTINELS); + } finally { + await safeRemoveAcceptanceLeaf(scanRoot, { kind: 'surfaces' }); + } + await writeAcceptanceManifest(outputDirectory, screenshotMetadata); + await verifyAcceptanceArtifacts(outputDirectory, { sentinels: SENTINELS }); + console.log(`Packaged desktop acceptance produced ${screenshotMetadata.length} deterministic screenshots with zero serious/critical findings.`); +} finally { + activeJourney = 'fixture-cleanup'; + for (const fixture of fixtures) { + await new Promise(resolveValue => fixture.io.close(resolveValue)); + if (fixture.server.listening) await new Promise(resolveValue => fixture.server.close(resolveValue)); + } +} diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index b6cebc1bf..cadf74a8b 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -29,6 +29,7 @@ import { collectAcceptedSocketEvidence, evaluatePackagedConnectEvidence, } from './packaged-connect-evidence.mjs'; +import { isExpectedScopedCurrentUserRequest } from './packaged-acceptance-current-user.mjs'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, @@ -58,6 +59,12 @@ let resourcesPath = process.platform === 'darwin' let unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); const endpoint = 'https://t-packaged123.propr.dev'; const identity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const EXPECTED_CURRENT_USER_SCOPE_GENERATION = 1; +const isExpectedCurrentUserRequest = request => isExpectedScopedCurrentUserRequest( + request.method, + request.url, + EXPECTED_CURRENT_USER_SCOPE_GENERATION, +); const secrets = [ 'tunnel-secret-SENTINEL', 'connector-secret-SENTINEL', 'relay-secret-SENTINEL', 'github-secret-SENTINEL', @@ -160,7 +167,7 @@ const createPackagedJourneyFixture = async () => { } if (request.method === 'GET' && request.url === '/__packaged/evidence') { const authenticatedRest = requests.filter(item => item.socketIo === false - && item.url === '/api/auth/user' + && isExpectedCurrentUserRequest(item) && item.authorization === `Bearer ${token}` && item.transportScope === null); const authenticatedSockets = requests.filter(item => item.socketIo === true @@ -263,7 +270,7 @@ const createPackagedJourneyFixture = async () => { response.end(); return; } - if (request.method === 'GET' && request.url === '/api/auth/user' + if (isExpectedCurrentUserRequest(request) && active && record.authorization === `Bearer ${token}`) { response.writeHead(200, cors); response.end(JSON.stringify({ @@ -639,7 +646,7 @@ try { && requests.every((request, index) => request.fixtureMode === intendedPairingModes[index]); const authenticatedRest = applicationRequests.filter(request => request.socketIo === false - && request.url === '/api/auth/user' + && isExpectedCurrentUserRequest(request) && request.authorization === `Bearer ${journeyFixture.secrets[2]}`); const socketEvidence = collectAcceptedSocketEvidence({ requests: applicationRequests, diff --git a/apps/desktop/src/acceptance-test-authorization.test.ts b/apps/desktop/src/acceptance-test-authorization.test.ts new file mode 100644 index 000000000..8050dafec --- /dev/null +++ b/apps/desktop/src/acceptance-test-authorization.test.ts @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; +import { parseDesktopPairingStart, ProprClientError } from '@propr/client'; +import { + PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS, + PACKAGED_ACCEPTANCE_TIME, +} from '../scripts/packaged-acceptance-clock.mjs'; +import { + authorizePackagedAcceptanceTest, + packagedAcceptancePairingTiming, +} from './acceptance-test-authorization'; +import { sanitizeDesktopLogFields } from './logger'; + +const acceptanceUserData = resolve('/tmp/propr-desktop-acceptance-a1'); +const defaultUserData = resolve('/tmp/default'); +const input = { + argv: ['app', '--propr-acceptance-test', `--user-data-dir=${acceptanceUserData}`], + defaultUserDataDirectory: defaultUserData, + environmentTriggered: true, + isPackaged: true, + platform: 'linux' as const, +}; + +describe('packaged acceptance authorization', () => { + it('accepts only the dual-trigger packaged Linux launch with an isolated profile', () => { + assert.equal(authorizePackagedAcceptanceTest(input), acceptanceUserData); + assert.equal(authorizePackagedAcceptanceTest({ ...input, argv: ['app'], environmentTriggered: false }), null); + }); + + it('fails closed for partial triggers, other platforms, and the default profile', () => { + assert.throws(() => authorizePackagedAcceptanceTest({ ...input, environmentTriggered: false }), /both/); + assert.throws(() => authorizePackagedAcceptanceTest({ ...input, platform: 'darwin' }), /Linux/); + assert.throws(() => authorizePackagedAcceptanceTest({ + ...input, + argv: ['app', '--propr-acceptance-test', `--user-data-dir=${defaultUserData}`], + }), /must use/); + assert.throws(() => authorizePackagedAcceptanceTest({ + ...input, + argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(acceptanceUserData, '..', 'default')}`], + }), /must use/); + assert.throws(() => authorizePackagedAcceptanceTest({ + ...input, + argv: ['app', '--propr-acceptance-test', `--user-data-dir=${join(dirname(acceptanceUserData), 'propr-desktop-acceptance-a1', '..', 'escaped')}`], + }), /must use/); + }); + + it('supplies sleep and the shared clock only through the dual-authorized acceptance result', async () => { + const authorizedDirectory = authorizePackagedAcceptanceTest(input); + const timing = packagedAcceptancePairingTiming(authorizedDirectory); + + assert.ok(timing); + assert.deepEqual(Object.keys(timing).sort(), ['now', 'sleep']); + assert.equal(timing.now(), PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS); + assert.equal(new Date(timing.now()).toISOString(), PACKAGED_ACCEPTANCE_TIME); + assert.equal(PACKAGED_ACCEPTANCE_TIME, '2026-01-02T03:04:05.000Z'); + await timing.sleep(60_000); + + for (const mode of ['production', 'packaged smoke', 'ordinary runtime']) { + assert.equal(packagedAcceptancePairingTiming(null), undefined, mode); + } + const untriggered = authorizePackagedAcceptanceTest({ + ...input, + argv: ['app'], + environmentTriggered: false, + }); + assert.equal(packagedAcceptancePairingTiming(untriggered), undefined); + + const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); + assert.match( + main, + /packagedAcceptancePairingTiming\(packagedAcceptanceUserDataDirectory\)/, + ); + assert.match(main, /acceptancePairingTiming \? \{ pairingTiming: acceptancePairingTiming \} : \{\}/); + assert.match(main, /packagedAcceptanceTest \? \{\s*reportWebSocketHandshake:/); + assert.match(main, /desktop\.acceptance\.websocket_handshake/); + assert.match(main, /desktop\.acceptance\.current_user_proxy/); + assert.match(main, /packagedAcceptanceTest \? \{\s*reportNetworkPermissionDecision:/); + assert.match(main, /desktop\.acceptance\.network_permission/); + const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8'); + const currentUserClassifier = readFileSync(fileURLToPath(new URL( + '../scripts/packaged-acceptance-current-user.mjs', import.meta.url, + )), 'utf8'); + assert.match(acceptanceRunner, /'Access-Control-Allow-Private-Network': 'true'/); + assert.match(acceptanceRunner, /network-permissions=/); + assert.match(acceptanceRunner, /request\.url === '\/api\/auth\/demo-mode'\) return json\(response, 200, \{ demoMode: false \}\)/); + const demoModeFixture = acceptanceRunner.indexOf("request.url === '/api/auth/demo-mode'"); + const genericApiFixture = acceptanceRunner.indexOf("request.url?.startsWith('/api/')"); + assert.ok(demoModeFixture >= 0 && demoModeFixture < genericApiFixture); + assert.match(currentUserClassifier, /networkPermissionDecisionSummary/); + assert.match(currentUserClassifier, /current-user-upstream-request-not-arrived/); + assert.match(currentUserClassifier, /current-user-parsed-schema-rejected/); + }); + + it('bounds the dashboard Connected timeout diagnostic after flushing renderer evidence', () => { + const acceptanceRunner = readFileSync(fileURLToPath(new URL('../scripts/run-packaged-acceptance.mjs', import.meta.url)), 'utf8'); + const dashboardStart = acceptanceRunner.indexOf("await runJourney('dashboard-profile-manager'"); + const dashboardEnd = acceptanceRunner.indexOf("await runJourney('offline'", dashboardStart); + assert.notEqual(dashboardStart, -1); + assert.notEqual(dashboardEnd, -1); + const dashboardJourney = acceptanceRunner.slice(dashboardStart, dashboardEnd); + + assert.match(dashboardJourney, /await opener\.waitFor\(\{ timeout: 15_000 \}\)/); + assert.match(dashboardJourney, /error instanceof Error\) \|\| error\.name !== 'TimeoutError'/); + const flush = dashboardJourney.indexOf('await settlePendingRendererConsoleCaptures()'); + const diagnostic = dashboardJourney.indexOf('const diagnostic = {'); + const rethrow = dashboardJourney.indexOf('Acceptance dashboard Connected control timed out:'); + assert.ok(flush >= 0 && flush < diagnostic && diagnostic < rethrow); + const diagnosticCatch = dashboardJourney.slice( + dashboardJourney.indexOf('} catch (error)'), + dashboardJourney.indexOf(' await waitForObserved'), + ); + + for (const boundedField of [ + 'currentUserPhases: currentUserPhaseSummary(journey)', + 'networkPermissions: networkPermissionSummary(journey)', + 'currentUserCategory: currentUserValidationFailureCategory(journey)', + 'rendererLifecycleCategory: rendererLifecycleCategory(journey)', + 'rendererLifecycle: rendererLifecycleDiagnosticSummary(journey)', + 'surfacePhase: await rendererSurfacePhase(page)', + 'uiState: await rendererUiStateSummary(page)', + 'rendererErrors: rendererErrorCountSummary(journey)', + ]) assert.ok(dashboardJourney.includes(boundedField), boundedField); + assert.doesNotMatch(diagnosticCatch, /error\.(?:message|stack)|record\.(?:text|arguments|location|url)/); + + assert.match( + acceptanceRunner, + /const settlePendingRendererConsoleCaptures = async \(\) => \{[\s\S]*?await Promise\.allSettled\(unsettled\);[\s\S]*?\n\};/, + ); + assert.match(acceptanceRunner, /const boundedAcceptanceDiagnosticCount = records => Math\.min\(records\.length, 9\)/); + assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-app'\)\) return 'app'/); + assert.match(acceptanceRunner, /if \(document\.querySelector\('\.desktop-entry'\)\) return 'entry'/); + assert.match(acceptanceRunner, /return 'loading'/); + + const uiSummaryStart = acceptanceRunner.indexOf('const rendererUiStateSummary = async page =>'); + const uiSummaryEnd = acceptanceRunner.indexOf('\nconst rendererSurfacePhase', uiSummaryStart); + assert.notEqual(uiSummaryStart, -1); + assert.notEqual(uiSummaryEnd, -1); + const uiSummary = acceptanceRunner.slice(uiSummaryStart, uiSummaryEnd); + for (const status of ["'ready'", "'offline'", "'incompatible'", "'unknown'", "'absent'"]) { + assert.ok(uiSummary.includes(status), status); + } + for (const labelCategory of [ + "'Connected: Operations'", + "'Offline: Operations'", + "'Update required: Operations'", + "'other'", + ]) assert.ok(uiSummary.includes(labelCategory), labelCategory); + for (const booleanField of [ + 'connectionPillPresent', + 'navigatorOnline', + 'desktopTitleBarPresent', + 'routeLayoutPresent', + 'loadingSpinnerPresent', + 'validatedCurrentUserMarkerPresent', + 'dashboardMarkerPresent', + ]) assert.ok(uiSummary.includes(booleanField), booleanField); + assert.match(uiSummary, /document\.querySelector\('\.desktop-connection-pill'\)/); + assert.match(uiSummary, /document\.querySelector\('\.desktop-titlebar'\)/); + assert.match(uiSummary, /main\.mobile-content-clearance/); + assert.match(uiSummary, /a\[href="\/admin\/members"\]/); + assert.doesNotMatch(uiSummary, /outerHTML|innerHTML|document\.body\.textContent/); + + assert.match(acceptanceRunner, /const rendererLifecycleDiagnosticSummary = journey => \{[\s\S]*?invalidCount:[\s\S]*?firstInvalidCategory:[\s\S]*?invalidCategories:/); + assert.match( + acceptanceRunner, + /recordCount: rendererLifecycleRecords\.filter\(record => record\.journey === journey\)\.length/, + ); + assert.match(acceptanceRunner, /recordRendererLifecycleInvalid\(journey, \[/); + assert.match(acceptanceRunner, /\.\.\.\(invalidCategory \? \[invalidCategory\] : \[\]\)/); + assert.match(acceptanceRunner, /\.\.\.\(overflow \? \['overflow'\] : \[\]\)/); + assert.match(acceptanceRunner, /rendererLifecycleInvalidStates\.has\(journey\)/); + assert.match(acceptanceRunner, /if \(rendererLifecycleInvalidStates\.size > 0\)/); + assert.match(acceptanceRunner, /firstInvalidCategory: firstInvalid\?\.firstCategory \?\? 'none'/); + assert.doesNotMatch(acceptanceRunner, /rendererLifecycleEvidenceInvalid/); + + const errorSummaryStart = acceptanceRunner.indexOf('const rendererConsoleErrorCategory = record =>'); + const errorSummaryEnd = acceptanceRunner.indexOf('\nconst rendererLifecycleDiagnosticSummary', errorSummaryStart); + assert.notEqual(errorSummaryStart, -1); + assert.notEqual(errorSummaryEnd, -1); + const errorSummary = acceptanceRunner.slice(errorSummaryStart, errorSummaryEnd); + for (const category of ['currentUserSync', 'apiLoad', 'socketContext', 'reactRuntime', 'other']) { + assert.ok(errorSummary.includes(category), category); + } + assert.match(errorSummary, /consoleErrorCategoryCounts: rendererConsoleErrorCategoryCounts\(rendererConsoleErrors\)/); + assert.match(errorSummary, /counts\[category\] = Math\.min\(counts\[category\] \+ 1, 9\)/); + assert.doesNotMatch(errorSummary, /arguments|location|stack|url/); + }); + + it('keeps the shared clock subject to exact pairing expiry validation', () => { + const timing = packagedAcceptancePairingTiming(authorizePackagedAcceptanceTest(input)); + assert.ok(timing); + const response = { + pairingId: `dpr_${'P'.repeat(22)}`, + deviceSecret: 'D'.repeat(43), + approvalUrl: 'https://propr.example.test/approve', + interval: 1, + }; + + assert.equal(parseDesktopPairingStart({ + ...response, + expiresAt: new Date(PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS + 60_000).toISOString(), + }, 'https://propr.example.test', timing.now).expiresAt, + '2026-01-02T03:05:05.000Z'); + assert.throws(() => parseDesktopPairingStart({ + ...response, + expiresAt: PACKAGED_ACCEPTANCE_TIME, + }, 'https://propr.example.test', timing.now), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + }); + + it('preserves only fixed secret-free handshake booleans through protected logging', () => { + const evidence = { + schemaVersion: 1, + path: 'socket-io', + transport: 'websocket', + resource: 'websocket', + scopeQueryPresent: true, + scopeQueryCount: 1, + scopeEqualsActive: true, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none', + }; + assert.deepEqual(sanitizeDesktopLogFields('desktop.acceptance.websocket_handshake', evidence), evidence); + assert.equal(JSON.stringify(evidence).includes('http'), false); + assert.equal(JSON.stringify(evidence).includes('Bearer '), false); + }); +}); diff --git a/apps/desktop/src/acceptance-test-authorization.ts b/apps/desktop/src/acceptance-test-authorization.ts new file mode 100644 index 000000000..7ee7c34e0 --- /dev/null +++ b/apps/desktop/src/acceptance-test-authorization.ts @@ -0,0 +1,71 @@ +import { basename, isAbsolute, resolve } from 'node:path'; +import { PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS } from '../scripts/packaged-acceptance-clock.mjs'; + +export const PACKAGED_ACCEPTANCE_USER_DATA_PREFIX = 'propr-desktop-acceptance-'; +export const PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS = Object.freeze([ + 'http://127.0.0.2:41731', + 'http://127.0.0.3:41732', + 'http://127.0.0.4:41733', +] as const); +const ACCEPTANCE_USER_DATA_LEAF = /^propr-desktop-acceptance-[A-Za-z0-9]+$/; + +interface AcceptanceAuthorizationInput { + argv: readonly string[]; + defaultUserDataDirectory: string; + environmentTriggered: boolean; + isPackaged: boolean; + platform: NodeJS.Platform; +} + +interface PackagedAcceptancePairingTiming { + now(): number; + sleep(milliseconds: number, signal?: AbortSignal): Promise; +} + +/** + * Acceptance-only main-process fakes are deliberately unreachable from an + * ordinary application launch. They require a packaged Linux binary, two + * independent triggers, and a fresh, explicitly scoped profile directory. + */ +export const authorizePackagedAcceptanceTest = ({ + argv, + defaultUserDataDirectory, + environmentTriggered, + isPackaged, + platform, +}: AcceptanceAuthorizationInput): string | null => { + const argumentTriggered = argv.includes('--propr-acceptance-test'); + if (!argumentTriggered && !environmentTriggered) return null; + if (!argumentTriggered || !environmentTriggered) { + throw new Error('Packaged desktop acceptance requires both explicit authorization triggers'); + } + if (!isPackaged || platform !== 'linux') { + throw new Error('Packaged desktop acceptance is supported only by the packaged Linux application'); + } + const values = argv + .filter(argument => argument.startsWith('--user-data-dir=')) + .map(argument => argument.slice('--user-data-dir='.length)); + if (values.length !== 1 || !values[0] || !isAbsolute(values[0])) { + throw new Error('Packaged desktop acceptance requires one absolute --user-data-dir'); + } + const requested = resolve(values[0]); + if (requested === resolve(defaultUserDataDirectory) + || !ACCEPTANCE_USER_DATA_LEAF.test(basename(requested))) { + throw new Error(`Packaged desktop acceptance userData must use ${PACKAGED_ACCEPTANCE_USER_DATA_PREFIX}`); + } + return requested; +}; + +/** + * Keep the deterministic protocol clock behind the same authorization result + * as the other packaged-acceptance-only behavior. A null result covers normal + * production, packaged smoke, and ordinary development launches. + */ +export const packagedAcceptancePairingTiming = ( + authorizedUserDataDirectory: string | null, +): PackagedAcceptancePairingTiming | undefined => authorizedUserDataDirectory === null + ? undefined + : { + now: () => PACKAGED_ACCEPTANCE_EPOCH_MILLISECONDS, + sleep: async (_milliseconds, _signal) => undefined, + }; diff --git a/apps/desktop/src/acceptance-zoom.test.ts b/apps/desktop/src/acceptance-zoom.test.ts new file mode 100644 index 000000000..ccd5933c5 --- /dev/null +++ b/apps/desktop/src/acceptance-zoom.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createPackagedAcceptanceZoomBridge, + PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, + PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, + registerPackagedAcceptanceZoomIpc, + type PackagedAcceptancePreloadIpc, +} from './acceptance-zoom'; + +class FakeIpcMain { + readonly listeners = new Map void>(); + readonly handlers = new Map unknown>(); + + on(channel: string, listener: (event: any, request: unknown) => void): void { this.listeners.set(channel, listener); } + removeListener(channel: string, listener: (event: any, request: unknown) => void): void { + if (this.listeners.get(channel) === listener) this.listeners.delete(channel); + } + handle(channel: string, listener: (event: any, factor: unknown) => unknown): void { this.handlers.set(channel, listener); } + removeHandler(channel: string): void { this.handlers.delete(channel); } +} + +const webContents = () => ({ isDestroyed: () => false }); + +describe('packaged acceptance Electron zoom authorization', () => { + it('registers no IPC capability outside the already-authorized acceptance mode', () => { + const ipcMain = new FakeIpcMain(); + const dispose = registerPackagedAcceptanceZoomIpc({ + authorized: false, + ipcMain: ipcMain as never, + webContents: webContents() as never, + }); + assert.equal(ipcMain.listeners.size, 0); + assert.equal(ipcMain.handlers.size, 0); + dispose(); + }); + + it('binds fixed channels, sender, protocol, and the exact finite factor allowlist', async () => { + const ipcMain = new FakeIpcMain(); + const trusted = webContents(); + const untrusted = webContents(); + const dispose = registerPackagedAcceptanceZoomIpc({ + authorized: true, + ipcMain: ipcMain as never, + webContents: trusted as never, + }); + assert.deepEqual([...ipcMain.listeners.keys()], [PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL]); + assert.deepEqual([...ipcMain.handlers.keys()], [PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL]); + + const authorize = ipcMain.listeners.get(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL)!; + const rejected: any = { sender: untrusted, returnValue: undefined }; + authorize(rejected, { protocolVersion: 1 }); + assert.equal(rejected.returnValue, null); + const malformed: any = { sender: trusted, returnValue: undefined }; + authorize(malformed, { protocolVersion: 1, extra: true }); + assert.equal(malformed.returnValue, null); + const admitted: any = { sender: trusted, returnValue: undefined }; + authorize(admitted, { protocolVersion: 1 }); + assert.deepEqual(admitted.returnValue, { authorized: true, protocolVersion: 1 }); + + const apply = ipcMain.handlers.get(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL)!; + assert.deepEqual(await apply({ sender: trusted }, 1), { authorized: true, requestedFactor: 1 }); + assert.deepEqual(await apply({ sender: trusted }, 2), { authorized: true, requestedFactor: 2 }); + for (const rejectedFactor of [0, 1.5, 3, '2', null, { factor: 2 }]) { + assert.throws(() => apply({ sender: trusted }, rejectedFactor), /not allowlisted/); + } + assert.throws(() => apply({ sender: untrusted }, 2), /sender is not authorized/); + + dispose(); + dispose(); + assert.equal(ipcMain.listeners.size, 0); + assert.equal(ipcMain.handlers.size, 0); + }); +}); + +describe('packaged acceptance Electron zoom preload bridge', () => { + it('does not attest or expose a bridge without the exact acceptance environment', () => { + let syncCalls = 0; + const ipc: PackagedAcceptancePreloadIpc = { + sendSync: () => { syncCalls += 1; return { authorized: true, protocolVersion: 1 }; }, + invoke: async () => ({ authorized: true, requestedFactor: 1 }), + }; + const frame = { setZoomFactor() {}, getZoomFactor: () => 1 }; + for (const environmentValue of [undefined, '', '0', 'true', '01', '1 ']) { + assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, environmentValue), null); + } + assert.equal(syncCalls, 0); + }); + + it('fails closed on an absent or malformed main acknowledgement', () => { + const frame = { setZoomFactor() {}, getZoomFactor: () => 1 }; + for (const acknowledgement of [ + undefined, + null, + false, + { authorized: false, protocolVersion: 1 }, + { authorized: true, protocolVersion: 2 }, + { authorized: true, protocolVersion: 1, extra: true }, + ]) { + const ipc: PackagedAcceptancePreloadIpc = { + sendSync: () => acknowledgement, + invoke: async () => undefined, + }; + assert.equal(createPackagedAcceptanceZoomBridge(ipc, frame, '1'), null); + } + }); + + it('accepts exact environment plus main attestation with real preload argv lacking the main switch', async () => { + let current = 2; + const calls: number[] = []; + const ipc: PackagedAcceptancePreloadIpc = { + sendSync: () => ({ authorized: true, protocolVersion: 1 }), + invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }), + }; + const originalArgv = process.argv; + const originalEnvironment = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST; + let bridge: ReturnType; + try { + process.argv = ['/opt/ProPR Desktop/propr-desktop', '--type=renderer']; + process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = '1'; + bridge = createPackagedAcceptanceZoomBridge(ipc, { + setZoomFactor(factor) { calls.push(factor); current = factor; }, + getZoomFactor: () => current, + }); + } finally { + process.argv = originalArgv; + if (originalEnvironment === undefined) delete process.env.PROPR_DESKTOP_ACCEPTANCE_TEST; + else process.env.PROPR_DESKTOP_ACCEPTANCE_TEST = originalEnvironment; + } + assert.ok(bridge); + assert.equal(Object.isFrozen(bridge), true); + assert.deepEqual(Object.keys(bridge), ['setZoomFactor']); + assert.deepEqual(await bridge.setZoomFactor(2), { + requestedFactor: 2, resetFactor: 1, appliedFactor: 2, mechanism: 'electron-web-frame', + }); + assert.deepEqual(calls, [1, 2]); + await assert.rejects(bridge.setZoomFactor(1.5 as never), /not allowlisted/); + assert.deepEqual(calls, [1, 2]); + + const malformedApply = createPackagedAcceptanceZoomBridge({ + sendSync: () => ({ authorized: true, protocolVersion: 1 }), + invoke: async () => ({ authorized: true, requestedFactor: 2, extra: true }), + }, { setZoomFactor() {}, getZoomFactor: () => 1 }, '1'); + assert.ok(malformedApply); + await assert.rejects(malformedApply.setZoomFactor(2), /acknowledgement is malformed/); + }); + + it('fails closed when reset or application reads back the wrong real webFrame factor', async () => { + const ipc: PackagedAcceptancePreloadIpc = { + sendSync: () => ({ authorized: true, protocolVersion: 1 }), + invoke: async (_channel, factor) => ({ authorized: true, requestedFactor: factor }), + }; + const resetFailure = createPackagedAcceptanceZoomBridge(ipc, { + setZoomFactor() {}, getZoomFactor: () => 2, + }, '1'); + assert.ok(resetFailure); + await assert.rejects(resetFailure.setZoomFactor(2), /zoom reset failed/); + + let reads = 0; + const applyFailure = createPackagedAcceptanceZoomBridge(ipc, { + setZoomFactor() {}, getZoomFactor: () => (++reads === 1 ? 1 : 1), + }, '1'); + assert.ok(applyFailure); + await assert.rejects(applyFailure.setZoomFactor(2), /zoom application failed/); + }); +}); + diff --git a/apps/desktop/src/acceptance-zoom.ts b/apps/desktop/src/acceptance-zoom.ts new file mode 100644 index 000000000..ee0a581ce --- /dev/null +++ b/apps/desktop/src/acceptance-zoom.ts @@ -0,0 +1,145 @@ +import type { IpcMainInvokeEvent, IpcMainEvent, WebContents } from 'electron'; + +export const PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL = 'desktop:packaged-acceptance-zoom-authorize'; +export const PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL = 'desktop:packaged-acceptance-zoom-apply'; +export const PACKAGED_ACCEPTANCE_ZOOM_MECHANISM = 'electron-web-frame'; +export const PACKAGED_ACCEPTANCE_ZOOM_FACTORS = Object.freeze([1, 2] as const); + +export type PackagedAcceptanceZoomFactor = typeof PACKAGED_ACCEPTANCE_ZOOM_FACTORS[number]; + +type ZoomAuthorizationRequest = { protocolVersion: 1 }; +type ZoomAuthorizationAcknowledgement = { authorized: true; protocolVersion: 1 }; +type ZoomApplyAcknowledgement = { authorized: true; requestedFactor: PackagedAcceptanceZoomFactor }; + +type IpcMainLike = { + on(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void; + removeListener(channel: string, listener: (event: IpcMainEvent, request: unknown) => void): void; + handle(channel: string, listener: (event: IpcMainInvokeEvent, factor: unknown) => unknown): void; + removeHandler(channel: string): void; +}; + +export type PackagedAcceptancePreloadIpc = { + sendSync(channel: string, request: unknown): unknown; + invoke(channel: string, factor: unknown): Promise; +}; + +export type PackagedAcceptanceWebFrame = { + setZoomFactor(factor: number): void; + getZoomFactor(): number; +}; + +export type PackagedAcceptanceZoomResult = { + requestedFactor: PackagedAcceptanceZoomFactor; + resetFactor: 1; + appliedFactor: PackagedAcceptanceZoomFactor; + mechanism: typeof PACKAGED_ACCEPTANCE_ZOOM_MECHANISM; +}; + +const exactKeys = (value: object, expected: string[]): boolean => ( + Object.keys(value).sort().join('\n') === [...expected].sort().join('\n') +); + +const isAuthorizationRequest = (value: unknown): value is ZoomAuthorizationRequest => ( + typeof value === 'object' && value !== null && !Array.isArray(value) + && exactKeys(value, ['protocolVersion']) + && (value as ZoomAuthorizationRequest).protocolVersion === 1 +); + +const isAuthorizationAcknowledgement = (value: unknown): value is ZoomAuthorizationAcknowledgement => ( + typeof value === 'object' && value !== null && !Array.isArray(value) + && exactKeys(value, ['authorized', 'protocolVersion']) + && (value as ZoomAuthorizationAcknowledgement).authorized === true + && (value as ZoomAuthorizationAcknowledgement).protocolVersion === 1 +); + +const isApplyAcknowledgement = ( + value: unknown, + requestedFactor: PackagedAcceptanceZoomFactor, +): value is ZoomApplyAcknowledgement => ( + typeof value === 'object' && value !== null && !Array.isArray(value) + && exactKeys(value, ['authorized', 'requestedFactor']) + && (value as ZoomApplyAcknowledgement).authorized === true + && (value as ZoomApplyAcknowledgement).requestedFactor === requestedFactor +); + +export const isPackagedAcceptanceZoomFactor = (value: unknown): value is PackagedAcceptanceZoomFactor => ( + value === 1 || value === 2 +); + +const preloadHasAcceptanceEnvironment = (environmentValue: string | undefined): boolean => environmentValue === '1'; + +/** + * Register the two fixed authorization routes only after the main process has + * completed packaged-acceptance authorization, and bind them to one renderer. + */ +export const registerPackagedAcceptanceZoomIpc = ({ + authorized, + ipcMain, + webContents, +}: { + authorized: boolean; + ipcMain: IpcMainLike; + webContents: WebContents; +}): (() => void) => { + if (!authorized) return () => undefined; + + const trustedSender = (sender: WebContents): boolean => sender === webContents && !webContents.isDestroyed(); + const authorize = (event: IpcMainEvent, request: unknown): void => { + if (!trustedSender(event.sender) || !isAuthorizationRequest(request)) { + event.returnValue = null; + return; + } + event.returnValue = { authorized: true, protocolVersion: 1 } satisfies ZoomAuthorizationAcknowledgement; + }; + ipcMain.on(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize); + ipcMain.handle(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, (event, factor) => { + if (!trustedSender(event.sender)) throw new Error('Packaged acceptance zoom sender is not authorized'); + if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted'); + return { authorized: true, requestedFactor: factor } satisfies ZoomApplyAcknowledgement; + }); + + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + ipcMain.removeListener(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, authorize); + ipcMain.removeHandler(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL); + }; +}; + +/** Build the only acceptance-only renderer capability after a synchronous main-process attestation. */ +export const createPackagedAcceptanceZoomBridge = ( + ipc: PackagedAcceptancePreloadIpc, + frame: PackagedAcceptanceWebFrame, + environmentValue: string | undefined = process.env.PROPR_DESKTOP_ACCEPTANCE_TEST, +): Readonly<{ setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise }> | null => { + if (!preloadHasAcceptanceEnvironment(environmentValue)) return null; + const authorization = ipc.sendSync(PACKAGED_ACCEPTANCE_ZOOM_AUTH_CHANNEL, { protocolVersion: 1 }); + if (!isAuthorizationAcknowledgement(authorization)) return null; + + return Object.freeze({ + async setZoomFactor(factor: PackagedAcceptanceZoomFactor): Promise { + if (!isPackagedAcceptanceZoomFactor(factor)) throw new Error('Packaged acceptance zoom factor is not allowlisted'); + const acknowledgement = await ipc.invoke(PACKAGED_ACCEPTANCE_ZOOM_APPLY_CHANNEL, factor); + if (!isApplyAcknowledgement(acknowledgement, factor)) { + throw new Error('Packaged acceptance zoom authorization acknowledgement is malformed'); + } + + frame.setZoomFactor(1); + const resetFactor = frame.getZoomFactor(); + if (resetFactor !== 1) throw new Error(`Packaged acceptance zoom reset failed: received ${resetFactor}`); + frame.setZoomFactor(factor); + const appliedFactor = frame.getZoomFactor(); + if (appliedFactor !== factor) { + throw new Error(`Packaged acceptance zoom application failed: expected ${factor}, received ${appliedFactor}`); + } + return { + requestedFactor: factor, + resetFactor: 1, + appliedFactor: factor, + mechanism: PACKAGED_ACCEPTANCE_ZOOM_MECHANISM, + }; + }, + }); +}; + diff --git a/apps/desktop/src/application-menu.test.ts b/apps/desktop/src/application-menu.test.ts new file mode 100644 index 000000000..9bc1e1983 --- /dev/null +++ b/apps/desktop/src/application-menu.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { configureApplicationMenu } from './application-menu'; + +describe('desktop application menu', () => { + it('removes Electron\'s stock menu from packaged Linux builds', () => { + const configuredMenus: null[] = []; + + configureApplicationMenu({ + setApplicationMenu(menu) { + configuredMenus.push(menu); + }, + }, true, 'linux'); + + assert.deepEqual(configuredMenus, [null]); + }); + + it('retains the development menu and native menus on other platforms', () => { + for (const scenario of [ + { isPackaged: false, platform: 'linux' as const }, + { isPackaged: true, platform: 'darwin' as const }, + { isPackaged: true, platform: 'win32' as const }, + ]) { + let configured = false; + configureApplicationMenu({ + setApplicationMenu() { + configured = true; + }, + }, scenario.isPackaged, scenario.platform); + assert.equal(configured, false, JSON.stringify(scenario)); + } + }); +}); + diff --git a/apps/desktop/src/application-menu.ts b/apps/desktop/src/application-menu.ts new file mode 100644 index 000000000..8ddf90d35 --- /dev/null +++ b/apps/desktop/src/application-menu.ts @@ -0,0 +1,14 @@ +type ApplicationMenuController = { + setApplicationMenu: (menu: null) => void; +}; + +export const configureApplicationMenu = ( + menu: ApplicationMenuController, + isPackaged: boolean, + platform: NodeJS.Platform = process.platform, +): void => { + if (isPackaged && platform === 'linux') { + menu.setApplicationMenu(null); + } +}; + diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index 9e6aa97d2..03d0b5059 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -53,10 +53,65 @@ export interface CredentialServiceDependencies { code: 'network' | 'http' | 'local-cleanup'; status?: number; }): void; + /** Fixed, bounded, secret-free evidence for packaged acceptance. */ + reportWebSocketHandshake?(evidence: DesktopWebSocketHandshakeEvidence): void; + /** Fixed, bounded, secret-free evidence for scoped renderer user validation. */ + reportCurrentUserValidation?(evidence: DesktopCurrentUserProxyEvidence): void; /** Main-owned Connect evidence; renderer input can never provide this snapshot. */ snapshotConnectIdentityClaim?(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot; } +export type DesktopWebSocketRejectionCategory = + | 'none' | 'untrusted-http-origin' | 'wrong-path' | 'wrong-transport' + | 'wrong-resource-type' | 'scope-missing' | 'scope-duplicate' + | 'scope-malformed' | 'no-active-binding' | 'stale-generation' + | 'wrong-origin' | 'stale-scope'; + +export interface DesktopWebSocketHandshakeEvidence { + schemaVersion: 1; + path: 'socket-io' | 'other'; + transport: 'websocket' | 'other'; + resource: 'websocket' | 'other'; + scopeQueryPresent: boolean; + scopeQueryCount: number; + scopeEqualsActive: boolean; + activeBindingPresent: boolean; + profileGenerationCurrent: boolean; + originEqualsActive: boolean; + rendererBearerPresent: boolean; + rendererCookiePresent: boolean; + outboundBearerPresent: boolean; + bearerMainInjected: boolean; + accepted: boolean; + rejectionCategory: DesktopWebSocketRejectionCategory; +} + +export type DesktopCurrentUserProxyRejectionCategory = + | 'none' | 'scope-missing' | 'scope-duplicate' | 'scope-malformed' + | 'no-active-binding' | 'stale-generation' | 'wrong-origin' | 'stale-scope'; + +export interface DesktopCurrentUserProxyEvidence { + schemaVersion: 2; + correlation: 'current-scope-user-validation'; + requestObserved: true; + method: 'get'; + rendererScopeGeneration: number | null; + scopeGenerationQueryCount: 0 | 1 | 2; + scopeGenerationQueryValid: boolean; + scopeHeaderCount: 0 | 1 | 2; + activeBindingPresent: boolean; + activeScopeGeneration: number; + profileGenerationCurrent: boolean; + scopeEqualsActive: boolean; + originEqualsActive: boolean; + rendererBearerPresent: boolean; + rendererCookiePresent: boolean; + outboundBearerPresent: boolean; + bearerMainInjected: boolean; + accepted: boolean; + rejectionCategory: DesktopCurrentUserProxyRejectionCategory; +} + export interface DesktopPairingBrowserRequest { apiBaseUrl: string; pairingId: string; @@ -179,6 +234,24 @@ const requestOrigin = (value: string): { origin: string; pathname: string; url: } }; +const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration'; +const currentUserScopeGeneration = (url: URL): { + count: 0 | 1 | 2; + generation: number | null; + valid: boolean; +} => { + const values = url.searchParams.getAll(CURRENT_USER_SCOPE_GENERATION_QUERY); + const count = Math.min(values.length, 2) as 0 | 1 | 2; + if (values.length !== 1 || !/^(?:0|[1-9]\d{0,15})$/.test(values[0])) { + return { count, generation: null, valid: false }; + } + const generation = Number(values[0]); + const exact = url.search === `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${values[0]}`; + return Number.isSafeInteger(generation) && exact + ? { count, generation, valid: true } + : { count, generation: null, valid: false }; +}; + const parseCode = async (response: Response): Promise => { try { const value = await response.clone().json() as { code?: unknown }; @@ -334,6 +407,8 @@ export class DesktopCredentialService { readonly #pairingTiming: Pick; readonly #pairingProtocol: PairingProtocolRequestOptions; readonly #reportRevocationFailure: NonNullable; + readonly #reportWebSocketHandshake: NonNullable; + readonly #reportCurrentUserValidation: NonNullable; readonly #revocationDeadlines: RevocationDeadlines; readonly #snapshotConnectIdentityClaim: NonNullable; readonly #internalRequestKey = randomBytes(32).toString('base64url'); @@ -363,6 +438,8 @@ export class DesktopCredentialService { this.#pairingTiming = dependencies.pairingTiming ?? {}; this.#pairingProtocol = dependencies.pairingProtocol ?? {}; this.#reportRevocationFailure = dependencies.reportRevocationFailure ?? (() => undefined); + this.#reportWebSocketHandshake = dependencies.reportWebSocketHandshake ?? (() => undefined); + this.#reportCurrentUserValidation = dependencies.reportCurrentUserValidation ?? (() => undefined); this.#revocationDeadlines = boundedRevocationDeadlines(dependencies.revocationDeadlines); this.#snapshotConnectIdentityClaim = dependencies.snapshotConnectIdentityClaim ?? (() => ({ status: 'unclaimed', @@ -1068,10 +1145,8 @@ export class DesktopCredentialService { ): DesktopRequestDecision { if (this.#closed) return { cancel: true }; const headers = { ...originalHeaders }; - if (/^(?:https?|wss?):/i.test(url)) { - const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'); - if (!canonicalProprHttpUrlOrigin(httpUrl)) return { cancel: true }; - } + const canonicalTarget = !/^(?:https?|wss?):/i.test(url) + || canonicalProprHttpUrlOrigin(url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')) !== null; const internalHeader = headerName(headers, 'x-propr-desktop-main-request'); const trustedMainRequest = internalHeader !== undefined && headers[internalHeader] === this.#internalRequestKey; @@ -1080,44 +1155,130 @@ export class DesktopCredentialService { const scopeValues = headerValues(headers, DESKTOP_TRANSPORT_SCOPE_HEADER); removeHeader(headers, DESKTOP_TRANSPORT_SCOPE_HEADER); + const target = requestOrigin(url); + const rendererAuthorizationPresent = !trustedMainRequest + && headerValues(originalHeaders, 'authorization').length > 0; + const rendererCookiePresent = !trustedMainRequest + && headerValues(originalHeaders, 'cookie').length > 0; + const path = target?.pathname === '/socket.io/' ? 'socket-io' : 'other'; + const transportValues = target?.url.searchParams.getAll('transport') ?? []; + const transport = transportValues.length === 1 && transportValues[0] === 'websocket' + ? 'websocket' : 'other'; + const resource = details.resourceType?.toLowerCase() === 'websocket' + || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket') + ? 'websocket' : 'other'; + const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? []; + const active = this.#active; + const activeIsCurrent = active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + const scopeEqualsActive = socketScopeValues.length === 1 + && active !== null && socketScopeValues[0] === active.transportScope; + const isSocketCandidate = path === 'socket-io' || socketScopeValues.length > 0; + const isCurrentUserRequest = !trustedMainRequest + && target?.pathname === '/api/auth/user' + && (details.method ?? 'GET').toUpperCase() === 'GET'; + const rendererCurrentUserGeneration = target && isCurrentUserRequest + ? currentUserScopeGeneration(target.url) + : { count: 0 as const, generation: null, valid: false }; + const reportHandshake = ( + accepted: boolean, + rejectionCategory: DesktopWebSocketRejectionCategory, + bearerMainInjected = false, + ): void => { + if (!isSocketCandidate || trustedMainRequest) return; + try { + this.#reportWebSocketHandshake({ + schemaVersion: 1, path, transport, resource, + scopeQueryPresent: socketScopeValues.length > 0, + scopeQueryCount: socketScopeValues.length, + scopeEqualsActive, + activeBindingPresent: active !== null, + profileGenerationCurrent: activeIsCurrent, + originEqualsActive: target !== null && active !== null && target.origin === active.origin, + rendererBearerPresent: rendererAuthorizationPresent, + rendererCookiePresent, + outboundBearerPresent: headerValues(headers, 'authorization').length > 0, + bearerMainInjected, + accepted, + rejectionCategory, + }); + } catch { + // Acceptance diagnostics cannot alter the authorization decision. + } + }; + const reportCurrentUser = ( + accepted: boolean, + rejectionCategory: DesktopCurrentUserProxyRejectionCategory, + bearerMainInjected = false, + ): void => { + if (!isCurrentUserRequest) return; + try { + this.#reportCurrentUserValidation({ + schemaVersion: 2, + correlation: 'current-scope-user-validation', + requestObserved: true, + method: 'get', + rendererScopeGeneration: rendererCurrentUserGeneration.generation, + scopeGenerationQueryCount: rendererCurrentUserGeneration.count, + scopeGenerationQueryValid: rendererCurrentUserGeneration.valid, + scopeHeaderCount: Math.min(scopeValues.length, 2) as 0 | 1 | 2, + activeBindingPresent: active !== null, + activeScopeGeneration: active?.profileGeneration ?? 0, + profileGenerationCurrent: activeIsCurrent, + scopeEqualsActive: scopeValues.length === 1 && active !== null + && scopeValues[0] === active.transportScope, + originEqualsActive: target !== null && active !== null && target.origin === active.origin, + rendererBearerPresent: rendererAuthorizationPresent, + rendererCookiePresent, + outboundBearerPresent: headerValues(headers, 'authorization').length > 0, + bearerMainInjected, + accepted, + rejectionCategory, + }); + } catch { + // Acceptance diagnostics cannot alter the authorization decision. + } + }; + // The packaged renderer has no cookie identity on any remote HTTP(S) or // WS(S) origin. It also cannot supply its own bearer. Main-process bearer // requests are distinguished by the per-process secret marker above. removeHeader(headers, 'cookie'); if (!trustedMainRequest) removeHeader(headers, 'authorization'); - const target = requestOrigin(url); + if (!canonicalTarget) { + reportHandshake(false, 'untrusted-http-origin'); + return { cancel: true }; + } if (target && target.url.protocol === 'http:' && !normalizeApiBaseUrl(target.origin)) { + reportHandshake(false, 'untrusted-http-origin'); return { cancel: true }; } if (trustedMainRequest) return { requestHeaders: headers }; const markedRestRequest = scopeValues.length > 0; if (markedRestRequest && (scopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(scopeValues[0]))) { + reportCurrentUser(false, scopeValues.length !== 1 ? 'scope-duplicate' : 'scope-malformed'); return { cancel: true }; } if (!trustedMainRequest && target && (target.pathname.startsWith('/api/desktop/pairings') || target.pathname.startsWith('/api/desktop/tokens'))) return { cancel: true }; - const active = this.#active; - const activeIsCurrent = active !== null - && this.#generation(active.profileId) === active.profileGeneration - && this.#selectionGeneration === active.selectionGeneration - && active.connectClaim.isCurrent(); const isApiRequest = target?.pathname.startsWith('/api/') === true; - const socketScopeValues = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? []; - const isSocketCandidate = target?.pathname === '/socket.io/' || socketScopeValues.length > 0; - const isSocketUpgrade = target?.pathname === '/socket.io/' - && target.url.searchParams.get('transport') === 'websocket' - && (details.resourceType === 'webSocket' - || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); + const isSocketUpgrade = path === 'socket-io' && transport === 'websocket' && resource === 'websocket'; // Session security supplies this ownership bit at the actual WebContents // boundary. A foreign renderer may load only unmarked credentialless // resources; it can never exercise a REST/Socket scope or receive a bearer. if (details.rendererOwned === false) { - if (markedRestRequest || isSocketCandidate) return { cancel: true }; + if (markedRestRequest || isSocketCandidate) { + reportHandshake(false, 'stale-generation'); + reportCurrentUser(false, 'stale-generation'); + return { cancel: true }; + } return { requestHeaders: headers }; } @@ -1125,21 +1286,47 @@ export class DesktopCredentialService { // The live main renderer must therefore remain pinned to the exact current // origin even for sanitized traffic that does not carry a transport scope. if (details.rendererOwned === true && target - && (!activeIsCurrent || target.origin !== active.origin)) return { cancel: true }; + && (!activeIsCurrent || target.origin !== active.origin)) { + reportHandshake(false, !active ? 'no-active-binding' : !activeIsCurrent ? 'stale-generation' : 'wrong-origin'); + reportCurrentUser(false, !active ? 'no-active-binding' : !activeIsCurrent ? 'stale-generation' : 'wrong-origin'); + return { cancel: true }; + } + + if (isSocketCandidate && !isSocketUpgrade) { + reportHandshake(false, path !== 'socket-io' + ? 'wrong-path' : transport !== 'websocket' ? 'wrong-transport' : 'wrong-resource-type'); + return { cancel: true }; + } if (isSocketUpgrade && target) { - if (socketScopeValues.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0]) - || !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin - || socketScopeValues[0] !== active.transportScope) return { cancel: true }; + const rejection = socketScopeValues.length === 0 ? 'scope-missing' + : socketScopeValues.length !== 1 ? 'scope-duplicate' + : !TRANSPORT_SCOPE_PATTERN.test(socketScopeValues[0]) ? 'scope-malformed' + : !active ? 'no-active-binding' + : !activeIsCurrent || active !== verifiedSocketCredential ? 'stale-generation' + : target.origin !== active.origin ? 'wrong-origin' + : socketScopeValues[0] !== active.transportScope ? 'stale-scope' : null; + if (rejection) { + reportHandshake(false, rejection); + return { cancel: true }; + } + if (!active) return { cancel: true }; headers.Authorization = `Bearer ${active.token}`; + reportHandshake(true, 'none', true); return { requestHeaders: headers }; } if (!markedRestRequest) return { requestHeaders: headers }; - if (!target || !isApiRequest || !activeIsCurrent || target.origin !== active.origin - || scopeValues[0] !== active.transportScope) return { cancel: true }; + if (!target || !isApiRequest || !active || !activeIsCurrent || target.origin !== active.origin + || scopeValues[0] !== active.transportScope) { + reportCurrentUser(false, !active ? 'no-active-binding' + : !activeIsCurrent ? 'stale-generation' + : target?.origin !== active.origin ? 'wrong-origin' : 'stale-scope'); + return { cancel: true }; + } if (details.method?.toUpperCase() === 'OPTIONS') return { requestHeaders: headers }; headers.Authorization = `Bearer ${active.token}`; + reportCurrentUser(true, 'none', true); return { requestHeaders: headers }; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4af1e56d1..4c9a8e006 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { lstatSync, realpathSync } from 'node:fs'; import { basename, isAbsolute, join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { app, BrowserWindow, crashReporter, dialog, ipcMain, net, protocol, safeStorage, screen, session, shell } from 'electron'; +import { app, BrowserWindow, crashReporter, dialog, ipcMain, Menu, net, protocol, safeStorage, screen, session, shell } from 'electron'; import type { Rectangle } from 'electron'; import { DESKTOP_RENDERER_ORIGIN, @@ -18,10 +18,22 @@ import { createDesktopSetupHost } from '@propr/cli/desktop-local-setup'; import type { SetupActions } from '@propr/local-setup'; import { launchDesktopAuthentication } from './authentication-handoff'; import { DesktopConnectDiscoveryService } from './connect-discovery'; +import { configureApplicationMenu } from './application-menu'; +import { + authorizePackagedAcceptanceTest, + packagedAcceptancePairingTiming, + PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS, +} from './acceptance-test-authorization'; +import { registerPackagedAcceptanceZoomIpc } from './acceptance-zoom'; import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery'; import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { clearDesktopInstanceCookies } from './desktop-session'; -import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; +import { + DesktopCredentialService, + type DesktopCurrentUserProxyEvidence, + type DesktopPairingBrowserRequest, + type DesktopWebSocketHandshakeEvidence, +} from './credential-service'; import { registerIpcHandlers, type DesktopAcceptanceOperation, @@ -130,9 +142,17 @@ let packagedConnectJourneyDiagnosticState: PackagedConnectJourneyDiagnosticState const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; let packagedSmokeUserDataDirectory: string | null = null; +let packagedAcceptanceUserDataDirectory: string | null = null; let packagedSmokeEvidence: ReturnType = null; let nativeSmokePhase: NativeSmokePhase | undefined; try { + packagedAcceptanceUserDataDirectory = authorizePackagedAcceptanceTest({ + argv: process.argv, + defaultUserDataDirectory: join(app.getPath('appData'), app.name), + environmentTriggered: process.env.PROPR_DESKTOP_ACCEPTANCE_TEST === '1', + isPackaged: app.isPackaged, + platform: process.platform, + }); packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({ argv: process.argv, defaultUserDataDirectory: join(app.getPath('appData'), app.name), @@ -162,10 +182,20 @@ try { packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } + if (packagedAcceptanceUserDataDirectory) { + if (packagedSmokeUserDataDirectory) throw new Error('Desktop test modes are mutually exclusive'); + const acceptanceDirectoryStats = lstatSync(packagedAcceptanceUserDataDirectory); + if (!acceptanceDirectoryStats.isDirectory() || acceptanceDirectoryStats.isSymbolicLink()) { + throw new Error('Packaged desktop acceptance --user-data-dir must be an existing non-link directory'); + } + app.setPath('userData', packagedAcceptanceUserDataDirectory); + } } catch { process.exit(1); } const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; +const packagedAcceptanceTest = packagedAcceptanceUserDataDirectory !== null; +const acceptancePairingTiming = packagedAcceptancePairingTiming(packagedAcceptanceUserDataDirectory); let mainWindow: BrowserWindow | null = null; let nativeSmokeWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); @@ -1191,6 +1221,12 @@ const createMainWindow = async ( const window = new BrowserWindow( createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged, workArea), ); + const disposeAcceptanceZoomIpc = registerPackagedAcceptanceZoomIpc({ + authorized: packagedAcceptanceTest, + ipcMain, + webContents: window.webContents, + }); + window.once('closed', disposeAcceptanceZoomIpc); const readyToShow = new Promise(resolveReady => window.once('ready-to-show', resolveReady)); window.webContents.setWindowOpenHandler(({ url }) => { @@ -1391,6 +1427,7 @@ if (!hasSingleInstanceLock) { registerProtocolClient(); void app.whenReady().then(async () => { + configureApplicationMenu(Menu, app.isPackaged); logger = createDesktopLogger( join(app.getPath('logs'), 'desktop.jsonl'), () => packagedSmokeEvidence?.write('desktop.log.write_failed'), @@ -1417,7 +1454,9 @@ if (!hasSingleInstanceLock) { const smokeProfileOrigin = packagedSmokeTest ? normalizeApiBaseUrl(process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL ?? '') : null; - let rendererPolicyOrigins: readonly string[] = transportSmoke + let rendererPolicyOrigins: readonly string[] = packagedAcceptanceTest + ? PACKAGED_ACCEPTANCE_LOOPBACK_ORIGINS + : transportSmoke ? [transportSmoke.firstOrigin, transportSmoke.secondOrigin] : connectSmoke?.journeyEndpoint ? [connectSmoke.journeyEndpoint] @@ -1501,13 +1540,24 @@ if (!hasSingleInstanceLock) { fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, openPairingBrowser: packagedJourneyApprovals ? packagedJourneyApprovals.open - : request => openApprovedDesktopPairingUrl(request, shell), + : packagedAcceptanceTest + ? async () => undefined + : request => openApprovedDesktopPairingUrl(request, shell), clientName: `ProPR Desktop (${process.platform})`, reportRevocationFailure: diagnostic => { log('warn', 'desktop.credential_revocation.retry_pending', diagnostic); }, + ...(packagedAcceptanceTest ? { + reportWebSocketHandshake: (evidence: DesktopWebSocketHandshakeEvidence) => { + log('info', 'desktop.acceptance.websocket_handshake', { ...evidence }); + }, + reportCurrentUserValidation: (evidence: DesktopCurrentUserProxyEvidence) => { + log('info', 'desktop.acceptance.current_user_proxy', { ...evidence }); + }, + } : {}), snapshotConnectIdentityClaim: (profileId, origin) => connectDiscovery.snapshotIdentityClaim(profileId, origin), + ...(acceptancePairingTiming ? { pairingTiming: acceptancePairingTiming } : {}), }); const sessionSecurity = configureDesktopSessionSecurity({ contentSecurityPolicy, @@ -1516,7 +1566,14 @@ if (!hasSingleInstanceLock) { enableRendererNetworkBoundary: process.platform !== 'win32', getMainRenderer: () => mainWindow?.webContents ?? null, isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl), - ...(connectSmoke?.journeyEndpoint ? { + ...(packagedAcceptanceTest ? { + reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => { + log('info', 'desktop.acceptance.network_permission', { ...evidence }); + }, + reportRendererOwnershipDecision: (evidence: DesktopRendererOwnershipEvidence) => { + log('info', 'desktop.acceptance.renderer_ownership', { ...evidence }); + }, + } : connectSmoke?.journeyEndpoint ? { reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => { log('info', 'desktop.renderer.connect_network_permission', { ...evidence }); }, @@ -1558,10 +1615,12 @@ if (!hasSingleInstanceLock) { } } if (app.isPackaged && !rendererPolicyPinnedForSmoke) { - const current = await credentials.listProfiles(); - const activeOrigin = current.profiles - .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl; - rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : []; + if (!packagedAcceptanceTest) { + const current = await credentials.listProfiles(); + const activeOrigin = current.profiles + .find(profile => profile.id === current.activeProfileId)?.apiBaseUrl; + rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : []; + } } const lifecycle = new LocalLifecycleController(); nativeProfiles = profiles; @@ -1607,6 +1666,7 @@ if (!hasSingleInstanceLock) { deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement), ...(app.isPackaged && !rendererPolicyPinnedForSmoke ? { onRendererActiveProfileChanged: (origin: string | null) => { + if (packagedAcceptanceTest) return; const nextOrigins = origin?.startsWith('http://') ? [origin] : []; if (rendererPolicyOrigins.length === nextOrigins.length && rendererPolicyOrigins.every((value, index) => value === nextOrigins[index])) return; @@ -1692,7 +1752,7 @@ if (!hasSingleInstanceLock) { windowsSignerPins: __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__, } : undefined; - if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest) { + if (app.isPackaged && process.platform !== 'win32' && updateConfig && !packagedSmokeTest && !packagedAcceptanceTest) { const runUpdateCheck = () => { void checkForSignedUpdates({ config: updateConfig, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 165e7f187..1da3e0363 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,5 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webFrame } from 'electron'; +import { createPackagedAcceptanceZoomBridge } from './acceptance-zoom'; import { createDesktopBridge } from './preload-bridge'; const connectJourneyAcceptance = process.env.PROPR_DESKTOP_CONNECT_SMOKE_TEST === '1' @@ -9,3 +10,12 @@ contextBridge.exposeInMainWorld( 'proprDesktop', createDesktopBridge(ipcRenderer, undefined, connectJourneyAcceptance), ); +const acceptanceZoom = createPackagedAcceptanceZoomBridge(ipcRenderer, webFrame); +if (acceptanceZoom) { + contextBridge.exposeInMainWorld('__PROPR_PACKAGED_ACCEPTANCE__', acceptanceZoom); + const scenario = process.env.PROPR_DESKTOP_ACCEPTANCE_SCENARIO; + contextBridge.exposeInMainWorld( + '__PROPR_PACKAGED_ACCEPTANCE_SCENARIO__', + scenario === 'setup-error' || scenario === 'setup-complete' ? scenario : 'default', + ); +} diff --git a/package-lock.json b/package-lock.json index 57379a338..48230a264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,9 @@ "@electron/windows-sign": "2.0.6", "@types/node": "^22.10.0", "@vitejs/plugin-react": "^4.6.0", + "axe-core": "4.10.3", "electron": "^44.0.0", + "playwright": "^1.62.1", "socket.io": "^4.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -4497,6 +4499,16 @@ "postcss": "^8.1.0" } }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/bail": { "version": "2.0.2", "license": "MIT", diff --git a/package.json b/package.json index 8e6a6e4b0..e52c76782 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "desktop:package": "npm run package -w @propr/desktop", "desktop:smoke": "npm run smoke:package -w @propr/desktop", "desktop:smoke:inspect": "npm run smoke:inspect -w @propr/desktop", + "desktop:acceptance": "npm run acceptance:package -w @propr/desktop", + "desktop:acceptance:verify": "npm run acceptance:verify -w @propr/desktop", "desktop:make": "npm run make -w @propr/desktop", "audit:runtime": "npm audit --package-lock-only --omit=dev --audit-level=low", "desktop:audit:packaging": "npm audit --package-lock-only --workspace=@propr/desktop --include=dev --audit-level=high", diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 5f64329be..fa72ac5e0 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import React, { lazy, Suspense, useEffect, useState } from 'react' import { BrowserRouter, HashRouter, Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom' import Layout from './components/Layout' import { ToastProvider } from './components/ui/Toast' @@ -7,7 +7,6 @@ import { useDemoMode } from './contexts/DemoModeContext' import { DemoModeProvider } from './contexts/DemoModeProvider' import DemoModeBanner from './components/DemoModeBanner' import './App.css' -import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from './api/proprApi' import { checkProprApiCompatibility, ProprCompatibilityCheckError } from './api/compatibility' import { hostedUiConnectionIssue, @@ -17,13 +16,14 @@ import { pathWithActiveHostedTunnelFlow, } from './config/runtimeConfig' import { AuthProvider, useCurrentUser, userHasPermission } from './contexts/AuthContext' -import type { CurrentUser, InstancePermission } from './api/proprTypes' +import type { InstancePermission } from './api/proprTypes' import RouteChunkErrorBoundary from './components/RouteChunkErrorBoundary' import { ConnectAccountProvider } from './contexts/ConnectAccountContext' import { BrowserPushProvider } from './hooks/useBrowserPush' import { NotificationCenterProvider } from './contexts/NotificationCenterContext' -import { currentUiPathname, isDesktopRuntime, publicAssetUrl } from './config/runtimeMode' +import { isDesktopRuntime, publicAssetUrl } from './config/runtimeMode' import { DesktopPresentationBoundary } from './desktop/DesktopPresentationBoundary' +import { useCurrentUserBootstrap } from './hooks/useCurrentUserBootstrap' const Router = isDesktopRuntime() ? HashRouter : BrowserRouter; @@ -44,8 +44,6 @@ const TasksPage = lazy(() => import('./pages/TasksPage')) type CompatibilityState = { status: 'checking' } | { status: 'ready' } | { status: 'blocked'; title: string; message: string }; -const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000; - const LoadingSpinner: React.FC = () => (
@@ -143,87 +141,17 @@ export const NotFoundRouteContent: React.FC<{ hostname?: string }> = ({ hostname const AppContent: React.FC = () => { const { isDemoMode, isLoading: isDemoModeLoading } = useDemoMode(); - // Auth check state - start loading unless already on login page - const [isLoading, setIsLoading] = useState(currentUiPathname() !== '/login'); - const [currentUser, setCurrentUser] = useState(null); - const refreshPromiseRef = useRef | null>(null); - - const refreshCurrentUser = useCallback(async () => { - if (refreshPromiseRef.current) return refreshPromiseRef.current; - const request = getCurrentUser() - .then(user => { setCurrentUser(user); }) - .finally(() => { - if (refreshPromiseRef.current === request) refreshPromiseRef.current = null; - }); - refreshPromiseRef.current = request; - return request; - }, []); - - // Perform initial auth check - useEffect(() => { - if (isDemoModeLoading) return; - - const checkSession = async () => { - // Don't check if we are already on login page - if (currentUiPathname() === '/login') { - setIsLoading(false); - return; - } - - try { - await refreshCurrentUser(); - // Session is valid - setIsLoading(false); - } catch (error) { - // If error is NOT 'Authentication required', we let the app render (to show errors). - // If it IS 'Authentication required', handleApiResponse handles the redirect, - // so we keep isLoading=true to prevent UI flash. - if (error instanceof Error && error.message !== 'Authentication required') { - setIsLoading(false); - } - } - }; - - checkSession(); - }, [isDemoModeLoading, refreshCurrentUser]); - - useEffect(() => { - const handleAuthorizationChanged = () => { - void refreshCurrentUser().catch(error => { - console.error('Failed to refresh instance authorization:', error); - }); - }; - window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged); - return () => window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged); - }, [refreshCurrentUser]); + const { + currentUser, + currentUserAbsent, + currentUserLoading, + isInitialLoading, + refreshCurrentUser, + } = useCurrentUserBootstrap({ isDemoMode, isDemoModeLoading }); - useEffect(() => { - if (isDemoMode || currentUiPathname() === '/login') return; - const refreshAuthorization = () => { - if (document.visibilityState === 'hidden') return; - void refreshCurrentUser().catch(error => { - console.error('Failed to synchronize instance authorization:', error); - }); - }; - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') refreshAuthorization(); - }; - const interval = window.setInterval(refreshAuthorization, AUTHORIZATION_REFRESH_INTERVAL_MS); - window.addEventListener('focus', refreshAuthorization); - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - window.clearInterval(interval); - window.removeEventListener('focus', refreshAuthorization); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [isDemoMode, refreshCurrentUser]); - - - // Render spinner while checking auth - if (isDemoModeLoading || isLoading) return ; - - return ( - + // Keep the provider mounted for lifecycle attribution, but do not construct a + // socket until the active desktop scope has an authenticated REST user. + const content = isDemoModeLoading || isInitialLoading ? : (
@@ -360,6 +288,20 @@ const AppContent: React.FC = () => {
+ ); + + const disableReasons = { + demoModeLoading: isDemoModeLoading, + demoMode: isDemoMode, + currentUserLoading, + currentUserAbsent, + }; + return ( + + {content} ); }; diff --git a/propr-ui/src/api/currentUserResponse.test.ts b/propr-ui/src/api/currentUserResponse.test.ts new file mode 100644 index 000000000..f68b7a292 --- /dev/null +++ b/propr-ui/src/api/currentUserResponse.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX } from '../desktop/packagedAcceptanceCurrentUserValidation'; +import { getCurrentUser, isCurrentUserResponse } from './proprApi'; + +const apiFetch = vi.hoisted(() => vi.fn()); + +vi.mock('./apiClient', () => ({ + API_BASE_URL: 'https://example.test', + apiFetch, + handleApiResponse: vi.fn(async (response: Response) => response), +})); + +type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown }; + +const validUser = { + id: 'user-1', + login: 'operator', + username: 'operator', + displayName: 'Operations', + email: null, + avatarUrl: null, + role: 'admin', + permissions: ['instance.manage_settings'], + authorizationSource: 'local', +}; + +describe('current-user response schema', () => { + afterEach(() => { + apiFetch.mockReset(); + delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__; + vi.restoreAllMocks(); + }); + + it('accepts the authenticated instance-user contract', () => { + expect(isCurrentUserResponse(validUser)).toBe(true); + }); + + it('rejects missing identity, unknown permissions, and unauthenticated bodies', () => { + expect(isCurrentUserResponse({ ...validUser, id: undefined })).toBe(false); + expect(isCurrentUserResponse({ ...validUser, permissions: ['instance.unknown'] })).toBe(false); + expect(isCurrentUserResponse({ code: 'INVALID_INSTANCE_TOKEN' })).toBe(false); + }); + + it('reports fixed accepted and rejected parse outcomes for an active scope', async () => { + (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { + setZoomFactor: vi.fn(), + }; + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), { + status: 200, headers: { 'Content-Type': 'application/json' }, + })); + + await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true })).resolves.toEqual(validUser); + expect(info.mock.calls.map(call => call[1])).toEqual([ + expect.objectContaining({ phase: 'request-issued', scopeGeneration: 3, classification: 'pending' }), + expect.objectContaining({ phase: 'response-completed', responseStatus: 200, classification: 'success' }), + expect.objectContaining({ phase: 'parsed-user-accepted', scopeGeneration: 3, schemaAccepted: true }), + ]); + expect(info.mock.calls.every(call => call[0] === PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX)).toBe(true); + + info.mockClear(); + apiFetch.mockResolvedValueOnce(new Response('{"authenticated":true}', { + status: 200, headers: { 'Content-Type': 'application/json' }, + })); + await expect(getCurrentUser({ scopeGeneration: 3, activeScopePresent: true })) + .rejects.toThrow('Current-user response schema was invalid.'); + expect(info.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({ + phase: 'parsed-user-rejected', classification: 'invalid-schema', schemaAccepted: false, + })); + }); + + it('preserves the exact hosted-web URL and no-store request contract', async () => { + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify(validUser), { + status: 200, headers: { 'Content-Type': 'application/json' }, + })); + + await expect(getCurrentUser()).resolves.toEqual(validUser); + + expect(apiFetch).toHaveBeenCalledOnce(); + expect(apiFetch).toHaveBeenCalledWith('https://example.test/api/auth/user', { + credentials: 'include', + cache: 'no-store', + }); + }); + + it('uses a bounded desktop generation cache key without fetch cache inputs', async () => { + apiFetch.mockImplementation(async () => new Response(JSON.stringify(validUser), { + status: 200, headers: { 'Content-Type': 'application/json' }, + })); + + await expect(getCurrentUser({ scopeGeneration: 4, activeScopePresent: true })).resolves.toEqual(validUser); + await expect(getCurrentUser({ scopeGeneration: 5, activeScopePresent: true })).resolves.toEqual(validUser); + await expect(getCurrentUser({ + scopeGeneration: Number.MAX_SAFE_INTEGER + 1, + activeScopePresent: true, + })).resolves.toEqual(validUser); + + expect(apiFetch).toHaveBeenCalledTimes(3); + expect(apiFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://example.test/api/auth/user?proprDesktopScopeGeneration=4', + 'https://example.test/api/auth/user?proprDesktopScopeGeneration=5', + 'https://example.test/api/auth/user?proprDesktopScopeGeneration=0', + ]); + for (const [, init] of apiFetch.mock.calls) { + expect(init).toEqual({ credentials: 'include' }); + expect(init).not.toHaveProperty('cache'); + const headers = new Headers(init?.headers); + expect(headers.has('Cache-Control')).toBe(false); + expect(headers.has('Pragma')).toBe(false); + } + }); +}); diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts index 30cb14a53..d2137f5e3 100644 --- a/propr-ui/src/api/proprApi.ts +++ b/propr-ui/src/api/proprApi.ts @@ -2,6 +2,10 @@ import type { Task as ApiTask } from './tasks'; import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; import { isHostedUiOrigin, pathWithActiveHostedTunnelFlow } from '../config/runtimeConfig'; import { isAccountStatusTimestamp, isProprProxyUrl } from '@propr/shared'; +import { + reportPackagedAcceptanceCurrentUser, + type PackagedAcceptanceCurrentUserClassification, +} from '../desktop/packagedAcceptanceCurrentUserValidation'; export * from './apiClient'; @@ -241,10 +245,103 @@ export const deleteTask = async (taskId: string, force?: boolean): Promise await handleApiResponse(response); }; -export const getCurrentUser = async (): Promise => { - const response = await apiFetch(`${API_BASE_URL}/api/auth/user`, { credentials: 'include' }); +export interface CurrentUserValidationOptions { + scopeGeneration?: number; + activeScopePresent?: boolean; +} + +const CURRENT_USER_SCOPE_GENERATION_QUERY = 'proprDesktopScopeGeneration'; + +const isNullableString = (value: unknown): value is string | null => value === null || typeof value === 'string'; + +export const isCurrentUserResponse = (value: unknown): value is CurrentUser => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const user = value as Partial; + const permissions = new Set(['instance.manage_agents', 'instance.manage_members', 'instance.manage_runtime', 'instance.manage_settings']); + return typeof user.id === 'string' + && typeof user.login === 'string' + && typeof user.username === 'string' + && typeof user.displayName === 'string' + && isNullableString(user.email) + && isNullableString(user.avatarUrl) + && (user.role === 'admin' || user.role === 'member') + && Array.isArray(user.permissions) + && user.permissions.every(permission => permissions.has(permission)) + && ['bootstrap', 'local', 'managed', 'implicit', 'demo'].includes(user.authorizationSource ?? ''); +}; + +const currentUserResponseClassification = async ( + response: Response, +): Promise => { + if (response.ok) return 'success'; + if (response.status === 403) return 'forbidden'; + if (response.status >= 500) return 'server-error'; + if (response.status !== 401) return 'unauthenticated'; + try { + const body = await response.clone().json() as { code?: unknown }; + return ['INVALID_INSTANCE_TOKEN', 'INSTANCE_TOKEN_EXPIRED', 'INSTANCE_TOKEN_REVOKED'].includes(String(body.code)) + ? 'revoked' + : 'unauthenticated'; + } catch { + return 'unauthenticated'; + } +}; + +export const getCurrentUser = async (options: CurrentUserValidationOptions = {}): Promise => { + const requestedScopeGeneration = options.scopeGeneration; + const scopeGeneration = typeof requestedScopeGeneration === 'number' + && Number.isSafeInteger(requestedScopeGeneration) && requestedScopeGeneration >= 0 + ? requestedScopeGeneration + : 0; + const activeScopePresent = options.activeScopePresent === true; + const currentUserUrl = activeScopePresent + ? `${API_BASE_URL}/api/auth/user?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${scopeGeneration}` + : `${API_BASE_URL}/api/auth/user`; + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'request-issued', scopeGeneration, activeScopePresent, + responseStatus: 0, classification: 'pending', schemaAccepted: false, + }); + } + const response = await apiFetch(currentUserUrl, activeScopePresent + ? { credentials: 'include' } + : { credentials: 'include', cache: 'no-store' }); + const classification = await currentUserResponseClassification(response); + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'response-completed', scopeGeneration, activeScopePresent, + responseStatus: response.status, classification, schemaAccepted: false, + }); + } await handleApiResponse(response); - return response.json(); + let body: unknown; + try { + body = await response.json(); + } catch { + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent, + responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false, + }); + } + throw new Error('Current-user response schema was invalid.'); + } + if (!isCurrentUserResponse(body)) { + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'parsed-user-rejected', scopeGeneration, activeScopePresent, + responseStatus: response.status, classification: 'invalid-schema', schemaAccepted: false, + }); + } + throw new Error('Current-user response schema was invalid.'); + } + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'parsed-user-accepted', scopeGeneration, activeScopePresent, + responseStatus: response.status, classification, schemaAccepted: true, + }); + } + return body; }; export const HOSTED_LOGOUT_FAILED_MESSAGE = diff --git a/propr-ui/src/components/GlobalSearch.tsx b/propr-ui/src/components/GlobalSearch.tsx index 5c1bcce5d..4370dbc4d 100644 --- a/propr-ui/src/components/GlobalSearch.tsx +++ b/propr-ui/src/components/GlobalSearch.tsx @@ -157,6 +157,7 @@ const GlobalSearch: React.FC = ({ inputRef: externalInputRef onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} onFocus={handleFocus} + aria-label="Search" placeholder="Search..." className="w-full pl-9 sm:pl-12 pr-8 sm:pr-10 py-1.5 sm:py-2 bg-gray-50 border border-slate-300 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 focus:bg-white transition-colors" /> diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index b419c804f..9a4c019ee 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -187,7 +187,7 @@ const Layout: React.FC = ({ children }) => { ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}>
- + ProPR + + ); + } + + if (initial === 'completion') { + return ( +
+ +
+ Local setup complete +

ProPR is ready

+

Your private local workspace and services are ready on this computer.

+
Local services are healthy.
+ +
+ ); + } + + if (installing) { + return ( +
+ +
+ Local setup +

Setting up ProPR

+

Preparing deterministic service images and starting the private local workspace…

+
Environment checks passed. Preparing local services…
+
+ ); + } + + const checks = [ + 'Docker Engine is available', + 'Private workspace selected', + 'Repository access reviewed', + 'Webhook credentials protected', + 'Local services confirmed', + ]; + return ( +
+ + + Local setup +

Check the essentials

+

Review the private local workspace requirements before ProPR starts its services.

+
+ {checks[Math.min(step, checks.length - 1)]} + Step {Math.min(step + 1, checks.length)} of {checks.length} +
+ {step < checks.length ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/propr-ui/src/desktop/desktop.css b/propr-ui/src/desktop/desktop.css index f3d6d31fd..fc2e75fa4 100644 --- a/propr-ui/src/desktop/desktop.css +++ b/propr-ui/src/desktop/desktop.css @@ -110,7 +110,7 @@ .desktop-choice-button strong, .desktop-choice-button small { display: block; } .desktop-choice-button strong { font-size: .9rem; } -.desktop-choice-button small { margin-top: .18rem; color: #728080; font-size: .75rem; } +.desktop-choice-button small { margin-top: .18rem; color: #657474; font-size: .75rem; } .desktop-choice-primary { border-color: #a8d2cf; background: #f7fbfa; } .desktop-recents { margin-top: 1.65rem; } @@ -122,7 +122,7 @@ .desktop-profile-connect strong, .desktop-profile-connect small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .desktop-profile-connect strong { color: #273939; font-size: .84rem; } -.desktop-profile-connect small { margin-top: .12rem; color: #778686; font-size: .7rem; } +.desktop-profile-connect small { margin-top: .12rem; color: #657474; font-size: .7rem; } .desktop-profile-icon { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: .5rem; background: #f0f5f5; color: #377b78; } .desktop-profile-icon svg, .desktop-profile-chevron { width: 1rem; height: 1rem; } diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts index 02ebed7e9..82bc037d3 100644 --- a/propr-ui/src/desktop/electronAdapters.ts +++ b/propr-ui/src/desktop/electronAdapters.ts @@ -3,6 +3,7 @@ import { isProprLoopbackHostname, parseProprConnectEndpoint } from '@propr/share import type { DesktopBridge, DesktopDiscoveryCandidate, DesktopProfile as StoredDesktopProfile } from '../../../apps/desktop/src/shared/contract'; import { getDesktopConnectionScope, setDesktopConnectionScope } from '../api/apiClient'; import type { DesktopAdapters, DesktopPlatform, DesktopProfile } from './types'; +import { reportPackagedAcceptanceRendererLifecycle } from './packagedAcceptanceRendererLifecycle'; const platform = (value: string): DesktopPlatform => { const normalized = value.toLowerCase(); @@ -220,6 +221,10 @@ export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAda profileId: result.profileId, transportScope: result.transportScope, }, profile.baseUrl); + reportPackagedAcceptanceRendererLifecycle('profile-activation-published', { + profileActivationPublished: true, + connectionScope: 'available', + }); publishedProfile = { id: profile.id, origin: normalizeApiBaseUrl(profile.baseUrl), diff --git a/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts b/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts new file mode 100644 index 000000000..50205ac2e --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts @@ -0,0 +1,56 @@ +export const PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX = '[ProPR Acceptance Current User]'; + +export type PackagedAcceptanceCurrentUserPhase = + | 'request-issued' + | 'response-completed' + | 'parsed-user-accepted' + | 'parsed-user-rejected' + | 'active-scope-accepted' + | 'stale-scope-rejected' + | 'revoked-scope-rejected' + | 'active-scope-rejected'; + +export type PackagedAcceptanceCurrentUserClassification = + | 'pending' + | 'success' + | 'unauthenticated' + | 'revoked' + | 'forbidden' + | 'server-error' + | 'network-error' + | 'invalid-schema'; + +export interface PackagedAcceptanceCurrentUserEvidence { + schemaVersion: 1; + correlation: 'current-scope-user-validation'; + phase: PackagedAcceptanceCurrentUserPhase; + scopeGeneration: number; + activeScopePresent: boolean; + responseStatus: number; + classification: PackagedAcceptanceCurrentUserClassification; + schemaAccepted: boolean; +} + +type AcceptanceWindow = Window & { + __PROPR_PACKAGED_ACCEPTANCE__?: unknown; +}; + +const REPORT_LIMIT = 10; +let reportCount = 0; + +const acceptanceEvidenceEnabled = (): boolean => typeof window !== 'undefined' + && typeof (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ === 'object' + && (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ !== null; + +export const reportPackagedAcceptanceCurrentUser = ( + evidence: Omit, +): void => { + if (!acceptanceEvidenceEnabled() || reportCount >= REPORT_LIMIT) return; + reportCount += 1; + console.info(PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX, { + schemaVersion: 1, + correlation: 'current-scope-user-validation', + ...evidence, + }); +}; + diff --git a/propr-ui/src/desktop/packagedAcceptanceLocalSetupSurface.ts b/propr-ui/src/desktop/packagedAcceptanceLocalSetupSurface.ts new file mode 100644 index 000000000..3dceacee0 --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceLocalSetupSurface.ts @@ -0,0 +1,10 @@ +export type PackagedAcceptanceSetupSurface = 'prerequisites' | 'error' | 'completion'; + +export const packagedAcceptanceSetupSurface = (): PackagedAcceptanceSetupSurface | null => { + if (typeof window === 'undefined' + || typeof window.__PROPR_PACKAGED_ACCEPTANCE__ !== 'object' + || window.__PROPR_PACKAGED_ACCEPTANCE__ === null) return null; + if (window.__PROPR_PACKAGED_ACCEPTANCE_SCENARIO__ === 'setup-error') return 'error'; + if (window.__PROPR_PACKAGED_ACCEPTANCE_SCENARIO__ === 'setup-complete') return 'completion'; + return 'prerequisites'; +}; diff --git a/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts new file mode 100644 index 000000000..933d3b236 --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX, + reportPackagedAcceptanceRendererLifecycle, + reportPackagedAcceptanceSocketConstructionInvocation, +} from './packagedAcceptanceRendererLifecycle'; +import { + PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX, + reportPackagedAcceptanceCurrentUser, +} from './packagedAcceptanceCurrentUserValidation'; + +type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown }; + +describe('packaged acceptance renderer lifecycle evidence', () => { + afterEach(() => { + delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__; + vi.restoreAllMocks(); + }); + + it('is silent outside the authenticated packaged acceptance boundary', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + reportPackagedAcceptanceRendererLifecycle('socket-provider-mounted', { + socketProviderMounted: true, + }); + + expect(info).not.toHaveBeenCalled(); + }); + + it('emits only fixed bounded secret-free state', () => { + (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { setZoomFactor: vi.fn() }; + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + reportPackagedAcceptanceRendererLifecycle('profile-activation-published', { + profileActivationPublished: true, + connectionScope: 'available', + }); + for (let index = 0; index < 20; index += 1) { + reportPackagedAcceptanceSocketConstructionInvocation(); + } + + const [message] = info.mock.calls.at(-1)!; + expect(info).toHaveBeenCalledTimes(12); + expect(typeof message).toBe('string'); + expect(message.startsWith(`${PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX} `)).toBe(true); + const evidence = JSON.parse(message.slice(PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX.length + 1)); + expect(Object.keys(evidence).sort()).toEqual([ + 'connectInvocations', 'connectionScope', 'desktopRuntime', 'disabledByCurrentUserAbsent', + 'disabledByCurrentUserLoading', 'disabledByDemoMode', 'disabledByDemoModeLoading', 'phase', + 'profileActivationPublished', 'providerDisabled', 'schemaVersion', 'socketConstructionInvocations', + 'socketConstructions', 'socketProviderMounted', + ]); + expect(evidence).toMatchObject({ + schemaVersion: 2, + phase: 'socket-construction-invoked', + profileActivationPublished: true, + connectionScope: 'available', + socketConstructionInvocations: 2, + disabledByDemoModeLoading: false, + disabledByDemoMode: false, + disabledByCurrentUserLoading: false, + disabledByCurrentUserAbsent: false, + }); + expect(message).not.toMatch(/scope-|https?:|profile-|bearer|authorization|cookie|path|error/i); + }); + + it('emits bounded current-user stages without identity or transport values', () => { + (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { setZoomFactor: vi.fn() }; + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + for (let index = 0; index < 20; index += 1) { + reportPackagedAcceptanceCurrentUser({ + phase: 'request-issued', + scopeGeneration: 1, + activeScopePresent: true, + responseStatus: 0, + classification: 'pending', + schemaAccepted: false, + }); + } + + expect(info).toHaveBeenCalledTimes(10); + const [prefix, evidence] = info.mock.calls.at(-1)!; + expect(prefix).toBe(PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX); + expect(evidence).toEqual({ + schemaVersion: 1, + correlation: 'current-scope-user-validation', + phase: 'request-issued', + scopeGeneration: 1, + activeScopePresent: true, + responseStatus: 0, + classification: 'pending', + schemaAccepted: false, + }); + expect(JSON.stringify(evidence)).not.toMatch(/https?:|profile-|bearer|authorization|cookie|user data|path/i); + }); +}); diff --git a/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts new file mode 100644 index 000000000..bc3a7dc46 --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts @@ -0,0 +1,91 @@ +export const PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX = '[ProPR Acceptance Renderer Lifecycle]'; + +export type PackagedAcceptanceRendererLifecyclePhase = + | 'profile-activation-published' + | 'socket-provider-mounted' + | 'socket-effect-disabled' + | 'socket-effect-scope-unavailable' + | 'socket-effect-ready' + | 'socket-construction-invoked' + | 'socket-constructed' + | 'socket-connect-invoked'; + +export interface PackagedAcceptanceRendererLifecycleEvidence { + schemaVersion: 2; + phase: PackagedAcceptanceRendererLifecyclePhase; + profileActivationPublished: boolean; + socketProviderMounted: boolean; + providerDisabled: boolean; + disabledByDemoModeLoading: boolean; + disabledByDemoMode: boolean; + disabledByCurrentUserLoading: boolean; + disabledByCurrentUserAbsent: boolean; + desktopRuntime: boolean; + connectionScope: 'unknown' | 'available' | 'unavailable'; + socketConstructionInvocations: number; + socketConstructions: number; + connectInvocations: number; +} + +type AcceptanceWindow = Window & { + __PROPR_PACKAGED_ACCEPTANCE__?: unknown; +}; + +const initialEvidence: PackagedAcceptanceRendererLifecycleEvidence = { + schemaVersion: 2, + phase: 'profile-activation-published', + profileActivationPublished: false, + socketProviderMounted: false, + providerDisabled: false, + disabledByDemoModeLoading: false, + disabledByDemoMode: false, + disabledByCurrentUserLoading: false, + disabledByCurrentUserAbsent: false, + desktopRuntime: false, + connectionScope: 'unknown', + socketConstructionInvocations: 0, + socketConstructions: 0, + connectInvocations: 0, +}; + +let evidence = initialEvidence; +let reportCount = 0; +const REPORT_LIMIT = 12; + +const acceptanceEvidenceEnabled = (): boolean => typeof window !== 'undefined' + && typeof (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ === 'object' + && (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ !== null; + +const boundedCount = (value: number): number => Math.min(2, value + 1); + +export const reportPackagedAcceptanceRendererLifecycle = ( + phase: PackagedAcceptanceRendererLifecyclePhase, + update: Partial> = {}, +): void => { + if (!acceptanceEvidenceEnabled() || reportCount >= REPORT_LIMIT) return; + reportCount += 1; + evidence = { ...evidence, ...update, schemaVersion: 2, phase }; + // CDP console argument handles are resolved asynchronously by the acceptance + // collector. Emit one immutable value so the collector observes the exact + // snapshot in renderer emission order, even if the page closes or the next + // lifecycle transition occurs before its general console capture settles. + console.info(`${PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX} ${JSON.stringify(evidence)}`); +}; + +export const reportPackagedAcceptanceSocketConstructionInvocation = (): void => { + reportPackagedAcceptanceRendererLifecycle('socket-construction-invoked', { + socketConstructionInvocations: boundedCount(evidence.socketConstructionInvocations), + }); +}; + +export const reportPackagedAcceptanceSocketConstructed = (): void => { + reportPackagedAcceptanceRendererLifecycle('socket-constructed', { + socketConstructions: boundedCount(evidence.socketConstructions), + }); +}; + +export const reportPackagedAcceptanceSocketConnectInvocation = (): void => { + reportPackagedAcceptanceRendererLifecycle('socket-connect-invoked', { + connectInvocations: boundedCount(evidence.connectInvocations), + }); +}; diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx b/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx new file mode 100644 index 000000000..38d5abaf1 --- /dev/null +++ b/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx @@ -0,0 +1,99 @@ +import { cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DESKTOP_TRANSPORT_SCOPE_HEADER } from '@propr/shared'; +import { setApiBaseUrl, setDesktopConnectionScope } from '../api/apiClient'; +import { PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX } from '../desktop/packagedAcceptanceCurrentUserValidation'; +import { useCurrentUserBootstrap } from './useCurrentUserBootstrap'; + +vi.mock('../config/runtimeMode', () => ({ + currentUiPathname: () => '/', + isDesktopRuntime: () => true, +})); + +type AcceptanceWindow = Window & { __PROPR_PACKAGED_ACCEPTANCE__?: unknown }; + +const scope = 'SSSSSSSSSSSSSSSSSSSSSS'; +const user = { + id: 'acceptance-user', + login: 'acceptance-admin', + username: 'acceptance-admin', + displayName: 'Acceptance Admin', + email: null, + avatarUrl: null, + role: 'admin' as const, + permissions: ['instance.manage_settings' as const], + authorizationSource: 'local' as const, +}; + +describe('desktop current-user bootstrap main-proxy integration', () => { + afterEach(() => { + cleanup(); + setDesktopConnectionScope(null); + setApiBaseUrl(''); + delete (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__; + vi.restoreAllMocks(); + }); + + it('correlates one fixed-alias React bootstrap request through apiFetch and main-only bearer custody', async () => { + (window as AcceptanceWindow).__PROPR_PACKAGED_ACCEPTANCE__ = { + setZoomFactor: vi.fn(), + }; + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: scope, + }, 'http://127.0.0.2:41731'); + + const mainProxyRecords: Array> = []; + const upstreamRecords: Array> = []; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(input.toString()); + const rendererHeaders = new Headers(init?.headers); + const generation = url.searchParams.get('proprDesktopScopeGeneration'); + const mainAccepted = (init?.method ?? 'GET') === 'GET' + && url.pathname === '/api/auth/user' + && generation === '1' + && [...url.searchParams].length === 1 + && rendererHeaders.get(DESKTOP_TRANSPORT_SCOPE_HEADER) === scope + && !rendererHeaders.has('Authorization') + && !rendererHeaders.has('Cookie'); + mainProxyRecords.push({ generation, mainAccepted, rendererBearerPresent: false, rendererCookiePresent: false }); + if (!mainAccepted) return new Response(null, { status: 401 }); + + const outboundHeaders = new Headers(rendererHeaders); + outboundHeaders.delete(DESKTOP_TRANSPORT_SCOPE_HEADER); + outboundHeaders.set('Authorization', 'Bearer main-injected'); + upstreamRecords.push({ + generation, + authorization: outboundHeaders.get('Authorization'), + cookiePresent: outboundHeaders.has('Cookie'), + }); + return new Response(JSON.stringify(user), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined); + + const { result } = renderHook(() => useCurrentUserBootstrap({ + isDemoMode: false, + isDemoModeLoading: false, + })); + + await waitFor(() => expect(result.current.currentUser).toEqual(user)); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(mainProxyRecords).toEqual([{ + generation: '1', mainAccepted: true, rendererBearerPresent: false, rendererCookiePresent: false, + }]); + expect(upstreamRecords).toEqual([{ + generation: '1', authorization: 'Bearer main-injected', cookiePresent: false, + }]); + expect(info.mock.calls.filter(call => call[0] === PACKAGED_ACCEPTANCE_CURRENT_USER_PREFIX) + .map(call => call[1])).toEqual([ + expect.objectContaining({ phase: 'request-issued', scopeGeneration: 1, activeScopePresent: true }), + expect.objectContaining({ phase: 'response-completed', scopeGeneration: 1, responseStatus: 200 }), + expect.objectContaining({ phase: 'parsed-user-accepted', scopeGeneration: 1, schemaAccepted: true }), + expect.objectContaining({ phase: 'active-scope-accepted', scopeGeneration: 1, schemaAccepted: true }), + ]); + }); +}); diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx b/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx new file mode 100644 index 000000000..64b93c6a3 --- /dev/null +++ b/propr-ui/src/hooks/useCurrentUserBootstrap.test.tsx @@ -0,0 +1,349 @@ +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CurrentUser } from '../api/proprTypes'; +import { SocketProvider } from '../contexts/SocketProvider'; +import { useCurrentUserBootstrap } from './useCurrentUserBootstrap'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +const scopeListeners = vi.hoisted(() => new Set<() => void>()); +const state = vi.hoisted(() => ({ + scope: null as null | { bridge: never; profileId: string; transportScope: string }, +})); +const getCurrentUser = vi.hoisted(() => vi.fn()); +const sockets = vi.hoisted(() => [] as Array<{ + connect: ReturnType; + disconnect: ReturnType; + on: ReturnType; + off: ReturnType; +}>); +const connectSocket = vi.hoisted(() => vi.fn(() => { + const socket = { + connect: vi.fn(), + disconnect: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + sockets.push(socket); + return socket; +})); + +vi.mock('../api/proprApi', () => ({ + getCurrentUser, + INSTANCE_AUTHORIZATION_CHANGED_EVENT: 'propr:instance-authorization-changed', +})); + +vi.mock('../api/apiClient', () => ({ + getDesktopConnectionScope: () => state.scope, + getDesktopSocketConfigurationKey: () => + `desktop\u0000https://example.test\u0000${state.scope?.profileId ?? ''}\u0000${state.scope?.transportScope ?? ''}`, + subscribeDesktopConnectionScope: (listener: () => void) => { + scopeListeners.add(listener); + return () => scopeListeners.delete(listener); + }, + getProprClient: () => ({ connectSocket }), + handleDesktopAccessCode: vi.fn(async () => 'retryable'), +})); + +vi.mock('../config/runtimeMode', () => ({ + currentUiPathname: () => '/', + isDesktopRuntime: () => true, +})); + +const user: CurrentUser = { + id: 'user-1', + login: 'operator', + username: 'operator', + displayName: 'Operations', + email: null, + avatarUrl: null, + role: 'admin', + permissions: ['instance.manage_settings'], + authorizationSource: 'local', +}; + +const publishScope = (scope: typeof state.scope): void => { + act(() => { + state.scope = scope; + scopeListeners.forEach(listener => listener()); + }); +}; + +const activeScope = { + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'AAAAAAAAAAAAAAAAAAAAAA', +}; + +describe('desktop current-user bootstrap', () => { + afterEach(() => { + cleanup(); + state.scope = null; + scopeListeners.clear(); + getCurrentUser.mockReset(); + connectSocket.mockClear(); + sockets.splice(0); + vi.restoreAllMocks(); + }); + + it('does not let a pre-scope bootstrap suppress or overwrite activation validation', async () => { + const initial = deferred(); + const activated = deferred(); + getCurrentUser.mockReturnValueOnce(initial.promise).mockReturnValueOnce(activated.promise); + + const { result } = renderHook(() => useCurrentUserBootstrap({ + isDemoMode: false, + isDemoModeLoading: false, + })); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce()); + expect(getCurrentUser).toHaveBeenNthCalledWith(1, { + activeScopePresent: false, + scopeGeneration: 0, + }); + + publishScope(activeScope); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2)); + expect(getCurrentUser).toHaveBeenNthCalledWith(2, { + activeScopePresent: true, + scopeGeneration: 1, + }); + expect(result.current.currentUserLoading).toBe(true); + + activated.resolve(user); + await waitFor(() => expect(result.current.currentUser).toEqual(user)); + expect(result.current.currentUserAbsent).toBe(false); + + initial.reject(new Error('Desktop authentication is required.')); + await act(async () => { await initial.promise.catch(() => undefined); }); + expect(result.current.currentUser).toEqual(user); + + publishScope(activeScope); + expect(getCurrentUser).toHaveBeenCalledTimes(2); + }); + + it('rejects an ABA response when the same scope key is republished at a newer generation', async () => { + const initial = deferred(); + const oldA = deferred(); + const scopeB = deferred(); + const currentA = deferred(); + getCurrentUser + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(oldA.promise) + .mockReturnValueOnce(scopeB.promise) + .mockReturnValueOnce(currentA.promise); + const { result } = renderHook(() => useCurrentUserBootstrap({ + isDemoMode: false, + isDemoModeLoading: false, + })); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce()); + + publishScope(activeScope); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2)); + publishScope({ ...activeScope, profileId: 'profile-b', transportScope: 'BBBBBBBBBBBBBBBBBBBBBB' }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3)); + publishScope(activeScope); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4)); + + oldA.resolve(user); + await act(async () => { await oldA.promise; }); + expect(result.current.currentUser).toBeNull(); + + currentA.resolve(user); + await waitFor(() => expect(result.current.currentUser).toEqual(user)); + initial.reject(new Error('Desktop authentication is required.')); + scopeB.reject(new Error('Desktop authentication is required.')); + await act(async () => { await Promise.allSettled([initial.promise, scopeB.promise]); }); + expect(result.current.currentUser).toEqual(user); + }); + + it('mounts an active scope at generation one and enables exactly one stable Manager after current validation', async () => { + const mountedA = deferred(); + const scopeB = deferred(); + const currentA = deferred(); + const refreshedA = deferred(); + const staleMountedUser = { ...user, id: 'stale-mounted-a' }; + const staleScopeBUser = { ...user, id: 'stale-scope-b' }; + getCurrentUser + .mockReturnValueOnce(mountedA.promise) + .mockReturnValueOnce(scopeB.promise) + .mockReturnValueOnce(currentA.promise) + .mockReturnValueOnce(refreshedA.promise); + state.scope = activeScope; + + let latestBootstrap: ReturnType | undefined; + const Harness = () => { + const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading: false }); + latestBootstrap = bootstrap; + const disableReasons = { + demoModeLoading: false, + demoMode: false, + currentUserLoading: bootstrap.currentUserLoading, + currentUserAbsent: bootstrap.currentUserAbsent, + }; + return ( + +
app
+
+ ); + }; + + render(); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce()); + expect(getCurrentUser).toHaveBeenNthCalledWith(1, { + activeScopePresent: true, + scopeGeneration: 1, + }); + expect(connectSocket).not.toHaveBeenCalled(); + + publishScope({ ...activeScope, profileId: 'profile-b', transportScope: 'BBBBBBBBBBBBBBBBBBBBBB' }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(2)); + expect(getCurrentUser).toHaveBeenNthCalledWith(2, { + activeScopePresent: true, + scopeGeneration: 2, + }); + publishScope(activeScope); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3)); + expect(getCurrentUser).toHaveBeenNthCalledWith(3, { + activeScopePresent: true, + scopeGeneration: 3, + }); + + currentA.resolve(user); + await waitFor(() => expect(latestBootstrap?.currentUser).toEqual(user)); + await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce()); + expect(sockets[0].connect).not.toHaveBeenCalled(); + + mountedA.resolve(staleMountedUser); + scopeB.resolve(staleScopeBUser); + await act(async () => { await Promise.all([mountedA.promise, scopeB.promise]); }); + expect(latestBootstrap?.currentUser).toEqual(user); + expect(connectSocket).toHaveBeenCalledOnce(); + expect(sockets[0].connect).not.toHaveBeenCalled(); + + act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4)); + expect(getCurrentUser).toHaveBeenNthCalledWith(4, { + activeScopePresent: true, + scopeGeneration: 3, + }); + refreshedA.resolve(user); + await act(async () => { await refreshedA.promise; }); + expect(latestBootstrap?.currentUser).toEqual(user); + expect(connectSocket).toHaveBeenCalledOnce(); + expect(sockets[0].connect).not.toHaveBeenCalled(); + expect(sockets[0].disconnect).not.toHaveBeenCalled(); + }); + + it('waits for demo-mode loading before one active-on-mount validation and Manager in StrictMode', async () => { + getCurrentUser.mockResolvedValue(user); + state.scope = activeScope; + + const Harness = ({ isDemoModeLoading }: { isDemoModeLoading: boolean }) => { + const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading }); + const disableReasons = { + demoModeLoading: isDemoModeLoading, + demoMode: false, + currentUserLoading: bootstrap.currentUserLoading, + currentUserAbsent: bootstrap.currentUserAbsent, + }; + return ( + +
app
+
+ ); + }; + + const { rerender } = render( + + + , + ); + + // Let an immediately resolved local validation fully settle if an effect + // incorrectly owns active-scope bootstrap while demo mode is still loading. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getCurrentUser).not.toHaveBeenCalled(); + expect(connectSocket).not.toHaveBeenCalled(); + + rerender( + + + , + ); + + await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce()); + expect(getCurrentUser).toHaveBeenCalledOnce(); + expect(getCurrentUser).toHaveBeenCalledWith({ + activeScopePresent: true, + scopeGeneration: 1, + }); + expect(sockets).toHaveLength(1); + expect(sockets[0].connect).not.toHaveBeenCalled(); + }); + + it('constructs once after activated validation and removes that Manager when revalidation fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + getCurrentUser + .mockRejectedValueOnce(new Error('Desktop authentication is required.')) + .mockResolvedValueOnce(user) + .mockResolvedValueOnce(user) + .mockRejectedValueOnce(new Error('This desktop connection was revoked or expired.')); + + let latestBootstrap: ReturnType | undefined; + const Harness = () => { + const bootstrap = useCurrentUserBootstrap({ isDemoMode: false, isDemoModeLoading: false }); + latestBootstrap = bootstrap; + const disableReasons = { + demoModeLoading: false, + demoMode: false, + currentUserLoading: bootstrap.currentUserLoading, + currentUserAbsent: bootstrap.currentUserAbsent, + }; + return ( + +
app
+
+ ); + }; + + render(); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledOnce()); + await waitFor(() => expect(latestBootstrap?.isInitialLoading).toBe(false)); + expect(latestBootstrap?.currentUserAbsent).toBe(true); + expect(connectSocket).not.toHaveBeenCalled(); + + publishScope(activeScope); + await waitFor(() => expect(connectSocket).toHaveBeenCalledOnce()); + expect(sockets[0].connect).not.toHaveBeenCalled(); + + act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3)); + expect(connectSocket).toHaveBeenCalledOnce(); + expect(sockets[0].connect).not.toHaveBeenCalled(); + expect(sockets[0].disconnect).not.toHaveBeenCalled(); + + act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(sockets[0].disconnect).toHaveBeenCalledOnce()); + expect(connectSocket).toHaveBeenCalledOnce(); + }); +}); diff --git a/propr-ui/src/hooks/useCurrentUserBootstrap.ts b/propr-ui/src/hooks/useCurrentUserBootstrap.ts new file mode 100644 index 000000000..cc4876765 --- /dev/null +++ b/propr-ui/src/hooks/useCurrentUserBootstrap.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { CurrentUser } from '../api/proprTypes'; +import { getCurrentUser, INSTANCE_AUTHORIZATION_CHANGED_EVENT } from '../api/proprApi'; +import { + getDesktopConnectionScope, + getDesktopSocketConfigurationKey, + subscribeDesktopConnectionScope, +} from '../api/apiClient'; +import { currentUiPathname, isDesktopRuntime } from '../config/runtimeMode'; +import { reportPackagedAcceptanceCurrentUser } from '../desktop/packagedAcceptanceCurrentUserValidation'; + +const AUTHORIZATION_REFRESH_INTERVAL_MS = 60_000; + +interface CurrentUserBootstrapOptions { + isDemoMode: boolean; + isDemoModeLoading: boolean; +} + +interface CurrentUserBootstrapResult { + currentUser: CurrentUser | null; + currentUserAbsent: boolean; + currentUserLoading: boolean; + isInitialLoading: boolean; + refreshCurrentUser: () => Promise; +} + +interface RefreshRequest { + configurationKey: string; + scopeGeneration: number; + promise: Promise; +} + +/** + * Binds authenticated renderer state to the desktop scope that was actually + * validated. Scope publication can supersede one pre-activation request, while + * same-scope callers share exactly one validation request. + */ +export const useCurrentUserBootstrap = ({ + isDemoMode, + isDemoModeLoading, +}: CurrentUserBootstrapOptions): CurrentUserBootstrapResult => { + const desktopRuntime = isDesktopRuntime(); + const socketConfigurationKey = useSyncExternalStore( + subscribeDesktopConnectionScope, + getDesktopSocketConfigurationKey, + getDesktopSocketConfigurationKey, + ); + const currentConfigurationKey = desktopRuntime ? socketConfigurationKey : 'browser'; + const currentConfigurationKeyRef = useRef(currentConfigurationKey); + const scopeGenerationRef = useRef({ + configurationKey: currentConfigurationKey, + generation: desktopRuntime && getDesktopConnectionScope() !== null ? 1 : 0, + }); + if (scopeGenerationRef.current.configurationKey !== currentConfigurationKey) { + scopeGenerationRef.current = { + configurationKey: currentConfigurationKey, + generation: scopeGenerationRef.current.generation + 1, + }; + } + currentConfigurationKeyRef.current = currentConfigurationKey; + + const [isInitialLoading, setIsInitialLoading] = useState(currentUiPathname() !== '/login'); + const [currentUser, setCurrentUser] = useState(null); + const [validatedConfigurationKey, setValidatedConfigurationKey] = useState(null); + const [validatedScopeGeneration, setValidatedScopeGeneration] = useState(null); + const [refreshingScope, setRefreshingScope] = useState<{ + configurationKey: string; + scopeGeneration: number; + } | null>(null); + const refreshRequestRef = useRef(null); + + const refreshCurrentUser = useCallback((): Promise => { + const configurationKey = currentConfigurationKeyRef.current; + const scopeGeneration = scopeGenerationRef.current.generation; + const activeScopePresent = desktopRuntime && getDesktopConnectionScope() !== null; + const existing = refreshRequestRef.current; + if (existing?.configurationKey === configurationKey && existing.scopeGeneration === scopeGeneration) { + return existing.promise; + } + + setRefreshingScope({ configurationKey, scopeGeneration }); + // Start the production request while the configuration key, generation, + // and active desktop scope above are still one synchronous snapshot. The + // request boundary itself emits request-issued evidence; reporting it here + // before a deferred call could claim a renderer request that never entered + // apiFetch. + const currentUserRequest = getCurrentUser({ scopeGeneration, activeScopePresent }); + const request = currentUserRequest + .then(user => { + if (currentConfigurationKeyRef.current !== configurationKey + || scopeGenerationRef.current.generation !== scopeGeneration) { + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'stale-scope-rejected', scopeGeneration, activeScopePresent, + responseStatus: 200, classification: 'success', schemaAccepted: true, + }); + } + return; + } + setCurrentUser(user); + setValidatedConfigurationKey(configurationKey); + setValidatedScopeGeneration(scopeGeneration); + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: 'active-scope-accepted', scopeGeneration, activeScopePresent, + responseStatus: 200, classification: 'success', schemaAccepted: true, + }); + } + }) + .catch(error => { + const currentScope = currentConfigurationKeyRef.current === configurationKey + && scopeGenerationRef.current.generation === scopeGeneration; + if (currentScope) { + setCurrentUser(null); + setValidatedConfigurationKey(null); + setValidatedScopeGeneration(null); + } + if (activeScopePresent) { + reportPackagedAcceptanceCurrentUser({ + phase: currentScope + ? (getDesktopConnectionScope() === null ? 'revoked-scope-rejected' : 'active-scope-rejected') + : 'stale-scope-rejected', + scopeGeneration, + activeScopePresent, + responseStatus: 0, + classification: getDesktopConnectionScope() === null ? 'revoked' : 'network-error', + schemaAccepted: false, + }); + } + throw error; + }) + .finally(() => { + if (refreshRequestRef.current?.promise === request) refreshRequestRef.current = null; + setRefreshingScope(current => current?.configurationKey === configurationKey + && current.scopeGeneration === scopeGeneration ? null : current); + }); + refreshRequestRef.current = { configurationKey, scopeGeneration, promise: request }; + return request; + }, [desktopRuntime]); + + useEffect(() => { + if (isDemoModeLoading) return; + + const checkSession = async () => { + if (currentUiPathname() === '/login') { + setIsInitialLoading(false); + return; + } + + try { + await refreshCurrentUser(); + setIsInitialLoading(false); + } catch (error) { + // Browser authentication redirects remain responsible for releasing + // their own loading surface. Desktop authentication has no redirect. + if (error instanceof Error && error.message !== 'Authentication required') { + setIsInitialLoading(false); + } + } + }; + + void checkSession(); + }, [currentConfigurationKey, isDemoModeLoading, refreshCurrentUser]); + + useEffect(() => { + const handleAuthorizationChanged = () => { + void refreshCurrentUser().catch(error => { + console.error('Failed to refresh instance authorization:', error); + }); + }; + window.addEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged); + return () => window.removeEventListener(INSTANCE_AUTHORIZATION_CHANGED_EVENT, handleAuthorizationChanged); + }, [refreshCurrentUser]); + + useEffect(() => { + if (isDemoMode || currentUiPathname() === '/login') return; + const refreshAuthorization = () => { + if (document.visibilityState === 'hidden') return; + void refreshCurrentUser().catch(error => { + console.error('Failed to synchronize instance authorization:', error); + }); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') refreshAuthorization(); + }; + const interval = window.setInterval(refreshAuthorization, AUTHORIZATION_REFRESH_INTERVAL_MS); + window.addEventListener('focus', refreshAuthorization); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.clearInterval(interval); + window.removeEventListener('focus', refreshAuthorization); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isDemoMode, refreshCurrentUser]); + + const currentScopeGeneration = scopeGenerationRef.current.generation; + const validatedCurrentUser = validatedConfigurationKey === currentConfigurationKey + && validatedScopeGeneration === currentScopeGeneration ? currentUser : null; + const currentUserLoading = validatedCurrentUser === null + && (isInitialLoading || (refreshingScope?.configurationKey === currentConfigurationKey + && refreshingScope.scopeGeneration === currentScopeGeneration)); + + return { + currentUser: validatedCurrentUser, + currentUserAbsent: validatedCurrentUser === null && !currentUserLoading, + currentUserLoading, + isInitialLoading, + refreshCurrentUser, + }; +}; + diff --git a/propr-ui/src/vite-env.d.ts b/propr-ui/src/vite-env.d.ts index 6abae6cad..b619fdd2c 100644 --- a/propr-ui/src/vite-env.d.ts +++ b/propr-ui/src/vite-env.d.ts @@ -7,4 +7,10 @@ declare const __PROPR_DESKTOP__: boolean; interface Window { proprDesktop?: import('../../apps/desktop/src/shared/contract').DesktopBridge; + /** @internal Main-attested, packaged-Linux acceptance capability. */ + __PROPR_PACKAGED_ACCEPTANCE__?: Readonly<{ + setZoomFactor(factor: 1 | 2): Promise; + }>; + /** @internal Fixed visual-fixture selector exposed only after acceptance attestation. */ + __PROPR_PACKAGED_ACCEPTANCE_SCENARIO__?: 'default' | 'setup-error' | 'setup-complete'; }