diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index e83f10bae..486c1be92 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -41,10 +41,37 @@ 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 + # This required predecessor step exclusively owns the real-browser opt-in. + 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 }} timeout-minutes: 60 strategy: @@ -142,7 +169,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: Package desktop app from clean checkout shell: bash @@ -207,6 +234,31 @@ 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 PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + 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: 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 @@ -474,7 +526,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' @@ -653,6 +705,31 @@ 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 PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1" + 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: matrix.platform == 'linux' && matrix.arch == 'x64' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-production-acceptance-linux-x64-${{ github.run_id }}-${{ github.run_attempt }} + path: desktop-acceptance-artifacts + if-no-files-found: error + retention-days: 30 + - name: Launch signed packaged Windows application and exercise MVP desktop flows if: matrix.platform == 'win32' shell: bash diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c268125ec..421dc9f8a 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: @@ -43,6 +44,30 @@ exit. `desktop:smoke:inspect` performs executable and fuse inspection without la both Linux architectures under Xvfb, 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. + The first-release Windows MVP packages only the normal desktop application. Native self-update installation authority is deferred to issue #2000: no broker, bootstrap, launcher, service, or authority custom action is built, copied into `resources`, or installed by the MSI. Both Windows architectures remain mandatory release targets, and package/MSI diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3e8032849..a2b16c0ca 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,6 +23,8 @@ "package": "electron-forge package", "smoke:package": "node scripts/smoke-packaged.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", @@ -46,11 +48,13 @@ "@electron-forge/maker-zip": "8.0.0-alpha.10", "@electron-forge/plugin-vite": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", - "@electron/windows-sign": "2.0.6", "@electron/fuses": "^2.1.3", + "@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", "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^7.3.5" diff --git a/apps/desktop/renderer.html b/apps/desktop/renderer.html index 374512fdf..555db588a 100644 --- a/apps/desktop/renderer.html +++ b/apps/desktop/renderer.html @@ -4,7 +4,7 @@ diff --git a/apps/desktop/scripts/acceptance-artifacts.mjs b/apps/desktop/scripts/acceptance-artifacts.mjs new file mode 100644 index 000000000..b6031363b --- /dev/null +++ b/apps/desktop/scripts/acceptance-artifacts.mjs @@ -0,0 +1,679 @@ +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..18984ac67 --- /dev/null +++ b/apps/desktop/scripts/acceptance-artifacts.test.mjs @@ -0,0 +1,524 @@ +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..b12bd225d --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-axe.mjs @@ -0,0 +1,171 @@ +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..f4dc875f6 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-clock.d.mts @@ -0,0 +1,2 @@ +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..bd554adfd --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-clock.mjs @@ -0,0 +1,8 @@ +/** + * 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..d9f86f946 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-current-user.mjs @@ -0,0 +1,183 @@ +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; +}; + +/** 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..888829385 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-current-user.test.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + classifyCurrentUserRequestShape, + currentUserValidationPhaseSummary, + currentUserValidationFailureCategory, + 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}`); + } + }); +}); + +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..3fd3df3f7 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-renderer.mjs @@ -0,0 +1,340 @@ +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..3bbce38e0 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-renderer.test.mjs @@ -0,0 +1,519 @@ +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..a835aba3c --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-socket.test.mjs @@ -0,0 +1,108 @@ +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..d267e9237 --- /dev/null +++ b/apps/desktop/scripts/packaged-acceptance-stats.test.mjs @@ -0,0 +1,106 @@ +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/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs index fa2eecff5..288b1838d 100644 --- a/apps/desktop/scripts/run-native-durability.mjs +++ b/apps/desktop/scripts/run-native-durability.mjs @@ -1,8 +1,20 @@ import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +const expectedTestsFor = (relativePath) => { + const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8'); + const matches = [...source.matchAll( + /^export const NATIVE_DURABILITY_EXPECTED_TESTS = ([1-9]\d*) as const;$/gm, + )]; + if (matches.length !== 1) { + throw new Error(`${relativePath} must export exactly one native durability expected-test contract`); + } + return Number(matches[0][1]); +}; + const EXPECTED = Object.freeze({ - 'credential-service': 69, + 'credential-service': expectedTestsFor('src/credential-service.test.ts'), 'profile-store': 39, 'pairing-shutdown': 10, }); diff --git a/apps/desktop/scripts/run-packaged-acceptance.mjs b/apps/desktop/scripts/run-packaged-acceptance.mjs new file mode 100644 index 000000000..647662f75 --- /dev/null +++ b/apps/desktop/scripts/run-packaged-acceptance.mjs @@ -0,0 +1,1576 @@ +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; +let rendererLifecycleEvidenceInvalid = false; +const rendererLifecycleInvalidCategories = new Set(); +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 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 = (argumentsValue, journey) => { + if (argumentsValue[0] !== RENDERER_LIFECYCLE_PREFIX) return; + const evidence = argumentsValue[1]; + 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 schemaOrShapeInvalid = !exactKeys || evidence.schemaVersion !== 2 || !RENDERER_LIFECYCLE_PHASES.has(evidence.phase) + || !['unknown', 'available', 'unavailable'].includes(evidence.connectionScope) + || !booleans || !counts || !disableReasonsConsistent; + const overflow = rendererLifecycleRecords.filter(record => record.journey === journey).length >= 12; + if (schemaOrShapeInvalid || overflow) { + rendererLifecycleEvidenceInvalid = true; + if (schemaOrShapeInvalid) rendererLifecycleInvalidCategories.add('schema-shape'); + if (overflow) rendererLifecycleInvalidCategories.add('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, { + product: 'ProPR', version: mode === 'incompatible' ? '99.0.0' : '0.8.15', + apiCompatibility: mode === 'incompatible' ? 'desktop-incompatible-v99' : PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + 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 }); + 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; + 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); + captureRendererLifecycleEvidence(record.arguments, journey); + 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 + && typeof window.__PROPR_DESKTOP__ === 'object' && window.__PROPR_DESKTOP__ !== 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 rendererLifecycleInvalidCategory = () => { + const schemaShape = rendererLifecycleInvalidCategories.has('schema-shape'); + const overflow = rendererLifecycleInvalidCategories.has('overflow'); + if (schemaShape && overflow) return 'multiple'; + if (schemaShape) return 'schema-shape'; + if (overflow) return 'overflow'; + return 'none'; +}; + +const rendererLifecycleDiagnosticSummary = journey => ({ + schemaVersion: 1, + recordCount: rendererLifecycleRecords.filter(record => record.journey === journey).length, + evidenceInvalid: rendererLifecycleEvidenceInvalid, + invalidCategory: rendererLifecycleInvalidCategory(), +}); + +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 (rendererLifecycleEvidenceInvalid) 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))}; 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))}; 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 (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.mjs b/apps/desktop/scripts/smoke-packaged.mjs index 98962219a..46a739933 100644 --- a/apps/desktop/scripts/smoke-packaged.mjs +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -18,12 +18,14 @@ import { createSmokeChildEnvironment, removePrivateSmokeProfile, } from './packaged-smoke-support.mjs'; +import { classifyCurrentUserRequestShape } from './packaged-acceptance-current-user.mjs'; const READY_EVENT = 'desktop.renderer.ready'; const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true'; const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready'; const MVP_FLOWS_PROOF = 'desktop.renderer.mvp_flows.ready'; const TRANSPORT_PROOF = 'desktop.renderer.transport_smoke.ready'; +const CURRENT_USER_BOUNDARY_PROOF = 'desktop.renderer.transport_smoke.current_user'; const LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; const MAIN_PROCESS_ERROR_MARKERS = [ @@ -51,6 +53,20 @@ const parseEventLayout = (output, expectedEvent) => { return undefined; }; +const parseEventRecords = (output, expectedEvent) => { + const records = []; + for (const line of output.split(/\r?\n/)) { + if (!line.includes(expectedEvent)) continue; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + if (record.event === expectedEvent) records.push(record); + } catch { + // Chromium may emit unrelated non-JSON diagnostics containing an event name. + } + } + return records; +}; + await access(binaryPath); const expectedFuses = new Map([ [FuseV1Options.RunAsNode, FuseState.DISABLE], @@ -109,10 +125,32 @@ const discovery = JSON.stringify({ socketIoBearerAuthentication: true, }, }); +const currentUser = JSON.stringify({ + id: 'packaged-smoke-user', + login: 'packaged-smoke', + username: 'packaged-smoke', + displayName: 'Packaged Smoke', + email: null, + avatarUrl: null, + role: 'admin', + permissions: [ + 'instance.manage_agents', + 'instance.manage_members', + 'instance.manage_runtime', + 'instance.manage_settings', + ], + authorizationSource: 'local', +}); const requests = []; const fixtures = []; const listenTransportFixture = async name => { + const socketBoundary = { authenticationAttempts: 0, rejectedAuthenticationAttempts: 0, connections: 0 }; const server = createServer((request, response) => { + const currentUserShape = classifyCurrentUserRequestShape( + request.method, + request.url, + request.headers.origin, + ); const record = { fixture: name, method: request.method, @@ -121,6 +159,8 @@ const listenTransportFixture = async name => { cookie: request.headers.cookie ?? null, origin: request.headers.origin ?? null, socketIo: false, + currentUserSource: currentUserShape?.source ?? null, + currentUserScopeGeneration: currentUserShape?.scopeGeneration ?? null, }; requests.push(record); if (request.method === 'OPTIONS') { @@ -148,10 +188,10 @@ const listenTransportFixture = async name => { response.end(); return; } - if ((request.url === '/api/auth/user' || request.url === '/api/smoke/rest') + if ((currentUserShape !== null || request.url === '/api/smoke/rest') && /^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '')) { response.writeHead(200, { ...corsHeaders, 'Set-Cookie': 'remote=must-not-persist; HttpOnly; SameSite=None' }); - response.end(request.url === '/api/auth/user' ? '{"username":"packaged-smoke"}' : '{"ok":true}'); + response.end(currentUserShape !== null ? currentUser : '{"ok":true}'); return; } response.writeHead(401, corsHeaders); @@ -161,9 +201,9 @@ const listenTransportFixture = async name => { path: '/socket.io/', transports: ['websocket'], cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, }); io.of('/').use((socket, next) => { + socketBoundary.authenticationAttempts += 1; const queryScopes = new URL(socket.handshake.url, 'http://fixture.invalid').searchParams .getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); - const activationScope = socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY]; const record = { fixture: name, method: 'SOCKET.IO', @@ -177,7 +217,9 @@ const listenTransportFixture = async name => { }; requests.push(record); if (!/^Bearer propr_it_[A-Za-z0-9_-]{43}$/.test(record.authorization ?? '') - || queryScopes.length !== 1 || typeof activationScope !== 'string' || activationScope !== queryScopes[0]) { + || queryScopes.length !== 1 || !/^[A-Za-z0-9_-]{22}$/.test(queryScopes[0]) + || Object.keys(socket.handshake.auth).length !== 0) { + socketBoundary.rejectedAuthenticationAttempts += 1; const error = new Error(INVALID_INSTANCE_TOKEN); error.data = { code: INVALID_INSTANCE_TOKEN }; next(error); @@ -185,12 +227,15 @@ const listenTransportFixture = async name => { } next(); }); - io.of('/').on('connection', socket => socket.emit('packaged-smoke:connected', { ok: true })); + io.of('/').on('connection', socket => { + socketBoundary.connections += 1; + socket.emit('packaged-smoke:connected', { ok: true }); + }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const address = server.address(); if (!address || typeof address === 'string') throw new Error(`Packaged ${name} fixture did not bind`); - const fixture = { server, io, origin: `http://127.0.0.1:${address.port}` }; + const fixture = { server, io, origin: `http://127.0.0.1:${address.port}`, socketBoundary }; fixtures.push(fixture); return fixture; }; @@ -269,6 +314,7 @@ const shutdownSteps = [ const launch = async mode => { const smokeProfile = await createPrivateSmokeProfile(); const requestStart = requests.length; + const socketBoundaryStart = new Map(fixtures.map(fixture => [fixture, { ...fixture.socketBoundary }])); let output = ''; try { const launchArguments = [ @@ -323,6 +369,41 @@ const launch = async mode => { if (!output.includes(`"storageBackend":"${expectedBackend}"`)) { throw new Error(`Packaged desktop did not use ${expectedBackend} production credential protection`); } + const staleBoundaryRecords = parseEventRecords(output, 'desktop.transport_smoke.stale_socket_boundary'); + if (staleBoundaryRecords.length !== 1) { + throw new Error(`Packaged ${mode} smoke missed exact main stale-socket boundary evidence`); + } + const staleBoundary = staleBoundaryRecords[0]; + if (staleBoundary.schemaVersion !== 1 || staleBoundary.mainAttempts !== 2 + || staleBoundary.staleRejectedByMain !== true || staleBoundary.staleRejectionCategory !== 'stale-scope' + || staleBoundary.freshAcceptedByMain !== true || staleBoundary.exactPath !== true + || staleBoundary.exactTransport !== true || staleBoundary.exactResource !== true + || staleBoundary.queryCount !== 1 || staleBoundary.activeBindingPresent !== true + || staleBoundary.profileGenerationCurrent !== true || staleBoundary.originEqualsActive !== true + || staleBoundary.rendererBearerPresent !== false || staleBoundary.rendererCookiePresent !== false + || staleBoundary.staleOutboundBearerPresent !== false || staleBoundary.staleBearerMainInjected !== false + || staleBoundary.freshBearerMainInjected !== true) { + throw new Error(`Packaged ${mode} smoke main stale-socket boundary evidence was invalid`); + } + const currentUserBoundaryRecords = parseEventRecords(output, CURRENT_USER_BOUNDARY_PROOF); + if (currentUserBoundaryRecords.length !== 1) { + throw new Error(`Packaged ${mode} smoke missed exact current-user main-boundary evidence`); + } + const currentUserBoundary = currentUserBoundaryRecords[0]; + if (currentUserBoundary.schemaVersion !== 2 || currentUserBoundary.rendererValidations !== 3 + || JSON.stringify(currentUserBoundary.rendererScopeGenerations) !== '[1,2,3]' + || currentUserBoundary.exactGet !== true || currentUserBoundary.scopeHeaderCount !== 1 + || currentUserBoundary.activeBindingPresent !== true + || currentUserBoundary.profileGenerationCurrent !== true + || currentUserBoundary.scopeEqualsActive !== true + || currentUserBoundary.originEqualsActive !== true + || currentUserBoundary.rendererBearerPresent !== false + || currentUserBoundary.rendererCookiePresent !== false + || currentUserBoundary.outboundBearerPresent !== true + || currentUserBoundary.bearerMainInjected !== true + || currentUserBoundary.accepted !== true) { + throw new Error(`Packaged ${mode} smoke current-user main-boundary evidence was invalid`); + } let previousStep = -1; for (const step of shutdownSteps) { const marker = `"step":"${step}"`; @@ -346,21 +427,84 @@ const launch = async mode => { const authenticated = runRequests.filter(request => request.authorization?.startsWith('Bearer propr_it_')); const secrets = [...new Set(authenticated.map(request => request.authorization.slice('Bearer '.length)))]; if (secrets.length !== 2) throw new Error(`Expected two ${mode} activation credentials, observed ${secrets.length}`); + const rendererScopeGenerations = []; for (const name of ['first', 'second']) { + const fixture = name === 'first' ? first : second; + const boundaryStart = socketBoundaryStart.get(fixture); + const fixtureBoundaryAttempts = boundaryStart + ? fixture.socketBoundary.authenticationAttempts - boundaryStart.authenticationAttempts + : -1; + const fixtureBoundaryRejections = boundaryStart + ? fixture.socketBoundary.rejectedAuthenticationAttempts - boundaryStart.rejectedAuthenticationAttempts + : -1; + const fixtureBoundaryConnections = boundaryStart + ? fixture.socketBoundary.connections - boundaryStart.connections + : -1; + if (!boundaryStart + || fixtureBoundaryAttempts < (name === 'second' ? 2 : 1) + || fixtureBoundaryRejections !== 0 + || fixtureBoundaryConnections !== fixtureBoundaryAttempts) { + throw new Error(`Packaged ${mode} ${name} fixture observed an unexpected Socket.IO boundary count`); + } const fixtureRequests = authenticated.filter(request => request.fixture === name); + const currentUserRequests = runRequests.filter(request => { + if (request.fixture !== name || request.method === 'OPTIONS') return false; + try { + return new URL(request.url, 'http://fixture.invalid').pathname === '/api/auth/user'; + } catch { + return false; + } + }); + const expectedActivations = name === 'first' ? 2 : 1; + const mainCurrentUserRequests = currentUserRequests.filter(request => request.currentUserSource === 'main'); + const rendererCurrentUserRequests = currentUserRequests.filter(request => request.currentUserSource === 'renderer'); + rendererScopeGenerations.push(...rendererCurrentUserRequests.map(request => request.currentUserScopeGeneration)); + const currentUserSequence = currentUserRequests.map(request => request.currentUserSource); + const expectedCurrentUserSequence = Array.from( + { length: expectedActivations }, + () => ['main', 'renderer'], + ).flat(); const namespaceConnections = fixtureRequests.filter(request => request.socketIo); - if (!fixtureRequests.some(request => request.url === '/api/auth/user') + const allNamespaceAttempts = runRequests.filter(request => request.fixture === name && request.socketIo); + if (mainCurrentUserRequests.length !== expectedActivations + || rendererCurrentUserRequests.length !== expectedActivations + || currentUserRequests.some(request => request.currentUserSource === null) + || currentUserSequence.some((source, index) => source !== expectedCurrentUserSequence[index]) + || currentUserRequests.some(request => request.authorization === null || request.cookie !== null) + || rendererCurrentUserRequests.some(request => request.origin !== DESKTOP_RENDERER_ORIGIN) + || mainCurrentUserRequests.some(request => request.origin === DESKTOP_RENDERER_ORIGIN) + || new Set(rendererCurrentUserRequests.map(request => request.currentUserScopeGeneration)).size !== expectedActivations + || rendererCurrentUserRequests.some(request => !Number.isSafeInteger(request.currentUserScopeGeneration) + || request.currentUserScopeGeneration < 1) + || expectedCurrentUserSequence.length !== currentUserSequence.length || !fixtureRequests.some(request => request.url === '/api/smoke/rest') || namespaceConnections.length < (name === 'second' ? 2 : 1) + || allNamespaceAttempts.length !== namespaceConnections.length + || allNamespaceAttempts.length !== fixtureBoundaryAttempts || namespaceConnections.some(request => request.namespace !== '/' || request.engineProtocol !== 4)) { throw new Error(`Packaged ${mode} ${name} fixture missed REST, Engine.IO, namespace auth, or reconnect proof`); } if (new Set(fixtureRequests.map(request => request.authorization)).size !== 1) { throw new Error(`Packaged ${mode} ${name} fixture observed cross-generation bearer use`); } + if (currentUserRequests.some(request => request.authorization !== fixtureRequests[0]?.authorization)) { + throw new Error(`Packaged ${mode} ${name} current-user proof lost main-only bearer custody`); + } + } + if (rendererScopeGenerations.length !== 3 + || rendererScopeGenerations.some((generation, index) => generation !== index + 1)) { + throw new Error(`Packaged ${mode} current-user validation generations were not exact and activation-scoped`); } if (runRequests.some(request => request.cookie !== null) - || runRequests.some(request => secrets.some(secret => request.url?.includes(secret)))) { + || runRequests.some(request => secrets.some(secret => { + if (request.url?.includes(secret)) return true; + try { + return [...new URL(request.url, 'http://fixture.invalid').searchParams] + .some(([key, value]) => key.includes(secret) || value.includes(secret)); + } catch { + return false; + } + }))) { throw new Error('Packaged renderer transport sent cookies or placed a credential in a URL'); } if (secrets.some(secret => output.includes(secret) || launchArguments.some(argument => argument.includes(secret)))) { diff --git a/apps/desktop/src/acceptance-setup-controller.test.ts b/apps/desktop/src/acceptance-setup-controller.test.ts new file mode 100644 index 000000000..bca5a8308 --- /dev/null +++ b/apps/desktop/src/acceptance-setup-controller.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AcceptanceSetupController } from './acceptance-setup-controller'; + +const requestFor = (sessionId: string) => ({ + sessionId, + root: { mode: 'default' as const }, + reinitialize: false, + agents: ['codex'], + github: { mode: 'demo' as const }, + intake: { mode: 'keep' as const }, + whitelist: null, + repository: null, +}); + +describe('packaged acceptance setup fixture', () => { + it('publishes deterministic progress through the production setup shape', async () => { + const published: string[] = []; + const controller = new AcceptanceSetupController({ + rootDir: '/tmp/private/local-stack', + emit: snapshot => published.push(snapshot.phase), + }); + const initial = await controller.status(); + const running = await controller.start(requestFor(initial.sessionId)); + assert.equal(running.phase, 'running'); + assert.equal(running.state?.steps.find(step => step.id === 'pull-images')?.status, 'active'); + assert.deepEqual(published, ['running']); + await controller.shutdown(); + }); + + it('provides fixed recovery and completion cold-start evidence without secrets', async () => { + const recovery = await new AcceptanceSetupController({ + rootDir: '/tmp/private/local-stack', scenario: 'setup-error', emit: () => undefined, + }).status(); + const completion = await new AcceptanceSetupController({ + rootDir: '/tmp/private/local-stack', scenario: 'setup-complete', emit: () => undefined, + }).status(); + assert.equal(recovery.phase, 'failed'); + assert.match(recovery.errors?.[0]?.nextAction ?? '', /Start Docker Engine/); + assert.equal(completion.phase, 'completed'); + assert.equal(completion.profile?.name, 'This computer'); + assert.doesNotMatch(JSON.stringify({ recovery, completion }), /token|password|secret/i); + }); +}); diff --git a/apps/desktop/src/acceptance-setup-controller.ts b/apps/desktop/src/acceptance-setup-controller.ts new file mode 100644 index 000000000..a47e179c1 --- /dev/null +++ b/apps/desktop/src/acceptance-setup-controller.ts @@ -0,0 +1,123 @@ +import { randomUUID } from 'node:crypto'; +import { createSetupState, updateStep } from '@propr/local-setup'; +import type { DesktopSetupRequest, DesktopSetupSnapshot } from './shared/contract'; + +type AcceptanceSetupScenario = 'default' | 'setup-error' | 'setup-complete'; + +const capability = { supported: true, kind: 'local', platform: 'linux' } as const; + +export class AcceptanceSetupController { + readonly #sessionId = randomUUID(); + readonly #rootDir: string; + readonly #emit: (snapshot: DesktopSetupSnapshot) => void; + readonly #scenario: AcceptanceSetupScenario; + #timers = new Set>(); + #snapshot: DesktopSetupSnapshot; + + constructor(options: { + rootDir: string; + scenario?: string; + emit(snapshot: DesktopSetupSnapshot): void; + }) { + this.#rootDir = options.rootDir; + this.#emit = options.emit; + this.#scenario = options.scenario === 'setup-error' || options.scenario === 'setup-complete' + ? options.scenario + : 'default'; + this.#snapshot = this.#initialSnapshot(); + } + + #initialSnapshot(): DesktopSetupSnapshot { + const common = { capability, sessionId: this.#sessionId, rootDir: this.#rootDir, logs: [] }; + if (this.#scenario === 'setup-error') { + return { + ...common, + phase: 'failed', + state: updateStep(createSetupState(this.#rootDir), 'check', { + status: 'failed', + detail: 'Docker Engine is not reachable.', + nextAction: 'Start Docker Engine, then retry setup.', + }), + error: 'The local prerequisites need attention.', + errors: [{ + code: 'step-failed', + message: 'Docker Engine is not reachable.', + stepId: 'check', + retryable: true, + nextAction: 'Start Docker Engine, then retry setup.', + }], + resumeAvailable: true, + }; + } + if (this.#scenario === 'setup-complete') { + return { + ...common, + phase: 'completed', + profile: { + id: 'acceptance-local', + name: 'This computer', + baseUrl: 'http://127.0.0.1:41771', + kind: 'local', + lastConnectedAt: '2026-01-02T03:04:05.000Z', + }, + }; + } + return { ...common, phase: 'idle', resumeAvailable: false }; + } + + async status(): Promise { return structuredClone(this.#snapshot); } + + async start(request: DesktopSetupRequest): Promise { + if (request.sessionId !== this.#sessionId) throw new Error('Acceptance setup session expired'); + let state = createSetupState(this.#rootDir); + state = updateStep(state, 'check', { status: 'done', detail: 'Docker Engine 27.5 is ready.' }); + state = updateStep(state, 'init-stack', { status: 'done', detail: 'Private local directory is ready.' }); + state = updateStep(state, 'pull-images', { status: 'active', detail: 'Preparing deterministic service images…' }); + this.#snapshot = { + phase: 'running', capability, sessionId: this.#sessionId, rootDir: this.#rootDir, + state, logs: ['Environment checks passed.', 'Preparing local services…'], resumeAvailable: false, + }; + this.#publish(); + // Keep the progress view stable for the complete screenshot/a11y variant + // set. Completion has its own deterministic cold-start scenario. + this.#later(60_000, () => { + let completed = createSetupState(this.#rootDir); + completed = { ...completed, steps: completed.steps.map(step => ({ ...step, status: step.optional ? 'skipped' : 'done', detail: step.optional ? 'Not requested' : 'Ready' })) }; + this.#snapshot = { + phase: 'completed', capability, sessionId: this.#sessionId, rootDir: this.#rootDir, + state: completed, logs: ['Environment checks passed.', 'Local services are healthy.'], + profile: { + id: 'acceptance-local', name: 'This computer', baseUrl: 'http://127.0.0.1:41771', kind: 'local', + lastConnectedAt: '2026-01-02T03:04:05.000Z', + }, + }; + this.#publish(); + }); + return structuredClone(this.#snapshot); + } + + retry(request?: DesktopSetupRequest): Promise { + if (request) return this.start(request); + this.#snapshot = { ...this.#snapshot, phase: 'idle', error: undefined, errors: undefined }; + this.#publish(); + return this.status(); + } + + async cancel(): Promise { + this.#clearTimers(); + this.#snapshot = { ...this.#snapshot, phase: 'cancelled', error: 'Setup was cancelled.' }; + this.#publish(); + return this.status(); + } + + async selectPrivateKey() { return { capability: 'A'.repeat(43), label: 'acceptance-private-key.pem' }; } + async acquireWebhookSecret() { return { capability: 'B'.repeat(43), label: 'Secret entered' as const }; } + async shutdown(): Promise { this.#clearTimers(); } + + #publish(): void { this.#emit(structuredClone(this.#snapshot)); } + #later(delay: number, callback: () => void): void { + const timer = setTimeout(() => { this.#timers.delete(timer); callback(); }, delay); + this.#timers.add(timer); + } + #clearTimers(): void { for (const timer of this.#timers) clearTimeout(timer); this.#timers.clear(); } +} 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..f09e00357 --- /dev/null +++ b/apps/desktop/src/acceptance-test-authorization.test.ts @@ -0,0 +1,232 @@ +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 { redactDesktopValue } from './secret-redaction'; + +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(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]*?recordCount:[\s\S]*?evidenceInvalid:[\s\S]*?invalidCategory:/, + ); + assert.match( + acceptanceRunner, + /recordCount: rendererLifecycleRecords\.filter\(record => record\.journey === journey\)\.length/, + ); + assert.match(acceptanceRunner, /rendererLifecycleInvalidCategories\.add\('schema-shape'\)/); + assert.match(acceptanceRunner, /rendererLifecycleInvalidCategories\.add\('overflow'\)/); + assert.match(acceptanceRunner, /if \(schemaShape && overflow\) return 'multiple'/); + + const errorSummaryStart = acceptanceRunner.indexOf('const rendererConsoleErrorCategory = record =>'); + const errorSummaryEnd = acceptanceRunner.indexOf('\nconst rendererLifecycleInvalidCategory', 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(redactDesktopValue(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..a80936101 --- /dev/null +++ b/apps/desktop/src/acceptance-test-authorization.ts @@ -0,0 +1,66 @@ +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-'; +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..cf2080591 --- /dev/null +++ b/apps/desktop/src/acceptance-zoom.test.ts @@ -0,0 +1,166 @@ +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..29285a8b6 --- /dev/null +++ b/apps/desktop/src/acceptance-zoom.ts @@ -0,0 +1,144 @@ +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/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts index 5db0b2c28..28cf08025 100644 --- a/apps/desktop/src/credential-service.test.ts +++ b/apps/desktop/src/credential-service.test.ts @@ -7,15 +7,22 @@ import { afterEach, describe, it } from 'node:test'; import { DESKTOP_RENDERER_ORIGIN, DESKTOP_REVOCATION_BINDING_HEADER, + DESKTOP_TRANSPORT_SCOPE_HEADER, DESKTOP_TOKEN_REVOCATION_ENDPOINT, DESKTOP_TOKEN_REVOCATION_SCHEMA, DESKTOP_TOKEN_REVOCATION_VERSION, PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY, } from '@propr/shared'; -import { DesktopCredentialService } from './credential-service'; +import { + DesktopCredentialService, + type DesktopCurrentUserProxyEvidence, + type DesktopWebSocketHandshakeEvidence, +} from './credential-service'; import { ProfileStore, type EncryptionProvider, type StoredCredential } from './profile-store'; +export const NATIVE_DURABILITY_EXPECTED_TESTS = 72 as const; + const temporaryDirectories: string[] = []; const credentialServices: DesktopCredentialService[] = []; const encryption: EncryptionProvider = { @@ -108,10 +115,26 @@ const createStore = async (): Promise => { return new ProfileStore(directory, encryption); }; +class MainRendererCredentialService extends DesktopCredentialService { + override prepareRequest( + url: Parameters[0], + originalHeaders: Parameters[1], + details: Parameters[2] = {}, + ) { + return super.prepareRequest(url, originalHeaders, { + rendererOwned: true, + ...details, + }); + } +} + const createCredentialService = ( dependencies: ConstructorParameters[0], ): DesktopCredentialService => { - const service = new DesktopCredentialService(dependencies); + // Existing service tests exercise requests already proven to belong to the + // live main renderer. Ownership-source behavior is covered through the real + // session interceptor in session-security.test.ts. + const service = new MainRendererCredentialService(dependencies); credentialServices.push(service); return service; }; @@ -191,7 +214,7 @@ describe('main-process desktop credential service', () => { // Simulate a session cookie Electron might otherwise append after the // main-process fetch has applied its unforgeable request marker. requestHeaders.Cookie = 'main-process=session'; - const decision = service.prepareRequest(url, requestHeaders); + const decision = service.prepareRequest(url, requestHeaders, { rendererOwned: false }); assert.equal(decision.cancel, undefined); wireRequests.push({ url, headers: decision.requestHeaders ?? {} }); return url.endsWith('/api/desktop/discovery') ? json(discovery) : json({ username: 'octocat' }); @@ -624,13 +647,24 @@ describe('main-process desktop credential service', () => { }); }); - it('passes through a realistic packaged-origin CORS preflight without renderer identity or bearer injection', () => { + it('passes through an active-origin CORS preflight without renderer identity or bearer injection', async () => { + const store = await createStore(); + const profile = await store.save({ + id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test', + }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); const service = createCredentialService({ - profiles: { awaitIdle: async () => undefined } as unknown as ProfileStore, + profiles: store, clientName: 'Test desktop', openExternal: async () => undefined, - fetch: async () => { throw new Error('Network is not expected'); }, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), }); + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + await service.activate(ready.activationTicket); assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', { Origin: DESKTOP_RENDERER_ORIGIN, @@ -647,6 +681,256 @@ describe('main-process desktop credential service', () => { }); }); + it('does not let a cached renderer grant outlive discard or revocation', async () => { + const store = await createStore(); + const profile = await store.save({ + id: 'profile-a', label: 'A', apiBaseUrl: 'https://same.example.test', + }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openExternal: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + }); + const targets = [ + 'http://127.0.0.1:41731/api/side-effect', + 'https://192.168.1.10/api/side-effect', + 'https://same.example.test.evil.invalid/api/side-effect', + 'https://same.example.test:444/api/side-effect', + ]; + const assertCancelled = (label: string) => { + for (const url of [...targets, `${profile.apiBaseUrl}/api/side-effect`]) { + assert.deepEqual(service.prepareRequest(url, { + Origin: DESKTOP_RENDERER_ORIGIN, + }), { cancel: true }, `${label}: ${url}`); + } + }; + + assertCancelled('no active binding'); + const firstReady = await service.probe(profile); + assert.equal(firstReady.status, 'ready'); + if (firstReady.status !== 'ready') return; + const first = await service.activate(firstReady.activationTicket); + for (const url of targets) { + assert.deepEqual(service.prepareRequest(url, { + Origin: DESKTOP_RENDERER_ORIGIN, + }), { cancel: true }, `active foreign origin: ${url}`); + } + assert.deepEqual(service.prepareRequest(`${profile.apiBaseUrl}/api/side-effect`, { + Origin: DESKTOP_RENDERER_ORIGIN, + }).requestHeaders, { Origin: DESKTOP_RENDERER_ORIGIN }); + + assert.deepEqual(await service.discardActivation(first), { discarded: true }); + assertCancelled('discarded binding'); + + const secondReady = await service.probe(profile); + assert.equal(secondReady.status, 'ready'); + if (secondReady.status !== 'ready') return; + const second = await service.activate(secondReady.activationTicket); + assert.deepEqual(await service.invalidate({ + profileId: profile.id, + transportScope: second.transportScope, + code: 'INSTANCE_TOKEN_REVOKED', + }), { invalidated: true }); + assertCancelled('revoked binding'); + }); + + it('reports every exact current-user proxy decision with bounded secret-free evidence', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const evidence: DesktopCurrentUserProxyEvidence[] = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openExternal: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + reportCurrentUserValidation: record => evidence.push(record), + }); + + const currentUserUrl = `${profile.apiBaseUrl}/api/auth/user?proprDesktopScopeGeneration=7`; + assert.deepEqual(service.prepareRequest(currentUserUrl, { + ...transportHeaders('N'.repeat(22)), + }, { method: 'GET', resourceType: 'xhr' }), { cancel: true }); + assert.deepEqual(evidence.at(-1), { + schemaVersion: 2, + correlation: 'current-scope-user-validation', + requestObserved: true, + method: 'get', + rendererScopeGeneration: 7, + scopeGenerationQueryCount: 1, + scopeGenerationQueryValid: true, + scopeHeaderCount: 1, + activeBindingPresent: false, + activeScopeGeneration: 0, + profileGenerationCurrent: false, + scopeEqualsActive: false, + originEqualsActive: false, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: false, + bearerMainInjected: false, + accepted: false, + rejectionCategory: 'no-active-binding', + }); + + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const activated = await service.activate(ready.activationTicket); + + assert.deepEqual(service.prepareRequest(currentUserUrl, {}, { + method: 'GET', resourceType: 'xhr', + }).requestHeaders, {}); + assert.deepEqual({ + count: evidence.at(-1)?.scopeHeaderCount, + accepted: evidence.at(-1)?.accepted, + rejectionCategory: evidence.at(-1)?.rejectionCategory, + outboundBearerPresent: evidence.at(-1)?.outboundBearerPresent, + }, { count: 0, accepted: false, rejectionCategory: 'scope-missing', outboundBearerPresent: false }); + + assert.deepEqual(service.prepareRequest(currentUserUrl, { + [DESKTOP_TRANSPORT_SCOPE_HEADER]: [activated.transportScope, activated.transportScope, activated.transportScope], + }, { method: 'GET', resourceType: 'xhr' }), { cancel: true }); + assert.deepEqual({ + count: evidence.at(-1)?.scopeHeaderCount, + accepted: evidence.at(-1)?.accepted, + rejectionCategory: evidence.at(-1)?.rejectionCategory, + bearerMainInjected: evidence.at(-1)?.bearerMainInjected, + }, { count: 2, accepted: false, rejectionCategory: 'scope-duplicate', bearerMainInjected: false }); + + assert.deepEqual(service.prepareRequest(currentUserUrl, { + ...transportHeaders('Z'.repeat(22)), + }, { method: 'GET', resourceType: 'xhr' }), { cancel: true }); + assert.deepEqual({ + count: evidence.at(-1)?.scopeHeaderCount, + accepted: evidence.at(-1)?.accepted, + rejectionCategory: evidence.at(-1)?.rejectionCategory, + bearerMainInjected: evidence.at(-1)?.bearerMainInjected, + }, { count: 1, accepted: false, rejectionCategory: 'stale-scope', bearerMainInjected: false }); + + assert.deepEqual(service.prepareRequest(currentUserUrl, { + ...transportHeaders(activated.transportScope), + }, { method: 'GET', resourceType: 'xhr' }).requestHeaders, { + Authorization: `Bearer ${token('A')}`, + }); + assert.deepEqual(evidence.at(-1), { + schemaVersion: 2, + correlation: 'current-scope-user-validation', + requestObserved: true, + method: 'get', + rendererScopeGeneration: 7, + 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', + }); + assert.equal(evidence.length, 5); + assert.doesNotMatch(JSON.stringify(evidence), /profile-a|example\.test|propr_it_/); + }); + + it('reports deterministic secret-free WebSocket decisions for positive and adversarial handshakes', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const evidence: DesktopWebSocketHandshakeEvidence[] = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openExternal: async () => undefined, + fetch: async input => input.toString().endsWith('/api/desktop/discovery') + ? json(discovery) + : json({ username: 'octocat' }), + reportWebSocketHandshake: record => evidence.push(record), + }); + const ready = await service.probe(profile); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const activated = await service.activate(ready.activationTicket); + const socketUrl = (scope = activated.transportScope, origin = profile.apiBaseUrl) => + `${origin.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket&proprDesktopTransportScope=${scope}`; + + assert.deepEqual(service.prepareRequest(socketUrl(), { + Authorization: 'Bearer renderer-controlled', + Cookie: 'renderer=session', + }, { resourceType: 'websocket' }).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + assert.deepEqual(evidence.at(-1), { + schemaVersion: 1, + path: 'socket-io', + transport: 'websocket', + resource: 'websocket', + scopeQueryPresent: true, + scopeQueryCount: 1, + scopeEqualsActive: true, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActive: true, + rendererBearerPresent: true, + rendererCookiePresent: true, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none', + }); + + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl.replace(/^http/, 'ws')}/socket.io/?EIO=4&transport=websocket`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.deepEqual({ + present: evidence.at(-1)?.scopeQueryPresent, + count: evidence.at(-1)?.scopeQueryCount, + equal: evidence.at(-1)?.scopeEqualsActive, + category: evidence.at(-1)?.rejectionCategory, + }, { present: false, count: 0, equal: false, category: 'scope-missing' }); + assert.deepEqual(service.prepareRequest(`${socketUrl()}&proprDesktopTransportScope=${activated.transportScope}`, {}, { + resourceType: 'webSocket', + }), { cancel: true }); + assert.deepEqual({ + present: evidence.at(-1)?.scopeQueryPresent, + count: evidence.at(-1)?.scopeQueryCount, + equal: evidence.at(-1)?.scopeEqualsActive, + category: evidence.at(-1)?.rejectionCategory, + }, { present: true, count: 2, equal: false, category: 'scope-duplicate' }); + assert.deepEqual(service.prepareRequest(socketUrl('S'.repeat(22)), {}, { resourceType: 'webSocket' }), { cancel: true }); + assert.deepEqual({ + count: evidence.at(-1)?.scopeQueryCount, + equal: evidence.at(-1)?.scopeEqualsActive, + category: evidence.at(-1)?.rejectionCategory, + }, { count: 1, equal: false, category: 'stale-scope' }); + assert.deepEqual(service.prepareRequest(socketUrl(activated.transportScope, 'https://b.example.test'), {}, { + resourceType: 'webSocket', + }), { cancel: true }); + assert.deepEqual({ + equal: evidence.at(-1)?.scopeEqualsActive, + originEqual: evidence.at(-1)?.originEqualsActive, + injected: evidence.at(-1)?.bearerMainInjected, + category: evidence.at(-1)?.rejectionCategory, + }, { equal: true, originEqual: false, injected: false, category: 'wrong-origin' }); + assert.deepEqual(service.prepareRequest(socketUrl(), {}, { resourceType: 'xhr' }), { cancel: true }); + assert.equal(evidence.at(-1)?.rejectionCategory, 'wrong-resource-type'); + + assert.equal(JSON.stringify(evidence).includes(activated.transportScope), false); + assert.equal(JSON.stringify(evidence).includes(token('A')), false); + assert.equal(JSON.stringify(evidence).includes('renderer-controlled'), false); + assert.equal(JSON.stringify(evidence).includes(profile.apiBaseUrl), false); + }); + it('rotates scope on every same-profile reprobe and rejects a cold reconnect from the old activation', async () => { const store = await createStore(); const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'http://localhost:3000' }); @@ -708,8 +992,26 @@ describe('main-process desktop credential service', () => { }); assert.equal(result.status, 'authentication-required'); - assert.equal(requests.filter(request => request.url.startsWith('https://attacker.example.test')) - .every(request => request.authorization === null), true); + assert.equal(requests.length, 1); + const request = requests.at(0); + assert.ok(request); + const requestUrl = new URL(request.url); + assert.deepEqual({ + origin: requestUrl.origin, + hostname: requestUrl.hostname, + pathname: requestUrl.pathname, + search: requestUrl.search, + authorization: request.authorization, + }, { + origin: 'https://attacker.example.test', + hostname: 'attacker.example.test', + pathname: '/api/desktop/discovery', + search: '', + authorization: null, + }); + const lookalikeUrl = new URL('https://attacker.example.test.evil.invalid/api/desktop/discovery'); + assert.notEqual(lookalikeUrl.origin, requestUrl.origin); + assert.notEqual(lookalikeUrl.hostname, requestUrl.hostname); assert.equal(requests.some(request => request.url === 'https://a.example.test/api/desktop/tokens/current'), false); assert.deepEqual(await store.readCredential(profile.id), credential(profile.id, profile.apiBaseUrl, 'A')); }); diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index 55c180cf6..b1ed7c3dc 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -51,6 +51,78 @@ export interface CredentialServiceDependencies { code: 'network' | 'http' | 'local-cleanup'; status?: number; }): void; + /** Fixed, secret-free evidence for the packaged acceptance boundary. */ + reportWebSocketHandshake?(evidence: DesktopWebSocketHandshakeEvidence): void; + /** Fixed, secret-free evidence for scoped renderer current-user validation. */ + reportCurrentUserValidation?(evidence: DesktopCurrentUserProxyEvidence): void; +} + +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'; + /** Renderer cache-boundary generation parsed from the one canonical query. */ + rendererScopeGeneration: number | null; + /** Exact for zero/one; two means two or more, keeping adversarial evidence bounded. */ + scopeGenerationQueryCount: 0 | 1 | 2; + scopeGenerationQueryValid: boolean; + /** Exact for zero/one; two means two or more, keeping adversarial evidence bounded. */ + 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 CredentialServiceInitialization { @@ -183,6 +255,25 @@ 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 || [...url.searchParams].length !== 1) { + return { count, generation: null, valid: false }; + } + const value = values[0]; + const generation = /^(?:0|[1-9]\d{0,15})$/.test(value) ? Number(value) : NaN; + const valid = Number.isSafeInteger(generation) + && url.search === `?${CURRENT_USER_SCOPE_GENERATION_QUERY}=${value}`; + return { count, generation: valid ? generation : null, valid }; +}; + const parseCode = async (response: Response): Promise => { try { const value = await response.clone().json() as { code?: unknown }; @@ -338,6 +429,8 @@ export class DesktopCredentialService { readonly #pairingTiming: Pick; readonly #pairingProtocol: PairingProtocolRequestOptions; readonly #reportRevocationFailure: NonNullable; + readonly #reportWebSocketHandshake: NonNullable; + readonly #reportCurrentUserValidation: NonNullable; readonly #revocationDeadlines: RevocationDeadlines; readonly #internalRequestKey = randomBytes(32).toString('base64url'); readonly #lifecycleController = new AbortController(); @@ -369,6 +462,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); } @@ -1045,17 +1140,21 @@ export class DesktopCredentialService { } } + /** Whether main currently owns the credential binding required by renderer transport. */ + hasActiveRendererBinding(): boolean { + const active = this.#active; + return active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration; + } + prepareRequest( url: string, originalHeaders: RequestHeaders, - details: { method?: string; resourceType?: string } = {}, + details: { method?: string; rendererOwned?: boolean; resourceType?: string } = {}, ): 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 internalHeader = headerName(headers, 'x-propr-desktop-main-request'); const trustedMainRequest = internalHeader !== undefined && headers[internalHeader] === this.#internalRequestKey; @@ -1067,47 +1166,215 @@ export class DesktopCredentialService { // 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. + 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'; + // Electron documents `webSocket`; normalizing the comparison also covers + // Chromium's lower-case serialization without accepting another resource. + const resource = details.resourceType?.toLowerCase() === 'websocket' + || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket') + ? 'websocket' + : 'other'; + const queryScopes = target?.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY) ?? []; + const active = this.#active; + const activeGenerationCurrent = active !== null + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration; + const scopeEqualsActive = queryScopes.length === 1 + && active !== null + && queryScopes[0] === active.transportScope; + const isHandshakeCandidate = path === 'socket-io' || queryScopes.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, + authorizationMainInjected = false, + ): void => { + if (!isHandshakeCandidate || trustedMainRequest) return; + try { + this.#reportWebSocketHandshake({ + schemaVersion: 1, + path, + transport, + resource, + scopeQueryPresent: queryScopes.length > 0, + scopeQueryCount: queryScopes.length, + scopeEqualsActive, + activeBindingPresent: active !== null, + profileGenerationCurrent: activeGenerationCurrent, + originEqualsActive: target !== null && active !== null && target.origin === active.origin, + rendererBearerPresent: rendererAuthorizationPresent, + rendererCookiePresent, + outboundBearerPresent: headerValues(headers, 'authorization').length > 0, + bearerMainInjected: authorizationMainInjected, + accepted, + rejectionCategory, + }); + } catch { + // Diagnostics cannot change the request authorization decision. + } + }; + const reportCurrentUser = ( + accepted: boolean, + rejectionCategory: DesktopCurrentUserProxyRejectionCategory, + authorizationMainInjected = 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: activeGenerationCurrent, + scopeEqualsActive: scopeValues.length === 1 && active !== null + && scopeValues[0] === active.transportScope, + originEqualsActive: target !== null && active !== null && target.origin === active.origin, + rendererBearerPresent: rendererAuthorizationPresent, + rendererCookiePresent: rendererCookiePresent, + outboundBearerPresent: headerValues(headers, 'authorization').length > 0, + bearerMainInjected: authorizationMainInjected, + accepted, + rejectionCategory, + }); + } catch { + // Diagnostics cannot change the request authorization decision. + } + }; + removeHeader(headers, 'cookie'); if (!trustedMainRequest) removeHeader(headers, 'authorization'); - const target = requestOrigin(url); + if (/^(?:https?|wss?):/i.test(url)) { + const httpUrl = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'); + if (!canonicalProprHttpUrlOrigin(httpUrl)) { + 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; + const activeIsCurrent = activeGenerationCurrent; const isApiRequest = target?.pathname.startsWith('/api/') === true; - 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'; + + // All WebContents in the desktop share this Electron session. Only the + // live main renderer, identified by session-security from webContentsId, + // may use the active credential binding. Foreign isolated fixtures may + // load ordinary credentialless resources, but no marker they supply can + // authorize REST or Socket.IO transport or trigger bearer injection. + if (details.rendererOwned !== true) { + if (markedRestRequest || isHandshakeCandidate) return { cancel: true }; + return { requestHeaders: headers }; + } + + if (isHandshakeCandidate && !isSocketUpgrade) { + const category = path !== 'socket-io' + ? 'wrong-path' + : transport !== 'websocket' + ? 'wrong-transport' + : 'wrong-resource-type'; + reportHandshake(false, category); + return { cancel: true }; + } if (isSocketUpgrade && target) { - const queryScopes = target.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); - if (queryScopes.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(queryScopes[0]) - || !activeIsCurrent || target.origin !== active.origin - || queryScopes[0] !== active.transportScope) return { cancel: true }; + if (queryScopes.length === 0) { + reportHandshake(false, 'scope-missing'); + return { cancel: true }; + } + if (queryScopes.length !== 1) { + reportHandshake(false, 'scope-duplicate'); + return { cancel: true }; + } + if (!TRANSPORT_SCOPE_PATTERN.test(queryScopes[0])) { + reportHandshake(false, 'scope-malformed'); + return { cancel: true }; + } + if (!active) { + reportHandshake(false, 'no-active-binding'); + return { cancel: true }; + } + if (!activeGenerationCurrent) { + reportHandshake(false, 'stale-generation'); + return { cancel: true }; + } + if (target.origin !== active.origin) { + reportHandshake(false, 'wrong-origin'); + return { cancel: true }; + } + if (!scopeEqualsActive) { + reportHandshake(false, 'stale-scope'); + 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 }; + // Electron's Local Network Access permission can remain cached after the + // main renderer binding is discarded or revoked. Every main-renderer + // HTTP(S)/WS(S) request therefore needs a current binding and the exact + // active origin before even sanitized, unmarked traffic may leave it. + if (target) { + if (!active) { + reportCurrentUser(false, 'no-active-binding'); + return { cancel: true }; + } + if (!activeIsCurrent) { + reportCurrentUser(false, 'stale-generation'); + return { cancel: true }; + } + if (target.origin !== active.origin) { + reportCurrentUser(false, 'wrong-origin'); + return { cancel: true }; + } + } + + if (!markedRestRequest) { + reportCurrentUser(false, 'scope-missing'); + return { requestHeaders: headers }; + } + // `target` traffic was required to prove these immediately above. + if (!active || !activeIsCurrent) return { cancel: true }; + if (!target || !isApiRequest || target.origin !== active.origin) { + reportCurrentUser(false, 'wrong-origin'); + return { cancel: true }; + } + if (scopeValues[0] !== active.transportScope) { + reportCurrentUser(false, '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/ipc.ts b/apps/desktop/src/ipc.ts index 975bcb4d9..53718837a 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -15,7 +15,8 @@ interface RegisterIpcOptions { profiles: ProfileStore; credentials: DesktopCredentialService; lifecycle: LocalLifecycleController; - setup?: DesktopSetupController; + setup?: Pick; logger: DesktopLogger; desktopSession: Session; devServerUrl: string | undefined; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2bdb7f961..751473740 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,7 +6,11 @@ import type { Rectangle } from 'electron'; import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; import type { SetupActions } from '@propr/local-setup'; import { DeepLinkDelivery } from './deep-link-delivery'; -import { DesktopCredentialService } from './credential-service'; +import { + DesktopCredentialService, + type DesktopCurrentUserProxyEvidence, + type DesktopWebSocketHandshakeEvidence, +} from './credential-service'; import { createDesktopLocalHost } from './desktop-host'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; @@ -34,7 +38,23 @@ import { import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; -import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { + createPackagedSmokeEvidenceSink, + PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT, + PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT, + type PackagedSmokeCurrentUserEvidenceBuffer, + type PackagedSmokeHandshakeEvidenceBuffer, +} from './smoke-test-evidence'; +import { + authorizePackagedAcceptanceTest, + packagedAcceptancePairingTiming, +} from './acceptance-test-authorization'; +import { registerPackagedAcceptanceZoomIpc } from './acceptance-zoom'; +import { AcceptanceSetupController } from './acceptance-setup-controller'; +import { + configureDesktopSessionSecurity, + type DesktopNetworkPermissionEvidence, +} from './session-security'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -51,8 +71,16 @@ const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_windo 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; 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), @@ -69,10 +97,28 @@ try { packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } + if (packagedAcceptanceUserDataDirectory) { + if (packagedSmokeUserDataDirectory) throw new Error('Desktop test modes are mutually exclusive'); + const stats = lstatSync(packagedAcceptanceUserDataDirectory); + if (!stats.isDirectory() || stats.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 packagedSmokeHandshakeEvidence: PackagedSmokeHandshakeEvidenceBuffer = { + records: [], + overflowed: false, +}; +const packagedSmokeCurrentUserEvidence: PackagedSmokeCurrentUserEvidenceBuffer = { + records: [], + overflowed: false, +}; +const acceptancePairingTiming = packagedAcceptancePairingTiming(packagedAcceptanceUserDataDirectory); const transportSmoke = packagedTransportSmoke(packagedSmokeTest); const inertSetupActions = new Proxy({} as SetupActions, { get() { @@ -87,7 +133,7 @@ const deepLinkDelivery = new DeepLinkDelivery( ); let logger: DesktopLogger | null = null; let shutdownStarted = false; -let setupController: DesktopSetupController | null = null; +let setupController: DesktopSetupController | AcceptanceSetupController | null = null; const operationCoordinator = new DesktopOperationCoordinator(); if (process.platform === 'win32') { @@ -129,41 +175,6 @@ const deliverDeepLink = (value: string): void => { deepLinkDelivery.deliver(value); }; -const configureSessionSecurity = (credentials: DesktopCredentialService): { - close(): void; - dispose(): void; -} => { - const desktopSession = session.defaultSession; - desktopSession.setPermissionCheckHandler(() => false); - desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); - desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { - callback(credentials.prepareRequest(details.url, details.requestHeaders, { - method: details.method, - resourceType: details.resourceType, - })); - }); - desktopSession.webRequest.onHeadersReceived((details, callback) => { - callback({ - responseHeaders: { - ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}), - 'Content-Security-Policy': [rendererContentSecurityPolicy(!app.isPackaged)], - }, - }); - }); - return { - close() { - desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true })); - desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true })); - }, - dispose() { - desktopSession.setPermissionCheckHandler(null); - desktopSession.setPermissionRequestHandler(null); - desktopSession.webRequest.onBeforeSendHeaders(null); - desktopSession.webRequest.onHeadersReceived(null); - }, - }; -}; - const configurePackagedRendererProtocol = (): (() => void) => { protocol.handle(PACKAGED_RENDERER_SCHEME, request => { const requestUrl = new URL(request.url); @@ -291,6 +302,12 @@ const createMainWindow = async (smoke: PackagedTransportSmoke | null = null): Pr 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 }) => { @@ -451,13 +468,52 @@ if (!hasSingleInstanceLock) { const credentials = new DesktopCredentialService({ profiles, fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, - openExternal: async url => { await shell.openExternal(url); }, + openExternal: async url => { + if (packagedAcceptanceTest) return; + await shell.openExternal(url); + }, 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 }); + }, + } : {}), + ...(packagedSmokeTest ? { + reportWebSocketHandshake: (evidence: DesktopWebSocketHandshakeEvidence) => { + if (packagedSmokeHandshakeEvidence.records.length >= PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT) { + packagedSmokeHandshakeEvidence.overflowed = true; + return; + } + packagedSmokeHandshakeEvidence.records.push(evidence); + }, + reportCurrentUserValidation: (evidence: DesktopCurrentUserProxyEvidence) => { + if (packagedSmokeCurrentUserEvidence.records.length >= PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT) { + packagedSmokeCurrentUserEvidence.overflowed = true; + return; + } + packagedSmokeCurrentUserEvidence.records.push(evidence); + }, + } : {}), + ...(acceptancePairingTiming ? { pairingTiming: acceptancePairingTiming } : {}), + }); + const sessionSecurity = configureDesktopSessionSecurity({ + contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged), + credentials, + desktopSession: session.defaultSession, + getMainRenderer: () => mainWindow?.webContents ?? null, + isTrustedRendererUrl: value => isTrustedRendererUrl(value, devServerUrl, packagedRendererUrl), + ...(packagedAcceptanceTest ? { + reportNetworkPermissionDecision: (evidence: DesktopNetworkPermissionEvidence) => { + log('info', 'desktop.acceptance.network_permission', { ...evidence }); + }, + } : {}), }); - const sessionSecurity = configureSessionSecurity(credentials); const credentialInitialization = await credentials.initialize(); if (credentialInitialization.status === 'degraded') { log('warn', 'desktop.credential_revocation.startup_degraded', { @@ -465,14 +521,24 @@ if (!hasSingleInstanceLock) { }); } const defaultRootDir = join(app.getPath('userData'), 'desktop', 'local-stack'); - const localHost = process.platform === 'linux' && !packagedSmokeTest + const localHost = process.platform === 'linux' && !packagedSmokeTest && !packagedAcceptanceTest ? await createDesktopLocalHost(app.isPackaged ? process.resourcesPath : undefined, defaultRootDir, app.getPath('userData')) : null; const lifecycle = new LocalLifecycleController( localHost?.lifecycle, (event, fields) => log('error', event, fields), ); - setupController = new DesktopSetupController({ + const emitSetupSnapshot = (snapshot: import('./shared/contract').DesktopSetupSnapshot) => { + const target = mainWindow; + if (target && !target.isDestroyed()) target.webContents.send(IPC_CHANNELS.setupProgress, snapshot); + }; + setupController = packagedAcceptanceTest + ? new AcceptanceSetupController({ + rootDir: defaultRootDir, + scenario: process.env.PROPR_DESKTOP_ACCEPTANCE_SCENARIO, + emit: emitSetupSnapshot, + }) + : new DesktopSetupController({ actions: localHost?.actions ?? inertSetupActions, platform: packagedSmokeTest ? 'darwin' : process.platform, appDataDir: app.getPath('userData'), @@ -504,10 +570,7 @@ if (!hasSingleInstanceLock) { lastConnectedAt: saved.updatedAt, }; }, - emit(snapshot) { - const target = mainWindow; - if (target && !target.isDestroyed()) target.webContents.send(IPC_CHANNELS.setupProgress, snapshot); - }, + emit: emitSetupSnapshot, diagnose(event, fields) { log('error', event, fields); }, }); const registeredIpc = registerIpcHandlers({ @@ -522,7 +585,10 @@ if (!hasSingleInstanceLock) { devServerUrl, packagedRendererUrl, coordinator: operationCoordinator, - openExternal: async url => { await shell.openExternal(url); }, + openExternal: async url => { + if (packagedAcceptanceTest) return; + await shell.openExternal(url); + }, }); const shutdownLifecycle = transportSmoke?.shutdownMode === 'forced-timeout' @@ -552,6 +618,8 @@ if (!hasSingleInstanceLock) { credentials, desktopSession: session.defaultSession, smoke: transportSmoke, + handshakeEvidence: packagedSmokeHandshakeEvidence, + currentUserEvidence: packagedSmokeCurrentUserEvidence, log: (event, fields) => log('info', event, fields), }); } @@ -571,7 +639,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/packaged-transport-smoke.test.ts b/apps/desktop/src/packaged-transport-smoke.test.ts new file mode 100644 index 000000000..aa216e59b --- /dev/null +++ b/apps/desktop/src/packaged-transport-smoke.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +describe('packaged transport smoke sequencing', () => { + it('proves same-host cookie cleanup before credentialless reseed, rollback, and all-origin cleanup', () => { + const smoke = readFileSync( + fileURLToPath(new URL('./packaged-transport-smoke.ts', import.meta.url)), + 'utf8', + ); + const perStorageTypeModel = smoke.indexOf( + "type OriginStorageExpectation = Readonly>;", + ); + const perOriginStateSelection = smoke.indexOf( + 'const expectedState = expected[item.name];', + perStorageTypeModel, + ); + const cookieExpectation = smoke.indexOf( + "const cookieExpectedPresent = expectedState.cookie === 'present';", + perOriginStateSelection, + ); + const exactSessionCookieInspection = smoke.indexOf( + "cookie.name === 'packaged-smoke-cookie' && cookie.value === 'present'", + cookieExpectation, + ); + const originScopedTypeExpectation = smoke.indexOf( + "rendererState[storageType] === (expectedState[storageType] === 'present')", + exactSessionCookieInspection, + ); + const allStoragePresentDefinition = smoke.indexOf(` const allStoragePresent: OriginStorageExpectation = { + cookie: 'present', + localStorage: 'present', + indexedDB: 'present', + cacheStorage: 'present', + serviceWorker: 'present', + };`); + const allStorageAbsentDefinition = smoke.indexOf(` const allStorageAbsent: OriginStorageExpectation = { + cookie: 'absent', + localStorage: 'absent', + indexedDB: 'absent', + cacheStorage: 'absent', + serviceWorker: 'absent', + };`); + const activationCleanupSplitDefinition = smoke.indexOf(` const activationCleanupSplit: StorageExpectation = { + first: allStorageAbsent, + second: { + cookie: 'absent', + localStorage: 'present', + indexedDB: 'present', + cacheStorage: 'present', + serviceWorker: 'present', + }, + };`); + const initialSeed = smoke.indexOf(' await seedStorage();'); + const preactivationPresent = smoke.indexOf( + 'if (!await storageState(bothOriginsPresent))', + initialSeed, + ); + const firstActivation = smoke.indexOf('const first = await smoke.activate(', preactivationPresent); + const staleSocketEvidence = smoke.indexOf( + "log('desktop.transport_smoke.stale_socket_boundary'", + firstActivation, + ); + const activationCleanupSplit = smoke.indexOf( + 'if (!await storageState(activationCleanupSplit))', + staleSocketEvidence, + ); + const credentiallessReseed = smoke.indexOf(' await seedStorage();', activationCleanupSplit); + const reseededPresent = smoke.indexOf( + 'if (!await storageState(bothOriginsPresent))', + credentiallessReseed, + ); + const rollback = smoke.indexOf( + 'const rollback = await profiles.readProfileCredential(profileId);', + reseededPresent, + ); + const rollbackPresent = smoke.indexOf( + '|| !await storageState(bothOriginsPresent)', + rollback, + ); + const allOriginCleanup = smoke.indexOf('await clearDesktopInstanceCookies(', rollbackPresent); + const absentBeforeCommit = smoke.indexOf( + 'precommitStorageCleared = await storageState(bothOriginsAbsent);', + allOriginCleanup, + ); + const absentAfterCommit = smoke.indexOf( + '!await storageState(bothOriginsAbsent)', + absentBeforeCommit, + ); + + assert.notEqual(perStorageTypeModel, -1); + assert.notEqual(allStoragePresentDefinition, -1); + assert.notEqual(allStorageAbsentDefinition, -1); + assert.notEqual(activationCleanupSplitDefinition, -1); + assert.ok(perStorageTypeModel < perOriginStateSelection); + assert.ok(perOriginStateSelection < cookieExpectation); + assert.ok(cookieExpectation < exactSessionCookieInspection); + assert.ok(exactSessionCookieInspection < originScopedTypeExpectation); + assert.ok(originScopedTypeExpectation < allStoragePresentDefinition); + assert.ok(allStoragePresentDefinition < allStorageAbsentDefinition); + assert.ok(allStorageAbsentDefinition < activationCleanupSplitDefinition); + assert.ok(activationCleanupSplitDefinition < initialSeed); + assert.ok(initialSeed < preactivationPresent && preactivationPresent < firstActivation); + assert.ok(firstActivation < staleSocketEvidence && staleSocketEvidence < activationCleanupSplit); + assert.ok(activationCleanupSplit < credentiallessReseed && credentiallessReseed < reseededPresent); + assert.ok(reseededPresent < rollback && rollback < rollbackPresent); + assert.ok(rollbackPresent < allOriginCleanup); + assert.ok(allOriginCleanup < absentBeforeCommit && absentBeforeCommit < absentAfterCommit); + }); +}); diff --git a/apps/desktop/src/packaged-transport-smoke.ts b/apps/desktop/src/packaged-transport-smoke.ts index 9b7bb7072..e1b3c6291 100644 --- a/apps/desktop/src/packaged-transport-smoke.ts +++ b/apps/desktop/src/packaged-transport-smoke.ts @@ -5,6 +5,12 @@ import type { DesktopCredentialService } from './credential-service'; import { clearDesktopInstanceCookies } from './desktop-session'; import type { ProfileStore } from './profile-store'; import { normalizeApiBaseUrl } from './security'; +import { + validatePackagedCurrentUserBoundaryEvidence, + validatePackagedStaleSocketBoundaryEvidence, + type PackagedSmokeCurrentUserEvidenceBuffer, + type PackagedSmokeHandshakeEvidenceBuffer, +} from './smoke-test-evidence'; export interface PackagedTransportSmoke { firstOrigin: string; @@ -40,6 +46,8 @@ interface RunPackagedTransportSmokeOptions { credentials: DesktopCredentialService; desktopSession: Session; smoke: PackagedTransportSmoke; + handshakeEvidence: PackagedSmokeHandshakeEvidenceBuffer; + currentUserEvidence: PackagedSmokeCurrentUserEvidenceBuffer; log(event: string, fields: Record): void; } @@ -50,6 +58,8 @@ export const runPackagedTransportSmoke = async ({ credentials, desktopSession, smoke, + handshakeEvidence, + currentUserEvidence, log, }: RunPackagedTransportSmokeOptions): Promise => { const profileId = 'packaged-transport-smoke'; @@ -59,6 +69,15 @@ export const runPackagedTransportSmoke = async ({ if (!security.available || security.backend === 'basic_text') { throw new Error('Packaged transport smoke requires the production OS credential backend'); } + const waitForCurrentUserEvidence = async (expected: number): Promise => { + const deadline = Date.now() + 5_000; + while (currentUserEvidence.records.length < expected && Date.now() < deadline) { + await new Promise(resolveWait => setTimeout(resolveWait, 20)); + } + if (currentUserEvidence.records.length !== expected || currentUserEvidence.overflowed) { + throw new Error('Packaged current-user main-boundary evidence count was invalid'); + } + }; const profileA = await profiles.save({ id: profileId, label: 'Packaged transport A', apiBaseUrl: smoke.firstOrigin, }); @@ -67,17 +86,19 @@ export const runPackagedTransportSmoke = async ({ }); if (!storedA.stored) throw new Error('Production credential encryption was unavailable'); - const storageWindows = await Promise.all([smoke.firstOrigin, smoke.secondOrigin].map(async origin => { + const storageWindows = await Promise.all([ + { name: 'first' as const, origin: smoke.firstOrigin }, + { name: 'second' as const, origin: smoke.secondOrigin }, + ].map(async fixture => { const storageWindow = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }, }); - await storageWindow.loadURL(`${origin}/smoke-storage`); - return { origin, window: storageWindow }; + await storageWindow.loadURL(`${fixture.origin}/smoke-storage`); + return { ...fixture, window: storageWindow }; })); const seedStorage = async (): Promise => { await Promise.all(storageWindows.map(item => item.window.webContents.executeJavaScript(`(async () => { - document.cookie = 'packaged-smoke-cookie=present; SameSite=Lax'; localStorage.setItem('packaged-smoke-local', 'present'); await new Promise((resolve, reject) => { const request = indexedDB.open('packaged-smoke-indexeddb', 1); @@ -87,12 +108,20 @@ export const runPackagedTransportSmoke = async ({ }); const cache = await caches.open('packaged-smoke-cache'); await cache.put('/packaged-smoke-cache-entry', new Response('present')); - await navigator.serviceWorker.register('/smoke-sw.js'); - await navigator.serviceWorker.ready; + const registrations = await navigator.serviceWorker.getRegistrations(); + if (!registrations.some(registration => registration.scope.startsWith(location.origin))) { + await navigator.serviceWorker.register('/smoke-sw.js'); + await navigator.serviceWorker.ready; + } + document.cookie = 'packaged-smoke-cookie=present; SameSite=Lax'; return true; })()`))); }; - const storageState = async (expected: 'present' | 'absent'): Promise => { + type StoragePresence = 'present' | 'absent'; + type StorageType = 'cookie' | 'localStorage' | 'indexedDB' | 'cacheStorage' | 'serviceWorker'; + type OriginStorageExpectation = Readonly>; + type StorageExpectation = Readonly>; + const storageState = async (expected: StorageExpectation): Promise => { const states = await Promise.all(storageWindows.map(async item => { const rendererState = await item.window.webContents.executeJavaScript(`(async () => ({ cookie: document.cookie.includes('packaged-smoke-cookie=present'), @@ -102,12 +131,62 @@ export const runPackagedTransportSmoke = async ({ serviceWorker: (await navigator.serviceWorker.getRegistrations()).some(registration => registration.scope.startsWith(location.origin)), }))()`); const cookies = await desktopSession.cookies.get({ url: item.origin }); - return { ...rendererState, cookie: rendererState.cookie || cookies.length > 0 } as Record; + const expectedState = expected[item.name]; + const cookieExpectedPresent = expectedState.cookie === 'present'; + const cookieMatches = cookieExpectedPresent + ? rendererState.cookie === true + && cookies.some(cookie => cookie.name === 'packaged-smoke-cookie' && cookie.value === 'present') + : rendererState.cookie === false && cookies.length === 0; + return cookieMatches + && (['localStorage', 'indexedDB', 'cacheStorage', 'serviceWorker'] as const) + .every(storageType => rendererState[storageType] === (expectedState[storageType] === 'present')); })); - return states.every(state => Object.values(state).every(value => value === (expected === 'present'))); + return states.every(Boolean); + }; + const allStoragePresent: OriginStorageExpectation = { + cookie: 'present', + localStorage: 'present', + indexedDB: 'present', + cacheStorage: 'present', + serviceWorker: 'present', + }; + const allStorageAbsent: OriginStorageExpectation = { + cookie: 'absent', + localStorage: 'absent', + indexedDB: 'absent', + cacheStorage: 'absent', + serviceWorker: 'absent', + }; + const bothOriginsPresent: StorageExpectation = { + first: allStoragePresent, + second: allStoragePresent, + }; + // Chromium cookies are shared by host across these fixture ports; the + // remaining stores are scoped to each complete origin. + const activationCleanupSplit: StorageExpectation = { + first: allStorageAbsent, + second: { + cookie: 'absent', + localStorage: 'present', + indexedDB: 'present', + cacheStorage: 'present', + serviceWorker: 'present', + }, + }; + const bothOriginsAbsent: StorageExpectation = { + first: allStorageAbsent, + second: allStorageAbsent, }; try { + // Both fixture origins must be populated while no renderer credential + // binding is active. Once activation publishes a binding, production + // correctly restricts renderer network traffic to that exact origin. + await seedStorage(); + if (!await storageState(bothOriginsPresent)) { + throw new Error('Packaged preactivation origin storage fixture was incomplete'); + } + await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { const started = Date.now(); const poll = () => { @@ -134,10 +213,6 @@ export const runPackagedTransportSmoke = async ({ }); staleRestRejected = !response.ok; } catch { staleRestRejected = true; } - await smoke.expectSocketRejected(socketId); - await smoke.rest(); - localStorage.setItem('packaged-smoke-local', 'non-secret sentinel'); - sessionStorage.setItem('packaged-smoke-session', 'non-secret sentinel'); return { first, rotated, socketId, staleRestRejected, rendererOrigin: location.origin }; })()`); if (first?.rendererOrigin !== DESKTOP_RENDERER_ORIGIN || first?.first?.profileId !== profileId @@ -146,8 +221,37 @@ export const runPackagedTransportSmoke = async ({ || first?.staleRestRejected !== true) { throw new Error('Packaged renderer protocol or first transport proof failed'); } + await waitForCurrentUserEvidence(2); + const staleAttemptEvidenceStart = handshakeEvidence.records.length; + const staleAttemptResult = await window.webContents.executeJavaScript(` + window.__proprPackagedTransportSmoke.expectSocketRejected(${JSON.stringify(first.socketId)}) + `); + const staleAttemptEvidence = handshakeEvidence.records.slice(staleAttemptEvidenceStart); + const staleBoundarySummary = validatePackagedStaleSocketBoundaryEvidence( + staleAttemptEvidence, + handshakeEvidence.overflowed, + ); + if (staleAttemptResult?.transportRejected !== true + || staleAttemptResult?.freshManagerConnected !== true) { + throw new Error('Packaged stale Socket.IO renderer-boundary evidence failed'); + } + log('desktop.transport_smoke.stale_socket_boundary', { ...staleBoundarySummary }); + await window.webContents.executeJavaScript(`(async () => { + const smoke = window.__proprPackagedTransportSmoke; + await smoke.rest(); + localStorage.setItem('packaged-smoke-local', 'non-secret sentinel'); + sessionStorage.setItem('packaged-smoke-session', 'non-secret sentinel'); + })()`); + if (!await storageState(activationCleanupSplit)) { + throw new Error('Packaged activation did not clear only the activated origin storage'); + } + + // These isolated fixture WebContents are not the trusted main renderer, so + // they can reseed both foreign origins without receiving renderer credentials. await seedStorage(); - if (!await storageState('present')) throw new Error('Packaged origin storage fixture was incomplete'); + if (!await storageState(bothOriginsPresent)) { + throw new Error('Packaged credentialless origin storage reseed was incomplete'); + } let cleanupFailed = false; try { @@ -160,7 +264,7 @@ export const runPackagedTransportSmoke = async ({ const rollback = await profiles.readProfileCredential(profileId); if (!cleanupFailed || rollback.profile?.apiBaseUrl !== smoke.firstOrigin || rollback.credential?.origin !== smoke.firstOrigin || rollback.credential.token !== tokenA - || !await storageState('present')) { + || !await storageState(bothOriginsPresent)) { throw new Error('Origin cleanup failure did not preserve complete durable A'); } let precommitStorageCleared = false; @@ -168,10 +272,10 @@ export const runPackagedTransportSmoke = async ({ id: profileId, label: 'Packaged transport B', apiBaseUrl: smoke.secondOrigin, }, async (previousOrigin, nextOrigin) => { await clearDesktopInstanceCookies(desktopSession, [previousOrigin, nextOrigin]); - precommitStorageCleared = await storageState('absent'); + precommitStorageCleared = await storageState(bothOriginsAbsent); if (!precommitStorageCleared) throw new Error('Complete origin storage was not cleared before commit'); }); - if (!precommitStorageCleared || !await storageState('absent')) { + if (!precommitStorageCleared || !await storageState(bothOriginsAbsent)) { throw new Error('Same-ID URL edit did not clear both complete Electron origin stores'); } const storedB = await profiles.writeCredential({ @@ -210,6 +314,13 @@ export const runPackagedTransportSmoke = async ({ || second?.rendererPersistenceContainsSecret !== false || secretInMainMetadata) { throw new Error('Packaged replacement scope or secret-custody proof failed'); } + await waitForCurrentUserEvidence(3); + log('desktop.renderer.transport_smoke.current_user', { + ...validatePackagedCurrentUserBoundaryEvidence( + currentUserEvidence.records, + currentUserEvidence.overflowed, + ), + }); log('desktop.renderer.transport_smoke.ready', { customProtocol: true, restBearer: true, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 60afc251d..e209c952d 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, createDesktopRendererBridge } from './preload-bridge'; const desktopBridge = createDesktopBridge(ipcRenderer); @@ -7,3 +8,5 @@ contextBridge.exposeInMainWorld( '__PROPR_DESKTOP__', createDesktopRendererBridge(ipcRenderer, process.platform, undefined, desktopBridge.app.onDeepLink), ); +const acceptanceZoom = createPackagedAcceptanceZoomBridge(ipcRenderer, webFrame); +if (acceptanceZoom) contextBridge.exposeInMainWorld('__PROPR_PACKAGED_ACCEPTANCE__', acceptanceZoom); diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index c59929645..cb06fff79 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -232,6 +232,33 @@ describe('desktop trusted release workflow', () => { assert.equal(normalizedFixture, workflow); }); + test('gives only the required stock-Chromium axe predecessor the real-browser opt-in', () => { + const predecessor = job('renderer-axe-boundary', 'package'); + const packages = job('package', 'finalize'); + assert.match( + predecessor, + /name: Prove stock Chromium existing-target axe boundary[\s\S]*- name: Run focused stock Chromium existing-target axe regressions\n\s+# This required predecessor step exclusively owns the real-browser opt-in\.\n\s+env:\n\s+PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY: '1'\n\s+run: node --test --test-name-pattern='packaged acceptance renderer variants'/, + ); + assert.equal(workflow.match(/^\s+PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY:/gm)?.length, 1); + assert.match(packages, /needs: \[validation-version, renderer-axe-boundary\]/); + assert.equal(packages.match(platformArchitecturePattern)?.length, 6); + assert.match(packages, /npm run desktop:test/); + assert.ok(!packages.includes('PROPR_DESKTOP_REAL_CHROMIUM_BOUNDARY')); + }); + + test('runs and uploads fail-closed visual acceptance only from Linux x64 packages', () => { + for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { + assert.match(section, /if: matrix\.platform == 'linux' && matrix\.arch == 'x64'[\s\S]*npm run desktop:acceptance/); + assert.match(section, /npm run desktop:acceptance:verify/); + assert.match(section, /if-no-files-found: error/); + assert.match(section, /path: desktop-acceptance-artifacts/); + assert.match(section, /dbus-run-session[\s\S]*gnome-keyring-daemon[\s\S]*xvfb-run/); + } + assert.equal(workflow.match(/npm run desktop:acceptance$/gm)?.length, 2); + assert.equal(workflow.match(/npm run desktop:acceptance:verify/g)?.length, 2); + assert.equal(workflow.match(/path: desktop-acceptance-artifacts/g)?.length, 2); + }); + test('runs the native DMG layout suite on both macOS architectures', () => { for (const [jobName, section] of [ ['unsigned validation', job('package', 'finalize')], diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index 437a8ab41..21392a7f7 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { deepLinkFromArguments, applyDevelopmentRendererCsp, @@ -14,6 +16,13 @@ import { validatedDevServerUrl, } from './security'; +const directiveSources = (policy: string, name: string): string[] => { + const directives = policy.split(';').map(directive => directive.trim()); + const matching = directives.filter(directive => directive.split(/\s+/, 1)[0] === name); + assert.equal(matching.length, 1, `expected exactly one ${name} directive`); + return matching[0].split(/\s+/).slice(1); +}; + describe('desktop URL security', () => { it('only accepts HTTPS and loopback HTTP API endpoints', () => { assert.equal(normalizeApiBaseUrl('https://propr.example.com///'), null); @@ -158,6 +167,31 @@ describe('desktop URL security', () => { assert.match(policy, /connect-src 'self' https: http: ws: wss:/); }); + it('keeps the renderer meta and response-header connect sources in exact scheme parity', () => { + const rendererHtml = readFileSync( + fileURLToPath(new URL('../renderer.html', import.meta.url)), + 'utf8', + ); + const metaPolicies = [...rendererHtml.matchAll( + //g, + )]; + assert.equal(metaPolicies.length, 1); + + const expectedSources = ["'self'", 'https:', 'http:', 'ws:', 'wss:']; + const headerSources = directiveSources(rendererContentSecurityPolicy(), 'connect-src'); + const metaSources = directiveSources(metaPolicies[0][1], 'connect-src'); + assert.deepEqual(headerSources, expectedSources); + assert.deepEqual(metaSources, headerSources); + + for (const target of [ + 'http://127.0.0.2:41731/api/auth/user', + 'http://192.168.1.10:41731/api/auth/user', + 'ws://10.0.0.8:41731/socket.io/', + ]) { + assert.equal(metaSources.includes(new URL(target).protocol), true, target); + } + }); + it('relaxes inline scripts only while Vite serves the development renderer', () => { const packagedPolicy = rendererContentSecurityPolicy(); const source = ``; diff --git a/apps/desktop/src/session-security.test.ts b/apps/desktop/src/session-security.test.ts new file mode 100644 index 000000000..a4e4ae365 --- /dev/null +++ b/apps/desktop/src/session-security.test.ts @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import type { Session, WebContents } from 'electron'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; +import { + DesktopCredentialService, + type DesktopCurrentUserProxyEvidence, +} from './credential-service'; +import { ProfileStore, type EncryptionProvider } from './profile-store'; +import { + configureDesktopSessionSecurity, + desktopNetworkPermissionAllowed, + type DesktopNetworkPermissionEvidence, +} from './session-security'; + +const RENDERER_URL = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; +// Acceptance uses an address distinct from the standard smoke's 127.0.0.1. +const REACT_FIXTURE_ORIGIN = 'http://127.0.0.2:41731'; +const TOKEN = `propr_it_${'T'.repeat(43)}`; + +const encryption: EncryptionProvider = { + isEncryptionAvailable: () => true, + backend: () => 'keychain', + encrypt: value => Buffer.from(value, 'utf8'), + decrypt: value => value.toString('utf8'), +}; + +describe('production desktop session security', () => { + it('denies every permission except active trusted-renderer local network access', () => { + const accepted = { + activeBindingCurrent: true, + decision: 'check' as const, + isMainFrame: true, + mainWindowPresent: true, + permission: 'loopback-network', + rendererDocumentUrlTrusted: true, + requestingOriginAuthorityEqual: true, + requestingOriginAuthorityValid: true, + requestingUrlAuthorityEqual: true, + requestingUrlPresent: false, + requestingUrlTrusted: false, + webContentsEqualsMainWindow: false, + webContentsPresent: false, + }; + assert.equal(desktopNetworkPermissionAllowed(accepted), true); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, activeBindingCurrent: false }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'notifications' }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, isMainFrame: false }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, rendererDocumentUrlTrusted: false }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, requestingOriginAuthorityEqual: false }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, requestingOriginAuthorityValid: false }), false); + assert.equal(desktopNetworkPermissionAllowed({ + ...accepted, + requestingUrlPresent: true, + requestingUrlTrusted: false, + }), false); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network' }), true); + assert.equal(desktopNetworkPermissionAllowed({ ...accepted, permission: 'local-network-access' }), true); + assert.equal(desktopNetworkPermissionAllowed({ + ...accepted, + decision: 'request', + requestingUrlPresent: true, + requestingUrlTrusted: true, + webContentsEqualsMainWindow: true, + webContentsPresent: true, + }), true); + assert.equal(desktopNetworkPermissionAllowed({ + ...accepted, + decision: 'request', + requestingUrlPresent: true, + requestingUrlTrusted: true, + }), false); + }); + + it('limits credential transport to the exact live main renderer and active origin', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-session-security-')); + const store = new ProfileStore(directory, encryption); + const mainEvidence: DesktopCurrentUserProxyEvidence[] = []; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Session security test', + openExternal: async () => undefined, + reportCurrentUserValidation: evidence => mainEvidence.push(evidence), + fetch: async input => new URL(input.toString()).pathname === '/api/desktop/discovery' + ? new Response(JSON.stringify({ + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + : new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + + try { + const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: REACT_FIXTURE_ORIGIN }); + await store.writeCredential({ version: 1, profileId: profile.id, origin: REACT_FIXTURE_ORIGIN, token: TOKEN }); + const probed = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: REACT_FIXTURE_ORIGIN }); + assert.equal(probed.status, 'ready'); + if (probed.status !== 'ready' || !probed.activationTicket) return; + + type PermissionCheck = ( + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => boolean; + type PermissionRequest = ( + webContents: WebContents, + permission: string, + callback: (allowed: boolean) => void, + details: { requestingUrl?: string; isMainFrame: boolean }, + ) => void; + type BeforeSendHeaders = ( + details: { + url: string; + method: string; + resourceType: string; + requestHeaders: Record; + webContentsId: number; + }, + callback: (decision: Record) => void, + ) => void; + let permissionCheck: PermissionCheck = () => { throw new Error('Permission check was not installed'); }; + let permissionRequest: PermissionRequest = () => { throw new Error('Permission request was not installed'); }; + let beforeSendHeaders: BeforeSendHeaders = () => { throw new Error('Request interceptor was not installed'); }; + const permissionEvidence: DesktopNetworkPermissionEvidence[] = []; + const desktopSession = { + setPermissionCheckHandler: (listener: PermissionCheck | null) => { + if (listener) permissionCheck = listener; + }, + setPermissionRequestHandler: (listener: PermissionRequest | null) => { + if (listener) permissionRequest = listener; + }, + webRequest: { + onBeforeSendHeaders: (listener: BeforeSendHeaders | null) => { + if (listener) beforeSendHeaders = listener; + }, + onHeadersReceived: (_listener: unknown) => undefined, + }, + } as unknown as Session; + let mainRendererDestroyed = false; + const mainRenderer = { + id: 41, + getURL: () => RENDERER_URL, + isDestroyed: () => mainRendererDestroyed, + } as unknown as WebContents; + const otherRenderer = { id: 42, getURL: () => RENDERER_URL } as unknown as WebContents; + const configured = configureDesktopSessionSecurity({ + contentSecurityPolicy: () => "default-src 'self'", + credentials: service, + desktopSession, + getMainRenderer: () => mainRenderer, + isTrustedRendererUrl: value => value === RENDERER_URL, + reportNetworkPermissionDecision: evidence => permissionEvidence.push(evidence), + }); + + const beforeActivation = async ( + url: string, + headers: Record, + webContentsId: number, + resourceType = 'xhr', + ) => await new Promise>(resolve => beforeSendHeaders({ + url, + method: 'GET', + resourceType, + requestHeaders: headers, + webContentsId, + }, resolve)); + assert.deepEqual(await beforeActivation( + 'http://127.0.0.1:41731/smoke-sw.js', + { + Accept: 'application/javascript', + Authorization: 'Bearer foreign-controlled', + Cookie: 'foreign=session', + }, + otherRenderer.id, + 'script', + ), { + requestHeaders: { Accept: 'application/javascript' }, + }); + assert.deepEqual(await beforeActivation(`${REACT_FIXTURE_ORIGIN}/api/side-effect`, { + Origin: DESKTOP_RENDERER_ORIGIN, + }, mainRenderer.id), { cancel: true }); + mainRendererDestroyed = true; + assert.deepEqual(await beforeActivation('http://127.0.0.1:41731/storage-fixture.html', { + Authorization: 'Bearer destroyed-renderer', + Cookie: 'destroyed=session', + }, mainRenderer.id), { requestHeaders: {} }); + mainRendererDestroyed = false; + + const activated = await service.activate(probed.activationTicket); + + // Electron documents a nullable WebContents for permission checks. The + // trusted main-frame authority remains sufficient without fabricating it. + assert.equal(permissionCheck( + null, + 'loopback-network', + DESKTOP_RENDERER_ORIGIN, + { isMainFrame: true }, + ), true); + assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, { + isMainFrame: false, + }), false); + assert.equal(permissionCheck(null, 'loopback-network', 'https://attacker.example.test', { + isMainFrame: true, + }), false); + assert.equal(permissionCheck(null, 'loopback-network', '', { + isMainFrame: true, + }), false); + assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, { + requestingUrl: 'https://attacker.example.test/frame', isMainFrame: true, + }), false); + assert.equal(permissionCheck(otherRenderer, 'loopback-network', DESKTOP_RENDERER_ORIGIN, { + requestingUrl: RENDERER_URL, isMainFrame: true, + }), false); + assert.equal(permissionCheck(null, 'notifications', DESKTOP_RENDERER_ORIGIN, { + isMainFrame: true, + }), false); + let requestedPermission = false; + permissionRequest( + mainRenderer, + 'loopback-network', + (allowed: boolean) => { requestedPermission = allowed; }, + { requestingUrl: RENDERER_URL, isMainFrame: true }, + ); + assert.equal(requestedPermission, true); + permissionRequest( + otherRenderer, + 'loopback-network', + (allowed: boolean) => { requestedPermission = allowed; }, + { requestingUrl: RENDERER_URL, isMainFrame: true }, + ); + assert.equal(requestedPermission, false); + permissionRequest( + mainRenderer, + 'loopback-network', + (allowed: boolean) => { requestedPermission = allowed; }, + { isMainFrame: true }, + ); + assert.equal(requestedPermission, false); + permissionRequest( + mainRenderer, + 'loopback-network', + (allowed: boolean) => { requestedPermission = allowed; }, + { requestingUrl: 'https://attacker.example.test/frame', isMainFrame: true }, + ); + assert.equal(requestedPermission, false); + permissionRequest( + mainRenderer, + 'loopback-network', + (allowed: boolean) => { requestedPermission = allowed; }, + { requestingUrl: RENDERER_URL, isMainFrame: false }, + ); + assert.equal(requestedPermission, false); + assert.equal(permissionEvidence.length, 11); + assert.deepEqual(permissionEvidence[0], { + schemaVersion: 1, + 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, + }); + assert.doesNotMatch(JSON.stringify(permissionEvidence), /attacker|renderer\.html|127\.0\.0\.2|propr_it_/); + + const intercepted = async ( + url: string, + method: 'OPTIONS' | 'GET', + headers: Record, + resourceType = 'xhr', + webContentsId = mainRenderer.id, + ) => { + return await new Promise>(resolve => beforeSendHeaders({ + url, + method, + resourceType, + requestHeaders: headers, + webContentsId, + }, resolve)); + }; + const unrelatedTargets = [ + 'http://127.0.0.1:41731/api/side-effect', + 'https://192.168.1.10/api/side-effect', + 'http://127.0.0.2:41732/api/side-effect', + 'https://127.0.0.2.evil.invalid:41731/api/side-effect', + ]; + for (const url of unrelatedTargets) { + assert.deepEqual(await intercepted(url, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + }), { cancel: true }, url); + } + + const currentUserUrl = `${REACT_FIXTURE_ORIGIN}/api/auth/user?proprDesktopScopeGeneration=1`; + assert.deepEqual(await intercepted(currentUserUrl, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + Referer: RENDERER_URL, + Authorization: 'Bearer foreign-controlled', + Cookie: 'foreign=session', + [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope, + }, 'xhr', otherRenderer.id), { cancel: true }); + const preflight = await intercepted(currentUserUrl, 'OPTIONS', { + Origin: DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': DESKTOP_TRANSPORT_SCOPE_HEADER, + }); + assert.equal(preflight.cancel, undefined); + assert.equal(new Headers(preflight.requestHeaders as HeadersInit).has('Authorization'), false); + + const get = await intercepted(currentUserUrl, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + Cookie: 'renderer=must-not-cross', + Authorization: 'Bearer renderer-controlled', + [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope, + }); + const outbound = new Headers(get.requestHeaders as HeadersInit); + assert.equal(get.cancel, undefined); + assert.deepEqual(get.requestHeaders, { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: `Bearer ${TOKEN}`, + }); + assert.equal(outbound.get('Authorization'), `Bearer ${TOKEN}`); + assert.equal(outbound.has('Cookie'), false); + assert.equal(outbound.has(DESKTOP_TRANSPORT_SCOPE_HEADER), false); + assert.equal(mainEvidence.length, 1); + assert.equal(mainEvidence[0].accepted, true); + assert.equal(mainEvidence[0].rendererScopeGeneration, 1); + assert.equal(mainEvidence[0].scopeHeaderCount, 1); + assert.equal(mainEvidence[0].bearerMainInjected, true); + + const socketUrl = `${REACT_FIXTURE_ORIGIN.replace(/^http/, 'ws')}/socket.io/` + + `?EIO=4&transport=websocket&proprDesktopTransportScope=${activated.transportScope}`; + assert.deepEqual(await intercepted(socketUrl, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + Referer: RENDERER_URL, + Authorization: 'Bearer foreign-controlled', + Cookie: 'foreign=session', + }, 'webSocket', otherRenderer.id), { cancel: true }); + const socket = await intercepted(socketUrl, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + Cookie: 'renderer=must-not-cross', + Authorization: 'Bearer renderer-controlled', + }, 'webSocket'); + assert.equal(socket.cancel, undefined); + assert.deepEqual(socket.requestHeaders, { + Origin: DESKTOP_RENDERER_ORIGIN, + Authorization: `Bearer ${TOKEN}`, + }); + assert.deepEqual(await service.discardActivation(activated), { discarded: true }); + assert.equal(permissionCheck(null, 'loopback-network', DESKTOP_RENDERER_ORIGIN, { + isMainFrame: true, + }), false); + for (const url of [...unrelatedTargets, `${REACT_FIXTURE_ORIGIN}/api/side-effect`]) { + assert.deepEqual(await intercepted(url, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + }), { cancel: true }, `cached grant after discard: ${url}`); + } + assert.deepEqual(await intercepted(socketUrl, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + }, 'webSocket'), { cancel: true }); + + const revokedReady = await service.probe(profile); + assert.equal(revokedReady.status, 'ready'); + if (revokedReady.status !== 'ready') return; + const revoked = await service.activate(revokedReady.activationTicket); + assert.deepEqual(await service.invalidate({ + profileId: profile.id, + transportScope: revoked.transportScope, + code: 'INSTANCE_TOKEN_REVOKED', + }), { invalidated: true }); + assert.deepEqual(await intercepted(`${REACT_FIXTURE_ORIGIN}/api/side-effect`, 'GET', { + Origin: DESKTOP_RENDERER_ORIGIN, + }), { cancel: true }); + configured.dispose(); + } finally { + await service.dispose(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/session-security.ts b/apps/desktop/src/session-security.ts new file mode 100644 index 000000000..48b21a3bb --- /dev/null +++ b/apps/desktop/src/session-security.ts @@ -0,0 +1,214 @@ +import type { Session, WebContents } from 'electron'; +import type { DesktopCredentialService } from './credential-service'; + +const DESKTOP_NETWORK_PERMISSIONS = new Set([ + // Chromium 145 split the original permission into address-space-specific + // permissions. Keep the original spelling for older supported runtimes. + 'local-network-access', + 'local-network', + 'loopback-network', +]); + +export type DesktopNetworkPermissionCategory = + | 'local-network-access' + | 'local-network' + | 'loopback-network'; + +export interface DesktopNetworkPermissionEvidence { + schemaVersion: 1; + permissionCategory: DesktopNetworkPermissionCategory; + decision: 'check' | 'request'; + allowed: boolean; + activeBindingCurrent: boolean; + webContentsPresent: boolean; + webContentsEqualsMainWindow: boolean; + mainWindowPresent: boolean; + isMainFrame: boolean; + requestingUrlPresent: boolean; + requestingUrlTrusted: boolean; + rendererDocumentUrlTrusted: boolean; + requestingOriginAuthorityValid: boolean; + requestingOriginAuthorityEqual: boolean; +} + +const rendererAuthority = (value: string): string | null => { + try { + const url = new URL(value); + if (!url.protocol || !url.hostname || url.username || url.password) return null; + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +}; + +export interface DesktopNetworkPermissionContext extends Omit { + permission: string; + requestingUrlAuthorityEqual: boolean; +} + +/** + * Chromium may require Local Network Access before a renderer request reaches + * webRequest. This grant authenticates only the packaged application's current + * main renderer and only while main owns a current credential binding. The + * credential service still authorizes every concrete URL, scope, and header. + */ +export const desktopNetworkPermissionAllowed = ({ + activeBindingCurrent, + decision, + isMainFrame, + mainWindowPresent, + permission, + rendererDocumentUrlTrusted, + requestingOriginAuthorityEqual, + requestingOriginAuthorityValid, + requestingUrlAuthorityEqual, + requestingUrlPresent, + requestingUrlTrusted, + webContentsEqualsMainWindow, + webContentsPresent, +}: DesktopNetworkPermissionContext): boolean => DESKTOP_NETWORK_PERMISSIONS.has(permission) + && activeBindingCurrent + && mainWindowPresent + && isMainFrame + && rendererDocumentUrlTrusted + && (!requestingUrlPresent || (requestingUrlTrusted && requestingUrlAuthorityEqual)) + && requestingOriginAuthorityValid + && requestingOriginAuthorityEqual + && (decision === 'check' + ? !webContentsPresent || webContentsEqualsMainWindow + : webContentsPresent && webContentsEqualsMainWindow && requestingUrlPresent); + +interface ConfigureDesktopSessionSecurityOptions { + contentSecurityPolicy(): string; + credentials: DesktopCredentialService; + desktopSession: Session; + getMainRenderer(): WebContents | null; + isTrustedRendererUrl(value: string): boolean; + reportNetworkPermissionDecision?(evidence: DesktopNetworkPermissionEvidence): void; +} + +/** Install the production permission, request, and response boundary on one session. */ +export const configureDesktopSessionSecurity = ({ + contentSecurityPolicy, + credentials, + desktopSession, + getMainRenderer, + isTrustedRendererUrl, + reportNetworkPermissionDecision = () => undefined, +}: ConfigureDesktopSessionSecurityOptions): { + close(): void; + dispose(): void; +} => { + const allowNetworkPermission = ( + decision: 'check' | 'request', + webContents: WebContents | null, + permission: string, + requestingOrigin: string, + isMainFrame: boolean, + requestingUrl?: string, + ): boolean => { + const mainRenderer = getMainRenderer(); + const rendererDocumentUrl = mainRenderer?.getURL() ?? ''; + const rendererDocumentAuthority = rendererAuthority(rendererDocumentUrl); + const requestingUrlPresent = typeof requestingUrl === 'string' && requestingUrl.length > 0; + const requestingUrlAuthority = requestingUrlPresent ? rendererAuthority(requestingUrl) : null; + const requestingOriginAuthority = rendererAuthority(requestingOrigin); + const context: DesktopNetworkPermissionContext = { + activeBindingCurrent: credentials.hasActiveRendererBinding(), + decision, + isMainFrame: isMainFrame === true, + mainWindowPresent: mainRenderer !== null, + permission, + rendererDocumentUrlTrusted: mainRenderer !== null && isTrustedRendererUrl(rendererDocumentUrl), + requestingOriginAuthorityEqual: rendererDocumentAuthority !== null + && requestingOriginAuthority === rendererDocumentAuthority, + requestingOriginAuthorityValid: requestingOriginAuthority !== null + && requestingOrigin === requestingOriginAuthority, + requestingUrlAuthorityEqual: !requestingUrlPresent || (rendererDocumentAuthority !== null + && requestingUrlAuthority === rendererDocumentAuthority), + requestingUrlPresent, + requestingUrlTrusted: requestingUrlPresent && isTrustedRendererUrl(requestingUrl), + webContentsEqualsMainWindow: webContents !== null && webContents === mainRenderer, + webContentsPresent: webContents !== null, + }; + const allowed = desktopNetworkPermissionAllowed(context); + if (DESKTOP_NETWORK_PERMISSIONS.has(permission)) { + try { + reportNetworkPermissionDecision({ + schemaVersion: 1, + permissionCategory: permission as DesktopNetworkPermissionCategory, + decision, + allowed, + activeBindingCurrent: context.activeBindingCurrent, + webContentsPresent: context.webContentsPresent, + webContentsEqualsMainWindow: context.webContentsEqualsMainWindow, + mainWindowPresent: context.mainWindowPresent, + isMainFrame: context.isMainFrame, + requestingUrlPresent: context.requestingUrlPresent, + requestingUrlTrusted: context.requestingUrlTrusted, + rendererDocumentUrlTrusted: context.rendererDocumentUrlTrusted, + requestingOriginAuthorityValid: context.requestingOriginAuthorityValid, + requestingOriginAuthorityEqual: context.requestingOriginAuthorityEqual, + }); + } catch { + // Diagnostics cannot change the permission decision. + } + } + return allowed; + }; + + desktopSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => + allowNetworkPermission( + 'check', + webContents, + String(permission), + requestingOrigin, + details.isMainFrame, + details.requestingUrl, + )); + desktopSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + const requestingUrl = 'requestingUrl' in details && typeof details.requestingUrl === 'string' + ? details.requestingUrl + : undefined; + callback(allowNetworkPermission( + 'request', + webContents, + String(permission), + requestingUrl ? rendererAuthority(requestingUrl) ?? '' : '', + details.isMainFrame, + requestingUrl, + )); + }); + desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { + const mainRenderer = getMainRenderer(); + const rendererOwned = mainRenderer !== null + && !mainRenderer.isDestroyed() + && details.webContentsId === mainRenderer.id; + callback(credentials.prepareRequest(details.url, details.requestHeaders, { + method: details.method, + rendererOwned, + resourceType: details.resourceType, + })); + }); + desktopSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...credentials.sanitizeResponseHeaders(details.url, details.responseHeaders ?? {}), + 'Content-Security-Policy': [contentSecurityPolicy()], + }, + }); + }); + return { + close() { + desktopSession.webRequest.onBeforeSendHeaders((_details, callback) => callback({ cancel: true })); + desktopSession.webRequest.onHeadersReceived((_details, callback) => callback({ cancel: true })); + }, + dispose() { + desktopSession.setPermissionCheckHandler(null); + desktopSession.setPermissionRequestHandler(null); + desktopSession.webRequest.onBeforeSendHeaders(null); + desktopSession.webRequest.onHeadersReceived(null); + }, + }; +}; diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts index d5ea9125e..07786d3ba 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -167,7 +167,7 @@ describe('packaged smoke profile authorization', () => { const smokeFlowEnd = main.indexOf("log('info', 'desktop.renderer.mvp_flows.ready'", smokeFlowStart); const smokeFlow = main.slice(smokeFlowStart, smokeFlowEnd); - assert.match(main, /process\.platform === 'linux' && !packagedSmokeTest\s*\? await createDesktopLocalHost/); + assert.match(main, /process\.platform === 'linux' && !packagedSmokeTest && !packagedAcceptanceTest\s*\? await createDesktopLocalHost/); assert.doesNotMatch(smokeFlow, /lifecycle\.(?:start|stop|restart)|localSetup\.(?:start|retry|cancel)/); assert.match(smokeFlow, /stagedConnectCandidate/); assert.match(smokeFlow, /window\.__PROPR_DESKTOP__/); diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..82e468ff2 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -5,8 +5,10 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { createPackagedSmokeEvidenceSink, + validatePackagedCurrentUserBoundaryEvidence, PACKAGED_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_FILE, + validatePackagedStaleSocketBoundaryEvidence, } from './smoke-test-evidence'; const withSmokeDirectory = (run: (directory: string) => void): void => { @@ -46,6 +48,132 @@ describe('packaged smoke evidence', () => { { event: 'desktop.renderer.ready' }, ]); assert.doesNotMatch(contents, /timestamp|path|url|error|exception|credential|secret|raw/i); + + const base = { + schemaVersion: 1 as const, + path: 'socket-io' as const, + transport: 'websocket' as const, + resource: 'websocket' as const, + scopeQueryPresent: true, + scopeQueryCount: 1, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + }; + const summary = validatePackagedStaleSocketBoundaryEvidence([ + { + ...base, + scopeEqualsActive: false, + outboundBearerPresent: false, + bearerMainInjected: false, + accepted: false, + rejectionCategory: 'stale-scope', + }, + { + ...base, + scopeEqualsActive: true, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none', + }, + ], false); + assert.deepEqual(summary, { + schemaVersion: 1, + mainAttempts: 2, + staleRejectedByMain: true, + staleRejectionCategory: 'stale-scope', + freshAcceptedByMain: true, + exactPath: true, + exactTransport: true, + exactResource: true, + queryCount: 1, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + staleOutboundBearerPresent: false, + staleBearerMainInjected: false, + freshBearerMainInjected: true, + }); + assert.throws( + () => validatePackagedStaleSocketBoundaryEvidence([], false), + /main-boundary evidence failed/, + ); + assert.throws( + () => validatePackagedStaleSocketBoundaryEvidence([ + { + ...base, + scopeEqualsActive: false, + outboundBearerPresent: false, + bearerMainInjected: false, + accepted: false, + rejectionCategory: 'stale-scope', + }, + { + ...base, + scopeEqualsActive: true, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none', + }, + ], true), + /main-boundary evidence failed/, + ); + + const currentUserEvidence = { + schemaVersion: 2 as const, + correlation: 'current-scope-user-validation' as const, + requestObserved: true as const, + method: 'get' as const, + rendererScopeGeneration: 1, + scopeGenerationQueryCount: 1 as const, + scopeGenerationQueryValid: true, + scopeHeaderCount: 1 as const, + activeBindingPresent: true, + activeScopeGeneration: 0, + profileGenerationCurrent: true, + scopeEqualsActive: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + rejectionCategory: 'none' as const, + }; + assert.deepEqual(validatePackagedCurrentUserBoundaryEvidence([ + currentUserEvidence, + { ...currentUserEvidence, rendererScopeGeneration: 2, activeScopeGeneration: 1 }, + { ...currentUserEvidence, rendererScopeGeneration: 3, activeScopeGeneration: 2 }, + ], false), { + schemaVersion: 2, + rendererValidations: 3, + rendererScopeGenerations: [1, 2, 3], + exactGet: true, + scopeHeaderCount: 1, + activeBindingPresent: true, + profileGenerationCurrent: true, + scopeEqualsActive: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + }); + assert.throws( + () => validatePackagedCurrentUserBoundaryEvidence([ + currentUserEvidence, + { ...currentUserEvidence, rendererScopeGeneration: 2 }, + { ...currentUserEvidence, rendererScopeGeneration: 3, rendererBearerPresent: true }, + ], false), + /current-user main-boundary evidence failed/, + ); }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..5e0a9515b 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -7,6 +7,10 @@ import { writeSync, } from 'node:fs'; import { join } from 'node:path'; +import type { + DesktopCurrentUserProxyEvidence, + DesktopWebSocketHandshakeEvidence, +} from './credential-service'; export const PACKAGED_SMOKE_EVIDENCE_FILE = 'application.smoke-evidence.jsonl'; @@ -32,6 +36,157 @@ export interface PackagedSmokeEvidenceSink { close(): void; } +export const PACKAGED_SMOKE_HANDSHAKE_EVIDENCE_LIMIT = 8; + +export interface PackagedSmokeHandshakeEvidenceBuffer { + records: DesktopWebSocketHandshakeEvidence[]; + overflowed: boolean; +} + +export const PACKAGED_SMOKE_CURRENT_USER_EVIDENCE_LIMIT = 4; + +export interface PackagedSmokeCurrentUserEvidenceBuffer { + records: DesktopCurrentUserProxyEvidence[]; + overflowed: boolean; +} + +export interface PackagedCurrentUserBoundarySummary { + schemaVersion: 2; + rendererValidations: 3; + rendererScopeGenerations: [1, 2, 3]; + exactGet: true; + scopeHeaderCount: 1; + activeBindingPresent: true; + profileGenerationCurrent: true; + scopeEqualsActive: true; + originEqualsActive: true; + rendererBearerPresent: false; + rendererCookiePresent: false; + outboundBearerPresent: true; + bearerMainInjected: true; + accepted: true; +} + +const exactCurrentUserEvidence = (evidence: DesktopCurrentUserProxyEvidence, generation: number): boolean => + evidence.schemaVersion === 2 + && evidence.correlation === 'current-scope-user-validation' + && evidence.requestObserved === true + && evidence.method === 'get' + && evidence.rendererScopeGeneration === generation + && evidence.scopeGenerationQueryCount === 1 + && evidence.scopeGenerationQueryValid === true + && evidence.scopeHeaderCount === 1 + && evidence.activeBindingPresent === true + && Number.isSafeInteger(evidence.activeScopeGeneration) + && evidence.activeScopeGeneration >= 0 + && evidence.profileGenerationCurrent === true + && evidence.scopeEqualsActive === true + && evidence.originEqualsActive === true + && evidence.rendererBearerPresent === false + && evidence.rendererCookiePresent === false + && evidence.outboundBearerPresent === true + && evidence.bearerMainInjected === true + && evidence.accepted === true + && evidence.rejectionCategory === 'none'; + +/** Validate the renderer request side of all three packaged smoke activations. */ +export const validatePackagedCurrentUserBoundaryEvidence = ( + evidence: readonly DesktopCurrentUserProxyEvidence[], + overflowed: boolean, +): PackagedCurrentUserBoundarySummary => { + if (overflowed || evidence.length !== 3 + || evidence.some((record, index) => !exactCurrentUserEvidence(record, index + 1))) { + throw new Error('Packaged current-user main-boundary evidence failed'); + } + return { + schemaVersion: 2, + rendererValidations: 3, + rendererScopeGenerations: [1, 2, 3], + exactGet: true, + scopeHeaderCount: 1, + activeBindingPresent: true, + profileGenerationCurrent: true, + scopeEqualsActive: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + outboundBearerPresent: true, + bearerMainInjected: true, + accepted: true, + }; +}; + +export interface PackagedStaleSocketBoundarySummary { + schemaVersion: 1; + mainAttempts: 2; + staleRejectedByMain: true; + staleRejectionCategory: 'stale-scope'; + freshAcceptedByMain: true; + exactPath: true; + exactTransport: true; + exactResource: true; + queryCount: 1; + activeBindingPresent: true; + profileGenerationCurrent: true; + originEqualsActive: true; + rendererBearerPresent: false; + rendererCookiePresent: false; + staleOutboundBearerPresent: false; + staleBearerMainInjected: false; + freshBearerMainInjected: true; +} + +const exactHandshakeEvidence = ( + evidence: DesktopWebSocketHandshakeEvidence | undefined, + expected: 'stale' | 'fresh', +): boolean => evidence?.schemaVersion === 1 + && evidence.path === 'socket-io' + && evidence.transport === 'websocket' + && evidence.resource === 'websocket' + && evidence.scopeQueryPresent === true + && evidence.scopeQueryCount === 1 + && evidence.scopeEqualsActive === (expected === 'fresh') + && evidence.activeBindingPresent === true + && evidence.profileGenerationCurrent === true + && evidence.originEqualsActive === true + && evidence.rendererBearerPresent === false + && evidence.rendererCookiePresent === false + && evidence.outboundBearerPresent === (expected === 'fresh') + && evidence.bearerMainInjected === (expected === 'fresh') + && evidence.accepted === (expected === 'fresh') + && evidence.rejectionCategory === (expected === 'fresh' ? 'none' : 'stale-scope'); + +/** Validate one renderer stale reconnect followed by its distinct fresh Manager. */ +export const validatePackagedStaleSocketBoundaryEvidence = ( + evidence: readonly DesktopWebSocketHandshakeEvidence[], + overflowed: boolean, +): PackagedStaleSocketBoundarySummary => { + if (overflowed || evidence.length !== 2 + || !exactHandshakeEvidence(evidence[0], 'stale') + || !exactHandshakeEvidence(evidence[1], 'fresh')) { + throw new Error('Packaged stale Socket.IO main-boundary evidence failed'); + } + return { + schemaVersion: 1, + mainAttempts: 2, + staleRejectedByMain: true, + staleRejectionCategory: 'stale-scope', + freshAcceptedByMain: true, + exactPath: true, + exactTransport: true, + exactResource: true, + queryCount: 1, + activeBindingPresent: true, + profileGenerationCurrent: true, + originEqualsActive: true, + rendererBearerPresent: false, + rendererCookiePresent: false, + staleOutboundBearerPresent: false, + staleBearerMainInjected: false, + freshBearerMainInjected: true, + }; +}; + export const createPackagedSmokeEvidenceSink = ( authorizedUserDataDirectory: string | null, ): PackagedSmokeEvidenceSink | null => { diff --git a/package-lock.json b/package-lock.json index 431e758d0..d4690a3ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,7 +92,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", "tsx": "^4.21.0", "typescript": "^5.9.3", "vite": "^7.3.5" @@ -4477,6 +4479,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", @@ -4527,11 +4539,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.7", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/before-after-hook": { @@ -4716,7 +4733,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -4734,11 +4753,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4862,7 +4881,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6140,7 +6161,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "version": "1.5.418", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", "dev": true, "license": "ISC" }, @@ -7416,9 +7439,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -11001,9 +11024,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -12003,9 +12031,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -14347,7 +14375,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 087e1281a..52a99af33 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/packages/client/src/desktopPairing.ts b/packages/client/src/desktopPairing.ts index b799870ac..6311de3cd 100644 --- a/packages/client/src/desktopPairing.ts +++ b/packages/client/src/desktopPairing.ts @@ -278,13 +278,16 @@ export const completeDesktopPairing = async ( const remainingBeforeSleep = requireRemainingLifetime(); const delay = safeDelay(Math.min(intervalSeconds * 1000, remainingBeforeSleep)); await raceLifetime(sleep(delay, lifetimeController.signal)); - const remaining = requireRemainingLifetime(); + requireRemainingLifetime(); let value: unknown; try { // The pairing reader owns cancellation through body drain/cancel. Do // not race it with a faster outer rejection: completion here is the - // operation's guarantee that no response task survives this poll. + // operation's guarantee that no response task survives this poll. The + // lifetime controller is the sole pairing-deadline owner; giving the + // transport its fixed per-request budget avoids a same-tick race that + // could misclassify pairing expiry as a request timeout. value = await client.requestDesktopPairing( `/api/desktop/pairings/${encodeURIComponent(start.pairingId)}/poll`, { @@ -294,7 +297,7 @@ export const completeDesktopPairing = async ( redirect: 'manual', signal: lifetimeController.signal, }, - Math.min(PAIRING_REQUEST_TIMEOUT_MS, safeDelay(remaining)), + PAIRING_REQUEST_TIMEOUT_MS, ); } catch (error) { if (terminal || remainingLifetime() <= 0) { diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index dd427691d..a56ffd3eb 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, @@ -16,13 +15,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 ( - + // Mount with the connected desktop surface for lifecycle attribution, but keep it disabled until + // current-user bootstrap succeeds so a revoked activation never opens a second socket. + 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..13c075798 --- /dev/null +++ b/propr-ui/src/api/currentUserResponse.test.ts @@ -0,0 +1,110 @@ +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__ = {}; + 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/demoMode.test.ts b/propr-ui/src/api/demoMode.test.ts index 316a2ec0c..694ffc3dd 100644 --- a/propr-ui/src/api/demoMode.test.ts +++ b/propr-ui/src/api/demoMode.test.ts @@ -4,6 +4,7 @@ import { apiFetch, CommittedConfigWriteError, getDemoModeStatus, + getCurrentUser, handleApiResponse, handleDesktopAccessCode, INSTANCE_AUTHORIZATION_CHANGED_EVENT, @@ -231,6 +232,60 @@ describe('demo mode API helpers', () => { expect(fetchMock).toHaveBeenCalledOnce(); }); + it('preserves hosted current-user cache bypass without a desktop query or scope header', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + id: 'user-1', + login: 'operator', + username: 'operator', + displayName: 'Operations', + email: null, + avatarUrl: null, + role: 'admin', + permissions: ['instance.manage_settings'], + authorizationSource: 'local', + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await getCurrentUser(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [input, init] = fetchMock.mock.calls[0]; + expect(input).toBe('/api/auth/user'); + expect(init).toEqual({ credentials: 'include', cache: 'no-store' }); + expect(new URLSearchParams(String(input).split('?')[1])).toEqual(new URLSearchParams()); + expect(new Headers(init?.headers).has('X-ProPR-Desktop-Transport-Scope')).toBe(false); + }); + + it('sends scoped current-user GETs without browser cache-control inputs', async () => { + setApiBaseUrl('https://example.test'); + setDesktopConnectionScope({ + bridge: {} as never, + profileId: 'profile-a', + transportScope: 'SSSSSSSSSSSSSSSSSSSSSS', + }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + id: 'user-1', + login: 'operator', + username: 'operator', + displayName: 'Operations', + email: null, + avatarUrl: null, + role: 'admin', + permissions: ['instance.manage_settings'], + authorizationSource: 'local', + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await getCurrentUser({ scopeGeneration: 7, activeScopePresent: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [input, init] = fetchMock.mock.calls[0]; + expect(input).toBe('https://example.test/api/auth/user?proprDesktopScopeGeneration=7'); + expect(init?.cache).toBeUndefined(); + const headers = new Headers(init?.headers); + expect(headers.get('X-ProPR-Desktop-Transport-Scope')).toBe('SSSSSSSSSSSSSSSSSSSSSS'); + expect(headers.has('Cache-Control')).toBe(false); + expect(headers.has('Pragma')).toBe(false); + }); + it('converts demo read-only 405 responses into a clear error', async () => { const response = new Response(JSON.stringify({ code: DEMO_MODE_READ_ONLY_CODE, diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts index d58d5ae1b..6a63e2561 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/contexts/SocketProvider.test.tsx b/propr-ui/src/contexts/SocketProvider.test.tsx index 9f3886e80..ceba509be 100644 --- a/propr-ui/src/contexts/SocketProvider.test.tsx +++ b/propr-ui/src/contexts/SocketProvider.test.tsx @@ -1,23 +1,32 @@ import { act, cleanup, render } from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DRAFT_UPDATE, INDEXING_UPDATE, QUEUE_STATS_UPDATE, TASK_LIVE_UPDATE, TASK_UPDATE } from '@propr/shared'; +import { + DESKTOP_TRANSPORT_SCOPE_QUERY, + DRAFT_UPDATE, + INDEXING_UPDATE, + QUEUE_STATS_UPDATE, + TASK_LIVE_UPDATE, + TASK_UPDATE, +} from '@propr/shared'; import { SocketProvider } from './SocketProvider'; import { useSocket } from './useSocket'; type Handler = (value?: unknown) => void; const sockets = vi.hoisted(() => [] as Array<{ handlers: Map; + options: Record; connect: ReturnType; disconnect: ReturnType; emit: ReturnType; on: ReturnType; off: ReturnType; }>); -const connectSocketMock = vi.hoisted(() => vi.fn(() => { +const connectSocketMock = vi.hoisted(() => vi.fn((options: Record) => { const handlers = new Map(); const socket = { handlers, + options, connect: vi.fn(), disconnect: vi.fn(), emit: vi.fn(), @@ -83,15 +92,42 @@ describe('SocketProvider', () => { expect(connectSocketMock).not.toHaveBeenCalled(); }); + it('constructs and explicitly connects once when bootstrap enables a published activation', () => { + state.scope = scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA'); + const { rerender } = render(
loading
); + expect(connectSocketMock).not.toHaveBeenCalled(); + + rerender(
ready
); + + expect(connectSocketMock).toHaveBeenCalledOnce(); + expect(sockets).toHaveLength(1); + expect(sockets[0].connect).toHaveBeenCalledOnce(); + }); + it('creates one force-new scoped Manager on null-to-A activation', () => { render(
app
); publish(scope('profile-a', 'AAAAAAAAAAAAAAAAAAAAAA')); expect(connectSocketMock).toHaveBeenCalledOnce(); expect(connectSocketMock).toHaveBeenCalledWith(expect.objectContaining({ + autoConnect: false, forceNew: true, + withCredentials: false, query: { proprDesktopTransportScope: 'AAAAAAAAAAAAAAAAAAAAAA' }, })); + expect(sockets[0].connect).toHaveBeenCalledOnce(); + expect(sockets[0].on.mock.invocationCallOrder.at(-1)) + .toBeLessThan(sockets[0].connect.mock.invocationCallOrder[0]); + const options = sockets[0].options; + const query = options.query as Record; + expect(Object.keys(query)).toEqual([DESKTOP_TRANSPORT_SCOPE_QUERY]); + expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).toBe('AAAAAAAAAAAAAAAAAAAAAA'); + expect(Object.prototype.hasOwnProperty.call(options, 'auth')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(options, 'extraHeaders')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(options, 'bearer')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(options, 'authorization')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(options, 'cookie')).toBe(false); + expect(JSON.stringify(options)).not.toMatch(/bearer|authorization|cookie/i); }); it.each([ @@ -173,7 +209,7 @@ describe('SocketProvider', () => { await vi.waitFor(() => expect(handleDesktopAccessCode).toHaveBeenCalledWith( 'INVALID_INSTANCE_TOKEN', state.scope, )); - expect(sockets[0].connect).not.toHaveBeenCalled(); + expect(sockets[0].connect).toHaveBeenCalledOnce(); }); it('reconnects the current Manager when authorization changes without invalidating its token', async () => { @@ -184,7 +220,7 @@ describe('SocketProvider', () => { socketA.handlers.get('authentication:error')?.({ code: 'AUTHORIZATION_CHANGED' }); - await vi.waitFor(() => expect(socketA.connect).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(socketA.connect).toHaveBeenCalledTimes(2)); expect(handleDesktopAccessCode).toHaveBeenCalledWith('AUTHORIZATION_CHANGED', state.scope); expect(socketA.disconnect).toHaveBeenCalledOnce(); }); @@ -206,12 +242,12 @@ describe('SocketProvider', () => { resolveClassification('authorization-changed'); await Promise.resolve(); - expect(socketA.connect).not.toHaveBeenCalled(); + expect(socketA.connect).toHaveBeenCalledOnce(); expect(socketA.disconnect).toHaveBeenCalledOnce(); expect(socketA.off).toHaveBeenCalledWith('authentication:error', staleAuthenticationHandler); expect(socketA.handlers.size).toBe(0); expect(socketB.disconnect).not.toHaveBeenCalled(); - expect(socketB.connect).not.toHaveBeenCalled(); + expect(socketB.connect).toHaveBeenCalledOnce(); }); it('drops every application event dispatched by a stale socket scope', () => { diff --git a/propr-ui/src/contexts/SocketProvider.tsx b/propr-ui/src/contexts/SocketProvider.tsx index b81ee0bf6..33d893fea 100644 --- a/propr-ui/src/contexts/SocketProvider.tsx +++ b/propr-ui/src/contexts/SocketProvider.tsx @@ -10,13 +10,38 @@ import { subscribeDesktopConnectionScope, } from '../api/apiClient'; import { isDesktopRuntime } from '../config/runtimeMode'; +import { + reportPackagedAcceptanceRendererLifecycle, + reportPackagedAcceptanceSocketConnectInvocation, + reportPackagedAcceptanceSocketConstructed, + reportPackagedAcceptanceSocketConstructionInvocation, +} from '../desktop/packagedAcceptanceRendererLifecycle'; interface SocketProviderProps { children: React.ReactNode; disabled?: boolean; + disableReasons?: SocketProviderDisableReasons; +} + +export interface SocketProviderDisableReasons { + demoModeLoading: boolean; + demoMode: boolean; + currentUserLoading: boolean; + currentUserAbsent: boolean; } -export const SocketProvider: React.FC = ({ children, disabled = false }) => { +const noDisableReasons: SocketProviderDisableReasons = { + demoModeLoading: false, + demoMode: false, + currentUserLoading: false, + currentUserAbsent: false, +}; + +export const SocketProvider: React.FC = ({ + children, + disabled = false, + disableReasons = noDisableReasons, +}) => { const [socket, setSocket] = useState(null); const [isConnected, setIsConnected] = useState(false); const taskUpdateCallbacksRef = useRef void>>(new Set()); @@ -29,9 +54,31 @@ export const SocketProvider: React.FC = ({ children, disabl getDesktopSocketConfigurationKey, getDesktopSocketConfigurationKey, ); + const { + demoModeLoading, + demoMode, + currentUserLoading, + currentUserAbsent, + } = disableReasons; + useEffect(() => { + reportPackagedAcceptanceRendererLifecycle('socket-provider-mounted', { + socketProviderMounted: true, + }); + }, []); useEffect(() => { + const disableReasonEvidence = { + disabledByDemoModeLoading: demoModeLoading, + disabledByDemoMode: demoMode, + disabledByCurrentUserLoading: currentUserLoading, + disabledByCurrentUserAbsent: currentUserAbsent, + }; if (disabled) { + reportPackagedAcceptanceRendererLifecycle('socket-effect-disabled', { + providerDisabled: true, + desktopRuntime: Boolean(isDesktopRuntime()), + ...disableReasonEvidence, + }); setSocket(null); setIsConnected(false); return; @@ -39,18 +86,38 @@ export const SocketProvider: React.FC = ({ children, disabl const desktopScope = getDesktopConnectionScope(); if (isDesktopRuntime() && !desktopScope) { + reportPackagedAcceptanceRendererLifecycle('socket-effect-scope-unavailable', { + providerDisabled: false, + desktopRuntime: true, + connectionScope: 'unavailable', + ...disableReasonEvidence, + }); setSocket(null); setIsConnected(false); return; } setIsConnected(false); + reportPackagedAcceptanceRendererLifecycle('socket-effect-ready', { + providerDisabled: false, + desktopRuntime: Boolean(isDesktopRuntime()), + connectionScope: desktopScope ? 'available' : 'unavailable', + ...disableReasonEvidence, + }); + reportPackagedAcceptanceSocketConstructionInvocation(); const newSocket = proprClient.connectSocket({ transports: ['websocket'], - autoConnect: true, + autoConnect: false, path: '/socket.io/', forceNew: true, - ...(desktopScope ? { query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope } } : {}), + ...(desktopScope ? { + // The renderer contributes only the opaque binding query. Browser + // cookies and Socket.IO auth payloads are never desktop credentials; + // Electron main adds the active bearer to the exact WS upgrade. + withCredentials: false, + query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: desktopScope.transportScope }, + } : {}), }); + reportPackagedAcceptanceSocketConstructed(); let disposed = false; const isCurrentScope = (): boolean => { if (disposed) return false; @@ -136,6 +203,14 @@ export const SocketProvider: React.FC = ({ children, disabl newSocket.on(TASK_LIVE_UPDATE, taskLiveUpdated); setSocket(newSocket); + reportPackagedAcceptanceRendererLifecycle('socket-constructed', { + providerDisabled: false, + desktopRuntime: Boolean(isDesktopRuntime()), + connectionScope: desktopScope ? 'available' : 'unavailable', + ...disableReasonEvidence, + }); + reportPackagedAcceptanceSocketConnectInvocation(); + newSocket.connect(); return () => { console.log('[SocketContext] Cleaning up socket connection'); @@ -152,7 +227,14 @@ export const SocketProvider: React.FC = ({ children, disabl newSocket.off(TASK_LIVE_UPDATE, taskLiveUpdated); newSocket.disconnect(); }; - }, [disabled, socketConfigurationKey]); + }, [ + currentUserAbsent, + currentUserLoading, + demoMode, + demoModeLoading, + disabled, + socketConfigurationKey, + ]); const subscribeToTask = useCallback((taskId: string) => { if (socket && isConnected) { diff --git a/propr-ui/src/desktop/DesktopExperience.management.test.tsx b/propr-ui/src/desktop/DesktopExperience.management.test.tsx index 105064d07..a7f0b2ca5 100644 --- a/propr-ui/src/desktop/DesktopExperience.management.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.management.test.tsx @@ -17,14 +17,38 @@ describe('DesktopExperience profile management', () => { afterEach(() => vi.restoreAllMocks()); - it('opens instance management with the desktop shortcut and exposes connection status', async () => { - const adapters = adaptersFor([localProfile], localProfile.id); + it('keeps Ctrl+, Cmd+, and reconnect immediately usable across connected-state rerenders with one listener', async () => { + const reconnect = deferred(); + const probe = vi.fn() + .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) + .mockImplementationOnce(() => reconnect.promise); + const addEventListener = vi.spyOn(document, 'addEventListener'); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + const adapters = adaptersFor([localProfile], localProfile.id, probe); renderConnectedExperience(adapters); expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + + const keyboardRegistrations = () => addEventListener.mock.calls.filter(([type]) => type === 'keydown'); + const keyboardRemovals = () => removeEventListener.mock.calls.filter(([type]) => type === 'keydown'); + expect(keyboardRegistrations()).toHaveLength(1); + expect(keyboardRemovals()).toHaveLength(0); + + vi.mocked(adapters.connection.probe).mockClear(); + fireEvent.keyDown(document, { key: 'r', ctrlKey: true, shiftKey: true }); + expect(adapters.connection.probe).toHaveBeenCalledOnce(); + expect(screen.getByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument(); + await act(async () => { reconnect.resolve({ status: 'ready', version: '0.8.15' }); }); + expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); + expect(keyboardRegistrations()).toHaveLength(1); + expect(keyboardRemovals()).toHaveLength(0); + fireEvent.keyDown(document, { key: ',', ctrlKey: true }); - expect(await screen.findByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + fireEvent.keyDown(document, { key: ',', metaKey: true }); + expect(screen.getByRole('dialog', { name: 'Manage instances' })).toBeInTheDocument(); }); it('traps modal focus, makes the app inert, and restores focus to the opener', async () => { diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx index b130a0531..f196f2b33 100644 --- a/propr-ui/src/desktop/DesktopExperience.tsx +++ b/propr-ui/src/desktop/DesktopExperience.tsx @@ -245,18 +245,19 @@ export const DesktopExperience: React.FC = ({ adapters, useEffect(() => { const handleKeyboard = (event: KeyboardEvent) => { - if (state.phase !== 'connected') return; + const currentState = stateRef.current; + if (currentState.phase !== 'connected') return; if ((event.metaKey || event.ctrlKey) && event.key === ',') { event.preventDefault(); openManager(); } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'r') { event.preventDefault(); - void connect(state.profile); + void connect(currentState.profile); } }; document.addEventListener('keydown', handleKeyboard); return () => document.removeEventListener('keydown', handleKeyboard); - }, [connect, openManager, state]); + }, [connect, openManager]); const removeProfile = async (profile: DesktopProfile) => { if (!window.confirm(`Remove “${profile.name}” from this computer?`)) return; diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts index a8c593b39..3b46be333 100644 --- a/propr-ui/src/desktop/electronAdapters.ts +++ b/propr-ui/src/desktop/electronAdapters.ts @@ -2,6 +2,7 @@ import { normalizeApiBaseUrl } from '@propr/client'; import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract'; import { getDesktopConnectionScope, setDesktopConnectionScope } from '../api/apiClient'; import type { DesktopAdapters } from './types'; +import { reportPackagedAcceptanceRendererLifecycle } from './packagedAcceptanceRendererLifecycle'; const snapshotStorage = (storage: Storage): [string, string][] => { const snapshot: [string, string][] = []; @@ -127,6 +128,10 @@ export const createElectronDesktopAdapters = (bridge: DesktopRendererBridge): De 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..d7d49fd34 --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceCurrentUserValidation.ts @@ -0,0 +1,55 @@ +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/packagedAcceptanceRendererLifecycle.test.ts b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts new file mode 100644 index 000000000..e9e1cbfdb --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.test.ts @@ -0,0 +1,95 @@ +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 [prefix, evidence] = info.mock.calls.at(-1)!; + expect(info).toHaveBeenCalledTimes(12); + expect(prefix).toBe(PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX); + 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(JSON.stringify(evidence)).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..3f3457131 --- /dev/null +++ b/propr-ui/src/desktop/packagedAcceptanceRendererLifecycle.ts @@ -0,0 +1,87 @@ +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 }; + console.info(PACKAGED_ACCEPTANCE_RENDERER_LIFECYCLE_PREFIX, 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/desktop/packagedTransportSmoke.test.ts b/propr-ui/src/desktop/packagedTransportSmoke.test.ts new file mode 100644 index 000000000..2b142ab33 --- /dev/null +++ b/propr-ui/src/desktop/packagedTransportSmoke.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DESKTOP_TRANSPORT_SCOPE_QUERY } from '@propr/shared'; +import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract'; +import { + createPackagedTransportSmokeHarness, + staleReconnectQuery, +} from './packagedTransportSmoke'; + +const smokeMocks = vi.hoisted(() => ({ + activate: vi.fn(), + getCurrentUser: vi.fn(), + probe: vi.fn(), + publishActivation: vi.fn(), +})); + +vi.mock('../api/proprApi', () => ({ + getCurrentUser: smokeMocks.getCurrentUser, +})); + +vi.mock('./electronAdapters', () => ({ + createElectronDesktopAdapters: () => ({ + connection: { + activate: smokeMocks.activate, + probe: smokeMocks.probe, + publishActivation: smokeMocks.publishActivation, + }, + }), +})); + +const profile = { + id: 'remote-1', + name: 'Operations', + baseUrl: 'https://operations.example.test', + kind: 'remote' as const, +}; + +const bridge = {} as DesktopRendererBridge; +const validCurrentUser = { + id: 'user-1', + login: 'operator', + username: 'operator', + displayName: 'Operations', + email: null, + avatarUrl: null, + role: 'admin', + permissions: ['instance.manage_settings'], + authorizationSource: 'local', +}; + +describe('packaged transport smoke current-user validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + smokeMocks.probe.mockResolvedValue({ status: 'ready', activationTicket: 'ticket' }); + smokeMocks.activate.mockImplementation(async () => ({ + status: 'ready', + profileId: profile.id, + transportScope: `scope-${smokeMocks.activate.mock.calls.length}`, + identityEpoch: `epoch-${smokeMocks.activate.mock.calls.length}`, + })); + smokeMocks.getCurrentUser.mockResolvedValue(validCurrentUser); + }); + + it('validates each published activation exactly once with canonical generations 1-3', async () => { + const order: string[] = []; + smokeMocks.probe.mockImplementation(async () => { + order.push('probe'); + return { status: 'ready', activationTicket: 'ticket' }; + }); + smokeMocks.activate.mockImplementation(async () => { + order.push('activate'); + const activation = smokeMocks.activate.mock.calls.length; + return { + status: 'ready', + profileId: profile.id, + transportScope: `scope-${activation}`, + identityEpoch: `epoch-${activation}`, + }; + }); + smokeMocks.publishActivation.mockImplementation(() => { order.push('publish'); }); + smokeMocks.getCurrentUser.mockImplementation(async ({ scopeGeneration }) => { + order.push(`current-user-${scopeGeneration}`); + return validCurrentUser; + }); + const harness = createPackagedTransportSmokeHarness(bridge); + + await harness.activate(profile); + await harness.activate(profile); + await harness.activate(profile); + + expect(smokeMocks.getCurrentUser.mock.calls.map(([options]) => options)).toEqual([ + { scopeGeneration: 1, activeScopePresent: true }, + { scopeGeneration: 2, activeScopePresent: true }, + { scopeGeneration: 3, activeScopePresent: true }, + ]); + expect(order).toEqual([ + 'probe', 'activate', 'publish', 'current-user-1', + 'probe', 'activate', 'publish', 'current-user-2', + 'probe', 'activate', 'publish', 'current-user-3', + ]); + }); + + it('fails activation when production current-user schema validation rejects', async () => { + smokeMocks.getCurrentUser.mockRejectedValue( + new Error('Current-user response schema was invalid.'), + ); + const harness = createPackagedTransportSmokeHarness(bridge); + + await expect(harness.activate(profile)) + .rejects.toThrow('Current-user response schema was invalid.'); + + expect(smokeMocks.publishActivation).toHaveBeenCalledOnce(); + expect(smokeMocks.getCurrentUser).toHaveBeenCalledOnce(); + expect(smokeMocks.getCurrentUser).toHaveBeenCalledWith({ + scopeGeneration: 1, + activeScopePresent: true, + }); + }); +}); + +describe('packaged transport smoke scope query', () => { + it('preserves the recorded stale scope instead of substituting the current scope', () => { + const recordedStaleScope = 'AAAAAAAAAAAAAAAAAAAAAA'; + const freshCurrentScope = 'BBBBBBBBBBBBBBBBBBBBBB'; + + const query = staleReconnectQuery(recordedStaleScope, freshCurrentScope); + + expect(Object.keys(query)).toEqual([DESKTOP_TRANSPORT_SCOPE_QUERY]); + expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).toBe(recordedStaleScope); + expect(query[DESKTOP_TRANSPORT_SCOPE_QUERY]).not.toBe(freshCurrentScope); + }); + + it('rejects a stale-boundary check without an actual scope rotation', () => { + expect(() => staleReconnectQuery( + 'AAAAAAAAAAAAAAAAAAAAAA', + 'AAAAAAAAAAAAAAAAAAAAAA', + )).toThrow('Packaged stale Socket.IO activation was not rotated'); + }); +}); diff --git a/propr-ui/src/desktop/packagedTransportSmoke.ts b/propr-ui/src/desktop/packagedTransportSmoke.ts index 043811aa7..a5a6f67ca 100644 --- a/propr-ui/src/desktop/packagedTransportSmoke.ts +++ b/propr-ui/src/desktop/packagedTransportSmoke.ts @@ -6,7 +6,9 @@ import { handleDesktopAccessCode, proprClient, } from '../api/apiClient'; +import { getCurrentUser } from '../api/proprApi'; import { createElectronDesktopAdapters } from './electronAdapters'; +import type { DesktopRendererBridge } from '../../../apps/desktop/src/shared/contract'; import type { DesktopProfile } from './types'; interface SocketRecord { @@ -15,10 +17,6 @@ interface SocketRecord { transportScope: string; } -interface SocketConnectionError extends Error { - data?: { code?: unknown }; -} - interface PackagedTransportSmokeHarness { activate(profile: DesktopProfile): Promise<{ profileId: string; @@ -29,7 +27,10 @@ interface PackagedTransportSmokeHarness { rest(): Promise; connectSocket(): Promise; reconnectSocket(id: number): Promise; - expectSocketRejected(id: number): Promise; + expectSocketRejected(id: number): Promise<{ + transportRejected: true; + freshManagerConnected: true; + }>; disconnectSocket(id: number): void; handleStaleInvalidation(profileId: string, transportScope: string): Promise; rendererEvidence(): unknown; @@ -41,7 +42,24 @@ declare global { } } -const INVALID_INSTANCE_TOKEN = 'INVALID_INSTANCE_TOKEN'; +const rendererSocketOptions = (transportScope: string) => ({ + transports: ['websocket'] as ['websocket'], + forceNew: true, + reconnection: true, + withCredentials: false, + query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: transportScope }, +}); + +/** Keep the rejected reconnect bound to the immutable scope recorded for that Manager. */ +export const staleReconnectQuery = ( + recordedTransportScope: string, + currentTransportScope: string, +): Record => { + if (recordedTransportScope === currentTransportScope) { + throw new Error('Packaged stale Socket.IO activation was not rotated'); + } + return { [DESKTOP_TRANSPORT_SCOPE_QUERY]: recordedTransportScope }; +}; const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): Promise => new Promise((resolve, reject) => { @@ -54,12 +72,10 @@ const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): P if (expected === 'connect') resolve(); else reject(new Error('Stale Socket.IO scope unexpectedly connected')); }; - const failed = (error: SocketConnectionError) => { + const failed = () => { cleanup(); if (expected !== 'connect_error') { - reject(new Error(`Packaged Socket.IO connection failed: ${error.message}`)); - } else if (error.message !== INVALID_INSTANCE_TOKEN || error.data?.code !== INVALID_INSTANCE_TOKEN) { - reject(new Error('Packaged stale Socket.IO rejection was not INVALID_INSTANCE_TOKEN')); + reject(new Error('Packaged Socket.IO connection failed')); } else { resolve(); } @@ -74,12 +90,13 @@ const waitForSocket = (socket: Socket, expected: 'connect' | 'connect_error'): P }); /** Packaged-only E2E driver composed from the production renderer adapters. */ -export const installPackagedTransportSmokeHarness = (): void => { - const bridge = window.__PROPR_DESKTOP__; - if (!bridge) throw new Error('Packaged renderer bridge is unavailable'); +export const createPackagedTransportSmokeHarness = ( + bridge: DesktopRendererBridge, +): PackagedTransportSmokeHarness => { const adapters = createElectronDesktopAdapters(bridge); const sockets = new Map(); let nextSocketId = 1; + let nextCurrentUserScopeGeneration = 1; const harness: PackagedTransportSmokeHarness = { async activate(profile) { @@ -93,6 +110,9 @@ export const installPackagedTransportSmokeHarness = (): void => { throw new Error('Packaged desktop activation failed'); } adapters.connection.publishActivation(profile, activated); + const scopeGeneration = nextCurrentUserScopeGeneration; + nextCurrentUserScopeGeneration += 1; + await getCurrentUser({ scopeGeneration, activeScopePresent: true }); return { profileId: activated.profileId, transportScope: activated.transportScope, @@ -109,13 +129,7 @@ export const installPackagedTransportSmokeHarness = (): void => { async connectSocket() { const scope = getDesktopConnectionScope(); if (!scope) throw new Error('Packaged Socket.IO scope is unavailable'); - const socket = proprClient.connectSocket({ - transports: ['websocket'], - forceNew: true, - reconnection: true, - auth: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope }, - query: { [DESKTOP_TRANSPORT_SCOPE_QUERY]: scope.transportScope }, - }); + const socket = proprClient.connectSocket(rendererSocketOptions(scope.transportScope)); const id = nextSocketId++; sockets.set(id, { socket, profileId: scope.profileId, transportScope: scope.transportScope }); await waitForSocket(socket, 'connect'); @@ -137,7 +151,10 @@ export const installPackagedTransportSmokeHarness = (): void => { throw new Error('Packaged stale Socket.IO activation was not rotated'); } record.socket.disconnect(); - record.socket.io.opts.query = { [DESKTOP_TRANSPORT_SCOPE_QUERY]: currentScope.transportScope }; + record.socket.io.opts.query = staleReconnectQuery( + record.transportScope, + currentScope.transportScope, + ); const rejected = waitForSocket(record.socket, 'connect_error'); try { record.socket.connect(); @@ -145,6 +162,16 @@ export const installPackagedTransportSmokeHarness = (): void => { } finally { record.socket.disconnect(); } + + // A distinct Manager carrying only the newly activated opaque scope must + // still succeed after main rejects the recorded stale scope above. + const freshSocket = proprClient.connectSocket(rendererSocketOptions(currentScope.transportScope)); + try { + await waitForSocket(freshSocket, 'connect'); + } finally { + freshSocket.disconnect(); + } + return { transportRejected: true, freshManagerConnected: true }; }, disconnectSocket(id) { sockets.get(id)?.socket.disconnect(); }, handleStaleInvalidation(profileId, transportScope) { @@ -161,6 +188,13 @@ export const installPackagedTransportSmokeHarness = (): void => { }; }, }; + return harness; +}; + +export const installPackagedTransportSmokeHarness = (): void => { + const bridge = window.__PROPR_DESKTOP__; + if (!bridge) throw new Error('Packaged renderer bridge is unavailable'); + const harness = createPackagedTransportSmokeHarness(bridge); Object.defineProperty(window, '__proprPackagedTransportSmoke', { configurable: false, enumerable: false, 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..261ee06c9 --- /dev/null +++ b/propr-ui/src/hooks/useCurrentUserBootstrap.mainProxy.test.tsx @@ -0,0 +1,97 @@ +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__ = {}; + 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..5cda0f4ed --- /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); + }, + proprClient: { 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).toHaveBeenCalledOnce(); + + 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).toHaveBeenCalledOnce(); + + 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).toHaveBeenCalledOnce(); + 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).toHaveBeenCalledOnce(); + }); + + 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).toHaveBeenCalledOnce(); + + act(() => { window.dispatchEvent(new Event('propr:instance-authorization-changed')); }); + await waitFor(() => expect(getCurrentUser).toHaveBeenCalledTimes(3)); + expect(connectSocket).toHaveBeenCalledOnce(); + expect(sockets[0].connect).toHaveBeenCalledOnce(); + 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..1b46e5d9c --- /dev/null +++ b/propr-ui/src/hooks/useCurrentUserBootstrap.ts @@ -0,0 +1,210 @@ +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, + }; +};