diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32a9c620..0b704fda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -230,11 +230,30 @@ jobs: retention-days: 1 workflow-tests: - name: PocketBase, Electron and browser workflows + name: Integration workflows (${{ matrix.suite }} ${{ matrix.shard-index }}/${{ matrix.shard-total }}) runs-on: ubuntu-latest timeout-minutes: 25 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + strategy: + fail-fast: false + matrix: + include: + - suite: electron + shard-index: 1 + shard-total: 4 + - suite: electron + shard-index: 2 + shard-total: 4 + - suite: electron + shard-index: 3 + shard-total: 4 + - suite: electron + shard-index: 4 + shard-total: 4 + - suite: web + shard-index: 1 + shard-total: 1 steps: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -250,28 +269,37 @@ jobs: run: npm ci --prefer-offline - name: Verify PocketBase replay against real storage + if: matrix.suite == 'web' run: npm run test:pocketbase -- verification/offline-replay-real-pb.test.ts verification/dynatrace-pipeline.test.ts - name: Install Playwright browsers and Linux dependencies + if: matrix.suite == 'web' run: npx playwright install --with-deps chromium webkit - # Each npm runner restores the Node ABI before the next suite starts. + - name: Install Electron Linux dependencies + if: matrix.suite == 'electron' + run: npx playwright install-deps chromium + + # Isolated runners avoid concurrent native rebuilds. Keep one worker per + # shard; fully-parallel distributes individual tests, including large specs. - name: Run Electron workflows + if: matrix.suite == 'electron' run: | sudo apt-get install --yes dbus-x11 gnome-keyring dbus-run-session -- bash -euo pipefail -c ' openssl rand -hex 32 | gnome-keyring-daemon --unlock --components=secrets - xvfb-run --auto-servernum npm run test:electron + xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --shard=${{ matrix.shard-index }}/${{ matrix.shard-total }} ' - name: Run browser workflows + if: matrix.suite == 'web' run: xvfb-run --auto-servernum npm run test:web - name: Upload workflow failure details if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: workflow-test-failures + name: workflow-test-failures-${{ matrix.suite }}-${{ matrix.shard-index }} path: test-results/ if-no-files-found: ignore retention-days: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f180db4..a899723c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,6 +285,26 @@ jobs: fetch-depth: 0 ref: ${{ needs.determine.outputs.source-sha }} + - name: Resolve release test mode + id: mode + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + SOURCE_SHA: ${{ needs.determine.outputs.source-sha }} + TEST_TREE: ${{ vars.RELAY_RELEASE_TEST_TREE }} + with: + script: | + const { resolveReleaseTestMode } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/releaseWorkflowContract.mjs` + ); + const { data: commit } = await github.rest.git.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: process.env.SOURCE_SHA, + }); + const testRelease = resolveReleaseTestMode(process.env.TEST_TREE, commit.tree.sha); + core.setOutput('test-release', String(testRelease)); + core.info(`Release source tree ${commit.tree.sha}; draft-only test: ${testRelease}`); + - name: Download Windows artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -369,6 +389,7 @@ jobs: CHECKSUM_SHA256: ${{ steps.assets.outputs.checksum_sha256 }} SOURCE_SHA: ${{ needs.determine.outputs.source-sha }} TAG: ${{ needs.determine.outputs.tag }} + TEST_RELEASE: ${{ steps.mode.outputs.test-release }} with: script: | const tag = process.env.TAG; @@ -415,6 +436,14 @@ jobs: throw new Error(`${tag} was missing an expected release asset`); } + if (!['true', 'false'].includes(process.env.TEST_RELEASE)) { + throw new Error('Release test mode was not resolved'); + } + if (process.env.TEST_RELEASE === 'true') { + core.info(`Verified ${tag} draft metadata; test releases are never published.`); + return; + } + let tagRef; try { tagRef = await github.rest.git.getRef({ @@ -455,6 +484,7 @@ jobs: }); - name: Verify published release + if: steps.mode.outputs.test-release == 'false' shell: bash env: ASSET_NAME: ${{ steps.assets.outputs.asset_name }} @@ -492,3 +522,53 @@ jobs: ) release_url="$(gh release view "$TAG" --json url --jq .url)" echo "Published and verified [$TAG]($release_url)." >> "$GITHUB_STEP_SUMMARY" + + - name: Verify test draft assets + if: steps.mode.outputs.test-release == 'true' + shell: bash + env: + ASSET_NAME: ${{ steps.assets.outputs.asset_name }} + ARCHIVE_SHA256: ${{ steps.assets.outputs.archive_sha256 }} + CHECKSUM_SHA256: ${{ steps.assets.outputs.checksum_sha256 }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ steps.publish.outputs.id }} + SOURCE_SHA: ${{ needs.determine.outputs.source-sha }} + TAG: ${{ needs.determine.outputs.tag }} + run: | + set -euo pipefail + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID")" + test "$(jq -r .draft <<<"$release_json")" = 'true' + test "$(jq -r .tag_name <<<"$release_json")" = "$TAG" + test "$(jq -r .target_commitish <<<"$release_json")" = "$SOURCE_SHA" + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" != "$TAG" + verify_dir="$(mktemp -d)" + gh release download "$TAG" --pattern "$ASSET_NAME" --pattern "$ASSET_NAME.sha256" --dir "$verify_dir" + ( + cd "$verify_dir" + sha256sum --check "$ASSET_NAME.sha256" + unzip -tqq "$ASSET_NAME" + test "$(unzip -Z1 "$ASSET_NAME")" = 'Relay.exe' + test "$(sha256sum "$ASSET_NAME" | cut -d' ' -f1)" = "$ARCHIVE_SHA256" + test "$(sha256sum "$ASSET_NAME.sha256" | cut -d' ' -f1)" = "$CHECKSUM_SHA256" + ) + echo "Verified draft-only test release $TAG; never published to the updater." >> "$GITHUB_STEP_SUMMARY" + + - name: Remove test draft release + if: always() && steps.mode.outputs.test-release == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + SOURCE_SHA: ${{ needs.determine.outputs.source-sha }} + TAG: ${{ needs.determine.outputs.tag }} + with: + script: | + const { deleteTestDraft } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/releaseWorkflowContract.mjs` + ); + await deleteTestDraft({ + github, + owner: context.repo.owner, + repo: context.repo.repo, + tag: process.env.TAG, + sourceSha: process.env.SOURCE_SHA, + }); + core.info(`Test draft ${process.env.TAG} is absent.`); diff --git a/.github/workflows/reusable-windows-package.yml b/.github/workflows/reusable-windows-package.yml index dac0a06f..263997cb 100644 --- a/.github/workflows/reusable-windows-package.yml +++ b/.github/workflows/reusable-windows-package.yml @@ -39,7 +39,7 @@ on: outputs: artifact-name: description: Artifact name uploaded by the packaging job. - value: ${{ jobs.package.outputs.artifact-name }} + value: ${{ jobs.verified.outputs.artifact-name }} permissions: actions: read @@ -47,9 +47,13 @@ permissions: jobs: package: + name: Build Windows package + timeout-minutes: 25 runs-on: windows-latest outputs: - artifact-name: ${{ inputs.artifact-name }} + artifact-sha256: ${{ steps.digests.outputs.artifact-sha256 }} + previous-sha256: ${{ steps.digests.outputs.previous-sha256 }} + previous-build-id: ${{ steps.previous.outputs.build_id }} env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 RELAY_BUILD_ID: r1-${{ inputs.source-sha }} @@ -200,7 +204,95 @@ jobs: } Write-Host "Verified packaged release version $actualCore." + - name: Record verification artifact digests + id: digests + shell: pwsh + run: | + $artifactHash = (Get-FileHash -LiteralPath ./release/Relay.exe -Algorithm SHA256).Hash.ToLowerInvariant() + $previousHash = (Get-FileHash -LiteralPath "$env:RUNNER_TEMP\RelayPrevious.exe" -Algorithm SHA256).Hash.ToLowerInvariant() + "artifact-sha256=$artifactHash" >> $env:GITHUB_OUTPUT + "previous-sha256=$previousHash" >> $env:GITHUB_OUTPUT + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ inputs.artifact-name }} + path: release/Relay.exe + if-no-files-found: error + compression-level: 0 + + - name: Upload bootstrap baseline + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ inputs.artifact-name }}-baseline + path: ${{ runner.temp }}/RelayPrevious.exe + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + runtime: + name: Verify Windows runtime (${{ matrix.suite }}) + needs: package + runs-on: windows-latest + timeout-minutes: 25 + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + strategy: + fail-fast: false + matrix: + suite: [bootstrap, startup] + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.source-sha }} + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + env: + RELAY_SKIP_POCKETBASE_DOWNLOAD: '1' + run: npm ci --prefer-offline + + - name: Download production artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ inputs.artifact-name }} + path: release + + - name: Download bootstrap baseline + if: matrix.suite == 'bootstrap' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ inputs.artifact-name }}-baseline + path: ${{ runner.temp }} + + - name: Verify downloaded artifact digests + shell: pwsh + env: + ARTIFACT_SHA256: ${{ needs.package.outputs.artifact-sha256 }} + PREVIOUS_SHA256: ${{ needs.package.outputs.previous-sha256 }} + CHECK_SUITE: ${{ matrix.suite }} + run: | + if ($env:ARTIFACT_SHA256 -notmatch '^[0-9a-f]{64}$' -or + (Get-FileHash -LiteralPath ./release/Relay.exe -Algorithm SHA256).Hash.ToLowerInvariant() -cne $env:ARTIFACT_SHA256) { + throw 'Production artifact digest mismatch.' + } + if ($env:CHECK_SUITE -eq 'bootstrap') { + if ($env:PREVIOUS_SHA256 -notmatch '^[0-9a-f]{64}$' -or + (Get-FileHash -LiteralPath "$env:RUNNER_TEMP\RelayPrevious.exe" -Algorithm SHA256).Hash.ToLowerInvariant() -cne $env:PREVIOUS_SHA256) { + throw 'Bootstrap baseline digest mismatch.' + } + } + - name: Smoke test persistent bootstrap + if: matrix.suite == 'bootstrap' shell: pwsh env: RELAY_EXPECTED_BUILD_ID: r1-${{ inputs.source-sha }} @@ -212,11 +304,12 @@ jobs: -Artifact ./release/Relay.exe -PreviousArtifact "$env:RUNNER_TEMP\RelayPrevious.exe" -ExpectedBuildId "$env:RELAY_EXPECTED_BUILD_ID" - -ExpectedPreviousBuildId "${{ steps.previous.outputs.build_id }}" + -ExpectedPreviousBuildId "${{ needs.package.outputs.previous-build-id }}" -ExpectedTargetCommitish "$env:RELAY_EXPECTED_TARGET_COMMITISH" -ExpectedLauncherProtocolExitCode "$env:RELAY_EXPECTED_LAUNCHER_PROTOCOL_EXIT_CODE" - name: Benchmark packaged startup paths + if: matrix.suite == 'startup' shell: pwsh env: COMPRESSION: ${{ inputs.compression }} @@ -267,12 +360,41 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: relay-windows-startup-diagnostics + name: ${{ inputs.artifact-name }}-diagnostics-${{ matrix.suite }} path: ${{ runner.temp }}/relay-startup-diagnostics - - name: Preserve production artifact for boundary harness - shell: pwsh - run: Move-Item -LiteralPath ./release/Relay.exe -Destination "$env:RUNNER_TEMP\RelayProduction.exe" + updater: + name: Verify Windows updater boundaries + runs-on: windows-latest + timeout-minutes: 25 + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.source-sha }} + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: package-lock.json + + - name: Cache electron-builder tooling + continue-on-error: true + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/AppData/Local/electron-builder/Cache + key: electron-builder-win-${{ hashFiles('package-lock.json') }} + restore-keys: electron-builder-win- + + - name: Install dependencies + env: + RELAY_SKIP_POCKETBASE_DOWNLOAD: '1' + run: npm ci --prefer-offline - name: Resolve updater fixture versions id: updater-fixture-versions @@ -345,14 +467,27 @@ jobs: ) npx vitest run src/main/releases/ReleaseUpdateManager.windows.integration.test.ts - - name: Restore production artifact - shell: pwsh + verified: + name: Windows package quality gate + if: always() + needs: [package, runtime, updater] + runs-on: ubuntu-latest + outputs: + artifact-name: ${{ steps.gate.outputs.artifact-name }} + steps: + - name: Require all Windows verification + id: gate + shell: bash + env: + PACKAGE_RESULT: ${{ needs.package.result }} + RUNTIME_RESULT: ${{ needs.runtime.result }} + UPDATER_RESULT: ${{ needs.updater.result }} + ARTIFACT_NAME: ${{ inputs.artifact-name }} run: | - Remove-Item -LiteralPath ./release/Relay.exe -Force - Move-Item -LiteralPath "$env:RUNNER_TEMP\RelayProduction.exe" -Destination ./release/Relay.exe - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ${{ inputs.artifact-name }} - path: release/*.exe + if [[ "$PACKAGE_RESULT" != "success" || + "$RUNTIME_RESULT" != "success" || + "$UPDATER_RESULT" != "success" ]]; then + echo "Windows verification failed: package=$PACKAGE_RESULT runtime=$RUNTIME_RESULT updater=$UPDATER_RESULT" + exit 1 + fi + echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 00000000..f0adcf10 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,29 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "side-tab", + "value": "*", + "files": ["src/renderer/src/features/tickets/tickets.css"], + "createdAt": "2026-09-20T00:41:18.962Z", + "reason": "Agent: docs/DESIGN.md specifies Accent Ink selection rails and edge-rail grouping; this existing queue summary uses that intentional visual grammar." + }, + { + "rule": "side-tab", + "value": "*", + "files": ["src/renderer/src/styles/responsive.css"], + "createdAt": "2026-09-20T00:41:19.057Z", + "reason": "Agent: docs/DESIGN.md preserves the compact navigation rail and accent selection state; the existing 3px navigation edge is intentional." + }, + { + "rule": "side-tab", + "value": "*", + "files": ["src/renderer/src/tabs/assembler/assembler.css"], + "createdAt": "2026-09-20T00:41:19.151Z", + "reason": "Agent: docs/DESIGN.md defines canonical 4px Accent Ink rails; existing bridge-history and warning-state rails follow that documented system." + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index fc727cb6..edb47db2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,29 @@ overrides it. tests. When behavior changes, update the applicable canonical document in the same change; never preserve a stale architecture description. +## Impeccable design tooling + +- Use the `impeccable` skill for UI work, grounded in `PRODUCT.md` and the relevant + sections of `docs/DESIGN.md`. In Pi, invoke it with `/skill:impeccable` followed + by the task, for example `critique the notifications center`. +- Keep `docs/DESIGN.md` authoritative. The context loader may report no design + record because `PRODUCT.md` lives at the repository root; read the guide + explicitly rather than treating Relay as unstyled or creating a root duplicate. +- Preserve the existing product context when running init. The documentation + lifecycle below also applies to generated surface briefs and design records; + keep task-specific artifacts in scratch storage outside the repository. +- Pi supports automatic checks through a hook bridge such as + `@hsingjui/pi-hooks`: successful `edit`/`write` events feed Impeccable's per-edit + pass, and `Stop` feeds its deferred pass. The bridge must deliver findings to + agent context and guard against repeated follow-up turns. Impeccable's own + hook-status/context discovery does not recognize this Pi bridge; verify the + extension is loaded rather than treating its default "enabled" status as proof. + Without a verified bridge, run the installed detector once over completed UI + changes. These checks supplement Relay's required gates; findings do not + authorize unrelated redesigns. +- Live overlay integration and image generation are optional. Do not weaken + Relay's CSP, enable network access, or store API keys to activate them implicitly. + ## Documentation lifecycle - The tracked Markdown set is exactly the canonical documents listed in diff --git a/PRODUCT.md b/PRODUCT.md index 58f435fe..09856e0b 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,5 +1,14 @@ # Product + + +## Platform + +web + +Relay's React interface runs inside Electron and in Relay Web. The existing visual +system is documented in [docs/DESIGN.md](docs/DESIGN.md). + ## Users Relay is used by on-call and operations staff who assemble bridge recipients, create incident communications, manage coverage, review service health and Dynatrace Problems, and consult shared Wiki, Contacts, and Servers data. They often work under time pressure, scan dense information, and rely on keyboard-heavy workflows. @@ -8,6 +17,81 @@ Relay is used by on-call and operations staff who assemble bridge recipients, cr Relay is an operations command center that keeps incident communications, on-call coverage, service health, and shared operational context in one focused workspace. Success means an operator can understand current conditions, reach the right people, consult trusted guidance, and act with confidence without stitching together disconnected tools. +## Service Desk + +Tickets opens the live SDP workspace on desktop. NOC, SOX, and Unassigned queues remain separate; +Unassigned means no support group regardless of technician. Work-account sign-in uses a single +administrator-configured OAuth app. SDP remains authoritative and controls each user's permissions. +Descriptions, properties, form answers, messages, notes and resolution load on demand. Tasks, worklogs and approvals have native controls. Attachments upload after review and download +through a Save dialog, with a 10 MB limit. + +Operators can create tickets, update subject/description, route or unassign groups and technicians, +change status (including close/reopen), priority, type, category, impact and urgency, add notes, +and submit resolution text. The native editor follows the active template and loads custom field names, types and limits from SDP’s read-only setup API. It supports text, +multiline text, choices, checkboxes, references, numbers, date/time and date-only fields. Replies are real technician emails with reviewed recipients. Operators can +search by ticket number, link/unlink separate tickets, or merge a selected ticket into the current +one with explicit direction shown before confirmation. +Every operation shows a review before a separate **Confirm live change** action. Existing tickets +are checked again for changes before submitting; this is a best-effort conflict check, not an +atomic SDP conditional update. Failed or uncertain submissions are never automatically retried. +Template-specific required fields and exact workflow names are enforced by SDP. Major incident +creation uses the default CWGS Incident/Request template with its Major Incident checkbox checked. +It preserves the subject and collects requester, request +type, impact and urgency; the operator can set priority and support group. + +Ticket navigation uses six sections, with secondary detail views grouped together. Related clearly +separates SDP ticket relationships from Dynatrace problem links; bridge preparation is a separate +More actions command. Problems keeps verified tickets visible and presents possible changes as +expandable context, with evidence and review actions available on demand. + +Live ticket/problem links store only identifiers in Relay and appear in both ticket and Dynatrace +problem views. Problem-side ticket links open SDP with the user's sign-in. Bridge preparation +brings the ticket reference, meeting link and selected groups into Relay's existing composer. +Ticket text is not automatically copied into shared bridge records; preparing context never +creates a meeting or sends a message. + +Queue monitoring starts automatically after work sign-in while the Tickets workspace remains +mounted and connected. The Relay server checks NOC, SOX and no-group queues for changes every +30 seconds and reconciles full queues every five minutes. Sessions of the same verified SDP user +share one job; different users retain their own credentials and results. Checks continue during +ticket editing/inspection, with at most 1,000 tickets per queue and an explicit partial-coverage +label. Users can pause monitoring. SDP errors trigger backoff; startup/recovery establishes a +fresh alert baseline. Deletions and moves out of the monitored groups can take until reconciliation. +Rules support all/any conditions for ticket ID, group, technician, priority, status, request type, +category, template and linked problems, with in-app inbox/popups, desktop notices, optional sound, +quiet hours, snooze and cooldowns. Live reply and native major-incident flag alerts are not yet +available. Live inbox contents remain in memory; desktop notices are generic. Only rule preferences +persist on the device. + +Encrypted per-user server copies allow read-only access during an SDP outage while Relay remains +reachable. Copies expire after 60 minutes by default; **Clear my saved SDP data** is available only in unpackaged test builds and removes the user's +Relay copies without changing SDP. Release builds omit it. Sign-in must be repeated after expiry or server restart, and +previous read-only grants require renewed consent for create/update/delete and read-only setup scopes. Existing ticket-only grants +must reconnect to enable custom field metadata. Live ticket bodies +never enter shared PocketBase collections or client offline storage. + +The synthetic workspace, sample loading and demo bridge/problem actions are removed. New +installations do not create sample ticket collections. Existing data is preserved. + +The request workspace includes forwarding a ticket or an individual message, paginated request +history, checklists and their item answers, personal SDP reminders, and bulk updates of up to 20 +selected tickets. Checklist definitions are selected from searchable read-only catalogs. Bulk +changes require a separate review and confirmation, report each ticket independently, and stop at +the first conflict or unconfirmed result without retrying. Forwarding starts with empty recipients +and private visibility; operators review the exact recipients and content before sending. + +Tenant-specific workflow extensions are excluded from the current implementation scope. Assets, +change editing, purchasing and SDP administration remain outside the request workspace. SDP +queue monitoring automatically links workflow-created tickets after verifying their exact Dynatrace +problem URL; ambiguous matches remain manual and unlinking suppresses automatic recreation. Dynatrace +problem details can read SDP change controls and correlate affected hostnames/services with +scheduled timing. Strong host matches are associated automatically; weaker matches are suggested +with evidence. Confirm/dismiss decisions are local to the view session. Change access requires +renewed work-account consent for the read-only Changes scope. Local tests +exercise these additional operations with fixtures. Sandbox read-only responses and the Cloud +client establish their contracts; no live forwarding, reminder, checklist or bulk writes were +performed for this change, and email delivery is not claimed. + ## Brand Personality Precise, dark, tactile. Relay should feel like a serious operations console with careful craft, not a generic SaaS dashboard or a decorative marketing surface. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bdbe1c18..529bc919 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -79,6 +79,13 @@ responsible for their domain content. - This contract applies only to the outer tab frame. Nested pane, editor, table, PDF, filter, and other domain-specific toolbars retain their own interaction and density rules. +### Compact Compose groups + +At 1120 px and below, Compose stacks a labelled **Choose groups** disclosure above recipients. +The selected-group count and **Add group** remain visible while collapsed. Expanding reveals full +group names, contact counts and selection state in a bounded scrolling list, not initials alone. +The same group controls and context actions are retained at desktop size and browser zoom. + ### Service Status provider rows Service Status remains an operational coverage list, not a generic vendor dashboard. Its overview @@ -115,6 +122,128 @@ or review steps owned by their feature. --- +### Global notifications + +The app header exposes one **Notifications** entry across tabs, with an unread count. Its Inbox +filters Tickets, Problems, Radar and Status without separate notification surfaces in each workspace. +Rows show a text severity label, source, time, title and summary; opening an entry marks it read and navigates to its target. +Mark-read and clear both follow the active source filter. **Undo clear** restores the latest user-cleared +batch in session, retaining read state without replaying banners, sounds or desktop notices. New arrivals +are retained within the 200-entry bound. Account resets and sign-out purge ticket entries from Undo too. +Ticket alerts open the ticket in Relay. An active ticket draft delays that navigation until the draft +is finished or explicitly discarded. The inbox is session-only and bounded to 200 entries. + +Preferences groups shared banners, desktop delivery and sound, with per-source options collapsed +under labelled disclosures. Quiet hours has an explicit enable toggle that retains its times while off; +existing saved schedules retain their behavior. The header shows **Snoozed** or **Quiet hours** while +interruptions are paused, even when the inbox is closed, and updates as the pause expires. +Quiet hours and snooze silence interruptions while preserving matching inbox entries. Each source +has its own enable control; Problems, Radar and Status can filter information, warning and error +levels and choose sound. Existing ticket event/condition/channel rules remain opt-in beneath Ticket +rules. Ticket monitoring runs across tabs while Relay is running and the account is connected. +Browser clients show supported sources and inbox controls, with desktop-only delivery disabled. +Colors, dividers, text hierarchy and controls use Relay's existing design tokens. + +### Tickets workspace + +Tickets uses the shared header and command bar with separate NOC, SOX and Unassigned queues; +Unassigned means no support group. It contains no synthetic workspace, sample loader, demo +problem links or demo bridge controls. Clear SDP data is visible only in unpackaged testing. +Before connection, a **Connect work account** action opens the existing account panel; unusable +queue filters, table, pagination and workflow commands are deferred. Loading, expired-session, +administrator-setup and desktop-only states remain explicit. Loaded outage copies and active drafts +retain the workspace rather than being hidden by the connection prompt. +Live tickets open beside the queue in a split workspace, with Conversations first. A narrower +screen shows the ticket in place of the queue. The editor follows SDP's template sections, real dropdown +choices, dependent assignments and custom fields in Relay controls. Queue filters apply on +request; an applied filter is identified beside the result count. Email replies show recipients +and message in a distinct review before sending. Drafts stay in memory, survive queue polling, +and require an explicit discard before closing. Queue rows and the ticket header show the last +message sender, role and time, plus a distinct unread-reply indicator. A new reply offers Load latest +reply; it never replaces an active draft. Pending and unavailable reply checks are explicit rather +than presented as an empty conversation. Reply is selectable in notification rules. + +The queue uses a compact table with technical IDs, subjects, reply indicators, priorities and +assignment context. An unboxed summary reports actual status and unread-reply counts on the +loaded page. Selecting a ticket narrows the queue and opens a conversation workspace with a +prominent Reply action. The conversation keeps the original request collapsed and recent messages visible; +automatic notifications are excluded by default, with a Show automatic notifications checkbox. +Description opens the full original request, and Notes remains a separate section. Reply drafts +open inline below that context. Description, Details, Messages, Notes, Resolution, +History, Work, Attachments and Links & bridge remain available as sections. History has its own +pagination and readable before/after values. Work includes checklists, checklist answers and +personal reminders alongside tasks, worklogs and approvals. Checklist choices are searchable by +name; reminder dates use local date/time controls. + +Ticket edit, creation, resolution and bulk-update forms use SDP dropdowns for support group, +technician, status, priority, request type, category, impact and urgency wherever those fields are +present. Choices support search and pagination. Changing the support group clears a selected +technician and scopes subsequent technician choices to that group. Loading failures offer a retry +without substituting a free-text assignment field. + +Queue selection is separate from opening a ticket. Select page selects at most 20 current rows; +Update selected opens a review listing every target and changed field. Per-ticket outcomes remain +visible after submission, including stopped and uncertain results. Forward appears in the ticket +header and on individual messages; its inline draft starts with no recipients and private +visibility. It follows the same explicit email review and draft-preservation rules as Reply. + +Ticket properties occupy a right inspector when the detail pane has room, +and a compact strip above the thread at smaller sizes. Narrow workspaces replace the queue with +the ticket in place and hide queue filters; Back to queue restores the queue and keyboard focus to its row. Charcoal surfaces, +accent selection rails, small square author markers and restrained dividers follow Accent Ink. +Compact ticket controls share 36 px heights, 2 px corners and visible accent focus outlines. Workflow +commands keep the shared 40 px height; form submission buttons retain the shared 48 px height. Queue filters, editor lookups and ticket dialogs use the +same dropdown styling. Supporting browsers render a themed native picker with bounded scrolling, +selected-option checks and wrapped long labels; other browsers retain their native picker and +keyboard behavior. Multi-select fields retain native list selection. Filled buttons identify the +next primary action; reset, cancel and monitoring utilities use quieter ghost buttons. +Reply, Edit ticket and Add note stay together in the ticket header; More actions exposes Forward, +Prepare incident bridge, Resolve, Refresh and Open in SDP through the shared keyboard-accessible menu. +The six ticket sections are Conversation, Notes, Work, Attachments, Related and Details. Conversation +contains the original request and messages; Details groups Properties, Resolution and History. +Related separates Dynatrace problems from SDP ticket relationships. Existing links stay visible; +manual problem linking and ticket link/merge searches open on demand. Merge labels name the surviving +ticket and retain the explicit review/confirmation step. Pagination belongs inside the +queue; single-page conversations omit pagination. The app header provides the shared Notifications inbox and preferences. +Routine explanatory text stays behind How monitoring works; live sync is a compact label with +its timestamp on hover. Delayed ticket monitoring flags the global Notifications button; Preferences shows monitoring status and pause controls. +Read-only states, errors and change confirmations remain explicit. Task, worklog and approval controls use +native Relay forms and explicit review/confirmation. Attachment upload reviews the filename and +size before sending; downloads use the desktop Save dialog. Attachment rows show a wrapping filename, +file size and a compact Save file action. Add attachment opens the file picker; read-only states and +size limits are explained beside the controls. Buttons pair labels with consistent stroke icons. +Problems and Tickets use matching dropdown chevrons and full-width disclosure rows with visible +expanded states, keyboard focus and a minimum 40 px height. No external content mounts inside Relay. + +The account panel contains work sign-in controls. Queue monitoring starts after work sign-in, offers a pause control, and shows coverage/backoff +status alongside a session-only notification inbox. Queue rows refresh without blocking ticket +inspection or discarding drafts; alert rules remain opt-in. Safe description tables keep labels beside values, source spacing/styles +are discarded, and long bodies wrap. Errors never look like empty history. At narrow widths, +forms stack and ticket content scrolls within the available workspace. Bridge actions use Relay's +existing composer and meeting links; no meeting is created automatically. + +Problem details lead with identity and impact, followed by compact **SDP tickets** and **Possible +changes** summaries. Verified ticket links remain visible. Ticket creation and manual-link guidance +sit under Ticket actions. Possible changes shows the match count or unavailable/partial state; +opening a match reveals evidence, scheduled timing and review actions. “Systems & time match” and +“Possible match” describe correlation; “Mark relevant” is a view-session decision, never an SDP write +or confirmation of cause. Refresh and detailed errors remain inside the expanded changes section. +System lists open under Systems affected, keeping NOC response controls close to the problem summary. +Ticket relationships use linked SDP tickets; there is no separate free-text reference entry. +NOC notes record the analyst response before marking a problem addressed locally. + +Queue monitoring automatically links NOC workflow tickets only after verifying an exact problem URL +in the ticket description. The monitoring status reports links or retry failures. Ambiguous or missing +references remain available for manual linking. Unlinking hides the relationship and suppresses +automatic recreation across the workspace; an explicit manual link restores it. + +Problems distinguishes **Linked SDP tickets** from historical **Ticket reference · Not linked to SDP** +notes. Reference text keeps its existing storage and copy behavior; a safe HTTPS reference may be +opened but is not promoted to a connected SDP relationship. Ticket labels and supporting copy use +the shared readable `--text-xs` scale rather than fixed 12 px text. + +--- + ## 3. Edge-Rail Pattern **Reference utility** — `.ink-rail` defines the canonical row/card treatment — a 4 px left border with no box diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 670e54c7..9c8f4fee 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -103,9 +103,20 @@ Do not cache the mutable better-sqlite3 build directory: packaging and test clea Node ABI, which can poison a later cache hit. Before upload, the Windows job verifies both native modules are x64 PE32+ binaries and loads packaged SQLite in a disposable copy of the packaged Electron executable to execute an in-memory query. Architecture checks alone cannot detect an ABI mismatch. -The reusable Windows job must still pass its native dependency build, Windows updater and private-DACL -integration tests, persistent bootstrap smoke test, packaged startup benchmark, and isolated boundary -harness. Native recovery coverage packages synthetic consecutive fixture versions and exercises +Host ABI restoration allows the dependency's matching prebuilt binary (including its download cache), +with the installer's source compilation fallback, then runs a fresh Node process and an in-memory +SQLite query to verify the result. It also clears Electron's stale `.forge-meta` ABI markers so the +next package build cannot mistake the restored Node binding for an Electron binding. +Prepackaged synthetic fixtures do not modify native binaries; +they run the host query without rebuilding those dependencies. +The reusable Windows workflow builds the production artifact once, including Windows updater and +private-DACL integration tests and the native checks above. Bootstrap smoke tests and startup +benchmarks then consume that exact artifact on separate disposable Windows runners; each verifies +its SHA-256 against the build job's output, and bootstrap also verifies the previous-artifact digest. +The synthetic updater/boundary job runs alongside the production build. Every branch must succeed +before `Windows package quality gate` exposes the artifact name to the caller. Uploaded candidates +from incomplete or failed runs are never release authority or successful baseline evidence. +Native recovery coverage packages synthetic consecutive fixture versions and exercises bootstrap activation, stable-launcher fallback, probation, promotion, and predecessor retention in a disposable `RUNNER_TEMP` root. The updater integration archives the target fixture as the only top-level `Relay.exe`, drives `ReleaseUpdateManager` through download, extraction, revalidation, @@ -122,6 +133,14 @@ The checksum covers the downloadable ZIP, not the executable inside it. Reposito immutability must remain enabled; a mutable published release is notification-only and cannot be installed by Relay. +For a release pipeline test, set the repository variable `RELAY_RELEASE_TEST_TREE` to the exact +verified Git tree SHA before merging. Only a matching source tree stays draft-only: all quality, +packaging, and Windows gates still run, uploaded ZIP/checksum bytes are downloaded and verified, +and an `always()` cleanup removes only the matching draft at the expected source commit. The test +never publishes a release or creates its release tag. Remove the variable after the run. This +measures push-to-verified-draft time; public promotion and immutability confirmation are excluded. +Malformed test-tree configuration fails closed. Other source trees follow normal publication. + The injected package version is also the installed version shown under **Settings > About**. Desktop Relay checks GitHub's latest public normal release at startup and every 15 minutes while running. Completed results are not cached, so each scheduled check can discover a newly published version; @@ -223,13 +242,17 @@ release-worthy conventional commit through the protected `main` pull-request wor The Build workflow owns the full pull-request and `main` verification graph. Its required `Build quality gate` fails closed over formatting, linting, type checking, dependency audit, the production build, unit coverage plus cache integration tests, four renderer-coverage shards, and -the mandatory `workflow-tests` job. That job installs PocketBase and runs -`npm run test:pocketbase -- verification/offline-replay-real-pb.test.ts verification/dynatrace-pipeline.test.ts`, then installs Playwright's -Chromium, WebKit, and Linux dependencies and runs `npm run test:electron` followed by -`npm run test:web` under Xvfb. Both browser-driven suites use the npm wrappers sequentially so each -restores the Node native-module ABI before the next suite starts. The job runs on every Build -invocation, including when exact-tree reuse succeeds; any unsuccessful or missing result blocks -the aggregate gate and the Release workflow that waits for it. +the mandatory `workflow-tests` matrix. Four isolated Electron runners execute +`npm run test:electron -- --fully-parallel --workers=1 --shard=N/4` under Xvfb with an unlocked +ephemeral keyring. Test-level sharding divides large specs across runners while keeping one worker +per runner. A fifth runner executes +`npm run test:pocketbase -- verification/offline-replay-real-pb.test.ts verification/dynatrace-pipeline.test.ts`, +then `npm run test:web` under Xvfb against Chromium and WebKit. Electron runners install only the +Linux libraries they need; the web runner downloads the browsers. Each job uses its own npm install, +and both browser-driven suites retain the npm wrappers that restore the Node native-module ABI. +The matrix runs on every Build invocation, including when exact-tree reuse succeeds; fail-fast is +disabled so every shard reports its result, with uniquely named failure artifacts. Any unsuccessful +or missing matrix result blocks the aggregate gate and the Release workflow that waits for it. Those coverage jobs are canonical: Sonar consumes their merged reports instead of rerunning the same tests. The required `SonarQube quality gate` and `Snyk security gate` names remain stable in the same workflow. Sonar always runs for the exact final `main` commit, including its reviewed-issue @@ -237,6 +260,13 @@ reconciliation; optimization never turns a post-merge branch Sonar scan into a r When validated PR Snyk findings are reused, a lightweight main-only monitor still refreshes the canonical Snyk project snapshot before the required Snyk gate succeeds. +The Sonar wrapper records analysis/upload, server wait, reviewed-issue reconciliation, issue indexing, +and quality-gate timings in the GitHub job summary, including failed phases. It also ranks completed +sensors reported in the retained normal scanner output. Sensor timings are included in the +analysis/upload phase and must not be added to that phase's elapsed time. This diagnostic summary +uses sanitized timing fields, enables no verbose credential-bearing logs, and cannot change a gate +verdict. Full final-main analysis, security rules, issue checks, and release blocking remain required. + Pull-request title validation runs in the lightweight `Pull Request Title` workflow. Title edits rerun only its `Release-compatible pull request title` check, not the heavy Build graph. Automatic Windows packaging runs only once per `main` commit through the Release workflow; the Build @@ -643,6 +673,20 @@ and relationships remain unchanged. Problems leaving scope are hidden, not delet removes resolved problems older than 365 days and scope-excluded records after the same grace period, with their related notes and dispositions, only when backup health permits retention. +While SDP queue monitoring is active, Relay uses the recorded workflow subject or a whole problem +display ID to select candidate tickets. It verifies at most five candidates per scan through the +signed-in account's request detail endpoint. Automatic linking requires an exact canonical problem ID +in a Dynatrace problem URL for the same environment, in the ticket description. SaaS Classic and +Platform hostnames for the same tenant are equivalent; Managed environment paths remain distinct. +Ambiguous candidates, missing URLs, inaccessible tickets, and tickets predating the problem remain +unlinked. Failed/no-match checks retry after five minutes; the normal queue limits still apply. +No workflow edits, workflow executions, SDP writes, or new OAuth scopes are needed. This runs while +Relay and monitoring are active; it is not an unattended server integration. + +Shared links retain identifiers plus a suppression flag. Unlink sets that flag so every current +client skips automatic recreation; explicitly linking again clears it. Existing links default to +unsuppressed. Deploy updated clients together: older clients do not understand suppression. + Email naming is independent background work, at most once a minute with one bounded attempt per interval. Canonical records are saved before naming starts. A configured workflow supplies execution references directly; otherwise Relay reads the existing `noc.notification` business events from @@ -776,6 +820,14 @@ npm run test:knowledge-upload-soak `npm test` runs the main/shared, cache, and renderer suites in sequence. `test:knowledge-upload-soak` is a standalone stress harness rather than a Vitest suite. +Change correlation fixtures cover the SDP Changes projection, per-account broker read and scope, +Classic/Grail host types, ambiguous names, scheduled windows, paginated coverage and stale-account +response rejection. `npm run test:electron -- sdp-changes.spec.ts` opens an isolated problem and +exercises automatic/suggested matches plus local confirm/dismiss controls. It never contacts SDP +or Dynatrace. Production grants need renewed consent for `SDPOnDemand.changes.READ`; sandbox GET verification confirmed the scheduled-window filter, pagination flag and +detail-only affected assets/services. Change links use the observed `ChangeDetails.cc?CHANGEID=` +route. Production field population and OAuth consent remain untested. + The focused PocketBase replay test starts the downloaded binary with disposable data and verifies concurrent update/delete rejection, normal API rules and field validation, and unchanged ordinary CRUD for older clients. Use its explicit filename to avoid invoking unrelated verification harnesses. @@ -807,6 +859,8 @@ isolated entry point selects `gnome-libsecret` before loading Relay because Play forces the `basic` password store, which cannot support privileged device pairing. CI provisions a disposable keyring, verifies that encryption is available, and retains failed workflow diagnostics for one day. Run the command through npm so its native-module ABI restoration always executes. +The Electron and web wrappers restore SQLite through its normal installer rather than forcing source +compilation, and require the fresh-process host SQLite query to pass even when the test suite fails. Changes to the Windows bootstrap, stable launcher, retained-runtime metadata, rollback, or repair path also require `npm run build:win`. The local package script compiles both NSIS executables, @@ -937,3 +991,28 @@ Renderer, main, preload, and shared code all have slightly different lint enviro - Validate new IPC payloads in shared schemas - Reuse existing hooks and shared UI primitives before adding new abstractions - Keep docs aligned with current code paths instead of preserving old architecture notes + +### SDP request-workspace verification + +The visible Tickets workspace refreshes its current queue (including filters and pagination) +and open conversation page every 30 seconds. It pauses while account, edit, or bulk dialogs +are open. The broker coalesces and throttles background reads, preserves the current projection +until a read succeeds, and discards results superseded by foreground actions. Refresh failures +back off without extending snapshot expiry; access denial clears the account and saved copies. +Background detail reads do not mark replies read or submit changes. + +The ticket backend tests under `src/main/sdp` and renderer tests under +`src/renderer/src/features/tickets` cover reviewed forwarding, request-history projection, +checklists, reminders and bounded bulk updates. Run them with the Node version from `.node-version`; +the native SQLite module must match that Node ABI. Desktop/browser suites must still run through +their npm scripts, which rebuild and restore the native module. + +Cloud checklist contracts are documented at +[Checklist](https://www.manageengine.com/products/service-desk/sdpod-v3-api/checklist/checklist.html) +and [Checklist item](https://www.manageengine.com/products/service-desk/sdpod-v3-api/checklist/checklist_item.html). +Sandbox read-only inspection confirmed `requests/{id}/_history` and the notification metadata; +the sandbox's loaded Cloud request client defines `REQFORWARD` and request-scoped reminder +summary/date/lead-time/status payloads. Fixtures verify Relay's behavior without external writes. +Do not treat browser-cookie access as proof of OAuth authorization or mock confirmation as a +successful live change. Any necessary live verification for this work is restricted to the +previously identified SDP sandbox, never production; tenant-specific workflows are excluded. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 411bc3d6..929acec1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -325,6 +325,157 @@ existing in-scope problems with newer naming metadata and cannot change lifecycl write back to Dynatrace. A subject is displayed only when its recorded status matches the canonical status. Failed reads and expired execution history retain the existing fallback. +### Service Desk Data Boundary + +The synthetic workspace and its runtime subscriptions are removed. New databases do not create +`relay_demo_ticket*` collections; existing collections are preserved without new UI access. Live +SDP content must never use those legacy broad shared collection rules. + +The **Connect SDP** desktop path uses a server-owned OAuth application configured once by an +active Owner or Administrator on the server computer through `sdp:server`. Users only use their +work sign-in. The server stores the client secret encrypted with AES-256-GCM under an OS-wrapped +key; unavailable protected storage fails closed. Secrets never enter PocketBase or client builds. +The callback listener binds only `127.0.0.1:8766`, validates Host/path and single-use state, and +expires after five minutes. PKCE S256 binds the code to the initiating authenticated Relay session. +Fixed US provider endpoints reject redirects. Requests and responses have time and size limits. +Native form metadata is limited to 1 MiB and projected to active template fields. Metadata URLs +are never followed; lookup paths use validated field names on the fixed tenant endpoint. Edit +permissions and field types are rechecked before confirmed submission. Forms, lookup choices, +reply recipients and unsent drafts remain in session memory and are cleared with the test data +control or sign-out. Latest-reply metadata is projected to message ID, sender name/role and time; +email addresses, phone numbers and message bodies from conversation-list profiles are discarded. +Reply trackers are isolated by verified provider owner and cleared with saved data or the last +connection. Sender names may appear in the session inbox; desktop alerts remain generic. Unread +acknowledgement is local to Relay and never silently writes SDP's read state. Filtered queue pages are not persisted or replaced by unfiltered outage data. + +Remote desktops use the existing workspace passphrase to establish a private Relay Web gateway +session in the main process; users do not create another Relay account. The gateway requires +session authentication, same-origin checks, CSRF, and bounded commands. Its logical session ID +binds the individual's provider tokens. A read-only discovery collection contains only enabled +state, gateway port, and configuration revision. The host comes from the existing Relay connection. +Existing trusted LAN/VPN restrictions remain; HTTP on a LAN does not itself encrypt traffic. +Relay Web does not expose desktop secret/configuration IPC or the desktop sign-in UI. + +Zoho's request READ/CREATE/UPDATE/DELETE scopes cover records accessible to the signed-in account; +Relay also requests `SDPOnDemand.setup.READ` for custom field definitions, never setup write scopes. +Change correlation additionally requests only `SDPOnDemand.changes.READ`; prior grants need +renewed consent. The existing authenticated account command accepts a bounded problem timestamp +and page, never a caller-supplied URL, query, or provider token. Reads project only identifiers, +change title/description, status/stage, site, scheduled times and asset/configuration-item/service names. +Cloud list reads hydrate at most ten eligible details per page on fixed numeric-ID paths, with +three concurrent GETs; incomplete coverage is explicit. Results +remain in renderer session memory, are rechecked against the account session after pagination, +and are cleared on sign-out or read failure. No change text or decisions enter PocketBase, +offline caches, logs, notifications, or provider writes. A Changes-specific HTTP 403 reports +missing permission without disconnecting an otherwise valid ticket account. + +Existing ticket-only grants require renewed user consent. Setup reads use a fixed `/udf_fields` +endpoint, request-module filtering, bounded pagination and response sizes; provider metadata URLs +are never followed. Field definitions remain in session memory. Queue reads allow only +NOC, SOX, and tickets with no support group, at 50 rows per page and at most 20 pages per queue. +The projection includes ticket ID/number, subject, status, priority, group, technician name and +request type, category, template and created/due timestamps. Subjects and technician names can contain personal information. Requester +contact details and full profiles are excluded from the retained projection. Ticket properties +retain requester/on-behalf-of names, workflow/category/site/SLA data, populated additional fields +and resource answers, attachment names, deadlines and resolution. Attachment bytes are fetched only after an explicit download action. Unexpected queue groups or +top-level fields fail closed. Opening a ticket from the current authorized queue page reads its +description and up to ten email conversation bodies and ten notes per activity page, with a twenty-page limit. The activity projection retains only body, subject, author name and timestamp; raw request/profile fields are discarded. +Bodies may contain personal information. An inert template parses them; React reconstructs only +allowlisted text-formatting elements, headings, lists and tables without source attributes. Scripts, +forms, active links, remote images are excluded. Conversation failures appear explicitly. Server errors never include upstream bodies or ticket content. `AaaServer.profile.READ` verifies the identity via Zoho's user-info +endpoint; only the immutable ZUID from that identity response is retained in memory. Other identity profile fields are discarded. +Access and refresh tokens remain in server memory for that connection, at most eight hours; +restart, disconnect, authorization failure, or configuration replacement requires fresh sign-in. +No service-account reads run in the background. Live writes require the signed-in user’s explicit review and confirmation, as described below. + +The server-only outage cache lives under `userData/sdp-server`, outside shared PocketBase and +client offline stores. Ticket, detail and queue-page snapshot payloads are AES-256-GCM encrypted and authenticated to a hashed +owner key combining tenant, configuration revision and verified Zoho identity. The default TTL +is 60 minutes, configurable from 5 to 240; expiry removes copies and old display projections. +Only network outages or HTTP 500/502/503/504 from an authorized ticket read permit read-only +fallback. Failed sign-in/refresh, 401/403/404, TLS failures, malformed responses and unavailable +Relay transport never permit fallback. Permission denial purges the identity's copy and disconnects +its active sessions. Configuration replacement/removal purges all copies and sessions. During an +upstream outage Relay cannot discover a new permission revocation; TTL bounds this stale-access +window. This is an availability control, not a claim of company compliance approval. + +Queue cache keys also bind the requested queue and page, with at most 1,000 encrypted pages +server-wide. Detail keys bind ticket ID and history page, with at most 200 encrypted detail pages +server-wide. The client reply limit is 15 MiB to accommodate a bounded 10 MiB attachment; individual provider JSON responses remain capped at 256 KiB. +**Clear my saved SDP data** is an unpackaged test control only. Packaged IPC rejects this action +and the release renderer never displays it. The test control removes the verified user’s ticket, detail and queue copies, +clears that identity’s active display projections, and cancels in-flight reads before they can +repopulate storage. It preserves other users and does not delete tickets from SDP. + +Live bodies and user profiles never enter synthetic collections or client offline databases. +`relay_sdp_links` contains shared ticket/problem identifiers and unlink suppression only; references may be included in +Relay backups and never authorize access to SDP bodies. Bridge handoff uses in-memory ticket +references, meeting URLs and selected groups, with explicit operator review before sharing. + +Automatic NOC ticket linking reads descriptions only for candidates from a fresh account-bound +monitor generation. It returns a verification boolean and neither persists nor logs the description. +The verifier compares exact problem IDs and environment-qualified HTTPS URLs; subjects alone cannot +create a link. Shared link rows include an unlink-suppression flag, retained across restarts and +clients. Authenticated workspace users can change that flag through ordinary PocketBase CRUD; +this grants no additional SDP permission. Background work stops on disconnect or monitor pause. + +Live changes require a five-minute session-bound server review and a one-use confirmation ID. +Renderer commands cannot substitute a different payload at confirmation time. Existing tickets +must come from a live authorized queue and are re-read for a best-effort conflict check. Plaintext +operator input is HTML-escaped before submission; notes default to technician-only visibility. +Deletion is supported for reviewed task, worklog and approval records. No arbitrary endpoints, +automatic write retries or offline write replay exist. Child records join the conflict baseline. +Ambiguous responses require checking SDP. Writes invalidate the identity's encrypted snapshots +and other same-identity sessions; permission denials revoke cached access. SDP enforces template +requirements and technician permissions on each request. + +Queue failure diagnostics contain only queue/page, duration, controlled failure categories and +validation field paths/codes; they omit ticket values, identifiers, response bodies and credentials. +Form failures log only the endpoint pattern with IDs removed, failure category and HTTP status. +Supporting editor endpoints can reject OAuth independently of ticket access. Before treating +such a 401/403/404 as editor-only unavailability, Relay revalidates the exact parent ticket through +the live API. Actual parent access denial retains account-wide revocation and cache removal; +outage copies never authorize keeping a rejected editor active. A successful recheck preserves +the account and displayed ticket. Optional request metadata may then fall back to known request +field types, but live template membership, allowed values and provider edit restrictions still gate +writes. Unknown custom types are not made writable. Link/unlink/merge commands use fixed endpoints, +recheck the target ticket and operation permissions, and hash both ticket records for conflicts. +No browser cookies or broader OAuth permissions are used to recover metadata. +Bulk ticket changes are bounded to 20 unique IDs from the authenticated live workspace. One +expiring confirmation covers the reviewed changes; all records receive a preflight conflict check +and a second check immediately before their sequential write. The first conflict or unconfirmed +result stops the batch, with per-ticket outcomes and no automatic retry. A denied write revokes +the account's sessions and saved copies. Checklist catalog and request-history reads require an authorized +live ticket and never populate shared collections or outage caches. Catalog paths are fixed +allowlisted routes. Standard creation/bulk dropdown catalogs require an authenticated account; +they accept only eight allowlisted request field names plus bounded search, pagination and an +optional numeric support-group ID for technician filtering. They never accept provider URLs or +populate shared storage. History values render as text. Forwarding rechecks the selected notification +under its parent request and sends only the recipients, visibility and content explicitly reviewed. + +Queue monitoring runs on the server using each verified SDP user's credentials. Only sessions +with the same verified identity/configuration share a job; per-session leases expire after 75 seconds +without a heartbeat. Disconnect, denial, configuration changes, confirmed writes and clearing +copies cancel affected scans so late responses cannot repopulate invalidated state. Monitoring +returns bounded projections only, retains summaries/comparison data/notices in memory, and clears +the alert baseline after failure. Summaries are not written to client storage or shared PocketBase +collections and do not authorize offline access. Notification links may select a ticket observed by +that session's current verified-account monitor generation, with a scan age below 75 seconds. The +broker re-reads detail using that account; this does not permit arbitrary IDs or cross-account reads. +Invalidating a monitor or clearing copies revokes that monitor-only eligibility. Native notifications +contain generic text and a strictly validated destination, never executable URLs. The shared inbox +is session memory only; ticket disconnect/failure clears its ticket entries and banners. An open live ticket remains addressable until its +detail expires even when queue rows shift; upstream permissions still govern each operation. Device-persisted alert rules may contain operator-entered +filter values; desktop notification text is generic. The gateway's request body limit is 15 MiB +for validated drafts and bounded attachment uploads, with unchanged authentication, CSRF and trusted network restrictions. + +Attachment uploads are held only in a five-minute private prepared command; review responses +omit their base64 bytes. Confirmation performs one multipart upload. Downloads re-read the parent +ticket and select a listed attachment, accept only its fixed tenant/request upload path, reject +redirects, and bound streamed bytes to 10 MiB. Only the desktop main process receives downloaded +bytes, presents a Save dialog, and writes the chosen file; the renderer receives the result message. +No file is opened or executed automatically. Task/worklog/approval data remains session-only. + ### External Dashboard Popouts Dynatrace dashboard popouts are handled by `src/main/dynatrace/DynatraceWindowManager.ts`. @@ -451,6 +602,15 @@ Treat any failing gate as a release blocker until the finding is validated and f Sonar analysis uses the official standalone SonarScanner CLI instead of the npm scanner and its `node-forge` dependency. CI pins the CLI version and verifies its ZIP against a checked-in SHA-256 digest before extraction or execution. The former scanner-specific Snyk exceptions are removed; development dependencies remain included in the blocking scan. +Build dependencies pin `@electron/get` to 5.1.0, removing the old Got HTTP-cache chain, +and replace Ajv 6's `uri-js` dependency with the already-used `fast-uri` 4.1.4 API. +Compatibility tests exercise schema reference resolution (including Unicode separators), +checksum-verified artifact downloads, cache reuse and rejected corrupt artifacts. These +are dependency replacements, not scanner exceptions; development dependencies remain +in the blocking Snyk scan. Packaging uses the repository's Node 22 runtime. Proxy builds +use `ELECTRON_GET_USE_PROXY=1` with `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, as supported +by the current Electron downloader. + ## Secrets And Local Data ### Connection Passphrase Storage diff --git a/docs/architecture.md b/docs/architecture.md index 8398aaec..9fc5b75c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -482,23 +482,271 @@ count can lag live event delivery. Saving returns before historical backfill com `docs/DEVELOPMENT.md` for token scopes and rollout requirements, and `docs/SECURITY.md` for payload, credential, and network boundaries. +### Service Desk + +Dynatrace problem details automatically read account-visible SDP Changes through the existing +account broker, using the read-only `SDPOnDemand.changes.READ` scope. Existing grants require +reconnection. `SdpChanges` requests scheduled starts in the seven days before problem onset, +50 rows per page with a 500-record ceiling. Cloud lists omit affected systems even when +explicitly requested: the reader hydrates up to ten time-compatible changes per page from +`changes/{id}`, in batches of three. Missing detail coverage is labelled alongside truncated +pagination. Detail projections include affected assets, configuration items and services. `useSdpChanges` checks sign-in every five seconds, +refreshes changes every minute while the detail view is active, and backs off failed reads to +five minutes. Pagination is deduplicated and partial coverage is labelled. Results are session +memory only: no outage snapshot, PocketBase collection, external writeback or alert suppression. + +`sdpChangeCorrelation` associates exact fully qualified affected-host/asset or CI names automatically +within the scheduled window or two hours afterward. Exact short hosts additionally require a +matching site in management zones/workflow tags. Ambiguous hosts, short/FQDN aliases, exact +service names, description mentions and missing end times remain suggestions; missing ends +use two hours after scheduled start. Different qualified domains never alias. Generic cancelled +or rejected statuses and invalid windows are excluded. These are scheduled-time correlations, +not causality claims, actual-execution detection or tenant workflow rules. Root-cause display text +alone cannot establish a host identity; service topology enrichment is not included. + +Each association shows evidence, change status/stage and a link to SDP. Confirm/dismiss decisions +last only for the mounted problem detail view session and do not write to either provider. +Account changes, sign-out, read failures and unmount discard loaded changes; asynchronous results +from superseded reads are ignored. Missing Changes permission is reported without revoking valid +ticket access on an HTTP 403. Other authentication failures retain the broker's existing handling. + +Tickets contains only the live, account-bound SDP workspace. Demo screens, sample seeding, +and demo subscriptions, problem links and bridge actions are removed. New databases do not create demo ticket +collections; existing legacy collections are left untouched under the unknown-collection policy. +Native ticket controls use Relay components and server-mediated API calls, never an embedded SDP page. + +Desktop Tickets defaults to live SDP and provides a **Work account** panel. One server administrator +configures the Zoho application through the local, role-guarded `sdp:server` IPC. `SdpRuntime` +opens OS-protected `SdpServerStore` storage only in server mode. `SdpAccountSession` on each +desktop owns only the temporary loopback callback, random state and PKCE verifier; the server +`SdpBroker` exchanges codes, verifies Zoho identity, refreshes per-user access and reads SDP. +The fixed callback is `http://127.0.0.1:8766/callback`. Secrets are never distributed to clients. + +Local server desktops invoke the broker directly. Remote desktops discover the private gateway +port from the server-owned `relay_sdp_discovery` record and use their existing Relay workspace +connection to authenticate. `/relay-api/v1/sdp/account` requires a gateway session and CSRF, binding +OAuth to the stable logical session. Gateway destruction disconnects its broker session. Client +mode and server mode retain their existing PocketBase connections; SDP does not add user accounts. + +The server requests `SDPOnDemand.requests.READ,SDPOnDemand.requests.CREATE,SDPOnDemand.requests.UPDATE,SDPOnDemand.requests.DELETE,SDPOnDemand.setup.READ,SDPOnDemand.changes.READ,AaaServer.profile.READ` with offline access. +Provider tokens and the verified ZUID remain in server memory for up to eight hours, requiring +sign-in after restart. `SdpServerStore` persists only encrypted application configuration and +per-identity ticket snapshots, with an OS-wrapped encryption key and a 5–240 minute expiry. +Only an SDP outage can expose a saved copy, with last-sync and expiry labels and read-only status. +Relay must remain reachable; no live tickets enter a client's offline database. Auth failures, +permission denials, invalid responses, config replacement and cancellation fail closed. + +The live workspace reads NOC, SOX and Unassigned (no support group), 50 tickets per page with a 20-page limit. The legacy diagnostic read remains internal; sign-in no longer offers a hardcoded test ticket. The statically linked SDP provider/contract chunk keeps the main entry within its build budget. +Provider filters and strict projections bound queue reads; subjects and technician names render as text. +Queue-page snapshots use the verified owner plus queue/page as their encrypted storage context. +Ticket details load on demand from the current authorized queue page. Description and conversation +bodies use a separate encrypted cache keyed by owner, ticket ID, history page and automatic-notification +filter. Legacy unfiltered cache keys are not reused. Messages use the conversations feed to exclude +notes, approval comments and system-user notifications (`created_by.user_type = 1`) before pagination; +Show automatic notifications removes only the system-user exclusion. Sender names and email types +do not determine visibility. Notes remain independently available. The renderer +parses provider HTML in an inert template and reconstructs an attribute-free formatting allowlist +with headings, lists and tables; remote content never mounts. Ticket sections separate description, +properties, messages, notes, resolution, work, attachments and links/bridge context. Populated custom properties use the same Cloud field definitions as the editor. Individual +property values support up to 100,000 characters within the existing bounded provider response, +so multiline answers longer than 4,000 characters do not invalidate ticket details. Other form +answers retain API field keys when the provider supplies no friendly label. Tasks, worklogs, approval levels and approvals load on demand into session memory. Create, update, +delete and approval decisions use the same one-use confirmation path; existing child records are +included in conflict checks. Checklists and nested checklist items use the documented Cloud `checklists` and +`checklistitems` routes. Read-only `checklist_templates` and `item_details` catalogs provide names +and IDs without granting setup writes. Personal reminders use request-scoped `reminders`, with +summary, date, email lead time and Open/Completed status. Parent IDs and field values are validated +at the shared boundary; individual checklist-item deletion is not exposed because the Cloud +contract does not document it. Resource baselines include existing child records before changes. + +Request history reads `requests/{id}/_history` with independent 50-entry pagination. Only bounded +author, time, operation, description and before/after values reach the renderer, where all history +content renders as text. History and checklist catalog results remain session-only and require a live, +account-authorized ticket. Forwarding uses `REQFORWARD`; it can quote the original description or +an explicitly selected notification fetched beneath the same request. It starts with empty +recipients, has private visibility by default, and uses the existing one-use email confirmation. + +Creation and bulk-update dropdowns use authenticated, read-only `requests/{field}` lookup +catalogs, restricted to eight standard field names. These lookups can run before a request exists; +results remain in the immediate response and are not cached with ticket snapshots. Search and +pagination are bounded, and technician lookups can filter by the selected support-group ID. +Existing request editors retain template-specific lookup permissions and dependencies. + +Bulk updates prepare a bounded set of at most 20 unique, currently authorized ticket IDs. The +server records all baselines, checks the entire batch before any write, then checks each ticket +again immediately before its sequential update. Outcomes distinguish confirmed, conflict, +uncertain and not-attempted records. An unconfirmed result stops later writes; there is no rollback +or automatic retry. Permission denial revokes the identity's sessions while preserving the batch +outcomes in the immediate response. The existing cache invalidation and monitoring suspension +apply to the entire confirmed batch. Tenant-specific workflow extensions are outside this scope. +Reply monitoring uses SDP's email-only conversations feed, excluding notes, approval comments and +system notifications. Queue projections carry SDP read/reply counters; latest-message projections +contain only message ID, sender name/role and time. A verified-owner RAM tracker shares reads across +that user's connections, checks changed tickets and visible rows, and caps background metadata work +at 12 reads per scan with three concurrent requests. Visible rows rotate through the budget, so busy +queues can require additional 30-second scans. Opening a ticket refreshes its latest message on demand. +Reply alerts compare stable message IDs, never generic request modification times. Initial history +establishes a silent baseline. Relay unread state clears only when the latest message is included in +successfully loaded conversation content; it does not change SDP's read state. The tracker respects +provider retry delays and clears on data-clear, final owner disconnect or server restart. Sender names +stay within the signed-in workspace/inbox; desktop notifications remain generic. +Activity is paginated ten messages and ten notes at a time, up to twenty pages, with explicit partial-failure states. +The unpackaged-only test control for clearing saved SDP data cancels reads and clears projections for the same identity without +ending work sign-in or altering SDP. The renderer does not persist live rows or search text. +The native ticket panel keeps the queue visible and opens with conversations. Editing loads the +current request, request metadata, technician template layout, allowed values and provider edit +permissions. Only active template fields are projected; lookup URLs are constructed from a +validated field key on the fixed tenant endpoint, never taken from provider metadata. Lookup +search is paginated and scoped by parent selections. Parent changes clear dependent values. +Forms and lookup results stay in session memory, with a 1 MiB bounded metadata response. +Custom definitions use the Cloud `/udf_fields` API with `SDPOnDemand.setup.READ`, filtered to the +request module and paginated at 100 rows (2,000-definition bound). Each response is limited to +1 MiB; definitions stay in memory and are not shared between users. Explicit `fields_required` +includes constraints so names, types, multiplicity and length limits come from SDP. Standard fields +use the public request API catalog. Relay does not call the OAuth-incompatible `_metainfo` endpoint. +Only active template fields are projected. Date-only values retain their calendar-date strings; +date/time values retain timestamps. Dates serialize using live field definitions, including custom +API names that do not encode the type. Multiline fields and multi-reference choices keep their types. +An older ticket-only grant may receive 401/403/404 from setup reads. Relay revalidates the exact +parent ticket before offering the existing limited editor with a reconnect notice; it never treats +setup denial as proof of ticket access. Unknown custom types cannot be written. Template, permission +and lookup failures still stop that operation. Parent denial revokes access; outage copies never +authorize edits. +Edits submit only changed fields, preserving untouched HTML and unknown fields. Changed rich-text +fields are sent as escaped plain text. SDP retains final validation of template rules. Email replies +use the notifications endpoint with `REQREPLY`, `in_reply_to`, explicit To/Cc/Bcc, subject, body and requester visibility; +opening a composer never sends or saves a provider draft. Both edits and emails require a +separate, explicit review and confirmation, with provider permissions checked again before submission. +Queue search and status/priority/technician/due filters can be applied across SDP before pagination. +Filtered result pages remain memory-only and refresh explicitly; monitor snapshots never replace +them with an incomplete recent-ticket subset, and outages never substitute an unfiltered saved page. +Live mutations use strict create/update/edit/reply/note/resource/attachment/relation schemas. The broker prepares a session-bound, +five-minute single-use review, compares an existing ticket's canonical hash against a fresh read, +then submits exactly once after confirmation. There is no write replay queue; the preflight conflict +check cannot prevent a concurrent upstream update between GET and PUT. Writes invalidate that +identity's saved projections and other active identity sessions to avoid stale reads. A lost +response requires checking SDP before another attempt. Major incidents use the verified default CWGS Incident/Request template +(`142866000146669084`) and set its custom checkbox with +`udf_fields.txt_major_incident: ["Yes"]`; subjects remain unchanged. The form starts with +request type Incident and the template defaults Single User impact and Medium urgency, +requires a requester email, and lets the operator adjust those values before confirmation. + +Ticket-to-ticket associations live in SDP. The Links & bridge section searches an accessible ticket +by display number, lists existing links, and reviews link, unlink or merge operations. Merge keeps +the current ticket and incorporates the selected ticket; its direction is explicit in the review. +The broker checks live operation permissions and target access before preparation and submission. +Both ticket records join the conflict baseline. Link lists reject upstream pagination parameters, +so Relay pages the byte-bounded response locally. No provider-supplied URL is followed. +HTTP 200 warning responses are partial changes, never successful confirmations. Field-validation +errors expose only bounded field identifiers, and the operator must refresh before preparing again. + +Attachments up to 10 MiB upload only after confirmation. Downloads re-read the ticket, validate +that the attachment belongs to it, reject cross-origin/cross-ticket URLs and redirects, and stream +under a byte limit. A native Save dialog controls the destination; file bytes never enter an outage +cache. Upload review responses omit file bytes; the private prepared command retains them until +confirmation, cancellation or expiry. + +`relay_sdp_links` stores ticket ID/number, problem ID, environment and an unlink suppression flag. +Ordinary renderer CRUD maintains these references, with uniqueness enforced server-side. Automatic +linking reuses the account-bound queue monitor, selecting candidates by recorded workflow subject or +whole display ID, then verifying an exact canonical problem URL through a read-only broker command. +Up to five detail reads run per scan; ambiguous candidates stay unlinked. No ticket descriptions are +retained by this process. Unlinking sets the shared suppression flag; explicit manual linking restores +the relationship. Monitoring must remain active in a connected desktop client. They are workspace-shared, +not SDP permission grants; opening one uses the user's SDP sign-in. Live bridge handoff remains +in-memory and contains only reference/meeting/group context. Legacy demo collections are not used by the ticket workspace. + +`SdpQueueMonitor` runs on the Relay server. `monitorQueues` subscribes/heartbeats a logical +session; it returns in-memory results without starting another upstream scan. One job is keyed by +the verified Zoho identity and server configuration revision, with separate jobs for different +users. Desktop clients heartbeat every five seconds; jobs stop when the last subscriber leaves, +loses authorization, or its 75-second lease expires. No service account, public endpoint, new IPC +channel, or external hosting is involved. + +The initial scan reads all three queues, bounded to 20 pages/1,000 tickets each. Every 30 seconds, +scoped `(last_updated_time OR created_time) >= watermark - 60 seconds` queries merge changed +summaries by ID/update time. The creation-time alternative includes new tickets with no update timestamp. The cursor advances only after all three queues succeed. Every five minutes a full scan +reconciles deletions and moves out of scope; those removals can lag until reconciliation. Overflow +is explicitly reported, and an overflowing delta triggers a new full scan. Provider indexing/clock +skew beyond the overlap is repaired by reconciliation. No descriptions, conversation bodies, or attachments are polled; bounded latest-message metadata +checks run after queue scans. There is one in-flight scan per job with a 60-second timeout. Failures discard the alert +baseline, back off from one minute to five minutes, and honor HTTP 429 Retry-After up to one hour. +There is no attempt to bypass tenant API limits. + +Queue projection accepts absent/null subjects as empty text; the renderer labels them “No subject”. +Other malformed field types still fail validation. Monitoring reports distinct validation, timeout, +throttling and connection failures instead of presenting every failure as an SDP outage. Local +diagnostics record only the queue/page, duration, failure category and validation field paths/codes. + +Monitor snapshots refresh unfiltered queues via the existing five-second status check. While the +Tickets workspace is visible, a separate 30-second read refreshes its current queue, applied +filters, pagination and open conversation page without clearing the displayed data or marking +replies read. It pauses for editor, account and bulk dialogs. The server coalesces and throttles +these reads, skips active operations and prepared reviews, and rejects results superseded by a +foreground operation. Failures retain the original expiry and honor provider retry delays; +permission denial clears the identity and saved data. Token refresh is deduplicated with +interactive reads. Confirmed writes suspend/invalidate the identity's monitor before revalidation +and submission; clearing copies, disconnect, configuration changes and denial abort stale scans. +Late responses cannot republish invalidated snapshots. Clearing saved data also pauses monitoring +and clears the clearing desktop's alert inbox; polling resumes only when the operator enables it +or signs in again. A paused session does not receive another same-user session's queue refreshes. An already open live detail can authorize +its ticket actions until its expiry, even if pagination shifts; SDP rechecks access on each read/write. + +Monitor summaries are session memory only, never offline evidence or a second persisted cache. +Rules persist per Relay URL; notices/comparison snapshots stay in RAM. First scans/recoveries +baseline changes; failures clear live ticket notices. Native notices use generic text. Bounded reply +metadata checks feed ticket reply rules. API quotas and the 1,000-per-queue cap remain practical coverage limits. + +See `SECURITY.md` for cache eligibility, identity isolation and network boundaries. + +### Global notification delivery + +`NotificationProvider` wraps the main application. Source detectors for tickets, Dynatrace problems, +Radar and cloud status publish to the shared session inbox and delivery policy. The header's +`NotificationCenter` owns the SDP status/monitor heartbeat outside retained tab Activities, so hiding +a tab does not clean up the monitor or reset its baseline. Pop-outs do not mount the notification +center or operational problem/Radar managers. Routine success/error toasts remain transient UI feedback. + +Preferences are device-local and scoped to the Relay URL. Shared quiet hours, snooze and channel +switches gate interruptions; source rules gate publication. Quiet delivery still records opted-in +inbox items. Existing SDP quiet-hour/snooze values seed shared preferences on first use. Ticket rules +retain event/condition/channel selection. The provider deduplicates event IDs, caps history at 200, +and stores no history or ticket metadata on disk. SDP disconnects, failures and generation changes +clear ticket notices and ticket banners without clearing other sources. + +In-app alerts retain their source action. Native notices use generic text and a validated destination; +the existing trusted `ticket:notify` IPC carries both notification requests and click destinations. +Preload uses a dependency-free destination guard because its sandbox cannot require external validation libraries, and exposes a removable desktop-only listener. Main and renderer boundaries retain schema validation. Ticket destinations +use internal numeric IDs and the existing ticket navigation event. An alert may open a ticket from +its session's observed monitor generation if the scan is less than 75 seconds old. The broker fetches +fresh detail under the same verified account; arbitrary IDs, foreign identities, invalidated generations +and stale monitor-only IDs do not gain read or write authority. Existing current-queue/live-detail +eligibility remains in force. Active drafts defer notification navigation. + ### Dispatcher Radar `src/main/handlers/radar/RadarManager.ts` owns polling, coalescing, stale-data behavior, and the CW -Dashboard session on the Relay server PC. `src/main/services/operationalServices.ts` exposes a -bounded Radar service to Electron handlers and `src/main/web/RelayWebGateway.ts`. +Dashboard session for each desktop instance. `src/main/services/operationalServices.ts` exposes a +bounded Radar service to Electron handlers and `src/main/web/RelayWebGateway.ts`. Desktop clients +use their own CW sessions; Relay Web uses the session on the Relay server PC. ```text -CW Dashboard session on server PC - -> RadarManager +CW Dashboard session on each desktop PC + -> local RadarManager -> validated RadarSnapshot - -> Electron renderer and authenticated Relay Web sessions + -> local Electron renderer + +Relay server PC's RadarManager + -> authenticated Relay Web sessions ``` -Clients never receive CW cookies or choose an alternate Radar target. +Relay Web clients never receive CW cookies or choose an alternate Radar target. Polling and the sign-in window share the hardened `persist:relay-radar` session, which permits an untrusted certificate authority only for `cw-intra-web`. Other TLS failures and hosts retain Chromium's normal verification. +HTTP 401 and recognized login pages request CW sign-in while retaining the last good +Radar reading. Other HTTP errors remain refresh failures. Polling reuses the sign-in +window's session cookies; signing in to a separate browser does not establish that session. ### Offline resilience diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b13fa2cd..b10a12a5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -91,6 +91,35 @@ function mainManualChunk(id: string): string | undefined { if (normalizedId.endsWith('/src/main/pocketbase/PocketBaseProcess.ts')) { return 'pocketbase-process'; } + if ( + normalizedId.endsWith('/src/main/sdp/SdpProvider.ts') || + normalizedId.endsWith('/src/main/sdp/SdpForms.ts') || + normalizedId.endsWith('/src/main/sdp/SdpFieldCatalog.ts') || + normalizedId.endsWith('/src/main/sdp/SdpTicketRelations.ts') || + normalizedId.endsWith('/src/shared/sdpTicketRelations.ts') || + normalizedId.endsWith('/src/main/sdp/SdpReplies.ts') || + normalizedId.endsWith('/src/shared/sdpReplies.ts') || + normalizedId.endsWith('/src/shared/sdpForm.ts') || + normalizedId.endsWith('/src/shared/sdpQueueFilters.ts') || + normalizedId.endsWith('/src/main/sdp/SdpBroker.ts') || + normalizedId.endsWith('/src/main/sdp/SdpBulk.ts') || + normalizedId.endsWith('/src/main/sdp/SdpHistory.ts') || + normalizedId.endsWith('/src/main/sdp/SdpChanges.ts') || + normalizedId.endsWith('/src/shared/sdpChanges.ts') || + normalizedId.endsWith('/src/shared/sdpWorkflowLink.ts') || + normalizedId.endsWith('/src/shared/sdpHistory.ts') || + normalizedId.endsWith('/src/main/sdp/SdpQueueMonitor.ts') || + normalizedId.endsWith('/src/main/sdp/SdpMutations.ts') || + normalizedId.endsWith('/src/main/sdp/SdpResources.ts') || + normalizedId.endsWith('/src/main/sdp/SdpAttachments.ts') || + normalizedId.endsWith('/src/shared/sdpResources.ts') || + normalizedId.endsWith('/src/shared/sdpAttachments.ts') || + normalizedId.endsWith('/src/shared/sdpMutation.ts') || + normalizedId.endsWith('/src/shared/sdpLinks.ts') || + normalizedId.endsWith('/src/shared/sdpAccount.ts') + ) { + return 'sdp-provider'; + } if (normalizedId.endsWith('/src/main/web/WebSessionStore.ts')) { return 'web-session-store'; } diff --git a/package-lock.json b/package-lock.json index 4bb3bbb6..bdf87fb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1553,6 +1553,24 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/node_modules/uri-js": { + "name": "fast-uri", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -3402,32 +3420,6 @@ "node": ">=8" } }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3574,19 +3566,6 @@ "@types/node": "*" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3632,13 +3611,6 @@ "@types/node": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3646,16 +3618,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3703,16 +3665,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", @@ -4230,53 +4182,6 @@ "electron-builder-squirrel-windows": "26.15.3" } }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -4293,26 +4198,6 @@ "node": ">=8" } }, - "node_modules/app-builder-lib/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4326,16 +4211,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4756,15 +4631,6 @@ "multicast-dns": "^7.2.5" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/brace-expansion": { "resolved": "vendor/brace-expansion-compat", "link": true @@ -4945,35 +4811,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -5130,29 +4967,6 @@ "node": ">=12" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clone-response/node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5440,16 +5254,6 @@ "dev": true, "license": "MIT" }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5515,14 +5319,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -6107,14 +5903,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -6485,6 +6273,24 @@ "node": "*" } }, + "node_modules/eslint/node_modules/uri-js": { + "name": "fast-uri", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -6958,22 +6764,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7029,25 +6819,6 @@ "node": ">=10.13.0" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/globals": { "version": "17.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", @@ -7091,32 +6862,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -7294,13 +7039,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -7315,20 +7053,6 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -8064,14 +7788,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8292,16 +8008,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8344,20 +8050,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8699,19 +8391,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8880,16 +8559,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9471,19 +9140,6 @@ "node": ">=16.0.0" } }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -9772,13 +9428,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9789,19 +9438,6 @@ "node": ">=4" } }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -9864,25 +9500,6 @@ "node": "*" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -10080,31 +9697,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10680,14 +10272,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -11275,20 +10859,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -11501,16 +11071,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 1e3551c6..bc29456b 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,9 @@ }, "vite": { "esbuild": "0.28.2" - } + }, + "@electron/get": "5.1.0", + "uri-js": "npm:fast-uri@4.1.4" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/playwright.electron.config.ts b/playwright.electron.config.ts index ad2b8252..5998efd6 100644 --- a/playwright.electron.config.ts +++ b/playwright.electron.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ 'radar-certificate.spec.ts', 'setup-auth.spec.ts', 'redesign-screenshots.spec.ts', + 'service-desk.spec.ts', + 'sdp-changes.spec.ts', ], timeout: 60 * 1000, expect: { diff --git a/scripts/build-dependency-compat.test.mjs b/scripts/build-dependency-compat.test.mjs new file mode 100644 index 00000000..27936117 --- /dev/null +++ b/scripts/build-dependency-compat.test.mjs @@ -0,0 +1,90 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); +const eslintRequire = createRequire(require.resolve('eslint')); + +describe('build dependency compatibility', () => { + it('resolves external, relative and fragment references through the ESLint Ajv dependency', () => { + const Ajv = eslintRequire('ajv'); + const ajv = new Ajv(); + ajv.addSchema({ + $id: 'https://schemas.example.test/shared.json', + definitions: { name: { type: 'string', minLength: 2 } }, + }); + const validate = ajv.compile({ + $id: 'https://schemas.example.test/rules/settings.json', + type: 'object', + properties: { name: { $ref: '../shared.json#/definitions/name' } }, + required: ['name'], + additionalProperties: false, + }); + expect(validate({ name: 'NOC' })).toBe(true); + for (const value of [{ name: 'x' }, { name: 42 }, {}, { name: 'NOC', extra: true }]) { + expect(validate(value)).toBe(false); + } + }); + + it('bounds URI resolution for Unicode line and paragraph separators', () => { + const ajvRequire = createRequire(eslintRequire.resolve('ajv')); + const uriPath = ajvRequire.resolve('uri-js'); + const result = spawnSync( + process.execPath, + [ + '-e', + `const uri = require(process.argv[1]); + for (const c of ['\\u2028', '\\u2029']) { + const value = uri.resolve('https://example.test/a/', c + '../b', { iri: true }); + if (typeof value !== 'string') process.exit(1); + }`, + uriPath, + ], + { timeout: 3000, encoding: 'utf8' }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + }); + + it('downloads through the builder dependency, verifies checksums and reuses its cache', async () => { + const builderRequire = createRequire(require.resolve('app-builder-lib')); + const { downloadArtifact } = builderRequire('@electron/get'); + const directory = await mkdtemp(join(tmpdir(), 'relay-downloader-test-')); + const body = Buffer.from('isolated build artifact'); + let requests = 0; + const server = createServer((_request, response) => { + requests++; + response.writeHead(200, { 'content-length': body.length }); + response.end(body); + }); + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const url = `http://127.0.0.1:${address.port}/artifact.zip`; + const options = { + version: '42.11.2', + artifactName: 'fixture.zip', + isGeneric: true, + cacheRoot: directory, + mirrorOptions: { resolveAssetURL: () => Promise.resolve(url) }, + checksums: { 'fixture.zip': createHash('sha256').update(body).digest('hex') }, + downloadOptions: { quiet: true, signal: AbortSignal.timeout(5000) }, + }; + const file = await downloadArtifact(options); + expect(await readFile(file)).toEqual(body); + expect(await downloadArtifact(options)).toBe(file); + expect(requests).toBe(1); + await expect( + downloadArtifact({ ...options, force: true, checksums: { 'fixture.zip': '0'.repeat(64) } }), + ).rejects.toThrow(); + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/ci-optimization-contract.test.mjs b/scripts/ci-optimization-contract.test.mjs index 612f91b8..157bf619 100644 --- a/scripts/ci-optimization-contract.test.mjs +++ b/scripts/ci-optimization-contract.test.mjs @@ -144,7 +144,7 @@ describe('CI optimization contracts', () => { }); }); - it('runs Electron and browser workflows sequentially through ABI-restoring npm scripts on every build', async () => { + it('runs every integration suite in isolated jobs through ABI-restoring npm scripts on every build', async () => { const build = await readYaml('.github/workflows/build.yml'); const workflows = build.jobs['workflow-tests']; @@ -153,6 +153,16 @@ describe('CI optimization contracts', () => { expect(workflows).not.toHaveProperty('needs'); expect(workflows['runs-on']).toBe('ubuntu-latest'); expect(workflows['continue-on-error']).not.toBe(true); + expect(workflows.strategy['fail-fast']).toBe(false); + expect(workflows.strategy.matrix).toEqual({ + include: [ + { suite: 'electron', 'shard-index': 1, 'shard-total': 4 }, + { suite: 'electron', 'shard-index': 2, 'shard-total': 4 }, + { suite: 'electron', 'shard-index': 3, 'shard-total': 4 }, + { suite: 'electron', 'shard-index': 4, 'shard-total': 4 }, + { suite: 'web', 'shard-index': 1, 'shard-total': 1 }, + ], + }); expect(workflows.env?.RELAY_SKIP_POCKETBASE_DOWNLOAD).not.toBe('1'); const install = findStep(workflows, 'Install dependencies'); expect(install.run).toBe('npm ci --prefer-offline'); @@ -164,6 +174,8 @@ describe('CI optimization contracts', () => { ); const browsers = findStep(workflows, 'Install Playwright browsers and Linux dependencies'); expect(browsers.run).toBe('npx playwright install --with-deps chromium webkit'); + const electronDependencies = findStep(workflows, 'Install Electron Linux dependencies'); + expect(electronDependencies.run).toBe('npx playwright install-deps chromium'); const electron = findStep(workflows, 'Run Electron workflows'); const web = findStep(workflows, 'Run browser workflows'); expect(electron.run).toContain('sudo apt-get install --yes dbus-x11 gnome-keyring'); @@ -171,16 +183,31 @@ describe('CI optimization contracts', () => { expect(electron.run).toContain( 'openssl rand -hex 32 | gnome-keyring-daemon --unlock --components=secrets', ); - expect(electron.run).toContain('xvfb-run --auto-servernum npm run test:electron'); + expect(electron.run).toContain( + 'xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --shard=${{ matrix.shard-index }}/${{ matrix.shard-total }}', + ); expect(web.run).toBe('xvfb-run --auto-servernum npm run test:web'); expect(workflows.steps.indexOf(pocketbase)).toBeGreaterThan(workflows.steps.indexOf(install)); expect(workflows.steps.indexOf(browsers)).toBeGreaterThan(workflows.steps.indexOf(pocketbase)); - expect(workflows.steps.indexOf(electron)).toBeGreaterThan(workflows.steps.indexOf(browsers)); - expect(workflows.steps.indexOf(web)).toBeGreaterThan(workflows.steps.indexOf(electron)); - for (const step of [install, pocketbase, browsers, electron, web]) { - expect(step).not.toHaveProperty('if'); + expect(workflows.steps.indexOf(electron)).toBeGreaterThan( + workflows.steps.indexOf(electronDependencies), + ); + expect(workflows.steps.indexOf(web)).toBeGreaterThan(workflows.steps.indexOf(browsers)); + expect(install).not.toHaveProperty('if'); + for (const step of [pocketbase, browsers, web]) { + expect(step.if).toBe("matrix.suite == 'web'"); + } + for (const step of [electronDependencies, electron]) { + expect(step.if).toBe("matrix.suite == 'electron'"); + } + for (const step of [install, pocketbase, browsers, electronDependencies, electron, web]) { expect(step['continue-on-error']).not.toBe(true); } + const failures = findStep(workflows, 'Upload workflow failure details'); + expect(failures.if).toBe('failure()'); + expect(failures.with.name).toBe( + 'workflow-test-failures-${{ matrix.suite }}-${{ matrix.shard-index }}', + ); }); describe.each(reuseModes)('Build gate with $name', ({ env }) => { diff --git a/scripts/electron-test-runner.mjs b/scripts/electron-test-runner.mjs index a8192327..81821e7b 100644 --- a/scripts/electron-test-runner.mjs +++ b/scripts/electron-test-runner.mjs @@ -1,7 +1,9 @@ import { spawnSync as defaultSpawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const FAILED_TO_START_EXIT_CODE = 1; const E2E_DESKTOP_SIDE_EFFECTS_FLAG = 'RELAY_E2E_DISABLE_DESKTOP_SIDE_EFFECTS'; +const hostSqliteProbe = fileURLToPath(new URL('./verify-host-sqlite.mjs', import.meta.url)); const runChild = (spawnSync, command, args, options) => { try { @@ -93,24 +95,24 @@ export function runElectronTests({ } stdout.write('Restoring better-sqlite3 for the current Node ABI...\n'); - const restoreOutcome = npmExecPath - ? runChild( - spawnSync, - nodePath, - [npmExecPath, 'rebuild', 'better-sqlite3', '--build-from-source'], - childOptions, - ) + let restoreOutcome = npmExecPath + ? runChild(spawnSync, nodePath, [npmExecPath, 'rebuild', 'better-sqlite3'], childOptions) : { status: null, signal: null, error: new Error('npm_execpath is not set'), }; + let restoreLabel = 'Node ABI restoration'; + if (exitCodeFor(restoreOutcome) === 0) { + restoreLabel = 'Node ABI verification'; + restoreOutcome = runChild(spawnSync, nodePath, [hostSqliteProbe], childOptions); + } const primaryExitCode = exitCodeFor(primaryOutcome); const restoreExitCode = exitCodeFor(restoreOutcome); if (primaryExitCode !== 0) reportFailure(primaryLabel, primaryOutcome, stderr); if (restoreExitCode !== 0) { - reportFailure('Node ABI restoration', restoreOutcome, stderr); + reportFailure(restoreLabel, restoreOutcome, stderr); } return primaryExitCode !== 0 ? primaryExitCode : restoreExitCode; diff --git a/scripts/electron-test-runner.test.mjs b/scripts/electron-test-runner.test.mjs index b21e2a6c..ea0490a1 100644 --- a/scripts/electron-test-runner.test.mjs +++ b/scripts/electron-test-runner.test.mjs @@ -31,7 +31,7 @@ describe('runElectronTests', () => { expect(spawnSync).toHaveBeenNthCalledWith( 3, options.nodePath, - [options.npmExecPath, 'rebuild', 'better-sqlite3', '--build-from-source'], + [options.npmExecPath, 'rebuild', 'better-sqlite3'], expect.any(Object), ); expect(spawnSync.mock.calls.map(([command]) => command)).not.toContain('npm.cmd'); @@ -40,17 +40,19 @@ describe('runElectronTests', () => { it('returns the Playwright exit code when restoration succeeds', () => { const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(failure(7)) .mockReturnValueOnce(success()); expect(runElectronTests(makeOptions(spawnSync))).toBe(7); - expect(spawnSync).toHaveBeenCalledTimes(3); + expect(spawnSync).toHaveBeenCalledTimes(4); }); it('returns the restoration exit code when Playwright succeeds', () => { const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(failure(9)); @@ -64,6 +66,7 @@ describe('runElectronTests', () => { }); const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(failure(7)) .mockReturnValueOnce({ status: null, signal: null, error: restoreError }); @@ -82,13 +85,14 @@ describe('runElectronTests', () => { }); const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce({ status: null, signal: null, error: spawnError }) .mockReturnValueOnce(success()); const options = makeOptions(spawnSync); expect(runElectronTests(options)).toBe(1); - expect(spawnSync).toHaveBeenCalledTimes(3); + expect(spawnSync).toHaveBeenCalledTimes(4); expect(outputFrom(options.stderr)).toContain( 'Playwright could not start: could not spawn Playwright', ); @@ -97,13 +101,14 @@ describe('runElectronTests', () => { it('attempts restoration after Playwright terminates from a signal', () => { const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce({ status: null, signal: 'SIGTERM' }) .mockReturnValueOnce(success()); const options = makeOptions(spawnSync); expect(runElectronTests(options)).toBe(1); - expect(spawnSync).toHaveBeenCalledTimes(3); + expect(spawnSync).toHaveBeenCalledTimes(4); expect(outputFrom(options.stderr)).toContain('Playwright terminated by signal SIGTERM'); }); @@ -111,12 +116,59 @@ describe('runElectronTests', () => { const spawnSync = vi.fn().mockReturnValue(success()); expect(runElectronTests(makeOptions(spawnSync))).toBe(0); + expect(spawnSync).toHaveBeenCalledTimes(4); + expect(spawnSync).toHaveBeenLastCalledWith( + '/node/bin/node', + [expect.stringMatching(/[/\\]scripts[/\\]verify-host-sqlite\.mjs$/u)], + expect.any(Object), + ); + }); + + it.each([failure(1), failure(256), { status: null, signal: 'SIGTERM' }])( + 'fails when npm succeeds but the restored binary cannot execute the verification query', + (probeOutcome) => { + const spawnSync = vi + .fn() + .mockReturnValueOnce(success()) + .mockReturnValueOnce(success()) + .mockReturnValueOnce(success()) + .mockReturnValueOnce(probeOutcome); + const options = makeOptions(spawnSync); + + expect(runElectronTests(options)).toBe(1); + expect(outputFrom(options.stderr)).toContain('Node ABI verification'); + }, + ); + + it('preserves the test failure when ABI verification also fails', () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce(success()) + .mockReturnValueOnce(failure(7)) + .mockReturnValueOnce(success()) + .mockReturnValueOnce(failure(1)); + + expect(runElectronTests(makeOptions(spawnSync))).toBe(7); + }); + + it('restores and verifies after the Electron rebuild fails without starting Playwright', () => { + const spawnSync = vi + .fn() + .mockReturnValueOnce(failure(7)) + .mockReturnValueOnce(success()) + .mockReturnValueOnce(success()); + + expect(runElectronTests(makeOptions(spawnSync))).toBe(7); expect(spawnSync).toHaveBeenCalledTimes(3); + expect(spawnSync.mock.calls.flat(2)).not.toContain( + '/relay/node_modules/@playwright/test/cli.js', + ); }); it('never reports success for a failing status whose low byte is zero', () => { const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(failure(256)) .mockReturnValueOnce(success()); @@ -129,6 +181,7 @@ describe('runElectronTests', () => { it('never reports success when only the restoration fails with a truncating status', () => { const spawnSync = vi .fn() + .mockReturnValue(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(success()) .mockReturnValueOnce(failure(65280)); diff --git a/scripts/package-windows.mjs b/scripts/package-windows.mjs index be21b024..9eca9824 100644 --- a/scripts/package-windows.mjs +++ b/scripts/package-windows.mjs @@ -157,7 +157,7 @@ export function resolveWindowsNativeDependencyInstall(koffiVersion, platform = p } export function resolveHostNativeDependencyRestore() { - return ['rebuild', 'better-sqlite3', '--build-from-source']; + return ['rebuild', 'better-sqlite3']; } export function resolveNpmInvocation({ @@ -187,9 +187,17 @@ async function stageWindowsNativeDependencies() { await runNpm(resolveWindowsNativeDependencyInstall(koffiVersion)); } -async function restoreHostNativeDependencies() { - console.log('Restoring better-sqlite3 for the current Node ABI...'); - await runNpm(resolveHostNativeDependencyRestore()); +export async function restoreHostNativeDependencies({ + fixture = false, + rebuild = runNpm, + verify = () => run(process.execPath, [join(scriptDir, 'verify-host-sqlite.mjs')]), +} = {}) { + // Prepackaged NSIS fixtures never rebuild or stage native dependencies. + if (!fixture) { + console.log('Restoring better-sqlite3 for the current Node ABI...'); + await rebuild(resolveHostNativeDependencyRestore()); + } + await verify(); } async function compileLauncher(harness) { @@ -318,7 +326,7 @@ export async function packageWindows(args = process.argv.slice(2)) { ...forwardedArgs, ]); } finally { - await restoreHostNativeDependencies(); + await restoreHostNativeDependencies({ fixture }); } } diff --git a/scripts/releaseWorkflowContract.mjs b/scripts/releaseWorkflowContract.mjs index fcec5784..11e6bf5b 100644 --- a/scripts/releaseWorkflowContract.mjs +++ b/scripts/releaseWorkflowContract.mjs @@ -32,3 +32,32 @@ export function classifyExistingRelease(release, { expectedAsset, expectedChecks if (release.draft === true) return 'replace-draft'; throw new Error('Refusing to modify incomplete published release'); } +export function resolveReleaseTestMode(configuredTree, sourceTree) { + if (!/^[0-9a-f]{40}$/u.test(sourceTree)) throw new Error('Invalid release source tree'); + if (configuredTree === '') return false; + if (!/^[0-9a-f]{40}$/u.test(configuredTree)) throw new Error('Invalid release test tree'); + return configuredTree === sourceTree; +} + +export async function deleteTestDraft({ github, owner, repo, tag, sourceSha }) { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const matches = releases.filter((release) => release.tag_name === tag); + if (matches.length === 0) return; + if (matches.length !== 1) throw new Error('Ambiguous test draft release'); + const release = matches[0]; + if (release.draft !== true || release.target_commitish !== sourceSha) { + throw new Error('Refusing to delete a published or unrelated release'); + } + await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); + try { + await github.rest.repos.getRelease({ owner, repo, release_id: release.id }); + } catch (error) { + if (error.status === 404) return; + throw error; + } + throw new Error('Test draft release still exists after deletion'); +} diff --git a/scripts/releaseWorkflowSecurity.test.mjs b/scripts/releaseWorkflowSecurity.test.mjs index 32f80243..f7577931 100644 --- a/scripts/releaseWorkflowSecurity.test.mjs +++ b/scripts/releaseWorkflowSecurity.test.mjs @@ -4,10 +4,14 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parse } from 'yaml'; import yauzl from 'yauzl'; -import { classifyExistingRelease } from './releaseWorkflowContract.mjs'; +import { + classifyExistingRelease, + deleteTestDraft, + resolveReleaseTestMode, +} from './releaseWorkflowContract.mjs'; const execFileAsync = promisify(execFile); const workflowUrl = new URL('../.github/workflows/release.yml', import.meta.url); @@ -61,6 +65,87 @@ const sha256File = async (filePath) => .digest('hex'); describe('release workflow authority boundary', () => { + it('enables draft-only testing for exactly the configured source tree', () => { + const tree = 'a'.repeat(40); + expect(resolveReleaseTestMode(tree, tree)).toBe(true); + expect(resolveReleaseTestMode('b'.repeat(40), tree)).toBe(false); + expect(resolveReleaseTestMode('', tree)).toBe(false); + expect(() => resolveReleaseTestMode('true', tree)).toThrow('Invalid release test tree'); + expect(() => resolveReleaseTestMode(tree, '')).toThrow('Invalid release source tree'); + }); + + it('keeps test drafts behind every gate and stops before any public release or tag mutation', async () => { + const { jobs } = await readWorkflow(); + const release = jobs.release; + const mode = findStep(release, 'Resolve release test mode'); + expect(mode.env.TEST_TREE).toBe('${{ vars.RELAY_RELEASE_TEST_TREE }}'); + expect(mode.env.SOURCE_SHA).toBe('${{ needs.determine.outputs.source-sha }}'); + expect(mode.with.script).toContain( + 'resolveReleaseTestMode(process.env.TEST_TREE, commit.tree.sha)', + ); + const finalize = findStep(release, 'Finalize immutable GitHub release'); + expect(finalize.env.TEST_RELEASE).toBe('${{ steps.mode.outputs.test-release }}'); + const script = finalize.with.script; + const guard = script.indexOf("if (process.env.TEST_RELEASE === 'true')"); + expect(guard).toBeGreaterThan(script.indexOf('expectedNames.size !== 0')); + expect(script.indexOf('return;', guard)).toBeLessThan(script.indexOf('let tagRef;')); + expect(findStep(release, 'Verify published release').if).toBe( + "steps.mode.outputs.test-release == 'false'", + ); + const verify = findStep(release, 'Verify test draft assets'); + expect(verify.if).toBe("steps.mode.outputs.test-release == 'true'"); + expect(verify.run).toContain('sha256sum --check'); + expect(verify.run).toContain('unzip -Z1'); + expect(verify.run).toContain('releases/latest'); + expect(findStep(release, 'Remove test draft release').if).toBe( + "always() && steps.mode.outputs.test-release == 'true'", + ); + }); + + it.each(['matching', 'published', 'other-commit', 'duplicate', 'absent', 'still-present'])( + 'cleans up only the matching draft and verifies deletion: %s', + async (scenario) => { + const sourceSha = 'a'.repeat(40); + const draft = { id: 123, tag_name: 'v1.13.0', draft: true, target_commitish: sourceSha }; + const release = { + ...draft, + ...(scenario === 'published' ? { draft: false } : {}), + ...(scenario === 'other-commit' ? { target_commitish: 'b'.repeat(40) } : {}), + }; + const repos = { + listReleases: vi.fn(), + deleteRelease: vi.fn(), + getRelease: + scenario === 'still-present' + ? vi.fn().mockResolvedValue({ data: draft }) + : vi.fn().mockRejectedValue(Object.assign(new Error('Not found'), { status: 404 })), + }; + let releases = [release]; + if (scenario === 'absent') releases = []; + if (scenario === 'duplicate') releases = [release, release]; + const github = { + rest: { repos }, + paginate: vi.fn().mockResolvedValue(releases), + }; + const result = deleteTestDraft({ + github, + owner: 'owner', + repo: 'repo', + tag: draft.tag_name, + sourceSha, + }); + if (['matching', 'absent'].includes(scenario)) await expect(result).resolves.toBeUndefined(); + else await expect(result).rejects.toThrow(); + if (['matching', 'still-present'].includes(scenario)) { + expect(repos.deleteRelease).toHaveBeenCalledExactlyOnceWith({ + owner: 'owner', + repo: 'repo', + release_id: 123, + }); + } else expect(repos.deleteRelease).not.toHaveBeenCalled(); + }, + ); + it('requires a clean main Sonar result before its GitHub success can authorize publication', async () => { const build = await readFile( new URL('../.github/workflows/build.yml', import.meta.url), diff --git a/scripts/run-sonar-ci.mjs b/scripts/run-sonar-ci.mjs index f33fd47b..7b4e3edc 100644 --- a/scripts/run-sonar-ci.mjs +++ b/scripts/run-sonar-ci.mjs @@ -14,6 +14,7 @@ import { import { parseScopeArgs, runSonarOpenFindings } from './sonar-open-findings.mjs'; import { runSonarQualityGate } from './sonar-quality-gate.mjs'; import { runSonarReviewedIssues } from './sonar-reviewed-issues.mjs'; +import { writeSonarPerformance } from './sonar-performance.mjs'; const COMMAND_TIMEOUT_MS = 600_000; const AGGREGATE_TIMEOUT_MS = 1_080_000; @@ -156,9 +157,20 @@ export async function runSonarCi({ readIssues = runSonarOpenFindings, checkGate = runSonarQualityGate, reportUnavailable = writeUnavailableReport, + reportPerformance = writeSonarPerformance, now = monotonicNow, sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), } = {}) { + const phases = []; + let scannerOutput = ''; + const measure = async (name, operation) => { + const started = now(); + try { + return await operation(); + } finally { + phases.push({ name, durationMs: Math.max(0, Math.round(now() - started)) }); + } + }; try { if (typeof now !== 'function') throw configurationError('Sonar CI timing function is invalid.'); if (typeof sleep !== 'function') { @@ -167,9 +179,10 @@ export async function runSonarCi({ const scope = validateConfiguration(argv, env); const deadline = now() + AGGREGATE_TIMEOUT_MS; const scopedArgument = scopeArgument(scope); - const upload = await runCommand( - scannerCommand(env, phaseTimeout(deadline, now, 'upload', COMMAND_TIMEOUT_MS)), + const upload = await measure('Scanner analysis and upload', () => + runCommand(scannerCommand(env, phaseTimeout(deadline, now, 'upload', COMMAND_TIMEOUT_MS))), ); + scannerOutput = upload.output; const uploadOutcome = classifyCommandResult(upload, SONAR_UPLOAD_POLICY); if (uploadOutcome === SCANNER_OUTCOME.UNAVAILABLE) { throw new ScannerGateError( @@ -183,33 +196,34 @@ export async function runSonarCi({ throw configurationError('Sonar upload failed without a confirmed scanner finding.'); } - await waitAnalysis({ - argv: ['wait-analysis', scopedArgument], - env, - timeoutMs: phaseTimeout(deadline, now, 'analysis wait', API_PHASE_TIMEOUT_MS), - }); + await measure('Server analysis wait', () => + waitAnalysis({ + argv: ['wait-analysis', scopedArgument], + env, + timeoutMs: phaseTimeout(deadline, now, 'analysis wait', API_PHASE_TIMEOUT_MS), + }), + ); if ('branch' in scope) { const timeoutMs = phaseTimeout(deadline, now, 'reviewed-issue reconciliation'); - await reconcile({ - argv: ['--branch=main', '--apply'], - env, - timeoutMs, - requestTimeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs), - }); + await measure('Reviewed issue reconciliation', () => + reconcile({ + argv: ['--branch=main', '--apply'], + env, + timeoutMs, + requestTimeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs), + }), + ); } - await waitForSettledIssues({ - deadline, - now, - readIssues, - scopedArgument, - env, - sleep, - }); - await checkGate({ - argv: ['check-quality-gate', scopedArgument], - env, - timeoutMs: phaseTimeout(deadline, now, 'quality gate', API_PHASE_TIMEOUT_MS), - }); + await measure('Issue indexing checks', () => + waitForSettledIssues({ deadline, now, readIssues, scopedArgument, env, sleep }), + ); + await measure('Quality gate', () => + checkGate({ + argv: ['check-quality-gate', scopedArgument], + env, + timeoutMs: phaseTimeout(deadline, now, 'quality gate', API_PHASE_TIMEOUT_MS), + }), + ); return { outcome: SCANNER_OUTCOME.CLEAN, scope }; } catch (error) { if (error instanceof ScannerGateError) { @@ -222,6 +236,13 @@ export async function runSonarCi({ ), { cause: error }, ); + } finally { + try { + reportPerformance({ phases, scannerOutput, env }); + } catch { + // Diagnostics cannot replace a security verdict or turn a failure into success. + process.stderr.write('Sonar performance summary could not be written.\n'); + } } } diff --git a/scripts/run-sonar-ci.test.mjs b/scripts/run-sonar-ci.test.mjs index 69d85412..5590769d 100644 --- a/scripts/run-sonar-ci.test.mjs +++ b/scripts/run-sonar-ci.test.mjs @@ -14,6 +14,73 @@ const configuredEnv = { const cleanCommand = async () => ({ code: 0, timedOut: false, output: '' }); const noSleep = async () => {}; +test('profiles analysis separately from server work without changing the main quality gate', async () => { + let clock = 0; + let profile; + const result = await runSonarCi({ + argv: ['--branch=main', '--require-clean'], + env: configuredEnv, + now: () => clock, + runCommand: async () => { + clock += 454000; + return { code: 0, output: 'INFO Sensor JsSecuritySensorV2 [jasmin] (done) | time=366272ms' }; + }, + waitAnalysis: async () => { + clock += 3000; + }, + reconcile: async () => { + clock += 2000; + }, + readIssues: async () => { + clock += 1000; + return { summary: { open: [] } }; + }, + sleep: async (milliseconds) => { + clock += milliseconds; + }, + checkGate: async () => { + clock += 1000; + }, + reportPerformance: (report) => { + profile = report; + }, + }); + assert.equal(result.outcome, SCANNER_OUTCOME.CLEAN); + assert.deepEqual(profile.phases, [ + { name: 'Scanner analysis and upload', durationMs: 454000 }, + { name: 'Server analysis wait', durationMs: 3000 }, + { name: 'Reviewed issue reconciliation', durationMs: 2000 }, + { name: 'Issue indexing checks', durationMs: 7000 }, + { name: 'Quality gate', durationMs: 1000 }, + ]); + assert.match(profile.scannerOutput, /JsSecuritySensorV2/u); +}); + +test('records a failed gate and preserves its verdict when diagnostics cannot be written', async () => { + let profile; + const finding = new ScannerGateError(SCANNER_OUTCOME.FINDING, 'Quality gate failed'); + await assert.rejects( + runSonarCi({ + argv: ['--branch=main', '--require-clean'], + env: configuredEnv, + runCommand: cleanCommand, + waitAnalysis: async () => {}, + reconcile: async () => {}, + readIssues: async () => ({ summary: { open: [] } }), + sleep: noSleep, + checkGate: async () => { + throw finding; + }, + reportPerformance: (report) => { + profile = report; + throw new Error('summary disk unavailable'); + }, + }), + (error) => error === finding, + ); + assert.equal(profile.phases.at(-1).name, 'Quality gate'); +}); + test('runs the clean pull-request phases in exact order with a bounded upload', async () => { const calls = []; let command; diff --git a/scripts/sonar-performance.mjs b/scripts/sonar-performance.mjs new file mode 100644 index 00000000..cb867b48 --- /dev/null +++ b/scripts/sonar-performance.mjs @@ -0,0 +1,36 @@ +import { appendFileSync } from 'node:fs'; +import { sanitizeScannerText } from './scanner-gate-policy.mjs'; + +export function parseSonarSensorTimings(output, env = {}) { + const sensors = []; + for (const line of sanitizeScannerText(output ?? '', env).split('\n')) { + const match = /\bSensor ([A-Za-z0-9 /.[\]_-]{1,160}) \(done\) \| time=(\d+)ms/u.exec(line); + if (!match) continue; + const durationMs = Number(match[2]); + if (Number.isSafeInteger(durationMs)) sensors.push({ name: match[1], durationMs }); + } + return sensors.sort((left, right) => right.durationMs - left.durationMs); +} + +export function writeSonarPerformance({ phases, scannerOutput, env = process.env }) { + if (!env.GITHUB_STEP_SUMMARY) return; + const sensors = parseSonarSensorTimings(scannerOutput, env).slice(0, 10); + const rows = (entries) => + entries.map(({ name, durationMs }) => `| ${name} | ${(durationMs / 1000).toFixed(2)} |`); + const lines = [ + '### Sonar performance', + '', + '| Phase | Seconds |', + '| --- | ---: |', + ...rows(phases), + '', + 'Sensor timings below come from the retained normal scanner output. They are part of the', + 'analysis/upload phase, not additional elapsed time. Failed phases are included.', + '', + '| Slowest reported sensors | Seconds |', + '| --- | ---: |', + ...rows(sensors), + '', + ]; + appendFileSync(env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`, 'utf8'); +} diff --git a/scripts/sonar-performance.test.mjs b/scripts/sonar-performance.test.mjs new file mode 100644 index 00000000..fa119c7b --- /dev/null +++ b/scripts/sonar-performance.test.mjs @@ -0,0 +1,58 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parseSonarSensorTimings, writeSonarPerformance } from './sonar-performance.mjs'; + +describe('Sonar performance diagnostics', () => { + it('ranks completed sensors without treating phase totals or progress lines as extra work', () => { + expect( + parseSonarSensorTimings( + [ + 'INFO Sensor JavaScript/TypeScript/CSS analysis [javascript] (done) | time=36437ms', + 'INFO Sensor JsSecuritySensorV2 [jasmin]', + 'INFO Analysis progress: 23% (150/636 files)', + 'INFO Sensor JsSecuritySensorV2 [jasmin] (done) | time=366272ms', + 'INFO Sensor incomplete (done) | time=oops', + 'INFO Sensor invalid (done) | time=999999999999999999999ms', + ].join('\n'), + ), + ).toEqual([ + { name: 'JsSecuritySensorV2 [jasmin]', durationMs: 366272 }, + { name: 'JavaScript/TypeScript/CSS analysis [javascript]', durationMs: 36437 }, + ]); + }); + + it('writes bounded sanitized timings without raw scanner output or markup from logs', async () => { + const root = await mkdtemp(join(tmpdir(), 'relay-sonar-timing-')); + const summary = join(root, 'summary'); + try { + writeSonarPerformance({ + phases: [{ name: 'Scanner analysis and upload', durationMs: 454000 }], + scannerOutput: [ + 'SONAR_TOKEN=sonar-sensitive-value', + 'INFO Sensor sonar-sensitive-value (done) | time=1ms', + 'INFO Sensor (done) | time=1ms', + ...Array.from( + { length: 15 }, + (_, index) => `INFO Sensor Safe${index} (done) | time=${index}ms`, + ), + ].join('\n'), + env: { GITHUB_STEP_SUMMARY: summary, SONAR_TOKEN: 'sonar-sensitive-value' }, + }); + const report = await readFile(summary, 'utf8'); + expect(report).toContain('| Scanner analysis and upload | 454.00 |'); + expect(report).toContain('| Safe14 | 0.01 |'); + expect(report).not.toContain('Safe4'); + expect(report).not.toContain('sonar-sensitive-value'); + expect(report).not.toContain('SONAR_TOKEN='); + expect(report).not.toContain(' { + expect(() => writeSonarPerformance({ phases: [], scannerOutput: '', env: {} })).not.toThrow(); + }); +}); diff --git a/scripts/sonar-reviewed-issues.test.mjs b/scripts/sonar-reviewed-issues.test.mjs index 4fd7703b..9294732b 100644 --- a/scripts/sonar-reviewed-issues.test.mjs +++ b/scripts/sonar-reviewed-issues.test.mjs @@ -755,8 +755,8 @@ test('the Sonar CI runner reconciles reviewed issues only on main-branch pushes' assert.doesNotMatch(String(parse(workflow).jobs.sonarqube.if), /workflow_dispatch/u); assert.match(workflow, /npm run security:sonar:ci --/u); const branchGuard = runner.indexOf("if ('branch' in scope)"); - const reconcileStep = runner.indexOf('await reconcile({'); - const openFindingGate = runner.indexOf('await waitForSettledIssues'); + const reconcileStep = runner.indexOf('reconcile({', branchGuard); + const openFindingGate = runner.indexOf('waitForSettledIssues({', reconcileStep); assert.ok(branchGuard >= 0, 'missing branch-only guard'); assert.ok(reconcileStep > branchGuard, 'reviewed reconciliation must follow branch-only guard'); assert.ok(openFindingGate > reconcileStep, 'reviewed reconciliation must precede the open gate'); diff --git a/scripts/verify-host-sqlite.mjs b/scripts/verify-host-sqlite.mjs new file mode 100644 index 00000000..748587e9 --- /dev/null +++ b/scripts/verify-host-sqlite.mjs @@ -0,0 +1,32 @@ +import Database from 'better-sqlite3'; +import { rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export function clearElectronRebuildMetadata(moduleDirectory) { + // prebuild-install replaces the binding without removing Electron's ABI marker. + // Leaving it behind makes electron-builder skip the next required rebuild. + for (const buildType of ['Release', 'Debug']) { + rmSync(join(moduleDirectory, 'build', buildType, '.forge-meta'), { force: true }); + } +} + +function verifyHostSqlite() { + // A fresh Node process must load the restored binary, not a previously loaded + // binding. Keep the probe independent of Relay's application data. + const database = new Database(':memory:'); + try { + const result = database.prepare('SELECT 1 AS ok').get(); + if (result?.ok !== 1) throw new Error('Host SQLite verification query failed'); + } finally { + database.close(); + } + const require = createRequire(import.meta.url); + clearElectronRebuildMetadata(dirname(require.resolve('better-sqlite3/package.json'))); + console.log(`Verified better-sqlite3 for Node ABI ${process.versions.modules}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyHostSqlite(); +} diff --git a/scripts/verify-host-sqlite.test.mjs b/scripts/verify-host-sqlite.test.mjs new file mode 100644 index 00000000..de38e144 --- /dev/null +++ b/scripts/verify-host-sqlite.test.mjs @@ -0,0 +1,30 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { clearElectronRebuildMetadata } from './verify-host-sqlite.mjs'; + +it('invalidates stale Electron ABI markers without changing restored native binaries', async () => { + const root = await mkdtemp(join(tmpdir(), 'relay-sqlite-metadata-')); + try { + for (const buildType of ['Release', 'Debug']) { + const directory = join(root, 'build', buildType); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, '.forge-meta'), 'x64--145'); + await writeFile(join(directory, 'better_sqlite3.node'), 'restored host binding'); + } + clearElectronRebuildMetadata(root); + for (const buildType of ['Release', 'Debug']) { + const directory = join(root, 'build', buildType); + await expect(readFile(join(directory, '.forge-meta'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await readFile(join(directory, 'better_sqlite3.node'), 'utf8')).toBe( + 'restored host binding', + ); + } + expect(() => clearElectronRebuildMetadata(root)).not.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/windows-nsis-contract.test.mjs b/scripts/windows-nsis-contract.test.mjs index 2621d124..e438a953 100644 --- a/scripts/windows-nsis-contract.test.mjs +++ b/scripts/windows-nsis-contract.test.mjs @@ -977,17 +977,17 @@ describe('Windows packaging integration contract', () => { runtimeContract.match(/^!define RELAY_LAUNCHER_PROTOCOL_EXIT_CODE (\d+)$/m)?.[1], ); const packageJob = reusable.jobs.package; - const smoke = findStep(packageJob, 'Smoke test persistent bootstrap'); - const benchmark = findStep(packageJob, 'Benchmark packaged startup paths'); + const smoke = findStep(reusable.jobs.runtime, 'Smoke test persistent bootstrap'); + const benchmark = findStep(reusable.jobs.runtime, 'Benchmark packaged startup paths'); const boundary = findStep( - packageJob, + reusable.jobs.updater, 'Exercise isolated activation boundaries and stable fallback', ); expect(packageJob.env.RELAY_BUILD_ID).toBe('r1-${{ inputs.source-sha }}'); expect(smoke.env.RELAY_EXPECTED_BUILD_ID).toBe('r1-${{ inputs.source-sha }}'); expect(smoke.env.RELAY_EXPECTED_LAUNCHER_PROTOCOL_EXIT_CODE).toBe(launcherProtocolExitCode); - expect(smoke.run).toContain('steps.previous.outputs.build_id'); + expect(smoke.run).toContain('needs.package.outputs.previous-build-id'); expect(smoke.run).toContain('scripts/windows-bootstrap-smoke.ps1'); expect(smoke.run).toContain('-PreviousArtifact'); expect(smoke.run).toContain('-ExpectedLauncherProtocolExitCode'); @@ -997,9 +997,9 @@ describe('Windows packaging integration contract', () => { expect(benchmark.run).toContain('--runs 5'); expect(benchmark.env.RELAY_BOOTSTRAP_BENCHMARK_CONFIRM).toBe(1); expect(boundary.run).toContain('scripts/windows-bootstrap-boundary-smoke.ps1'); - expect(findStep(packageJob, 'Build previous isolated boundary fixture').env).toHaveProperty( - 'RELAY_BOOTSTRAP_HARNESS_ROOT', - ); + expect( + findStep(reusable.jobs.updater, 'Build previous isolated boundary fixture').env, + ).toHaveProperty('RELAY_BOOTSTRAP_HARNESS_ROOT'); expect(boundary.env.RELAY_BOOTSTRAP_BOUNDARY_CONFIRM).toBe(1); for (const file of ['.github/workflows/build.yml', '.github/workflows/release.yml']) { @@ -1013,10 +1013,9 @@ describe('Windows packaging integration contract', () => { it('joins the updater manager to the real bootstrap and stable launcher in Windows CI', () => { const reusable = readWorkflow('.github/workflows/reusable-windows-package.yml'); - const packageJob = reusable.jobs.package; const integration = read('src/main/releases/ReleaseUpdateManager.windows.integration.test.ts'); const updater = findStep( - packageJob, + reusable.jobs.updater, 'Exercise updater manager through native install and restart', ); @@ -1066,8 +1065,8 @@ describe('Windows packaging integration contract', () => { expect( findStep(packageJob, 'Build lightweight previous fixture when no artifact exists').run, ).toContain('node scripts/package-windows.mjs --fixture'); - expect(findStep(packageJob, 'Smoke test persistent bootstrap').run).toContain( - 'steps.previous.outputs.build_id', + expect(findStep(reusable.jobs.runtime, 'Smoke test persistent bootstrap').run).toContain( + 'needs.package.outputs.previous-build-id', ); expect(commands.join('\n')).not.toContain('npm run package:win'); }); diff --git a/scripts/windows-package-contract.test.mjs b/scripts/windows-package-contract.test.mjs index 1dd12798..c75176e8 100644 --- a/scripts/windows-package-contract.test.mjs +++ b/scripts/windows-package-contract.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { renderBuildDefines, @@ -14,6 +14,7 @@ import { resolveNpmInvocation, resolvePackageMode, resolveWindowsNativeDependencyInstall, + restoreHostNativeDependencies, } from './package-windows.mjs'; function makePortableExecutable({ machine = 0x8664, optionalHeaderMagic = 0x20b } = {}) { @@ -143,17 +144,45 @@ describe('Windows package contract', () => { }); it('restores host native dependencies after Windows packaging', () => { - expect(resolveHostNativeDependencyRestore()).toEqual([ - 'rebuild', - 'better-sqlite3', - '--build-from-source', - ]); + expect(resolveHostNativeDependencyRestore()).toEqual(['rebuild', 'better-sqlite3']); const source = readFileSync('scripts/package-windows.mjs', 'utf8'); expect(source).toContain('finally {'); - expect(source).toContain('await restoreHostNativeDependencies()'); + expect(source).toContain('await restoreHostNativeDependencies({ fixture })'); + }); + + it('restores production native dependencies before verifying the host ABI', async () => { + const calls = []; + await restoreHostNativeDependencies({ + rebuild: async (args) => calls.push(args), + verify: async () => calls.push('verify'), + }); + expect(calls).toEqual([['rebuild', 'better-sqlite3'], 'verify']); }); + it('verifies prepackaged fixtures without rebuilding untouched native dependencies', async () => { + const rebuild = vi.fn(); + const verify = vi.fn(); + await restoreHostNativeDependencies({ fixture: true, rebuild, verify }); + expect(rebuild).not.toHaveBeenCalled(); + expect(verify).toHaveBeenCalledOnce(); + }); + + it.each([true, false])( + 'rejects a broken host ABI after packaging with fixture=%s', + async (fixture) => { + await expect( + restoreHostNativeDependencies({ + fixture, + rebuild: async () => {}, + verify: async () => { + throw new Error('incompatible native module'); + }, + }), + ).rejects.toThrow('incompatible native module'); + }, + ); + it('marks untracked non-ignored package inputs as dirty', () => { const source = readFileSync('scripts/package-windows.mjs', 'utf8'); diff --git a/scripts/workflow-ci-contract.test.mjs b/scripts/workflow-ci-contract.test.mjs index 19e4a1a3..21de8ca8 100644 --- a/scripts/workflow-ci-contract.test.mjs +++ b/scripts/workflow-ci-contract.test.mjs @@ -1,4 +1,7 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; @@ -126,14 +129,16 @@ describe('CI workflow contracts', () => { }, }); expect(call.outputs?.['artifact-name']?.value).toBe( - '${{ jobs.package.outputs.artifact-name }}', + '${{ jobs.verified.outputs.artifact-name }}', ); expect(workflow.permissions).toEqual({ actions: 'read', contents: 'read' }); const packageJob = workflow.jobs.package; expect(packageJob['runs-on']).toBe('windows-latest'); expect(packageJob.env.RELAY_BUILD_ID).toBe('r1-${{ inputs.source-sha }}'); - expect(packageJob.outputs['artifact-name']).toBe('${{ inputs.artifact-name }}'); + expect(packageJob.outputs['artifact-sha256']).toBe( + '${{ steps.digests.outputs.artifact-sha256 }}', + ); expect(findStep(packageJob, 'Checkout repository').with.ref).toBe('${{ inputs.source-sha }}'); expect(findStep(packageJob, 'Setup Node.js').with['node-version-file']).toBe('.node-version'); expect(findStep(packageJob, 'Get PocketBase version').run).toContain('--print-version'); @@ -160,13 +165,15 @@ describe('CI workflow contracts', () => { expect(versionStep.env.RELAY_RELEASE_VERSION).toBe('${{ inputs.release-version }}'); expect(versionStep.run).toContain("(Get-Item -LiteralPath './release/Relay.exe').VersionInfo"); expect(versionStep.run).toContain('$actualCore -ne $env:RELAY_RELEASE_VERSION'); - const smokeStep = findStep(packageJob, 'Smoke test persistent bootstrap'); + const smokeStep = findStep(workflow.jobs.runtime, 'Smoke test persistent bootstrap'); expect(smokeStep.env.RELAY_EXPECTED_TARGET_COMMITISH).toBe('${{ inputs.source-sha }}'); expect(smokeStep.run).toContain('-ExpectedTargetCommitish'); - const benchmarkStep = findStep(packageJob, 'Benchmark packaged startup paths'); + const benchmarkStep = findStep(workflow.jobs.runtime, 'Benchmark packaged startup paths'); expect(benchmarkStep.env.COMPRESSION).toBe('${{ inputs.compression }}'); expect(benchmarkStep.run).not.toContain('${{ inputs.compression }}'); - expect(findStep(packageJob, 'Upload packaged startup diagnostics').if).toBe('failure()'); + expect(findStep(workflow.jobs.runtime, 'Upload packaged startup diagnostics').if).toBe( + 'failure()', + ); expect(findStep(packageJob, 'Upload artifact').with.name).toBe('${{ inputs.artifact-name }}'); }); @@ -191,6 +198,92 @@ describe('CI workflow contracts', () => { } }); + it('isolates Windows runtime checks and starts synthetic updater verification alongside packaging', async () => { + const { jobs } = await readWorkflow('reusable-windows-package.yml'); + expect(jobs.runtime.needs).toBe('package'); + expect(jobs.runtime.strategy).toEqual({ + 'fail-fast': false, + matrix: { suite: ['bootstrap', 'startup'] }, + }); + expect(jobs.updater.needs).toBeUndefined(); + // Bootstrap calls the Node PE verifier, which imports pe-library. + expect(findStep(jobs.runtime, 'Setup Node.js').if).toBeUndefined(); + expect(findStep(jobs.runtime, 'Install dependencies').if).toBeUndefined(); + for (const job of [jobs.package, jobs.runtime, jobs.updater]) { + expect(job['runs-on']).toBe('windows-latest'); + expect(job.if).toBeUndefined(); + expect(job['continue-on-error']).toBeUndefined(); + expect(findStep(job, 'Checkout repository').with.ref).toBe('${{ inputs.source-sha }}'); + } + expect(findStep(jobs.runtime, 'Smoke test persistent bootstrap').if).toBe( + "matrix.suite == 'bootstrap'", + ); + expect(findStep(jobs.runtime, 'Benchmark packaged startup paths').if).toBe( + "matrix.suite == 'startup'", + ); + const digests = findStep(jobs.runtime, 'Verify downloaded artifact digests'); + expect(digests.if).toBeUndefined(); + expect(digests['continue-on-error']).toBeUndefined(); + expect(digests.env.ARTIFACT_SHA256).toBe('${{ needs.package.outputs.artifact-sha256 }}'); + expect(digests.env.PREVIOUS_SHA256).toBe('${{ needs.package.outputs.previous-sha256 }}'); + expect(digests.run).toContain("throw 'Production artifact digest mismatch.'"); + expect(digests.run).toContain("throw 'Bootstrap baseline digest mismatch.'"); + expect(jobs.runtime.steps.indexOf(digests)).toBeLessThan( + jobs.runtime.steps.indexOf(findStep(jobs.runtime, 'Smoke test persistent bootstrap')), + ); + expect(findStep(jobs.package, 'Upload artifact').with).toMatchObject({ + name: '${{ inputs.artifact-name }}', + path: 'release/Relay.exe', + 'if-no-files-found': 'error', + 'compression-level': 0, + }); + expect(findStep(jobs.runtime, 'Upload packaged startup diagnostics').with.name).toBe( + '${{ inputs.artifact-name }}-diagnostics-${{ matrix.suite }}', + ); + expect(jobs.verified.if).toBe('always()'); + expect(jobs.verified.needs).toEqual(['package', 'runtime', 'updater']); + expect(jobs.verified.outputs['artifact-name']).toBe('${{ steps.gate.outputs.artifact-name }}'); + }); + + describe.each(['PACKAGE_RESULT', 'RUNTIME_RESULT', 'UPDATER_RESULT'])( + 'Windows publication gate with %s', + (component) => { + it.each(['success', 'failure', 'cancelled', 'skipped', 'neutral', ''])( + 'exposes the artifact only for a successful result, received "%s"', + async (result) => { + const { jobs } = await readWorkflow('reusable-windows-package.yml'); + const step = findStep(jobs.verified, 'Require all Windows verification'); + const root = await mkdtemp(join(tmpdir(), 'relay-windows-gate-')); + const outputPath = join(root, 'outputs'); + try { + const outcome = spawnSync('/bin/bash', ['-e', '-o', 'pipefail', '-c', step.run], { + encoding: 'utf8', + env: { + PACKAGE_RESULT: 'success', + RUNTIME_RESULT: 'success', + UPDATER_RESULT: 'success', + [component]: result, + ARTIFACT_NAME: 'relay-windows', + GITHUB_OUTPUT: outputPath, + }, + }); + expect(outcome.error).toBeUndefined(); + expect(outcome.status, outcome.stdout + outcome.stderr).toBe( + result === 'success' ? 0 : 1, + ); + if (result === 'success') { + expect(await readFile(outputPath, 'utf8')).toBe('artifact-name=relay-windows\n'); + } else { + await expect(readFile(outputPath)).rejects.toThrow(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + ); + }, + ); + it('keeps every explicit cache failure-tolerant with exact dependency identity', async () => { const names = await readWorkflowNames(); const caches = []; @@ -279,6 +372,15 @@ describe('CI workflow contracts', () => { restoreKeys: undefined, step: 'Cache PocketBase binary', }, + { + continueOnError: true, + job: 'updater', + key: "electron-builder-win-${{ hashFiles('package-lock.json') }}", + name: 'reusable-windows-package.yml', + path: '~/AppData/Local/electron-builder/Cache', + restoreKeys: 'electron-builder-win-', + step: 'Cache electron-builder tooling', + }, { continueOnError: true, job: 'compare', diff --git a/src/main/__tests__/ipcHandlers.test.ts b/src/main/__tests__/ipcHandlers.test.ts index 7c891233..cfa1892c 100644 --- a/src/main/__tests__/ipcHandlers.test.ts +++ b/src/main/__tests__/ipcHandlers.test.ts @@ -17,6 +17,7 @@ vi.mock('@shared/types', () => ({ const mockSetupCloudStatusHandlers = vi.fn(); const mockSetupWindowHandlers = vi.fn(); +vi.mock('../handlers/sdpAccountHandlers', () => ({ setupSdpAccountHandlers: vi.fn() })); const mockSetupReleaseUpdateHandlers = vi.fn(); const mockSetupRecoveryHandlers = vi.fn(); const mockSetupSetupHandlers = vi.fn(); diff --git a/src/main/handlers/radar/fetchRadar.test.ts b/src/main/handlers/radar/fetchRadar.test.ts index dbfd9776..c73e8fdd 100644 --- a/src/main/handlers/radar/fetchRadar.test.ts +++ b/src/main/handlers/radar/fetchRadar.test.ts @@ -1,7 +1,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { emptyRadarSnapshot, fetchRadarSnapshot } from './fetchRadar'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { emptyRadarSnapshot, fetchRadarHtml, fetchRadarSnapshot } from './fetchRadar'; + +const { fetchSession } = vi.hoisted(() => ({ fetchSession: vi.fn() })); +vi.mock('./radarSession', () => ({ getRadarSession: () => ({ fetch: fetchSession }) })); + +beforeEach(() => { + fetchSession.mockReset(); +}); const FIXTURE = readFileSync( join(process.cwd(), 'tests', 'fixtures', 'radar', 'radar-green.html'), @@ -9,6 +16,31 @@ const FIXTURE = readFileSync( ); describe('fetchRadarSnapshot', () => { + it('offers sign-in after HTTP 401 while preserving the last successful reading', async () => { + const previous = await fetchRadarSnapshot(emptyRadarSnapshot(), async () => FIXTURE); + fetchSession.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + + const snapshot = await fetchRadarSnapshot({ ...previous, error: 'Earlier network error' }); + + expect(snapshot).toEqual({ ...previous, signInRequired: true, error: null }); + fetchSession.mockResolvedValue(new Response(FIXTURE)); + expect(await fetchRadarSnapshot(snapshot)).toMatchObject({ + signInRequired: false, + error: null, + color: 'green', + }); + }); + + it.each([403, 500])('keeps HTTP %s distinct from a sign-in challenge', async (status) => { + fetchSession.mockResolvedValue(new Response('Unavailable', { status })); + + expect(await fetchRadarSnapshot()).toMatchObject({ + signInRequired: false, + error: `Radar responded ${status}`, + }); + await expect(fetchRadarHtml()).rejects.toThrow(`Radar responded ${status}`); + }); + it('builds a snapshot from the real dashboard', async () => { const snapshot = await fetchRadarSnapshot(emptyRadarSnapshot(), async () => FIXTURE); diff --git a/src/main/handlers/radar/fetchRadar.ts b/src/main/handlers/radar/fetchRadar.ts index c39a3269..97397031 100644 --- a/src/main/handlers/radar/fetchRadar.ts +++ b/src/main/handlers/radar/fetchRadar.ts @@ -6,6 +6,8 @@ import { getRadarSession } from './radarSession'; const REQUEST_TIMEOUT_MS = 15_000; +class RadarSignInRequiredError extends Error {} + /** * Electron's `Session.fetch` init type omits the standard `cache` member even * though Chromium's network stack honours it — the same gap `fetchNoStore` @@ -42,6 +44,9 @@ export async function fetchRadarHtml(url: string = RADAR_URL): Promise { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }; const response = await getRadarSession().fetch(url, init); + if (response.status === 401) { + throw new RadarSignInRequiredError('Radar responded 401'); + } if (!response.ok) { throw new Error(`Radar responded ${response.status}`); } @@ -60,8 +65,7 @@ export async function fetchRadarSnapshot( try { const html = await fetchHtml(); - // An expired session comes back as a 200 carrying the login form, so this - // has to be decided on content rather than status code. + // Some sign-in flows return a 200 carrying the login form rather than 401. if (looksLikeSignInPage(html)) { return { ...previous, signInRequired: true, error: null }; } @@ -89,6 +93,9 @@ export async function fetchRadarSnapshot( error: null, }; } catch (error) { + if (error instanceof RadarSignInRequiredError) { + return { ...previous, signInRequired: true, error: null }; + } return { ...previous, signInRequired: false, diff --git a/src/main/handlers/sdpAccountHandlers.test.ts b/src/main/handlers/sdpAccountHandlers.test.ts new file mode 100644 index 00000000..6348cd5b --- /dev/null +++ b/src/main/handlers/sdpAccountHandlers.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupSdpAccountHandlers } from './sdpAccountHandlers'; +import { IPC_CHANNELS } from '@shared/ipc'; +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + once: vi.fn(), + trusted: vi.fn(() => true), + open: vi.fn(), + invoke: vi.fn(), + server: vi.fn(), + packaged: false, + save: vi.fn(), + write: vi.fn(), +})); +vi.mock('electron', () => ({ + app: { + once: mocks.once, + get isPackaged() { + return mocks.packaged; + }, + }, + ipcMain: { handle: mocks.handle }, + shell: { openExternal: mocks.open }, + dialog: { showSaveDialog: mocks.save }, +})); +vi.mock('node:fs/promises', () => ({ writeFile: mocks.write })); +vi.mock('../sdp/SdpRuntime', () => ({ + sdpBackend: { invoke: mocks.invoke }, + sdpServerCommand: mocks.server, +})); +vi.mock('../utils/trustedSender', () => ({ assertTrustedIpcSender: mocks.trusted })); +const sender = {}; +function setup(role?: string) { + setupSdpAccountHandlers( + () => ({ webContents: sender }) as never, + () => ({ getView: () => ({ state: 'active', role }) }) as never, + ); + return { + account: mocks.handle.mock.calls.find(([channel]) => channel === IPC_CHANNELS.SDP_ACCOUNT)![1], + server: mocks.handle.mock.calls.find(([channel]) => channel === IPC_CHANNELS.SDP_SERVER)![1], + }; +} +describe('SDP account and administration IPC boundaries', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.packaged = false; + mocks.trusted.mockReturnValue(true); + mocks.invoke.mockResolvedValue({ view: { configured: true, status: 'disconnected' } }); + mocks.server.mockResolvedValue({ configured: false }); + }); + it('rejects web, subframe and popout callers', async () => { + const { account, server } = setup('owner'); + expect((await account({ sender: {} }, { action: 'status' })).success).toBe(false); + mocks.trusted.mockReturnValue(false); + expect((await server({ sender }, { action: 'status' })).success).toBe(false); + expect(mocks.invoke).not.toHaveBeenCalled(); + expect(mocks.server).not.toHaveBeenCalled(); + }); + it('rejects client setup and arbitrary ticket identifiers on the user channel', async () => { + const { account } = setup(); + for (const command of [ + { action: 'configure', client: { clientId: '1000.TEST', clientSecret: 'private' } }, + { action: 'readTestTicket', ticketId: '999' }, + ]) + expect((await account({ sender }, command)).success).toBe(false); + expect((await account({ sender }, { action: 'status' })).success).toBe(true); + }); + it('refreshes only the current server-owned projection through the trusted account channel', async () => { + const { account } = setup(); + expect((await account({ sender }, { action: 'refreshVisible' })).success).toBe(true); + expect(mocks.invoke).toHaveBeenCalledWith({ action: 'refreshVisible' }); + mocks.invoke.mockClear(); + expect((await account({ sender }, { action: 'refreshVisible', id: '999' })).success).toBe( + false, + ); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + it('requires an existing active owner or admin session for server configuration', async () => { + const { server } = setup(); + expect((await server({ sender }, { action: 'status' })).success).toBe(false); + expect(mocks.server).not.toHaveBeenCalled(); + mocks.handle.mockClear(); + const admin = setup('admin'); + expect((await admin.server({ sender }, { action: 'status' })).success).toBe(true); + }); + it('keeps cache clearing out of packaged builds', async () => { + const { account } = setup(); + mocks.packaged = true; + expect((await account({ sender }, { action: 'clearCopies' })).success).toBe(false); + expect(mocks.invoke).not.toHaveBeenCalled(); + const result = await account({ sender }, { action: 'status' }); + expect(result.data.testControls).toBe(false); + mocks.packaged = false; + expect((await account({ sender }, { action: 'status' })).data.testControls).toBe(true); + }); +}); + +it('keeps attachment bytes in the desktop process and saves only after the native dialog accepts', async () => { + const { account } = setup(); + const data = Buffer.from('dummy attachment').toString('base64'); + mocks.invoke.mockResolvedValue({ + view: { + configured: true, + status: 'connected', + attachmentFile: { name: 'example.txt', contentType: 'text/plain', data }, + }, + }); + mocks.save.mockResolvedValueOnce({ canceled: true }); + const command = { action: 'downloadAttachment', id: '123', attachmentId: '4' }; + const cancelled = await account({ sender }, command); + expect(cancelled.data.message).toBe('Download cancelled.'); + expect(JSON.stringify(cancelled)).not.toContain(data); + expect(mocks.write).not.toHaveBeenCalled(); + mocks.save.mockResolvedValueOnce({ canceled: false, filePath: '/chosen/example.txt' }); + const saved = await account({ sender }, command); + expect(saved.data.message).toBe('Attachment saved.'); + expect(saved.data.attachmentFile).toBeUndefined(); + expect(mocks.write).toHaveBeenCalledWith('/chosen/example.txt', Buffer.from('dummy attachment'), { + mode: 0o600, + }); +}); + +it.each([ + { action: 'readChanges', problemStart: 1789950000000, page: 0 }, + { action: 'readHistory', id: '123', page: 0 }, + { action: 'readStandardOptions', field: 'group', search: '', page: 0 }, + { action: 'readForwardContext', id: '123' }, + { action: 'readResourceChoices', id: '123', catalog: 'checklist_templates', search: '', page: 0 }, +])('forwards validated $action through the native account channel', async (command) => { + vi.clearAllMocks(); + mocks.trusted.mockReturnValue(true); + mocks.invoke.mockResolvedValue({ view: { configured: true, status: 'connected' } }); + const { account } = setup(); + expect((await account({ sender }, command)).success).toBe(true); + expect(mocks.invoke).toHaveBeenCalledWith(command); +}); diff --git a/src/main/handlers/sdpAccountHandlers.ts b/src/main/handlers/sdpAccountHandlers.ts new file mode 100644 index 00000000..d28b92e7 --- /dev/null +++ b/src/main/handlers/sdpAccountHandlers.ts @@ -0,0 +1,116 @@ +import { writeFile } from 'node:fs/promises'; +import { app, dialog, ipcMain, shell, type BrowserWindow } from 'electron'; +import { IPC_CHANNELS, type IpcResult } from '@shared/ipc'; +import { + SdpAccountCommandSchema, + type SdpAccountView, + SdpServerCommandSchema, + type SdpServerView, +} from '@shared/sdpAccount'; +import { sdpBackend, sdpServerCommand } from '../sdp/SdpRuntime'; +import type { PrivilegedAccessRuntime } from './privilegedAccessHandlers'; +import { SdpAccountSession } from '../sdp/SdpAccountSession'; +import { assertTrustedIpcSender } from '../utils/trustedSender'; +import { shouldSuppressDesktopSideEffects } from '../app/e2eSafety'; + +/** OAuth secrets, token exchange and a loopback listener require the desktop process. */ +export function setupSdpAccountHandlers( + getMainWindow: () => BrowserWindow | null, + getRuntime: () => PrivilegedAccessRuntime | null = () => null, +): void { + const account = new SdpAccountSession(async (url) => { + if (shouldSuppressDesktopSideEffects()) throw new Error('External sign-in disabled in tests.'); + await shell.openExternal(url); + }, sdpBackend); + app.once('before-quit', () => { + void account.disconnect().catch(() => undefined); + }); + ipcMain.handle( + IPC_CHANNELS.SDP_SERVER, + async (event, payload: unknown): Promise> => { + if ( + !assertTrustedIpcSender(event, IPC_CHANNELS.SDP_SERVER) || + event.sender !== getMainWindow()?.webContents + ) + return { success: false, error: 'Use the main server window.' }; + const session = getRuntime()?.getView(); + if (session?.state !== 'active' || !['owner', 'admin'].includes(session.role ?? '')) + return { success: false, error: 'An active server administrator session is required.' }; + const command = SdpServerCommandSchema.safeParse(payload); + if (!command.success) return { success: false, error: 'Invalid SDP server request.' }; + try { + return { success: true, data: await sdpServerCommand(command.data) }; + } catch { + return { + success: false, + error: + 'SDP setup could not be saved or loaded. Use the server computer, check protected storage, and refresh settings before retrying.', + }; + } + }, + ); + ipcMain.handle( + IPC_CHANNELS.SDP_ACCOUNT, + async (event, payload: unknown): Promise> => { + if ( + !assertTrustedIpcSender(event, IPC_CHANNELS.SDP_ACCOUNT) || + event.sender !== getMainWindow()?.webContents + ) + return { + success: false, + error: 'SDP account access is available only in the main desktop window.', + }; + const parsed = SdpAccountCommandSchema.safeParse(payload); + if (!parsed.success) return { success: false, error: 'Invalid SDP account request.' }; + try { + const command = parsed.data; + if (command.action === 'clearCopies' && app.isPackaged) + return { success: false, error: 'Test controls are unavailable in release builds.' }; + let data: SdpAccountView; + switch (command.action) { + case 'connect': + data = await account.connect(); + break; + case 'disconnect': + data = await account.disconnect(); + break; + case 'readTestTicket': + data = await account.readTestTicket(); + break; + case 'status': + data = await account.status(); + break; + default: + data = await account.invoke(command); + } + if (command.action === 'downloadAttachment') + data = await saveAttachment(data, getMainWindow()); + return { success: true, data: { ...data, testControls: app.isPackaged === false } }; + } catch { + // Never forward provider errors, callback URLs, credentials or response bodies over IPC. + return { + success: false, + error: + parsed.data.action === 'connect' + ? 'Could not open SDP sign-in. Close the standalone login tester on port 8766 and try again.' + : 'SDP could not complete this action. Check your connection, account permissions, and sign-in status.', + }; + } + }, + ); +} + +async function saveAttachment( + data: SdpAccountView, + window: BrowserWindow | null, +): Promise { + const { attachmentFile, ...view } = data; + if (!attachmentFile || !window) throw new Error('Attachment unavailable.'); + const chosen = await dialog.showSaveDialog(window, { + title: 'Save SDP attachment', + defaultPath: attachmentFile.name, + }); + if (chosen.canceled || !chosen.filePath) return { ...view, message: 'Download cancelled.' }; + await writeFile(chosen.filePath, Buffer.from(attachmentFile.data, 'base64'), { mode: 0o600 }); + return { ...view, message: 'Attachment saved.' }; +} diff --git a/src/main/handlers/window/ticketNotificationHandler.test.ts b/src/main/handlers/window/ticketNotificationHandler.test.ts new file mode 100644 index 00000000..125333b3 --- /dev/null +++ b/src/main/handlers/window/ticketNotificationHandler.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerTicketNotificationHandler } from './ticketNotificationHandler'; + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + trusted: vi.fn(() => true), + supported: vi.fn(() => true), + show: vi.fn(), + on: vi.fn(), +})); +vi.mock('electron', () => ({ + ipcMain: { handle: mocks.handle }, + Notification: class { + static readonly isSupported = mocks.supported; + show = mocks.show; + on = mocks.on; + }, +})); +vi.mock('../../utils/trustedSender', () => ({ assertTrustedIpcSender: mocks.trusted })); + +describe('ticket desktop notifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.trusted.mockReturnValue(true); + mocks.supported.mockReturnValue(true); + }); + it('requires a trusted sender, bounded payload, and native support', () => { + registerTicketNotificationHandler(() => null); + const handler = mocks.handle.mock.calls[0]![1]; + expect(handler({}, { title: 'Ticket', body: 'x'.repeat(301) })).toBe(false); + mocks.trusted.mockReturnValue(false); + expect(handler({}, { title: 'Ticket', body: 'Synthetic' })).toBe(false); + mocks.trusted.mockReturnValue(true); + mocks.supported.mockReturnValue(false); + expect(handler({}, { title: 'Ticket', body: 'Synthetic' })).toBe(false); + expect( + handler( + {}, + { + title: 'Ticket', + body: 'x', + target: { source: 'Tickets', ticketId: 'https://evil.test' }, + }, + ), + ).toBe(false); + expect(mocks.show).not.toHaveBeenCalled(); + }); + it('rate limits notifications and brings the existing window forward on click', () => { + const window = { + isDestroyed: () => false, + isMinimized: () => true, + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + webContents: { send: vi.fn() }, + }; + registerTicketNotificationHandler(() => window as never); + const handler = mocks.handle.mock.calls[0]![1]; + expect( + handler( + {}, + { + title: 'Relay', + body: 'Synthetic ticket changed.', + target: { source: 'Tickets', ticketId: '123' }, + }, + ), + ).toBe(true); + expect(handler({}, { title: 'Relay', body: 'Synthetic ticket changed.' })).toBe(false); + mocks.on.mock.calls[0]![1](); + expect(window.restore).toHaveBeenCalledOnce(); + expect(window.focus).toHaveBeenCalledOnce(); + expect(window.webContents.send).toHaveBeenCalledWith('ticket:notify', { + source: 'Tickets', + ticketId: '123', + }); + }); +}); diff --git a/src/main/handlers/window/ticketNotificationHandler.ts b/src/main/handlers/window/ticketNotificationHandler.ts new file mode 100644 index 00000000..c683bdd2 --- /dev/null +++ b/src/main/handlers/window/ticketNotificationHandler.ts @@ -0,0 +1,32 @@ +import { BrowserWindow, ipcMain, Notification } from 'electron'; +import { IPC_CHANNELS } from '@shared/ipc'; +import { TicketNotificationPayloadSchema } from '@shared/serviceDesk'; +import { assertTrustedIpcSender } from '../../utils/trustedSender'; + +// Native notifications are a desktop capability; Relay Web cannot invoke this handler. +export function registerTicketNotificationHandler(getMainWindow: () => BrowserWindow | null): void { + let lastShown = 0; + ipcMain.handle(IPC_CHANNELS.TICKET_NOTIFY, (event, payload: unknown) => { + if (!assertTrustedIpcSender(event, IPC_CHANNELS.TICKET_NOTIFY)) return false; + const parsed = TicketNotificationPayloadSchema.safeParse(payload); + if (!parsed.success || !Notification.isSupported() || Date.now() - lastShown < 1000) + return false; + try { + const notification = new Notification({ title: parsed.data.title, body: parsed.data.body }); + notification.on('click', () => { + const window = getMainWindow(); + if (!window || window.isDestroyed()) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + if (parsed.data.target) + window.webContents.send(IPC_CHANNELS.TICKET_NOTIFY, parsed.data.target); + }); + notification.show(); + lastShown = Date.now(); + return true; + } catch { + return false; + } + }); +} diff --git a/src/main/handlers/windowHandlers.ts b/src/main/handlers/windowHandlers.ts index bac07290..c678227b 100644 --- a/src/main/handlers/windowHandlers.ts +++ b/src/main/handlers/windowHandlers.ts @@ -13,6 +13,7 @@ import { registerFooterBrandAssetHandlers, } from './window/brandAssetHandlers'; import { registerClipboardWriteHandler } from './window/clipboardHandlers'; +import { registerTicketNotificationHandler } from './window/ticketNotificationHandler'; import { registerDragStartedHandler, registerDragStoppedHandler, @@ -47,6 +48,7 @@ export function setupWindowHandlers( registerDragStoppedHandler(); registerOnCallAlertDismissedHandler(); registerClipboardWriteHandler(); + registerTicketNotificationHandler(getMainWindow); registerOptimizeAlertImageHandler(); registerSaveAlertImageHandler(); registerCompanyBrandAssetHandlers(brandAssets); diff --git a/src/main/index.ts b/src/main/index.ts index ed8b336b..ca32325d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import { initializeSdpRuntime, getSdpBroker } from './sdp/SdpRuntime'; import { registerShutdownHandlers } from './app/shutdown'; import { recoverInterruptedRestore } from './pocketbase/BackupRestore'; import { @@ -631,6 +632,7 @@ if (manualUpdateCheckpointTransaction !== null) { // Initialize AppConfig — PocketBase data always lives in %APPDATA%/Relay/data, // NOT in any custom dataRoot. setAppConfig(new AppConfig(configDataDir)); + initializeSdpRuntime({ getConfig: getAppConfig, getPb: getPbClient }); const authenticateWebSession = createWebSessionAuthenticator({ getAppConfig, getPbProcess, @@ -642,6 +644,7 @@ if (manualUpdateCheckpointTransaction !== null) { new RelayWebGateway({ config, authenticate: authenticateWebSession, + getSdpBroker, privilegedHost: getPrivilegedHost(), getAccountManager: () => { const pb = getPbClient(); diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 4c4dcf54..99798745 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -3,6 +3,7 @@ import type PocketBase from 'pocketbase'; import { setupCloudStatusHandlers } from './handlers/cloudStatus'; import { setupRadarHandlers } from './handlers/radar'; import { setupWindowHandlers } from './handlers/windowHandlers'; +import { setupSdpAccountHandlers } from './handlers/sdpAccountHandlers'; import { setupReleaseUpdateHandlers } from './handlers/releaseUpdateHandlers'; import { setupSetupHandlers } from './handlers/setupHandlers'; import { setupRelayWebServerHandlers } from './handlers/webServerHandlers'; @@ -161,6 +162,9 @@ export async function setupIpcHandlers(opts: { // Window Management safeSetup('window', () => setupWindowHandlers(getMainWindow, getDataRoot)); + safeSetup('sdpAccount', () => + setupSdpAccountHandlers(getMainWindow, getPrivilegedRuntime ?? (() => null)), + ); // PocketBase Setup Handlers (always registered — uses getter for lazy access) safeSetup('setup', () => diff --git a/src/main/pocketbase/__tests__/CollectionBootstrap.test.ts b/src/main/pocketbase/__tests__/CollectionBootstrap.test.ts index d5a0ca58..49376cab 100644 --- a/src/main/pocketbase/__tests__/CollectionBootstrap.test.ts +++ b/src/main/pocketbase/__tests__/CollectionBootstrap.test.ts @@ -616,6 +616,8 @@ describe('ensureCollections', () => { 'dynatrace_problem_states', 'dynatrace_problem_notes', 'dynatrace_problem_sync', + 'relay_sdp_links', + 'relay_sdp_discovery', ]); expect(mockGetOne).not.toHaveBeenCalledWith('custom-archive-id'); expect(mockUpdate).not.toHaveBeenCalledWith('custom-archive-id', expect.anything()); @@ -697,7 +699,7 @@ describe('ensureCollections', () => { await ensureCollections(mockPb); - expect(mockCreate).toHaveBeenCalledTimes(33); + expect(mockCreate).toHaveBeenCalledTimes(35); expect( mockCreate.mock.calls.some( (call: unknown[]) => (call[0] as { name: string }).name === 'alert_reminders', diff --git a/src/main/pocketbase/schema/collectionCatalog.ts b/src/main/pocketbase/schema/collectionCatalog.ts index 4ba4d576..50779c14 100644 --- a/src/main/pocketbase/schema/collectionCatalog.ts +++ b/src/main/pocketbase/schema/collectionCatalog.ts @@ -1,3 +1,4 @@ +import { SERVICE_DESK_COLLECTIONS } from './serviceDeskCollections'; import { DYNATRACE_PROBLEMS_COLLECTION, DYNATRACE_PROBLEM_NOTES_COLLECTION, @@ -1097,6 +1098,8 @@ export const COLLECTIONS: CollectionDef[] = [ }, ]; +COLLECTIONS.push(...SERVICE_DESK_COLLECTIONS); + export const KNOWN_NAMES = new Set([ ...COLLECTIONS.map((c) => c.name), KNOWLEDGE_SEARCH_CHUNKS_COLLECTION, diff --git a/src/main/pocketbase/schema/serviceDeskCollections.ts b/src/main/pocketbase/schema/serviceDeskCollections.ts new file mode 100644 index 00000000..c3357074 --- /dev/null +++ b/src/main/pocketbase/schema/serviceDeskCollections.ts @@ -0,0 +1,51 @@ +import { SDP_LINK_COLLECTION } from '../../../shared/sdpLinks'; +import { SDP_DISCOVERY_COLLECTION } from '../../../shared/sdpAccount'; +import type { CollectionDef } from './collectionTypes'; +const authenticated = '@request.auth.id != ""'; + +/** Shared connection discovery and ticket references only; ticket data remains in SDP. */ +export const SERVICE_DESK_COLLECTIONS: CollectionDef[] = [ + { + name: SDP_LINK_COLLECTION, + type: 'base', + rules: { + listRule: authenticated, + viewRule: authenticated, + createRule: authenticated, + updateRule: authenticated, + deleteRule: authenticated, + }, + fields: [ + { type: 'bool', name: 'suppressed' }, + { type: 'text', name: 'ticketId', required: true, max: 30, pattern: String.raw`^\d{1,30}$` }, + { + type: 'text', + name: 'ticketNumber', + required: true, + max: 30, + pattern: String.raw`^\d{1,30}$`, + }, + { type: 'text', name: 'problemId', required: true, max: 256 }, + { type: 'text', name: 'environment', required: true, max: 2048 }, + ], + indexes: [ + 'CREATE UNIQUE INDEX idx_sdp_problem_link ON relay_sdp_links (ticketId, problemId, environment)', + ], + }, + { + name: SDP_DISCOVERY_COLLECTION, + type: 'base', + rules: { + listRule: authenticated, + viewRule: authenticated, + createRule: null, + updateRule: null, + deleteRule: null, + }, + fields: [ + { type: 'bool', name: 'enabled' }, + { type: 'number', name: 'gatewayPort', required: true, min: 1, max: 65535, onlyInt: true }, + { type: 'text', name: 'revision', max: 64 }, + ], + }, +]; diff --git a/src/main/sdp/SdpAccountSession.test.ts b/src/main/sdp/SdpAccountSession.test.ts new file mode 100644 index 00000000..3d162d38 --- /dev/null +++ b/src/main/sdp/SdpAccountSession.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { SDP_CALLBACK, type SdpBrokerCommand, type SdpBrokerReply } from '@shared/sdpAccount'; +import { SdpAccountSession } from './SdpAccountSession'; +const sessions: SdpAccountSession[] = []; +function setup() { + const open = vi.fn(async (_url: string) => {}); + const invoke = vi.fn(async (command: SdpBrokerCommand): Promise => { + if (command.action === 'begin') { + const url = new URL('https://accounts.zoho.com/oauth/v2/auth'); + url.search = new URLSearchParams({ + state: command.state, + redirect_uri: SDP_CALLBACK, + }).toString(); + return { view: { configured: true, status: 'connecting' }, authorizationUrl: url.toString() }; + } + return { + view: { + configured: true, + status: command.action === 'complete' ? 'connected' : 'disconnected', + }, + }; + }); + const account = new SdpAccountSession(open, { invoke }); + sessions.push(account); + return { open, invoke, account }; +} +afterEach(async () => { + await Promise.all(sessions.splice(0).map((account) => account.disconnect())); +}); +describe('native SDP sign-in callback', () => { + it('sends a bound PKCE callback to the server without client credentials', async () => { + const { account, invoke, open } = setup(); + await account.connect(); + const authorization = new URL(open.mock.calls[0]![0]); + const response = await fetch( + `${SDP_CALLBACK}?state=${authorization.searchParams.get('state')}&code=dummy`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain('SDP connected'); + const begin = invoke.mock.calls.find(([command]) => command.action === 'begin')![0]; + const complete = invoke.mock.calls.find(([command]) => command.action === 'complete')![0]; + if (begin.action !== 'begin' || complete.action !== 'complete') + throw new Error('Missing proof'); + expect(createHash('sha256').update(complete.verifier).digest('base64url')).toBe( + begin.challenge, + ); + expect(JSON.stringify(invoke.mock.calls)).not.toContain('clientSecret'); + }); + it('rejects wrong state, duplicate parameters, wrong region and provider errors', async () => { + const { account, invoke, open } = setup(); + await account.connect(); + const state = new URL(open.mock.calls[0]![0]).searchParams.get('state'); + for (const query of [ + 'state=bad&code=dummy', + `state=${state}&state=${state}&code=dummy`, + `state=${state}&code=dummy&location=eu`, + ]) { + const response = await fetch(`${SDP_CALLBACK}?${query}`); + expect(response.status).toBe(400); + await response.text(); + } + expect(invoke.mock.calls.some(([command]) => command.action === 'complete')).toBe(false); + }); + it('fails closed on an occupied callback port', async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(8766, '127.0.0.1', resolve)); + try { + const { account, open } = setup(); + await expect(account.connect()).rejects.toThrow(); + expect(open).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('never opens an untrusted authorization destination', async () => { + const { account, invoke, open } = setup(); + invoke.mockImplementation(async () => ({ + view: { configured: true, status: 'connecting' }, + authorizationUrl: 'https://evil.example/', + })); + await expect(account.connect()).rejects.toThrow(); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/sdp/SdpAccountSession.ts b/src/main/sdp/SdpAccountSession.ts new file mode 100644 index 00000000..fda3bb7e --- /dev/null +++ b/src/main/sdp/SdpAccountSession.ts @@ -0,0 +1,155 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import { + SDP_CALLBACK, + type SdpAccountView, + type SdpBackend, + type SdpBrokerCommand, +} from '@shared/sdpAccount'; +import { SDP_ACCOUNTS } from './SdpProvider'; + +/** Native loopback callback only. Provider credentials and ticket storage belong to the server. */ +export class SdpAccountSession { + private listener?: Server; + private timer?: ReturnType; + private generation = 0; + constructor( + private readonly openBrowser: (url: string) => Promise, + private readonly backend: SdpBackend, + ) {} + private stop(): void { + this.generation++; + clearTimeout(this.timer); + this.timer = undefined; + this.listener?.close(); + this.listener?.closeAllConnections(); + this.listener = undefined; + } + async status(): Promise { + return this.invoke({ action: 'status' }); + } + async disconnect(): Promise { + this.stop(); + return this.invoke({ action: 'disconnect' }); + } + async readTestTicket(): Promise { + return this.invoke({ action: 'readTestTicket' }); + } + async invoke(command: SdpBrokerCommand): Promise { + return (await this.backend.invoke(command)).view; + } + async connect(): Promise { + await this.disconnect(); + const generation = this.generation; + const state = randomBytes(32).toString('base64url'); + const verifier = randomBytes(32).toString('base64url'); + const deadline = Date.now() + 300_000; + let consumed = false; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', SDP_CALLBACK); + const candidate = url.searchParams.get('state') ?? ''; + if ( + request.method !== 'GET' || + url.pathname !== '/callback' || + request.headers.host !== '127.0.0.1:8766' + ) { + this.reply(response, false); + return; + } + if ( + consumed || + Date.now() >= deadline || + generation !== this.generation || + new Set(url.searchParams.keys()).size !== [...url.searchParams.keys()].length || + Buffer.byteLength(candidate) !== Buffer.byteLength(state) || + !timingSafeEqual(Buffer.from(candidate), Buffer.from(state)) + ) { + this.reply(response, false); + return; + } + consumed = true; + void this.callback(response, url.searchParams, state, verifier, generation); + }); + server.requestTimeout = 5000; + server.headersTimeout = 5000; + server.maxConnections = 8; + this.listener = server; + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(8766, '127.0.0.1', resolve); + }); + const result = await this.backend.invoke({ + action: 'begin', + state, + challenge: createHash('sha256').update(verifier).digest('base64url'), + }); + if (generation !== this.generation) { + server.close(); + return this.status(); + } + if (!result.authorizationUrl) throw new Error('Sign-in unavailable.'); + const authorization = new URL(result.authorizationUrl); + if ( + authorization.origin !== SDP_ACCOUNTS || + authorization.pathname !== '/oauth/v2/auth' || + authorization.searchParams.get('state') !== state || + authorization.searchParams.get('redirect_uri') !== SDP_CALLBACK + ) + throw new Error('Invalid sign-in destination.'); + this.timer = setTimeout(() => { + if (generation === this.generation) void this.disconnect().catch(() => undefined); + }, 300_000); + this.timer.unref(); + await this.openBrowser(authorization.toString()); + return result.view; + } catch { + if (generation === this.generation) await this.disconnect().catch(() => undefined); + throw new Error('Could not start SDP sign-in.'); + } + } + private async callback( + response: ServerResponse, + params: URLSearchParams, + state: string, + verifier: string, + generation: number, + ): Promise { + let success = false; + try { + const code = params.get('code'); + if ( + !code || + code.length > 2048 || + params.has('error') || + (params.has('location') && params.get('location') !== 'us') || + (params.has('accounts-server') && params.get('accounts-server') !== SDP_ACCOUNTS) + ) + throw new Error('Invalid callback.'); + const result = await this.backend.invoke({ action: 'complete', code, state, verifier }); + success = generation === this.generation && result.view.status === 'connected'; + } catch { + if (generation === this.generation) + await this.backend.invoke({ action: 'disconnect' }).catch(() => undefined); + } + this.reply(response, success); + if (generation === this.generation) { + clearTimeout(this.timer); + this.listener?.close(); + this.listener = undefined; + } + } + private reply(response: ServerResponse, success: boolean): void { + response.writeHead(success ? 200 : 400, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + Connection: 'close', + }); + response.end( + `Relay SDP

${success ? 'SDP connected' : 'Sign-in not completed'}

Return to Relay to continue. You can close this tab.

`, + ); + } +} diff --git a/src/main/sdp/SdpAttachments.test.ts b/src/main/sdp/SdpAttachments.test.ts new file mode 100644 index 00000000..3b8582e9 --- /dev/null +++ b/src/main/sdp/SdpAttachments.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SdpAttachmentMutationSchema } from '@shared/sdpAttachments'; +import { attachmentBody, attachmentDownloadUrl, downloadAttachment } from './SdpAttachments'; +import { SdpProvider } from './SdpProvider'; +import { submitMutation } from './SdpMutations'; +describe('SDP attachment boundary', () => { + it('uses the documented multipart upload without treating file text as markup', async () => { + const mutation = SdpAttachmentMutationSchema.parse({ + kind: 'attachment', + id: '123', + name: 'test.txt', + contentType: 'text/plain', + data: Buffer.from('').toString('base64'), + }); + const body = attachmentBody(mutation); + expect(body.get('addtoattachment')).toBe('true'); + expect(await (body.get('filename') as File).text()).toBe(''); + const provider = new SdpProvider(); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValue({ response_status: { status_code: 2000 } }); + await submitMutation(provider, 'token', new AbortController().signal, mutation); + expect(json.mock.calls[0]?.[0]).toContain('/requests/123/_uploads'); + expect(json.mock.calls[0]?.[2]?.body).toBeInstanceOf(FormData); + expect(json.mock.calls[0]?.[2]?.headers).not.toHaveProperty('Content-Type'); + }); + it('rejects path-like filenames, malformed bytes and cross-ticket or external download paths', () => { + const value = { + kind: 'attachment', + id: '123', + name: 'test.txt', + contentType: 'text/plain', + data: 'dGVzdA==', + }; + for (const name of ['../test', 'folder\\test', 'test\n.txt']) + expect(SdpAttachmentMutationSchema.safeParse({ ...value, name }).success).toBe(false); + expect(() => attachmentBody({ ...value, kind: 'attachment', data: '' })).toThrow(); + expect(() => attachmentBody({ ...value, kind: 'attachment', data: 'a' })).toThrow(); + expect(attachmentDownloadUrl('123', '/requests/123/_uploads/4')).toBe( + 'https://support.campingworld.com/app/itdesk/api/v3/requests/123/_uploads/4', + ); + for (const url of [ + 'https://evil.test/requests/123/_uploads/4', + '/requests/124/_uploads/4', + '/requests/123/_uploads/4?token=anything', + 'file:///tmp/private', + '/requests/123/_uploads/../secrets', + ]) + expect(() => attachmentDownloadUrl('123', url)).toThrow(); + }); + it('rechecks the authorized ticket and accepts only its listed attachment', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue({ + request: { + id: '123', + attachments: [ + { + id: '4', + name: 'test.txt', + size: 4, + content_type: 'text/plain', + content_url: '/requests/123/_uploads/4', + }, + ], + }, + }); + const binary = vi.spyOn(provider, 'binary').mockResolvedValue(Buffer.from('test')); + const signal = new AbortController().signal; + expect(await downloadAttachment(provider, 'token', signal, '123', '4')).toEqual({ + name: 'test.txt', + contentType: 'text/plain', + data: 'dGVzdA==', + }); + await expect(downloadAttachment(provider, 'token', signal, '123', '5')).rejects.toThrow(); + expect(binary).toHaveBeenCalledTimes(1); + }); + it('bounds streaming downloads and refuses redirect responses', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response('redirect', { status: 302, headers: { Location: 'https://evil.test' } }), + ); + const provider = new SdpProvider(fetchImpl); + await expect( + provider.binary('https://support.campingworld.com', new AbortController().signal), + ).rejects.toThrow(); + expect(fetchImpl.mock.calls[0]?.[1].redirect).toBe('error'); + fetchImpl.mockResolvedValueOnce(new Response(new Uint8Array(10 * 1024 * 1024 + 1))); + await expect( + provider.binary('https://support.campingworld.com', new AbortController().signal), + ).rejects.toThrow(); + }); +}); diff --git a/src/main/sdp/SdpAttachments.ts b/src/main/sdp/SdpAttachments.ts new file mode 100644 index 00000000..6f7bc92c --- /dev/null +++ b/src/main/sdp/SdpAttachments.ts @@ -0,0 +1,77 @@ +import { + SDP_ATTACHMENT_MAX_BYTES, + SdpAttachmentSchema, + type SdpAttachment, + type SdpAttachmentUpload, + type SdpAttachmentFile, +} from '@shared/sdpAttachments'; +import { SdpProvider, SdpProviderError, isObject } from './SdpProvider'; +import { resourceHeaders } from './SdpResources'; +const API = 'https://support.campingworld.com/app/itdesk/api/v3'; +export function projectAttachments(request: Record): SdpAttachment[] { + if (!Array.isArray(request.attachments)) return []; + return request.attachments.map((value) => { + if (!isObject(value)) throw new SdpProviderError('invalid'); + return SdpAttachmentSchema.parse({ + id: String(value.id ?? value.file_id), + name: value.name, + size: Number(value.size ?? 0), + contentType: value.content_type || 'application/octet-stream', + }); + }); +} +export function attachmentBody(upload: SdpAttachmentUpload): FormData { + const bytes = Buffer.from(upload.data, 'base64'); + if ( + !bytes.length || + bytes.length > SDP_ATTACHMENT_MAX_BYTES || + bytes.toString('base64') !== upload.data + ) + throw new Error('Choose a file between 1 byte and 10 MB.'); + const form = new FormData(); + form.append('filename', new Blob([bytes], { type: upload.contentType }), upload.name); + form.append('addtoattachment', 'true'); + return form; +} +export function attachmentDownloadUrl(requestId: string, value: unknown): string { + if (typeof value !== 'string') throw new SdpProviderError('invalid'); + const relative = value.startsWith('/requests/') ? API + value : value; + const url = new URL(relative, 'https://support.campingworld.com'); + const prefix = `/app/itdesk/api/v3/requests/${requestId}/_uploads/`; + if ( + url.origin !== 'https://support.campingworld.com' || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith(prefix) || + !/^\d{1,30}$/.test(url.pathname.slice(prefix.length)) + ) + throw new SdpProviderError('invalid'); + return url.toString(); +} +export async function downloadAttachment( + provider: SdpProvider, + token: string, + signal: AbortSignal, + id: string, + attachmentId: string, +): Promise { + const response = await provider.json(`${API}/requests/${id}`, signal, { + headers: resourceHeaders(token), + }); + const request = isObject(response) && isObject(response.request) ? response.request : undefined; + if (!request || String(request.id) !== id || !Array.isArray(request.attachments)) + throw new SdpProviderError('denied'); + const metadata = projectAttachments(request).find((file) => file.id === attachmentId); + const raw = request.attachments.find( + (file) => isObject(file) && String(file.id ?? file.file_id) === attachmentId, + ); + if (!metadata || !isObject(raw)) throw new SdpProviderError('denied'); + if (metadata.size > SDP_ATTACHMENT_MAX_BYTES) + throw new Error('This attachment exceeds the 10 MB download limit.'); + const bytes = await provider.binary(attachmentDownloadUrl(id, raw.content_url), signal, { + headers: resourceHeaders(token), + }); + return { name: metadata.name, contentType: metadata.contentType, data: bytes.toString('base64') }; +} diff --git a/src/main/sdp/SdpBroker.test.ts b/src/main/sdp/SdpBroker.test.ts new file mode 100644 index 00000000..08531b47 --- /dev/null +++ b/src/main/sdp/SdpBroker.test.ts @@ -0,0 +1,1177 @@ +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SdpBroker } from './SdpBroker'; +import { SdpServerStore } from './SdpServerStore'; +import { SdpProvider, SdpProviderError } from './SdpProvider'; +const ticket = { number: '810129', status: 'Open', priority: 'Low', group: 'NOC' }; +const client = { clientId: '1000.TEST', clientSecret: 'never-on-disk-plain' }; +const cleanup: (() => void)[] = []; +function setup() { + const root = mkdtempSync(join(tmpdir(), 'relay-sdp-broker-test-')); + // Test key wrapping only: production uses OS safeStorage, never this adapter. + const protection = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }; + const store = new SdpServerStore(root, protection); + store.save(client, 5, ''); + const provider = new SdpProvider(); + vi.spyOn(provider, 'token').mockResolvedValue({ + access_token: 'access-secret', + refresh_token: 'refresh-secret', + expires_in: 3600, + }); + vi.spyOn(provider, 'identity').mockResolvedValue('123'); + vi.spyOn(provider, 'ticket').mockResolvedValue(ticket); + vi.spyOn(provider, 'queue').mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: true, + tickets: [ + { + id: '123456', + number: '810129', + subject: 'Synthetic queue subject', + status: 'Open', + priority: 'Low', + group: queue, + technician: 'Example technician', + createdAt: 1000, + dueAt: null, + }, + ], + })); + vi.spyOn(provider, 'json').mockResolvedValue({ conversations: [] }); + const broker = new SdpBroker(store, provider); + cleanup.push(() => { + broker.dispose(); + rmSync(root, { recursive: true, force: true }); + }); + return { broker, store, provider, root }; +} +const state = 's'.repeat(43); +const verifier = 'v'.repeat(43); +const challenge = createHash('sha256').update(verifier).digest('base64url'); +async function signIn(broker: SdpBroker, id = 'alice') { + await broker.invoke(id, { action: 'begin', state, challenge }); + return broker.invoke(id, { action: 'complete', state, verifier, code: 'one-use-code' }); +} +afterEach(() => { + cleanup.splice(0).forEach((fn) => fn()); + vi.useRealTimers(); +}); +describe('SDP server broker and encrypted outage storage', () => { + it('requests read-only setup access with ticket scopes through explicit OAuth consent', async () => { + const { broker, provider } = setup(); + const result = await broker.invoke('alice', { action: 'begin', state, challenge }); + const authorization = new URL(result.authorizationUrl!); + const scopes = authorization.searchParams.get('scope')!.split(','); + expect(scopes).toContain('SDPOnDemand.setup.READ'); + expect(scopes.filter((s) => s.startsWith('SDPOnDemand.changes.'))).toEqual([ + 'SDPOnDemand.changes.READ', + ]); + expect(scopes.filter((s) => s.startsWith('SDPOnDemand.setup.'))).toEqual([ + 'SDPOnDemand.setup.READ', + ]); + expect(authorization.searchParams.get('prompt')).toBe('consent'); + expect(authorization.searchParams.get('code_challenge')).toBe(challenge); + expect(provider.token).not.toHaveBeenCalled(); + }); + it('binds identity to Zoho and keeps credentials out of public replies and stored plaintext', async () => { + const { broker, store, provider, root } = setup(); + expect((await signIn(broker)).view.status).toBe('connected'); + expect(provider.identity).toHaveBeenCalledWith('access-secret', expect.any(AbortSignal)); + const result = await broker.invoke('alice', { action: 'readTestTicket' }); + expect(result.view.ticket).toEqual(ticket); + expect(result.view.snapshot?.source).toBe('live'); + for (const secret of ['access-secret', 'refresh-secret', client.clientSecret, '123']) + expect(JSON.stringify(result)).not.toContain(secret); + expect( + readFileSync(join(root, 'connection.enc')).includes(Buffer.from(client.clientSecret)), + ).toBe(false); + expect(readFileSync(join(root, 'outage-cache.sqlite')).includes(Buffer.from('NOC'))).toBe( + false, + ); + expect(store.get(store.owner('123', store.settings()!.revision))?.ticket).toEqual(ticket); + }); + it('detects swapped ciphertext and refuses plaintext storage when OS protection is unavailable', async () => { + const { broker, store, root } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + const owner = store.owner('123', store.settings()!.revision); + const other = store.owner('456', store.settings()!.revision); + const db = new Database(join(root, 'outage-cache.sqlite')); + try { + db.prepare('UPDATE snapshots SET owner = ? WHERE owner = ?').run(other, owner); + } finally { + db.close(); + } + expect(store.get(other)).toBeNull(); + expect( + () => + new SdpServerStore(join(root, 'unprotected'), { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.alloc(0), + decryptString: () => '', + }), + ).toThrow('OS-protected'); + }); + it('uses saved copies only for the same verified identity during an upstream outage', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + vi.mocked(provider.identity).mockResolvedValue('456'); + await signIn(broker, 'bob'); + vi.mocked(provider.ticket).mockRejectedValue(new SdpProviderError('outage')); + expect((await broker.invoke('alice', { action: 'readTestTicket' })).view.snapshot?.source).toBe( + 'outage-cache', + ); + expect((await broker.invoke('bob', { action: 'readTestTicket' })).view.ticket).toBeUndefined(); + expect((await broker.invoke('unknown', { action: 'status' })).view.ticket).toBeUndefined(); + }); + it.each(['denied', 'invalid'] as const)( + 'never falls back on %s responses and removes previous copies', + async (kind) => { + const { broker, provider, store } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + vi.mocked(provider.ticket).mockRejectedValue(new SdpProviderError(kind)); + expect( + (await broker.invoke('alice', { action: 'readTestTicket' })).view.ticket, + ).toBeUndefined(); + expect(store.get(store.owner('123', store.settings()!.revision))).toBeNull(); + }, + ); + it('expires saved copies and active sessions without needing an upstream response', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + vi.advanceTimersByTime(300_001); + expect((await broker.invoke('alice', { action: 'status' })).view.ticket).toBeUndefined(); + vi.mocked(provider.ticket).mockRejectedValue(new SdpProviderError('outage')); + expect( + (await broker.invoke('alice', { action: 'readTestTicket' })).view.ticket, + ).toBeUndefined(); + vi.advanceTimersByTime(8 * 3600_000); + expect((await broker.invoke('alice', { action: 'status' })).view.status).toBe('disconnected'); + }); + it('rejects cross-session, wrong-proof and replayed callback codes before token exchange', async () => { + const { broker, provider } = setup(); + await broker.invoke('alice', { action: 'begin', state, challenge }); + await expect( + broker.invoke('bob', { action: 'complete', state, verifier, code: 'code' }), + ).rejects.toThrow(); + await expect( + broker.invoke('alice', { action: 'complete', state, verifier: 'x'.repeat(43), code: 'code' }), + ).rejects.toThrow(); + expect(provider.token).not.toHaveBeenCalled(); + await signIn(broker); + await expect( + broker.invoke('alice', { action: 'complete', state, verifier, code: 'code' }), + ).rejects.toThrow(); + expect(provider.token).toHaveBeenCalledTimes(1); + }); + it('does not revive disconnected sessions or store an in-flight read after cancellation', async () => { + const { broker, provider, store } = setup(); + await signIn(broker); + let resolve!: (value: typeof ticket) => void; + vi.mocked(provider.ticket).mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const read = broker.invoke('alice', { action: 'readTestTicket' }); + await vi.waitFor(() => expect(provider.ticket).toHaveBeenCalled()); + broker.disconnect('alice'); + resolve(ticket); + await expect(read).rejects.toThrow(); + expect(store.get(store.owner('123', store.settings()!.revision))).toBeNull(); + }); + it('invalidates other sessions of the same identity on denied access', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await signIn(broker, 'alice-second'); + await broker.invoke('alice-second', { action: 'readTestTicket' }); + vi.mocked(provider.ticket).mockRejectedValue(new SdpProviderError('denied')); + await broker.invoke('alice', { action: 'readTestTicket' }); + expect((await broker.invoke('alice-second', { action: 'status' })).view.status).toBe( + 'disconnected', + ); + }); + it('refreshes as the individual user and never uses cached data after failed refresh', async () => { + vi.useFakeTimers(); + const { broker, provider, store } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + vi.advanceTimersByTime(3580_000); + await broker.invoke('alice', { action: 'readTestTicket' }); + const body = vi.mocked(provider.token).mock.calls[1]![0] as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh-secret'); + vi.advanceTimersByTime(3580_000); + vi.mocked(provider.token).mockRejectedValue(new SdpProviderError('outage')); + expect((await broker.invoke('alice', { action: 'readTestTicket' })).view.status).toBe( + 'expired', + ); + expect(store.get(store.owner('123', store.settings()!.revision))).toBeNull(); + }); + it('isolates cached queue pages by queue, page, and verified identity', async () => { + const { broker, provider, store } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.identity).mockResolvedValue('456'); + await signIn(broker, 'bob'); + vi.mocked(provider.queue).mockRejectedValue(new SdpProviderError('outage')); + expect( + (await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 })).view.snapshot + ?.source, + ).toBe('outage-cache'); + for (const [id, queue, page] of [ + ['alice', 'SOX', 0], + ['alice', 'NOC', 1], + ['bob', 'NOC', 0], + ] as const) + expect( + (await broker.invoke(id, { action: 'readQueue', queue, page })).view.queuePage, + ).toBeUndefined(); + const owner = store.owner('123', store.settings()!.revision); + expect(store.getQueue(owner, 'NOC', 0)?.queuePage.tickets[0]?.subject).toBe( + 'Synthetic queue subject', + ); + }); + it('clears all copies and projections for one identity while preserving another user and sign-in', async () => { + const { broker, provider, store, root } = setup(); + await signIn(broker); + await signIn(broker, 'alice-second'); + await broker.invoke('alice', { action: 'readTestTicket' }); + await broker.invoke('alice-second', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.identity).mockResolvedValue('456'); + await signIn(broker, 'bob'); + await broker.invoke('bob', { action: 'readQueue', queue: 'SOX', page: 0 }); + expect( + readFileSync(join(root, 'outage-cache.sqlite')).includes( + Buffer.from('Synthetic queue subject'), + ), + ).toBe(false); + await broker.invoke('alice', { action: 'clearCopies' }); + const owner = store.owner('123', store.settings()!.revision); + expect(store.get(owner)).toBeNull(); + expect(store.getQueue(owner, 'NOC', 0)).toBeNull(); + expect((await broker.invoke('alice-second', { action: 'status' })).view).toMatchObject({ + status: 'connected', + }); + expect( + (await broker.invoke('alice-second', { action: 'status' })).view.queuePage, + ).toBeUndefined(); + expect((await broker.invoke('bob', { action: 'status' })).view.queuePage?.queue).toBe('SOX'); + }); + it('does not repopulate cleared data from an in-flight queue request', async () => { + const { broker, provider, store } = setup(); + await signIn(broker); + const fixture = { queue: 'NOC' as const, page: 0, hasMore: false, tickets: [] }; + let complete!: (value: typeof fixture) => void; + vi.mocked(provider.queue).mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const read = broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + await vi.waitFor(() => expect(provider.queue).toHaveBeenCalled()); + await broker.invoke('alice', { action: 'clearCopies' }); + complete(fixture); + await expect(read).rejects.toThrow('Connection ended'); + expect(store.getQueue(store.owner('123', store.settings()!.revision), 'NOC', 0)).toBeNull(); + }); + it('purges every saved queue page after a permission denial', async () => { + const { broker, provider, store } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.queue).mockRejectedValue(new SdpProviderError('denied')); + expect( + (await broker.invoke('alice', { action: 'readQueue', queue: 'SOX', page: 0 })).view.status, + ).toBe('expired'); + expect(store.getQueue(store.owner('123', store.settings()!.revision), 'NOC', 0)).toBeNull(); + }); + it('rejects stale settings updates and invalidates sessions and snapshots after replacement', async () => { + const { broker, store } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readTestTicket' }); + const old = store.settings()!; + expect(() => store.save(client, 5, 'stale')).toThrow(); + store.save(client, 10, old.revision); + expect(store.get(store.owner('123', old.revision))).toBeNull(); + expect((await broker.invoke('alice', { action: 'status' })).view.status).toBe('disconnected'); + }); +}); + +it('restricts detail reads to the current queue, isolates saved details and purges them on clear', async () => { + const { broker, provider, store, root } = setup(); + const detail = { + id: '123456', + page: 0, + description: 'Private synthetic description', + conversations: [], + hasMore: false, + }; + vi.spyOn(provider, 'detail').mockResolvedValue(detail); + await signIn(broker); + await expect( + broker.invoke('alice', { action: 'readDetail', id: detail.id, page: 0 }), + ).rejects.toThrow('Load the ticket queue'); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + expect( + (await broker.invoke('alice', { action: 'readDetail', id: detail.id, page: 0 })).view.detail, + ).toEqual(detail); + const owner = store.owner('123', store.settings()!.revision); + expect(store.getDetail(owner, detail.id, 0)?.detail).toEqual(detail); + expect(store.getDetail('other-owner', detail.id, 0)).toBeNull(); + expect(store.getDetail(owner, detail.id, 1)).toBeNull(); + expect( + readFileSync(join(root, 'outage-cache.sqlite')).includes(Buffer.from(detail.description)), + ).toBe(false); + vi.mocked(provider.detail).mockRejectedValue(new SdpProviderError('outage')); + expect( + (await broker.invoke('alice', { action: 'readDetail', id: detail.id, page: 0 })).view + .detailSnapshot?.source, + ).toBe('outage-cache'); + await broker.invoke('alice', { action: 'clearCopies' }); + expect(store.getDetail(owner, detail.id, 0)).toBeNull(); + expect((await broker.invoke('alice', { action: 'status' })).view.detail).toBeUndefined(); +}); + +describe('SDP confirmed changes', () => { + it('requires a session-bound one-use review before a live write and purges stale copies', async () => { + const { broker, provider, store } = setup(); + await signIn(broker); + await signIn(broker, 'bob'); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValue({ request: { id: '123456', subject: 'Original' } }); + json.mockClear(); + const prepared = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { kind: 'update', id: '123456', fields: { status: 'In progress' } }, + }); + expect(json).toHaveBeenCalledTimes(1); + expect(json.mock.calls[0]![2]?.method).toBeUndefined(); + const confirmationId = prepared.view.review!.confirmationId; + await expect( + broker.invoke('bob', { action: 'confirmChange', confirmationId }), + ).rejects.toThrow(); + json + .mockResolvedValueOnce({ request: { id: '123456', subject: 'Original' } }) + .mockResolvedValueOnce({ + response_status: { status_code: 2000 }, + request: { id: '123456', display_id: '810129' }, + }); + const result = await broker.invoke('alice', { action: 'confirmChange', confirmationId }); + expect(result.view.changeResult?.id).toBe('123456'); + expect(json.mock.calls.at(-1)?.[2]?.method).toBe('PUT'); + expect(store.getQueue(store.owner('123', store.settings()!.revision), 'NOC', 0)).toBeNull(); + await expect( + broker.invoke('alice', { action: 'confirmChange', confirmationId }), + ).rejects.toThrow(); + expect(json).toHaveBeenCalledTimes(3); + }); + it('refuses changed records, expired reviews, unlisted tickets and uncertain replays', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const mutation = { kind: 'update', id: '123456', fields: { priority: 'High' } } as const; + await expect(broker.invoke('alice', { action: 'prepareChange', mutation })).rejects.toThrow(); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValue({ request: { id: '123456', subject: 'Original' } }); + const first = await broker.invoke('alice', { action: 'prepareChange', mutation }); + json.mockResolvedValueOnce({ request: { id: '123456', subject: 'Changed elsewhere' } }); + expect( + ( + await broker.invoke('alice', { + action: 'confirmChange', + confirmationId: first.view.review!.confirmationId, + }) + ).view.message, + ).toContain('changed in SDP'); + const second = await broker.invoke('alice', { action: 'prepareChange', mutation }); + vi.spyOn(Date, 'now').mockReturnValue(second.view.review!.expiresAt + 1); + await expect( + broker.invoke('alice', { + action: 'confirmChange', + confirmationId: second.view.review!.confirmationId, + }), + ).rejects.toThrow(); + vi.restoreAllMocks(); + expect(json.mock.calls.every((call) => !call[2]?.method)).toBe(true); + }); + it('does not replay a create after an ambiguous result', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const json = vi + .spyOn(provider, 'json') + .mockRejectedValue(new Error('private provider response')); + const prepared = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { kind: 'create', fields: { subject: 'Synthetic incident' }, majorIncident: true }, + }); + const command = { + action: 'confirmChange', + confirmationId: prepared.view.review!.confirmationId, + } as const; + const result = await broker.invoke('alice', command); + expect(result.view.changeResult).toBeUndefined(); + expect(result.view.message).toContain('will not retry'); + expect(JSON.stringify(result)).not.toContain('private provider'); + await expect(broker.invoke('alice', command)).rejects.toThrow(); + expect(json).toHaveBeenCalledTimes(1); + }); + it('serves shared background snapshots without repeating provider scans for heartbeats', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.queue).mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: false, + tickets: [], + })); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + const result = await broker.invoke('alice', { action: 'monitorQueues' }); + expect(result.view.monitor).toMatchObject({ tickets: [], truncated: false }); + expect((await broker.invoke('alice', { action: 'status' })).view.queuePage?.queue).toBe('NOC'); + expect((await broker.invoke('alice', { action: 'status' })).view.monitor).toBeUndefined(); + const calls = vi.mocked(provider.queue).mock.calls.length; + await broker.invoke('alice', { + action: 'monitorQueues', + after: result.view.monitor!.fetchedAt, + }); + expect(provider.queue).toHaveBeenCalledTimes(calls); + }); +}); + +it('revokes the session on a denied write without keeping stale ticket projections', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ request: { id: '123456' } }); + const prepared = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { kind: 'update', id: '123456', fields: { status: 'Resolved' } }, + }); + json + .mockResolvedValueOnce({ request: { id: '123456' } }) + .mockRejectedValueOnce(new SdpProviderError('denied')); + await expect( + broker.invoke('alice', { + action: 'confirmChange', + confirmationId: prepared.view.review!.confirmationId, + }), + ).rejects.toThrow(); + const result = await broker.invoke('alice', { action: 'status' }); + expect(result.view.status).toBe('disconnected'); + expect(result.view.queuePage).toBeUndefined(); +}); + +it('checks child resources again before confirming, including edits that do not change the parent', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + let title = 'Original task'; + const json = vi + .spyOn(provider, 'json') + .mockImplementation(async (url) => + url.endsWith('/tasks/4') ? { task: { id: '4', title } } : { request: { id: '123456' } }, + ); + const first = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { + kind: 'resource', + id: '123456', + resource: 'tasks', + recordId: '4', + operation: 'update', + fields: { title: 'My edit' }, + }, + }); + title = 'Edited elsewhere'; + const result = await broker.invoke('alice', { + action: 'confirmChange', + confirmationId: first.view.review!.confirmationId, + }); + expect(result.view.message).toContain('changed in SDP'); + expect(json.mock.calls.every((call) => !call[2]?.method)).toBe(true); +}); + +it('redacts upload bytes from reviews and status but sends the original file only after confirmation', async () => { + const { broker, provider, root } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ request: { id: '123456' } }); + const data = Buffer.from('private test attachment bytes').toString('base64'); + const prepared = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { + kind: 'attachment', + id: '123456', + name: 'example.txt', + contentType: 'text/plain', + data, + }, + }); + expect(JSON.stringify(prepared)).not.toContain(data); + expect(JSON.stringify(await broker.invoke('alice', { action: 'status' }))).not.toContain(data); + expect(readFileSync(join(root, 'outage-cache.sqlite')).includes(Buffer.from(data))).toBe(false); + json + .mockResolvedValueOnce({ request: { id: '123456' } }) + .mockResolvedValueOnce({ response_status: { status_code: 2000 } }); + const command = { + action: 'confirmChange', + confirmationId: prepared.view.review!.confirmationId, + } as const; + expect((await broker.invoke('alice', command)).view.changeResult?.kind).toBe('attachment'); + const form = json.mock.calls.at(-1)![2]!.body as FormData; + expect(await (form.get('filename') as Blob).text()).toBe('private test attachment bytes'); + await expect(broker.invoke('alice', command)).rejects.toThrow(); + expect(json.mock.calls.filter((call) => call[2]?.method === 'POST')).toHaveLength(1); +}); + +it('continues polling during a detail read, keeps its open detail, and shares work across the same identity', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + await signIn(broker, 'peer'); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + let finish!: (value: import('@shared/sdpAccount').SdpDetail) => void; + vi.spyOn(provider, 'detail').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const detailRead = broker.invoke('alice', { action: 'readDetail', id: '123456', page: 0 }); + await vi.advanceTimersByTimeAsync(0); + vi.mocked(provider.queue) + .mockClear() + .mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: false, + tickets: [], + })); + await broker.invoke('alice', { action: 'monitorQueues' }); + await broker.invoke('peer', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + expect(provider.queue).toHaveBeenCalledTimes(3); + const detail = { + id: '123456', + description: 'Dummy detail', + page: 0, + hasMore: false, + conversations: [], + }; + finish(detail); + await detailRead; + await vi.advanceTimersByTimeAsync(30000); + expect(provider.queue).toHaveBeenCalledTimes(6); + expect((await broker.invoke('alice', { action: 'status' })).view.detail).toEqual(detail); + expect((await broker.invoke('alice', { action: 'status' })).view.queuePage?.tickets).toEqual([]); +}); +it('cancels a background scan when saved copies are cleared and ignores its late completion', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + let finish!: (value: import('@shared/sdpAccount').SdpQueuePage) => void; + vi.mocked(provider.queue).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + await broker.invoke('alice', { action: 'clearCopies' }); + finish({ queue: 'NOC', page: 0, hasMore: false, tickets: [] }); + await vi.advanceTimersByTimeAsync(0); + expect((await broker.invoke('alice', { action: 'status' })).view.queuePage).toBeUndefined(); +}); +it('deduplicates token refresh when queue polling overlaps an interactive read', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + vi.advanceTimersByTime(3580000); + const read = broker.invoke('alice', { action: 'readTestTicket' }); + await broker.invoke('alice', { action: 'monitorQueues' }); + await read; + await vi.advanceTimersByTimeAsync(0); + expect(provider.token).toHaveBeenCalledTimes(2); +}); +it('suspends monitoring during a confirmed write and resumes with a fresh baseline', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + await signIn(broker); + vi.mocked(provider.queue).mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: false, + tickets: [], + })); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + const before = await broker.invoke('alice', { action: 'monitorQueues' }); + let finish!: (value: unknown) => void; + vi.spyOn(provider, 'json').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const prepared = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { kind: 'create', fields: { subject: 'Dummy' }, majorIncident: false }, + }); + const saving = broker.invoke('alice', { + action: 'confirmChange', + confirmationId: prepared.view.review!.confirmationId, + }); + await vi.advanceTimersByTimeAsync(0); + expect((await broker.invoke('alice', { action: 'monitorQueues' })).view.monitoring?.state).toBe( + 'off', + ); + await vi.advanceTimersByTimeAsync(30000); + expect(provider.queue).toHaveBeenCalledTimes(3); + finish({ request: { id: '999', display_id: '810130' } }); + await saving; + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + expect( + (await broker.invoke('alice', { action: 'monitorQueues' })).view.monitor?.generation, + ).not.toBe(before.view.monitor?.generation); + expect(provider.queue).toHaveBeenCalledTimes(6); +}); + +it('does not restore an unfiltered outage cache for a filtered queue request', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.queue).mockRejectedValue(new SdpProviderError('outage')); + const result = await broker.invoke('alice', { + action: 'readQueue', + queue: 'NOC', + page: 0, + filters: { status: 'Closed' }, + }); + expect(result.view.queuePage).toBeUndefined(); + expect(result.view.snapshot).toBeUndefined(); + expect(result.view.message).toContain('Filtered results require a live connection'); +}); +it('requires an authorized live ticket before form, dropdown or reply reads', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const json = vi.spyOn(provider, 'json'); + for (const command of [ + { action: 'readForm', id: '123456' }, + { action: 'readReplyContext', id: '123456' }, + { action: 'readTicketRelations', id: '123456', page: 0 }, + { action: 'readOptions', id: '123456', field: 'group', dependencies: {}, search: '', page: 0 }, + ] as const) + await expect(broker.invoke('alice', command)).rejects.toThrow('Load a live ticket first'); + expect(json).not.toHaveBeenCalled(); +}); +it.each([401, 403, 404])( + 'keeps live ticket data and the session when editor metadata returns HTTP %s but ticket access is valid', + async (status) => { + const { broker, provider } = setup(); + await signIn(broker); + const queue = await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.spyOn(provider, 'detail').mockResolvedValue({ + id: '123456', + description: 'Saved description', + page: 0, + hasMore: false, + conversations: [], + }); + await broker.invoke('alice', { action: 'readDetail', id: '123456', page: 0 }); + vi.mocked(provider.json).mockImplementation(async (url) => { + if (url.endsWith('/123456')) return { request: { id: '123456', template: { id: '7' } } }; + throw new SdpProviderError('denied', 0, 'http', status); + }); + const form = await broker.invoke('alice', { action: 'readForm', id: '123456' }); + expect(form.view.status).toBe('connected'); + expect(form.view.message).toContain('Your account is still connected'); + expect(form.view.queuePage).toEqual(queue.view.queuePage); + expect(form.view.detail?.description).toBe('Saved description'); + expect(form.view.form).toBeUndefined(); + expect((await broker.invoke('alice', { action: 'status' })).view.status).toBe('connected'); + }, +); +it('still revokes the account when the live ticket recheck also denies access', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.json).mockRejectedValue(new SdpProviderError('denied', 0, 'http', 401)); + await expect(broker.invoke('alice', { action: 'readForm', id: '123456' })).rejects.toMatchObject({ + kind: 'denied', + }); + const status = await broker.invoke('alice', { action: 'status' }); + expect(status.view.status).toBe('disconnected'); + expect(status.view.queuePage).toBeUndefined(); +}); +it('delivers reply metadata across the strict gateway contract and marks read only when the latest message is loaded', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const { SdpBrokerReplySchema } = await import('@shared/sdpAccount'); + const feed = (id: string) => ({ + conversations: [ + { + id, + type: 'REQREPLY', + created_by: { name: 'Example requester', is_technician: false }, + created_time: { value: '1000' }, + }, + ], + }); + vi.mocked(provider.json).mockResolvedValue(feed('10')); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.json).mockResolvedValue(feed('11')); + const detail = { + id: '123456', + description: 'Dummy', + page: 0, + hasMore: false, + conversations: [] as { + id: string; + author: string; + subject: string; + body: string; + createdAt: number; + }[], + }; + vi.spyOn(provider, 'detail').mockResolvedValue(detail); + let result = await broker.invoke('alice', { action: 'readDetail', id: '123456', page: 0 }); + expect(SdpBrokerReplySchema.parse(result).view.replyActivity).toMatchObject({ + lastReply: { id: '11', author: 'Example requester' }, + replyUnread: true, + }); + vi.mocked(provider.detail).mockResolvedValue({ + ...detail, + conversations: [ + { + id: '11', + author: 'Example requester', + subject: 'Dummy', + body: 'Dummy body', + createdAt: 1000, + }, + ], + }); + result = await broker.invoke('alice', { action: 'readDetail', id: '123456', page: 0 }); + expect(result.view.replyActivity?.replyUnread).toBe(false); + expect(result.view.queuePage?.tickets[0]?.replyUnread).toBe(false); + await broker.invoke('alice', { action: 'clearCopies' }); + expect((await broker.invoke('alice', { action: 'status' })).view.replyActivity).toBeUndefined(); + expect(provider.json).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ method: 'PUT' }), + ); +}); + +it('keeps filtered and all-notification outage pages separate and forwards the requested filter', async () => { + const { broker, provider, store, root } = setup(); + const base = { + id: '123456', + page: 0, + description: 'Original', + conversations: [], + hasMore: false, + }; + vi.spyOn(provider, 'detail').mockImplementation( + async (_token, _signal, _id, _page, includeAutoNotifications) => ({ + ...base, + includeAutoNotifications, + }), + ); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const owner = store.owner('123', store.settings()!.revision); + const db = new Database(join(root, 'outage-cache.sqlite')); + db.prepare('INSERT INTO detail_snapshots (owner,detail_key,expires,body) VALUES (?,?,?,?)').run( + owner, + '123456:0', + Date.now() + 60000, + Buffer.from('legacy unfiltered page'), + ); + db.close(); + expect(store.getDetail(owner, base.id, 0)).toBeNull(); + await broker.invoke('alice', { + action: 'readDetail', + id: base.id, + page: 0, + includeAutoNotifications: true, + }); + expect(provider.detail).toHaveBeenLastCalledWith( + expect.any(String), + expect.any(AbortSignal), + base.id, + 0, + true, + ); + expect(store.getDetail(owner, base.id, 0)).toBeNull(); + expect(store.getDetail(owner, base.id, 0, true)?.detail.includeAutoNotifications).toBe(true); + await broker.invoke('alice', { action: 'readDetail', id: base.id, page: 0 }); + vi.mocked(provider.detail).mockRejectedValue(new SdpProviderError('outage')); + for (const includeAutoNotifications of [false, true]) { + const reply = await broker.invoke('alice', { + action: 'readDetail', + id: base.id, + page: 0, + includeAutoNotifications, + }); + expect(reply.view.detailSnapshot?.source).toBe('outage-cache'); + expect(reply.view.detail?.includeAutoNotifications).toBe(includeAutoNotifications); + } +}); + +it('opens notification tickets outside the visible queue only from a fresh account-bound monitor', async () => { + vi.useFakeTimers(); + const { broker, provider } = setup(); + vi.mocked(provider.queue).mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: false, + tickets: + queue === 'SOX' + ? [ + { + id: '999', + number: '99', + subject: 'Monitored SOX ticket', + status: 'Open', + priority: 'Low', + group: 'SOX', + technician: '', + createdAt: 1000, + dueAt: null, + }, + ] + : [], + })); + const detail = { + id: '999', + description: 'Notification detail', + page: 0, + hasMore: false, + conversations: [], + }; + vi.spyOn(provider, 'detail').mockResolvedValue(detail); + await signIn(broker); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + await broker.invoke('alice', { action: 'monitorQueues' }); + expect((await broker.invoke('alice', { action: 'status' })).view.queuePage?.tickets).toEqual([]); + await signIn(broker, 'peer'); + await expect(broker.invoke('peer', { action: 'readDetail', id: '999', page: 0 })).rejects.toThrow( + 'Load the ticket queue', + ); + vi.mocked(provider.identity).mockResolvedValue('456'); + await signIn(broker, 'other-account'); + await expect( + broker.invoke('other-account', { action: 'readDetail', id: '999', page: 0 }), + ).rejects.toThrow('Load the ticket queue'); + vi.setSystemTime(Date.now() + 75_001); + await expect( + broker.invoke('alice', { action: 'readDetail', id: '999', page: 0 }), + ).rejects.toThrow('Load the ticket queue'); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.advanceTimersByTimeAsync(0); + await broker.invoke('alice', { action: 'monitorQueues' }); + const queueSnapshot = (await broker.invoke('alice', { action: 'status' })).view.snapshot; + vi.setSystemTime(Date.now() + 1000); + const opened = (await broker.invoke('alice', { action: 'readDetail', id: '999', page: 0 })).view; + expect(opened.snapshot?.fetchedAt).toBe(queueSnapshot?.fetchedAt); + expect(opened.detail).toEqual(detail); + expect(opened.replyActivity).toMatchObject({ id: '999', subject: 'Monitored SOX ticket' }); + expect(opened.snapshot?.source).toBe('live'); + await expect( + broker.invoke('alice', { action: 'readDetail', id: '998', page: 0 }), + ).rejects.toThrow('Load the ticket queue'); + await broker.invoke('alice', { action: 'clearCopies' }); + await expect( + broker.invoke('alice', { action: 'readDetail', id: '999', page: 0 }), + ).rejects.toThrow('Load the ticket queue'); +}); + +it('requires every bulk target, history and checklist catalog read to belong to a live account-bound ticket', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + for (const command of [ + { + action: 'prepareChange' as const, + mutation: { kind: 'bulk' as const, ids: ['123456', '999'], fields: { priority: 'High' } }, + }, + { action: 'readHistory' as const, id: '999', page: 0 }, + { + action: 'readResourceChoices' as const, + id: '999', + catalog: 'checklist_templates' as const, + search: '', + page: 0, + }, + { action: 'readForwardContext' as const, id: '999' }, + ]) + await expect(broker.invoke('alice', command)).rejects.toThrow(/live|Refresh/); + expect(vi.mocked(provider.json).mock.calls.some((c) => c[2]?.method)).toBe(false); +}); +it('confirms a bulk review once and invalidates the previous queue projection', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const json = vi + .mocked(provider.json) + .mockImplementation(async (_url, _signal, init) => + init?.method + ? { response_status: { status_code: 2000 } } + : { request: { id: '123456', status: { name: 'Open' } } }, + ); + const review = await broker.invoke('alice', { + action: 'prepareChange', + mutation: { kind: 'bulk', ids: ['123456'], fields: { priority: 'High' } }, + }); + expect(json.mock.calls.some((c) => c[2]?.method)).toBe(false); + const command = { + action: 'confirmChange' as const, + confirmationId: review.view.review!.confirmationId, + }; + const result = await broker.invoke('alice', command); + expect(result.view.bulkResult).toEqual([{ id: '123456', status: 'confirmed' }]); + expect(result.view.queuePage).toBeUndefined(); + await expect(broker.invoke('alice', command)).rejects.toThrow(/expired|already used/); + expect(json.mock.calls.filter((c) => c[2]?.method)).toHaveLength(1); +}); + +it('requires an authenticated account for allowlisted creation and bulk dropdowns', async () => { + const { broker, provider } = setup(); + await expect( + broker.invoke('alice', { action: 'readStandardOptions', field: 'group', search: '', page: 0 }), + ).rejects.toThrow(); + await signIn(broker); + vi.mocked(provider.json).mockResolvedValue({ + group: [{ id: '1', name: 'NOC' }], + list_info: { has_more_rows: false }, + }); + const result = await broker.invoke('alice', { + action: 'readStandardOptions', + field: 'group', + search: '', + page: 0, + }); + expect(result.view.options?.choices[0]).toEqual({ + label: 'NOC', + value: { id: '1', name: 'NOC' }, + }); + await expect( + broker.invoke('alice', { + action: 'readStandardOptions', + field: '../../users', + search: '', + page: 0, + } as never), + ).rejects.toThrow(); + expect((await broker.invoke('alice', { action: 'status' })).view.options).toBeUndefined(); +}); + +it('reads Changes under the signed-in account without saving data or requiring an open ticket', async () => { + const { broker, provider } = setup(); + await expect( + broker.invoke('alice', { action: 'readChanges', problemStart: 1000, page: 0 }), + ).rejects.toThrow('Sign in'); + await signIn(broker); + vi.mocked(provider.json).mockResolvedValue({ changes: [], list_info: { has_more_rows: false } }); + const result = await broker.invoke('alice', { + action: 'readChanges', + problemStart: 1000, + page: 0, + }); + expect(result.view.changesPage).toEqual({ + page: 0, + changes: [], + hasMore: false, + detailsComplete: true, + }); + expect((await broker.invoke('alice', { action: 'status' })).view.changesPage).toBeUndefined(); + vi.mocked(provider.json).mockRejectedValue(new SdpProviderError('denied', 0, 'http', 403)); + const denied = await broker.invoke('alice', { + action: 'readChanges', + problemStart: 1000, + page: 0, + }); + expect(denied.view.message).toContain('Changes read access'); + expect((await broker.invoke('alice', { action: 'status' })).view.status).toBe('connected'); +}); + +it('verifies workflow ticket URLs only in a current account monitor and never saves the description', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const command = { + action: 'verifyWorkflowTicket', + id: '123456', + problemId: 'canonical', + environment: 'https://abc.live.dynatrace.com', + } as const; + await expect(broker.invoke('alice', command)).rejects.toThrow('current ticket queue scan'); + vi.mocked(provider.queue).mockImplementation(async (_token, _signal, queue, page) => ({ + queue, + page, + hasMore: false, + tickets: [ + { + id: '123456', + number: '810129', + subject: 'NOC', + status: 'Open', + priority: 'Low', + group: queue, + technician: '', + createdAt: 1000, + dueAt: null, + }, + ], + })); + await broker.invoke('alice', { action: 'monitorQueues' }); + await vi.waitFor(async () => { + const result = await broker.invoke('alice', { action: 'monitorQueues' }); + expect(result.view.monitor).toBeDefined(); + }); + vi.mocked(provider.json).mockResolvedValue({ + request: { + id: '123456', + description: + 'Problem', + }, + }); + expect((await broker.invoke('alice', command)).view.workflowTicketMatch).toBe(true); + expect( + (await broker.invoke('alice', { action: 'status' })).view.workflowTicketMatch, + ).toBeUndefined(); + expect((await broker.invoke('alice', { action: 'status' })).view.detail).toBeUndefined(); + await expect(broker.invoke('alice', { ...command, id: '999' })).rejects.toThrow( + 'current ticket queue scan', + ); + vi.mocked(provider.json).mockResolvedValue({ request: { id: '999', description: '' } }); + await expect(broker.invoke('alice', command)).rejects.toThrow(); +}); + +it('refreshes filtered pages and open detail without clearing the view, and throttles repeats', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const queue = { + queue: 'NOC' as const, + page: 1, + filters: { status: 'Open' }, + hasMore: false, + tickets: [ + { + id: '123456', + number: '810129', + subject: 'Before', + status: 'Open', + priority: 'Low', + group: 'NOC' as const, + technician: '', + createdAt: 1000, + dueAt: null, + }, + ], + }; + vi.mocked(provider.queue).mockResolvedValue(queue); + const detail = { + id: '123456', + page: 1, + includeAutoNotifications: true, + description: 'Before', + conversations: [], + hasMore: false, + }; + vi.spyOn(provider, 'detail').mockResolvedValue(detail); + await broker.invoke('alice', { + action: 'readQueue', + queue: 'NOC', + page: 1, + filters: queue.filters, + }); + await broker.invoke('alice', { + action: 'readDetail', + id: detail.id, + page: 1, + includeAutoNotifications: true, + }); + vi.mocked(provider.detail).mockResolvedValue({ ...detail, description: 'After' }); + const fresh = await broker.invoke('alice', { action: 'refreshVisible' }); + expect(fresh.view.detail?.description).toBe('After'); + expect(fresh.view.queuePage).toMatchObject({ page: 1, filters: queue.filters }); + expect(provider.queue).toHaveBeenLastCalledWith( + 'access-secret', + expect.any(AbortSignal), + 'NOC', + 1, + undefined, + queue.filters, + ); + expect(provider.detail).toHaveBeenLastCalledWith( + 'access-secret', + expect.any(AbortSignal), + '123456', + 1, + true, + ); + await broker.invoke('alice', { action: 'refreshVisible' }); + expect(provider.detail).toHaveBeenCalledTimes(2); +}); + +it('discards a late automatic refresh when the analyst changes queues', async () => { + const { broker, provider } = setup(); + await signIn(broker); + await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + const old = (await broker.invoke('alice', { action: 'status' })).view.queuePage!; + let resolve!: (page: typeof old) => void; + vi.mocked(provider.queue).mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const refreshing = broker.invoke('alice', { action: 'refreshVisible' }); + await vi.waitFor(() => expect(resolve).toBeDefined()); + expect((await broker.invoke('alice', { action: 'status' })).view.queuePage?.queue).toBe('NOC'); + await broker.invoke('alice', { action: 'readQueue', queue: 'SOX', page: 0 }); + resolve(old); + expect((await refreshing).view.queuePage?.queue).toBe('SOX'); +}); + +it('keeps the original expiry during automatic-refresh outages and clears denied data', async () => { + const { broker, provider } = setup(); + await signIn(broker); + const initial = await broker.invoke('alice', { action: 'readQueue', queue: 'NOC', page: 0 }); + vi.mocked(provider.queue).mockRejectedValue(new SdpProviderError('outage')); + const failed = await broker.invoke('alice', { action: 'refreshVisible' }); + expect(failed.view.snapshot).toEqual(initial.view.snapshot); + expect(failed.view.message).toContain('refresh is delayed'); + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 60_001); + vi.mocked(provider.queue).mockRejectedValue(new SdpProviderError('denied')); + const denied = await broker.invoke('alice', { action: 'refreshVisible' }); + expect(denied.view.status).toBe('expired'); + expect(denied.view.queuePage).toBeUndefined(); + expect((await broker.invoke('alice', { action: 'status' })).view.status).toBe('disconnected'); +}); diff --git a/src/main/sdp/SdpBroker.ts b/src/main/sdp/SdpBroker.ts new file mode 100644 index 00000000..4ec60f7c --- /dev/null +++ b/src/main/sdp/SdpBroker.ts @@ -0,0 +1,1162 @@ +import { hasWorkflowProblemUrl } from '@shared/sdpWorkflowLink'; +import { readChanges } from './SdpChanges'; +import { readHistory } from './SdpHistory'; +import { prepareBulk, confirmBulk, SdpBulkDeniedError } from './SdpBulk'; +import { latestReply, SdpReplyTracker } from './SdpReplies'; +import { + readForm, + readOptions, + readStandardOptions, + readReplyContext, + validateFormMutation, + SdpFormUnavailableError, +} from './SdpForms'; +import { attachmentBody, downloadAttachment } from './SdpAttachments'; +import { createHash, randomUUID } from 'node:crypto'; +import { + SDP_ACCOUNT_SCOPE, + SDP_PAGE_SIZE, + type SdpMonitor, + type SdpQueueTicket, + SDP_CALLBACK, + SdpBrokerCommandSchema, + type SdpAccountView, + type SdpBrokerCommand, + type SdpBrokerReply, +} from '@shared/sdpAccount'; +import { SdpQueueMonitor } from './SdpQueueMonitor'; +import { readResources, readResourceChoices } from './SdpResources'; +import { readTicketRelations } from './SdpTicketRelations'; +import { mutationBaseline, submitMutation } from './SdpMutations'; +import type { SdpReview } from '@shared/sdpMutation'; +import { isObject, SdpProvider, SdpProviderError, SDP_ACCOUNTS } from './SdpProvider'; +import { SdpServerStore, type SdpSettings } from './SdpServerStore'; + +const isOutage = (error: unknown): boolean => + error instanceof SdpProviderError && error.kind === 'outage'; + +type Connection = { + controller: AbortController; + revision: string; + expires: number; + pending?: { state: string; challenge: string; deadline: number }; + identity?: string; + openedTicket?: SdpQueueTicket; + monitorGeneration?: string; + token?: string; + refresh?: string; + tokenExpires?: number; + view: SdpAccountView; + reading?: Promise; + visibleRefresh?: Promise; + nextVisibleRefreshAt?: number; + operation: number; + form?: import('@shared/sdpForm').SdpForm; + refreshing?: Promise; + writing?: boolean; + prepared?: { review: SdpReview; baseline?: string }; +}; +const SESSION_MS = 8 * 60 * 60 * 1000; +/** Server only. Connections are bound to authenticated Relay sessions; identities come from Zoho. */ +export class SdpBroker { + private readonly monitorSuspended = new Set(); + private readonly replies = new SdpReplyTracker(); + private readonly monitors = new SdpQueueMonitor(); + private readonly connections = new Map(); + private readonly timer: ReturnType; + constructor( + readonly store: SdpServerStore, + private readonly provider = new SdpProvider(), + ) { + this.timer = setInterval(() => { + for (const [id, connection] of this.connections) + if (connection.expires <= Date.now()) this.disconnect(id); + this.store.prune(); + }, 60_000); + this.timer.unref(); + } + disconnect(id: string): void { + const connection = this.connections.get(id); + this.monitors.unsubscribe(id); + connection?.controller.abort(); + this.connections.delete(id); + if ( + connection?.identity && + ![...this.connections.values()].some( + (peer) => peer.identity === connection.identity && peer.revision === connection.revision, + ) + ) + this.replies.clear(this.store.owner(connection.identity, connection.revision)); + } + reset(): void { + for (const id of this.connections.keys()) this.disconnect(id); + } + dispose(): void { + clearInterval(this.timer); + this.reset(); + this.monitors.dispose(); + this.store.close(); + } + private current(id: string, connection: Connection): boolean { + return ( + this.connections.get(id) === connection && + !connection.controller.signal.aborted && + connection.expires > Date.now() && + this.store.settings()?.revision === connection.revision + ); + } + private view(connection?: Connection): SdpAccountView { + if (!connection) return { configured: !!this.store.settings()?.client, status: 'disconnected' }; + if (connection.view.snapshot && connection.view.snapshot.expiresAt <= Date.now()) { + delete connection.view.snapshot; + delete connection.view.ticket; + delete connection.view.queuePage; + } + if (connection.view.detailSnapshot && connection.view.detailSnapshot.expiresAt <= Date.now()) { + delete connection.view.detail; + delete connection.view.detailSnapshot; + } + if (connection.prepared && connection.prepared.review.expiresAt <= Date.now()) { + connection.prepared = undefined; + delete connection.view.review; + } + if (connection.pending && connection.pending.deadline <= Date.now()) { + connection.pending = undefined; + connection.view = { + configured: true, + status: 'disconnected', + message: 'Sign-in timed out. Try again.', + }; + } + return this.replyView(connection); + } + private replyView(connection: Connection): SdpAccountView { + const view = structuredClone(connection.view); + if (connection.identity) { + const owner = this.store.owner(connection.identity, connection.revision); + if (view.snapshot?.source === 'live' && view.queuePage) + view.queuePage.tickets = this.replies.decorate(owner, view.queuePage.tickets); + if (view.detailSnapshot?.source === 'live' && connection.openedTicket) + view.replyActivity = this.replies.decorate(owner, [connection.openedTicket])[0]; + } + return view; + } + async invoke(id: string, input: SdpBrokerCommand): Promise { + const command = SdpBrokerCommandSchema.parse(input); + const settings = this.store.settings(); + let connection = this.connections.get(id); + if (connection && !this.current(id, connection)) { + this.disconnect(id); + connection = undefined; + } + if (command.action === 'disconnect') { + this.disconnect(id); + return { view: this.view() }; + } + if (command.action === 'status') return this.status(id, connection); + if (!settings?.client) throw new Error('SDP server setup is required.'); + if (command.action === 'begin') return this.begin(id, command, settings); + if (!connection) throw new Error('Sign in to SDP first.'); + const active = connection; + const ensureCurrent = (): void => { + if (!this.current(id, active)) throw new Error('Connection ended.'); + }; + if (command.action === 'complete') + return this.complete(id, active, command, settings, ensureCurrent); + if (!active.identity || !active.token) throw new Error('Sign in to SDP first.'); + if (command.action === 'refreshVisible') return this.refreshVisible(id, active, ensureCurrent); + if (command.action !== 'monitorQueues') active.operation++; + if (command.action === 'clearCopies') return this.clearCopies(active); + if (command.action === 'monitorQueues') return this.monitor(id, active, ensureCurrent, command); + if (active.reading) throw new Error('An SDP operation is already in progress.'); + active.reading = this.execute(active, ensureCurrent, command) + .catch((error: unknown): SdpBrokerReply => { + if (error instanceof SdpFormUnavailableError) { + ensureCurrent(); + active.form = undefined; + return { view: { ...this.view(active), message: error.message } }; + } + if (error instanceof SdpBulkDeniedError) { + this.store.remove(this.store.owner(active.identity!, active.revision)); + this.disconnectIdentity(active.identity!); + return { + view: { + configured: true, + status: 'expired', + bulkResult: error.results, + message: + 'SDP denied access. The batch stopped; check the unconfirmed ticket before signing in again.', + }, + }; + } + if (error instanceof SdpProviderError && error.kind === 'denied') { + this.store.remove(this.store.owner(active.identity!, active.revision)); + this.disconnectIdentity(active.identity!); + } + throw error; + }) + .finally(() => { + active.reading = undefined; + }); + return active.reading; + } + private status(id: string, connection?: Connection): SdpBrokerReply { + if (connection?.identity && !connection.reading) { + const snapshot = this.monitors.snapshot( + this.store.owner(connection.identity, connection.revision), + id, + ); + if (snapshot) this.applyMonitor(connection, snapshot); + } + return { view: this.view(connection) }; + } + private begin( + id: string, + command: Extract, + settings: SdpSettings, + ): SdpBrokerReply { + this.disconnect(id); + if (this.connections.size >= 1000) throw new Error('Connection limit reached.'); + const connection: Connection = { + controller: new AbortController(), + operation: 0, + revision: settings.revision, + expires: Date.now() + SESSION_MS, + pending: { + state: command.state, + challenge: command.challenge, + deadline: Date.now() + 300_000, + }, + view: { configured: true, status: 'connecting' }, + }; + this.connections.set(id, connection); + const url = new URL(`${SDP_ACCOUNTS}/oauth/v2/auth`); + url.search = new URLSearchParams({ + client_id: settings.client!.clientId, + response_type: 'code', + redirect_uri: SDP_CALLBACK, + scope: SDP_ACCOUNT_SCOPE, + state: command.state, + code_challenge_method: 'S256', + code_challenge: command.challenge, + access_type: 'offline', + prompt: 'consent', + }).toString(); + return { view: this.view(connection), authorizationUrl: url.toString() }; + } + private execute( + connection: Connection, + ensureCurrent: () => void, + command: Extract< + SdpBrokerCommand, + { + action: + | 'prepareChange' + | 'confirmChange' + | 'cancelChange' + | 'downloadAttachment' + | 'readForm' + | 'readOptions' + | 'readChanges' + | 'verifyWorkflowTicket' + | 'readStandardOptions' + | 'readForwardContext' + | 'readReplyContext' + | 'readHistory' + | 'readTicketRelations' + | 'readResources' + | 'readResourceChoices' + | 'readDetail' + | 'readQueue' + | 'readTestTicket'; + } + >, + ): Promise { + switch (command.action) { + case 'prepareChange': + case 'confirmChange': + case 'cancelChange': + return this.change(connection, ensureCurrent, command); + case 'verifyWorkflowTicket': + return this.verifyWorkflowTicket(connection, ensureCurrent, command); + case 'readChanges': + return this.changes(connection, ensureCurrent, command); + case 'readStandardOptions': + return this.standardOptions(connection, ensureCurrent, command); + case 'downloadAttachment': + return this.download(connection, ensureCurrent, command); + case 'readForm': + case 'readOptions': + case 'readForwardContext': + case 'readReplyContext': + case 'readHistory': + case 'readTicketRelations': + return this.form(connection, ensureCurrent, command); + case 'readResourceChoices': + case 'readResources': + return this.resources(connection, ensureCurrent, command); + case 'readDetail': + return this.readDetail(connection, ensureCurrent, command); + default: + return this.read(connection, ensureCurrent, command); + } + } + private async verifyWorkflowTicket( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + const owner = this.store.owner(connection.identity!, connection.revision); + const monitor = this.monitors.snapshot(owner); + if ( + !monitor || + monitor.generation !== connection.monitorGeneration || + Date.now() - monitor.fetchedAt >= 75_000 || + !monitor.tickets.some((ticket) => ticket.id === command.id) + ) + throw new Error('Wait for a current ticket queue scan before linking.'); + await this.refresh(connection, this.store.settings()!, ensureCurrent); + ensureCurrent(); + const value = await this.provider.json( + `https://support.campingworld.com/app/itdesk/api/v3/requests/${command.id}`, + connection.controller.signal, + { + headers: { + Authorization: `Zoho-oauthtoken ${connection.token!}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', + }, + }, + ); + ensureCurrent(); + if ( + !isObject(value) || + !isObject(value.request) || + String(value.request.id) !== command.id || + (value.request.description != null && typeof value.request.description !== 'string') + ) + throw new SdpProviderError('invalid'); + return { + view: { + configured: true, + status: 'connected', + workflowTicketMatch: hasWorkflowProblemUrl( + String(value.request.description ?? ''), + command.environment, + command.problemId, + ), + }, + }; + } + private async changes( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + await this.refresh(connection, this.store.settings()!, ensureCurrent); + try { + const changesPage = await readChanges( + this.provider, + connection.token!, + connection.controller.signal, + command, + ); + ensureCurrent(); + return { view: { configured: true, status: 'connected', changesPage } }; + } catch (error) { + // Missing Changes permission must not disconnect an otherwise valid ticket account. + if ( + error instanceof SdpProviderError && + error.kind === 'denied' && + error.httpStatus === 403 + ) { + ensureCurrent(); + return { + view: { + configured: true, + status: 'connected', + message: + 'Changes access is unavailable. Reconnect your work account to grant Changes read access, or ask your SDP administrator for permission.', + }, + }; + } + throw error; + } + } + private async standardOptions( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + await this.refresh(connection, this.store.settings()!, ensureCurrent); + const options = await readStandardOptions( + this.provider, + connection.token!, + connection.controller.signal, + command, + ); + ensureCurrent(); + return { view: { ...this.view(connection), options } }; + } + private async form( + connection: Connection, + ensureCurrent: () => void, + command: Extract< + SdpBrokerCommand, + { + action: + | 'readForm' + | 'readOptions' + | 'readReplyContext' + | 'readForwardContext' + | 'readTicketRelations' + | 'readHistory'; + } + >, + ): Promise { + if ( + !this.authorizedTicket(connection, command.id) || + connection.view.snapshot?.source !== 'live' + ) + throw new Error('Load a live ticket first.'); + await this.refresh(connection, this.store.settings()!, ensureCurrent); + const args = [this.provider, connection.token!, connection.controller.signal] as const; + let data: Partial; + if (command.action === 'readHistory') data = { history: await readHistory(...args, command) }; + else if (command.action === 'readTicketRelations') + data = { ticketRelations: await readTicketRelations(...args, command) }; + else if (command.action === 'readReplyContext' || command.action === 'readForwardContext') + data = { + replyContext: await readReplyContext( + ...args, + command.id, + command.action === 'readForwardContext', + command.action === 'readForwardContext' ? command.sourceId : undefined, + ), + }; + else if (command.action === 'readForm') { + connection.form = await readForm(...args, command.id); + data = { form: connection.form }; + } else { + if (connection.form?.id !== command.id) throw new Error('Load this ticket form first.'); + data = { options: await readOptions(...args, command, connection.form) }; + } + ensureCurrent(); + return { view: { configured: true, status: 'connected', ...data } }; + } + private async download( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + if ( + !this.authorizedTicket(connection, command.id) || + connection.view.snapshot?.source !== 'live' + ) + throw new Error('Load a live queue first.'); + await this.refresh(connection, this.store.settings()!, ensureCurrent); + const attachmentFile = await downloadAttachment( + this.provider, + connection.token!, + connection.controller.signal, + command.id, + command.attachmentId, + ); + ensureCurrent(); + return { view: { configured: true, status: 'connected', attachmentFile } }; + } + private async resources( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + if ( + !this.authorizedTicket(connection, command.id) || + connection.view.snapshot?.source !== 'live' + ) + throw new Error('Load a live queue before opening ticket actions.'); + await this.refresh(connection, this.store.settings()!, ensureCurrent); + if (command.action === 'readResourceChoices') { + const resourceChoices = await readResourceChoices( + this.provider, + connection.token!, + connection.controller.signal, + command, + ); + ensureCurrent(); + return { view: { configured: true, status: 'connected', resourceChoices } }; + } + const resources = await readResources( + this.provider, + connection.token!, + connection.controller.signal, + command, + ); + ensureCurrent(); + // Action data is session memory only and is never stored in shared or outage caches. + return { + view: { configured: true, status: 'connected', expiresAt: connection.expires, resources }, + }; + } + private monitor( + id: string, + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): SdpBrokerReply { + const owner = this.store.owner(connection.identity!, connection.revision); + if (command.enabled === false || this.monitorSuspended.has(owner)) { + this.monitors.unsubscribe(id); + connection.monitorGeneration = undefined; + return { + view: { + configured: true, + status: 'connected', + monitoring: { state: 'off', nextCheckAt: 0 }, + }, + }; + } + const result = this.monitors.subscribe( + owner, + id, + { + valid: () => this.current(id, connection) && !connection.writing, + read: async (queue, page, since, signal) => { + await this.refresh(connection, this.store.settings()!, ensureCurrent); + ensureCurrent(); + return this.provider.queue( + connection.token!, + AbortSignal.any([signal, connection.controller.signal]), + queue, + page, + since, + ); + }, + enrich: async (tickets, signal) => { + await this.refresh(connection, this.store.settings()!, ensureCurrent); + ensureCurrent(); + return this.replies.update(owner, tickets, (ticketId) => + latestReply( + this.provider, + connection.token!, + AbortSignal.any([signal, connection.controller.signal]), + ticketId, + ), + ); + }, + denied: () => { + this.store.remove(owner); + this.disconnectIdentity(connection.identity!); + }, + }, + command.after, + ); + const snapshot = this.monitors.snapshot(owner, id); + if (snapshot) connection.monitorGeneration = snapshot.generation; + if (snapshot && !connection.reading) this.applyMonitor(connection, snapshot); + return { view: { configured: true, status: 'connected', ...result } }; + } + private applyMonitor(connection: Connection, monitor: SdpMonitor): void { + connection.openedTicket = + monitor.tickets.find((ticket) => ticket.id === connection.openedTicket?.id) ?? + connection.openedTicket; + // Filtered results can include older tickets outside the bounded monitor baseline. + if (connection.view.queuePage?.filters) return; + if (monitor.fetchedAt <= (connection.view.snapshot?.fetchedAt ?? 0)) return; + const settings = this.store.settings()!; + const queue = connection.view.queuePage?.queue ?? 'NOC'; + const page = connection.view.queuePage?.page ?? 0; + const tickets = monitor.tickets.filter((ticket) => ticket.group === queue); + const end = (page + 1) * SDP_PAGE_SIZE; + connection.view.queuePage = { + queue, + page, + tickets: tickets.slice(page * SDP_PAGE_SIZE, end), + hasMore: tickets.length > end, + }; + connection.view.snapshot = { + source: 'live', + fetchedAt: monitor.fetchedAt, + expiresAt: Math.min(connection.expires, monitor.fetchedAt + settings.cacheMinutes * 60_000), + }; + } + private authorizedTicket(connection: Connection, id: string): boolean { + const view = this.view(connection); + return ( + !!view.queuePage?.tickets.some((ticket) => ticket.id === id) || + (view.detail?.id === id && view.detailSnapshot?.source === 'live') + ); + } + private async prepare( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + const settings = this.store.settings()!; + connection.prepared = undefined; + delete connection.view.review; + const mutation = command.mutation; + if (mutation.kind === 'attachment') attachmentBody(mutation); + if ( + mutation.kind !== 'create' && + (!(mutation.kind === 'bulk' ? mutation.ids : [mutation.id]).every((id) => + this.authorizedTicket(connection, id), + ) || + connection.view.snapshot?.source !== 'live') + ) + throw new Error('Refresh this ticket from a live queue before editing.'); + await this.refresh(connection, settings, ensureCurrent); + await validateFormMutation( + this.provider, + connection.token!, + connection.controller.signal, + mutation, + ); + ensureCurrent(); + let baseline: string | undefined; + if (mutation.kind === 'bulk') + baseline = await prepareBulk( + this.provider, + connection.token!, + connection.controller.signal, + mutation, + ensureCurrent, + ); + else if (mutation.kind !== 'create') + baseline = await mutationBaseline( + this.provider, + connection.token!, + connection.controller.signal, + mutation.id, + mutation, + ); + ensureCurrent(); + const review = { confirmationId: randomUUID(), expiresAt: Date.now() + 300000, mutation }; + connection.prepared = { review, baseline }; + connection.view.review = review; + if (mutation.kind === 'attachment') + connection.view.review = { ...review, mutation: { ...mutation, data: '' } }; + return { view: this.view(connection) }; + } + private async change( + connection: Connection, + ensureCurrent: () => void, + command: Extract< + SdpBrokerCommand, + { action: 'prepareChange' | 'confirmChange' | 'cancelChange' } + >, + ): Promise { + delete connection.view.changeResult; + delete connection.view.bulkResult; + delete connection.view.message; + if (command.action === 'cancelChange') { + connection.prepared = undefined; + delete connection.view.review; + return { view: this.view(connection) }; + } + const settings = this.store.settings()!; + if (command.action === 'prepareChange') return this.prepare(connection, ensureCurrent, command); + const prepared = connection.prepared; + connection.prepared = undefined; + delete connection.view.review; + if ( + prepared?.review.confirmationId !== command.confirmationId || + prepared.review.expiresAt <= Date.now() + ) + throw new Error('This confirmation has expired or was already used.'); + const owner = this.store.owner(connection.identity!, connection.revision); + if (this.monitorSuspended.has(owner)) throw new Error('Another SDP change is in progress.'); + connection.writing = true; + this.monitorSuspended.add(owner); + this.monitors.invalidate(owner); + try { + return await this.confirm(connection, settings, ensureCurrent, prepared); + } finally { + connection.writing = false; + this.monitorSuspended.delete(owner); + } + } + private async confirm( + connection: Connection, + settings: SdpSettings, + ensureCurrent: () => void, + prepared: NonNullable, + ): Promise { + await this.refresh(connection, settings, ensureCurrent); + ensureCurrent(); + const mutation = prepared.review.mutation; + if ( + mutation.kind !== 'create' && + mutation.kind !== 'bulk' && + prepared.baseline !== + (await mutationBaseline( + this.provider, + connection.token!, + connection.controller.signal, + mutation.id, + mutation, + )) + ) { + ensureCurrent(); + connection.view.message = + 'This ticket changed in SDP. Refresh it and review a new change before saving.'; + return { view: this.view(connection) }; + } + ensureCurrent(); + // Invalidate saved copies and peer projections before the write. A failed response can still mean success upstream. + this.store.remove(this.store.owner(connection.identity!, connection.revision)); + for (const [id, peer] of this.connections) { + if (peer !== connection && peer.identity === connection.identity) this.disconnect(id); + } + connection.view = { configured: true, status: 'connected', expiresAt: connection.expires }; + try { + if (mutation.kind === 'bulk') { + connection.view.bulkResult = await confirmBulk( + this.provider, + connection.token!, + connection.controller.signal, + mutation, + prepared.baseline!, + ensureCurrent, + ); + ensureCurrent(); + connection.view.message = + 'Bulk operation finished. Review each result and refresh the queue. Unconfirmed tickets are never retried automatically.'; + return { view: this.view(connection) }; + } + const result = await submitMutation( + this.provider, + connection.token!, + connection.controller.signal, + mutation, + ); + ensureCurrent(); + connection.view.changeResult = result; + connection.view.message = 'Change confirmed by SDP. Refresh the queue to see current values.'; + } catch (error) { + ensureCurrent(); + if (error instanceof SdpProviderError && error.kind === 'denied') throw error; + // submitMutation emits only fixed messages, never provider bodies or ticket content. + connection.view.message = + error instanceof Error + ? error.message + : 'Change could not be confirmed. Check SDP before retrying.'; + } + return { view: this.view(connection) }; + } + private async complete( + id: string, + active: Connection, + command: Extract, + settings: SdpSettings, + ensureCurrent: () => void, + ): Promise { + const pending = active.pending; + active.pending = undefined; + if ( + !pending || + pending.deadline <= Date.now() || + command.state !== pending.state || + createHash('sha256').update(command.verifier).digest('base64url') !== pending.challenge + ) { + this.disconnect(id); + throw new Error('Invalid sign-in response.'); + } + try { + const token = await this.provider.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: command.code, + client_id: settings.client.clientId, + client_secret: settings.client.clientSecret, + redirect_uri: SDP_CALLBACK, + code_verifier: command.verifier, + }), + active.controller.signal, + ); + ensureCurrent(); + this.setToken(active, token); + active.identity = await this.provider.identity(active.token!, active.controller.signal); + ensureCurrent(); + active.view = { configured: true, status: 'connected', expiresAt: active.expires }; + return { view: this.view(active) }; + } catch { + if (this.connections.get(id) === active) this.disconnect(id); + throw new Error('Work sign-in could not be verified.'); + } + } + private setToken(connection: Connection, value: unknown): void { + if (!value || typeof value !== 'object') throw new SdpProviderError('invalid'); + const token = value as Record; + const seconds = Number(token.expires_in ?? token.expires_in_sec); + if ( + typeof token.access_token !== 'string' || + !token.access_token || + token.access_token.length > 4096 || + !Number.isFinite(seconds) || + seconds < 1 || + seconds > 86400 || + token.error + ) + throw new SdpProviderError('denied'); + connection.token = token.access_token; + connection.tokenExpires = Date.now() + seconds * 1000; + if (typeof token.refresh_token === 'string' && token.refresh_token.length <= 4096) + connection.refresh = token.refresh_token; + } + private async refresh( + connection: Connection, + settings: SdpSettings, + ensureCurrent: () => void, + ): Promise { + connection.refreshing ??= this.refreshToken(connection, settings, ensureCurrent); + const pending = connection.refreshing; + try { + await pending; + ensureCurrent(); + } finally { + if (connection.refreshing === pending) connection.refreshing = undefined; + } + } + private async refreshToken( + connection: Connection, + settings: SdpSettings, + ensureCurrent: () => void, + ): Promise { + if ((connection.tokenExpires ?? 0) <= Date.now() + 30_000) { + if (!connection.refresh) throw new SdpProviderError('denied'); + // Refresh failures never authorize cached access. + try { + const result = await this.provider.token( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: connection.refresh, + client_id: settings.client!.clientId, + client_secret: settings.client!.clientSecret, + }), + connection.controller.signal, + ); + ensureCurrent(); + this.setToken(connection, result); + } catch { + throw new SdpProviderError('denied'); + } + } + } + private clearCopies(active: Connection): SdpBrokerReply { + this.replies.clear(this.store.owner(active.identity!, active.revision)); + this.monitors.invalidate(this.store.owner(active.identity!, active.revision)); + this.store.remove(this.store.owner(active.identity!, active.revision)); + for (const [id, connection] of this.connections) { + if (connection.identity !== active.identity) continue; + connection.controller.abort(); + this.connections.set(id, { + ...connection, + controller: new AbortController(), + reading: undefined, + visibleRefresh: undefined, + nextVisibleRefreshAt: undefined, + refreshing: undefined, + writing: false, + prepared: undefined, + form: undefined, + monitorGeneration: undefined, + openedTicket: undefined, + view: { + configured: true, + status: 'connected', + expiresAt: connection.expires, + message: 'Your saved SDP copies have been cleared.', + }, + }); + } + return { + view: { + configured: true, + status: 'connected', + expiresAt: active.expires, + message: 'Your saved SDP copies have been cleared.', + }, + }; + } + /** Refresh the current projection without interrupting foreground work or marking replies read. */ + private async refreshVisible( + id: string, + connection: Connection, + ensureCurrent: () => void, + ): Promise { + if (connection.visibleRefresh) return connection.visibleRefresh; + if ( + connection.reading || + connection.writing || + connection.prepared || + Date.now() < (connection.nextVisibleRefreshAt ?? 0) + ) + return { view: this.view(connection) }; + const view = this.view(connection); + const queue = view.queuePage; + const detail = view.detail; + if (!queue && !detail) return { view }; + const operation = connection.operation; + const current = () => this.current(id, connection) && operation === connection.operation; + const settings = this.store.settings()!; + const owner = this.store.owner(connection.identity!, connection.revision); + connection.nextVisibleRefreshAt = Date.now() + 30_000; + connection.visibleRefresh = (async (): Promise => { + try { + await this.refresh(connection, settings, ensureCurrent); + if (!current()) return { view: this.view(this.connections.get(id)) }; + const queuePage = queue + ? await this.provider.queue( + connection.token!, + connection.controller.signal, + queue.queue, + queue.page, + undefined, + queue.filters, + ) + : undefined; + if (!current()) return { view: this.view(this.connections.get(id)) }; + const freshDetail = detail + ? await this.provider.detail( + connection.token!, + connection.controller.signal, + detail.id, + detail.page, + detail.includeAutoNotifications, + ) + : undefined; + if (!current()) return { view: this.view(this.connections.get(id)) }; + this.updateVisible(connection, queuePage, freshDetail); + } catch (error) { + if (!this.current(id, connection)) return { view: this.view(this.connections.get(id)) }; + if (error instanceof SdpProviderError && error.kind === 'denied') { + this.store.remove(owner); + this.disconnectIdentity(connection.identity!); + return { + view: { + configured: true, + status: 'expired', + message: 'SDP denied access. Sign in again.', + }, + }; + } + if (!current()) return { view: this.view(this.connections.get(id)) }; + const retryAfter = error instanceof SdpProviderError ? error.retryAfterMs : 0; + connection.nextVisibleRefreshAt = Date.now() + Math.max(60_000, retryAfter); + connection.view.message = 'Automatic refresh is delayed. Showing the last fetched data.'; + } + return { view: this.view(this.connections.get(id)) }; + })().finally(() => { + connection.visibleRefresh = undefined; + }); + return connection.visibleRefresh; + } + private updateVisible( + connection: Connection, + queuePage: SdpAccountView['queuePage'], + freshDetail: SdpAccountView['detail'], + ): void { + const settings = this.store.settings()!; + const owner = this.store.owner(connection.identity!, connection.revision); + const fetchedAt = Date.now(); + const expiresAt = Math.min(fetchedAt + settings.cacheMinutes * 60_000, connection.expires); + const snapshot = { source: 'live' as const, fetchedAt, expiresAt }; + if (queuePage) { + if (!queuePage.filters) this.store.putQueue(owner, { queuePage, fetchedAt, expiresAt }); + connection.view.queuePage = queuePage; + connection.view.snapshot = snapshot; + connection.openedTicket = + queuePage.tickets.find((ticket) => ticket.id === connection.openedTicket?.id) ?? + connection.openedTicket; + } + if (freshDetail) { + this.store.putDetail(owner, { detail: freshDetail, fetchedAt, expiresAt }); + connection.view.detail = freshDetail; + connection.view.detailSnapshot = snapshot; + } + delete connection.view.message; + } + private async read( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + const settings = this.store.settings()!; + const owner = this.store.owner(connection.identity!, connection.revision); + // Clear the previous projection before every attempt; only the explicit outage branch may restore it. + connection.view = { configured: true, status: 'connected', expiresAt: connection.expires }; + try { + await this.refresh(connection, settings, ensureCurrent); + ensureCurrent(); + const result = + command.action === 'readQueue' + ? { + queuePage: await this.provider.queue( + connection.token!, + connection.controller.signal, + command.queue, + command.page, + undefined, + command.filters, + ), + } + : { ticket: await this.provider.ticket(connection.token!, connection.controller.signal) }; + ensureCurrent(); + if (result.queuePage) { + result.queuePage.tickets = await this.replies.update( + owner, + result.queuePage.tickets, + (ticketId) => + latestReply(this.provider, connection.token!, connection.controller.signal, ticketId), + true, + ); + ensureCurrent(); + } + const fetchedAt = Date.now(); + const expiresAt = Math.min(fetchedAt + settings.cacheMinutes * 60_000, connection.expires); + if (result.queuePage && !result.queuePage.filters) + this.store.putQueue(owner, { queuePage: result.queuePage, fetchedAt, expiresAt }); + else if (result.ticket) + this.store.put(owner, { ticket: result.ticket, fetchedAt, expiresAt }); + connection.view = { + ...connection.view, + ...result, + snapshot: { source: 'live', fetchedAt, expiresAt }, + }; + } catch (error) { + ensureCurrent(); + return this.readFailure(connection, owner, error, command); + } + return { view: this.view(connection) }; + } + private async readDetail( + connection: Connection, + ensureCurrent: () => void, + command: Extract, + ): Promise { + const settings = this.store.settings()!; + const owner = this.store.owner(connection.identity!, connection.revision); + const monitor = this.monitors.snapshot(owner); + const monitoredTicket = + monitor && + monitor.generation === connection.monitorGeneration && + Date.now() - monitor.fetchedAt < 75_000 + ? monitor.tickets.find((ticket) => ticket.id === command.id) + : undefined; + // Notification links may open a ticket observed by this session's current account monitor. + // Arbitrary IDs, other accounts, expired generations and stale scans remain ineligible. + if (!this.authorizedTicket(connection, command.id) && !monitoredTicket) + throw new Error('Load the ticket queue before opening this ticket.'); + connection.openedTicket = + monitoredTicket ?? + connection.view.queuePage?.tickets.find((ticket) => ticket.id === command.id) ?? + connection.openedTicket; + delete connection.view.detail; + delete connection.view.detailSnapshot; + try { + await this.refresh(connection, settings, ensureCurrent); + ensureCurrent(); + if (connection.openedTicket) + await this.replies.refreshTicket(owner, connection.openedTicket, (ticketId) => + latestReply(this.provider, connection.token!, connection.controller.signal, ticketId), + ); + ensureCurrent(); + const detail = await this.provider.detail( + connection.token!, + connection.controller.signal, + command.id, + command.page, + command.includeAutoNotifications ?? false, + ); + ensureCurrent(); + const fetchedAt = Date.now(); + const expiresAt = Math.min(fetchedAt + settings.cacheMinutes * 60000, connection.expires); + this.replies.markRead( + owner, + command.id, + detail.conversations.map((message) => message.id), + ); + this.store.putDetail(owner, { detail, fetchedAt, expiresAt }); + connection.view.detail = detail; + connection.view.detailSnapshot = { source: 'live', fetchedAt, expiresAt }; + if (monitoredTicket && connection.view.snapshot?.source !== 'live') { + delete connection.view.queuePage; + connection.view.snapshot = { source: 'live', fetchedAt, expiresAt }; + } + } catch (error) { + ensureCurrent(); + if (isOutage(error)) { + const saved = this.store.getDetail( + owner, + command.id, + command.page, + command.includeAutoNotifications ?? false, + ); + if (saved) { + connection.view.detail = saved.detail; + connection.view.detailSnapshot = { + source: 'outage-cache', + fetchedAt: saved.fetchedAt, + expiresAt: saved.expiresAt, + }; + } else + connection.view.message = + 'SDP is unavailable. No saved copy of this ticket is available.'; + } else { + connection.view = { configured: true, status: 'connected', expiresAt: connection.expires }; + return this.readFailure(connection, owner, error, { action: 'readTestTicket' }); + } + } + return { view: this.view(connection) }; + } + private disconnectIdentity(identity: string): void { + for (const [id, connection] of this.connections) { + if (connection.identity === identity) this.disconnect(id); + } + } + private readFailure( + connection: Connection, + owner: string, + error: unknown, + command: Extract, + ): SdpBrokerReply { + const filteredOutage = isOutage(error) && command.action === 'readQueue' && !!command.filters; + if (filteredOutage) { + connection.view.message = 'SDP is unavailable. Filtered results require a live connection.'; + return { view: this.view(connection) }; + } + if (isOutage(error)) { + const cached = + command.action === 'readQueue' + ? this.store.getQueue(owner, command.queue, command.page) + : this.store.get(owner); + if (cached) + connection.view = { + ...connection.view, + ...('queuePage' in cached ? { queuePage: cached.queuePage } : { ticket: cached.ticket }), + snapshot: { + source: 'outage-cache', + fetchedAt: cached.fetchedAt, + expiresAt: cached.expiresAt, + }, + message: 'SDP is unavailable. Showing a read-only saved copy.', + }; + else connection.view.message = 'SDP is unavailable and no unexpired saved copy is available.'; + } else { + this.store.remove(owner); + if (error instanceof SdpProviderError && error.kind === 'denied') { + // A denial revokes all connections for this provider identity, including concurrent reads. + this.disconnectIdentity(connection.identity!); + return { + view: { + configured: true, + status: 'expired', + message: 'SDP access could not be verified. Sign in again.', + }, + }; + } + connection.view.message = 'SDP returned an unexpected response. No saved copy was used.'; + } + return { view: this.view(connection) }; + } +} diff --git a/src/main/sdp/SdpBulk.test.ts b/src/main/sdp/SdpBulk.test.ts new file mode 100644 index 00000000..2e1ff5ec --- /dev/null +++ b/src/main/sdp/SdpBulk.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SdpBulkMutationSchema } from '@shared/sdpMutation'; +import { prepareBulk, confirmBulk, SdpBulkDeniedError } from './SdpBulk'; +import { SdpProvider, SdpProviderError } from './SdpProvider'; +const signal = new AbortController().signal; +const mutation = SdpBulkMutationSchema.parse({ + kind: 'bulk', + ids: ['123', '456', '789'], + fields: { status: 'Closed', group: null }, +}); +function setup() { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockImplementation(async (url, _signal, init) => { + const id = new URL(url).pathname.split('/').at(-1); + return init?.method + ? { response_status: { status_code: 2000 }, request: { id } } + : { request: { id, status: { name: 'Open' } } }; + }); + return { provider, json }; +} +describe('reviewed bulk ticket updates', () => { + it('rejects duplicate IDs, oversized batches and empty changes', () => { + for (const invalid of [ + { ...mutation, ids: ['123', '123'] }, + { ...mutation, ids: Array.from({ length: 21 }, (_, i) => String(i)) }, + { ...mutation, fields: {} }, + ]) + expect(SdpBulkMutationSchema.safeParse(invalid).success).toBe(false); + }); + it('prepares without writes, confirms sequentially and maps each result', async () => { + const { provider, json } = setup(); + const current = vi.fn(); + const baseline = await prepareBulk(provider, 'token', signal, mutation, current); + expect(json.mock.calls.every((call) => !call[2]?.method)).toBe(true); + const result = await confirmBulk(provider, 'token', signal, mutation, baseline, current); + expect(result.map((r) => r.status)).toEqual(['confirmed', 'confirmed', 'confirmed']); + const writes = json.mock.calls.filter((call) => call[2]?.method); + expect(writes).toHaveLength(3); + expect( + JSON.parse(new URLSearchParams(writes[0]![2]!.body as string).get('input_data')!), + ).toEqual({ request: { status: { name: 'Closed' }, group: null } }); + }); + it('preflights the whole batch and writes nothing when any ticket changed', async () => { + const { provider, json } = setup(); + const baseline = await prepareBulk(provider, 'token', signal, mutation, () => {}); + json.mockImplementation(async (url) => ({ + request: { id: new URL(url).pathname.split('/').at(-1), status: { name: 'Changed' } }, + })); + const results = await confirmBulk(provider, 'token', signal, mutation, baseline, () => {}); + expect(results[0]!.status).toBe('conflict'); + expect(json.mock.calls.some((call) => call[2]?.method)).toBe(false); + }); + it('stops after an ambiguous write and never retries or sends later tickets', async () => { + const { provider, json } = setup(); + const baseline = await prepareBulk(provider, 'token', signal, mutation, () => {}); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => { + if (args[2]?.method && args[0].endsWith('/456')) throw new Error('timeout'); + return original(...args); + }); + const results = await confirmBulk(provider, 'token', signal, mutation, baseline, () => {}); + expect(results.map((r) => r.status)).toEqual(['confirmed', 'uncertain', 'not-attempted']); + expect(json.mock.calls.filter((c) => c[2]?.method).map((c) => c[0].split('/').at(-1))).toEqual([ + '123', + '456', + ]); + }); + it('carries partial results with permission denial so the broker revokes the session', async () => { + const { provider, json } = setup(); + const baseline = await prepareBulk(provider, 'token', signal, mutation, () => {}); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => { + if (args[2]?.method) throw new SdpProviderError('denied'); + return original(...args); + }); + await expect( + confirmBulk(provider, 'token', signal, mutation, baseline, () => {}), + ).rejects.toMatchObject({ + results: [ + { id: '123', status: 'uncertain' }, + { id: '456', status: 'not-attempted' }, + { id: '789', status: 'not-attempted' }, + ], + }); + expect(SdpBulkDeniedError.prototype).toBeInstanceOf(SdpProviderError); + }); +}); diff --git a/src/main/sdp/SdpBulk.ts b/src/main/sdp/SdpBulk.ts new file mode 100644 index 00000000..22150bcf --- /dev/null +++ b/src/main/sdp/SdpBulk.ts @@ -0,0 +1,70 @@ +import type { SdpBulkMutation, SdpBulkResult } from '@shared/sdpMutation'; +import { mutationBaseline, submitMutation } from './SdpMutations'; +import { SdpProviderError, type SdpProvider } from './SdpProvider'; + +export class SdpBulkDeniedError extends SdpProviderError { + constructor(readonly results: SdpBulkResult) { + super('denied'); + } +} + +/** Bounded batches retain independent outcomes; SDP does not promise a transaction. */ +export async function prepareBulk( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpBulkMutation, + ensureCurrent: () => void, +): Promise { + const baselines: Record = {}; + for (const id of mutation.ids) { + ensureCurrent(); + baselines[id] = await mutationBaseline(provider, token, signal, id); + ensureCurrent(); + } + return JSON.stringify(baselines); +} + +export async function confirmBulk( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpBulkMutation, + baseline: string, + ensureCurrent: () => void, +): Promise { + const baselines = JSON.parse(baseline) as Record; + const results: SdpBulkResult = mutation.ids.map((id) => ({ id, status: 'not-attempted' })); + // Reject changed batches before any write, then recheck each record immediately before its write. + for (const result of results) { + ensureCurrent(); + if ((await mutationBaseline(provider, token, signal, result.id)) !== baselines[result.id]) { + result.status = 'conflict'; + return results; + } + } + for (const result of results) { + ensureCurrent(); + try { + if ((await mutationBaseline(provider, token, signal, result.id)) !== baselines[result.id]) { + result.status = 'conflict'; + break; + } + ensureCurrent(); + await submitMutation(provider, token, signal, { + kind: 'update', + id: result.id, + fields: mutation.fields, + }); + ensureCurrent(); + result.status = 'confirmed'; + } catch (error) { + // A timeout, denial, warning or partial response is never a reason to continue or retry. + result.status = 'uncertain'; + if (error instanceof SdpProviderError && error.kind === 'denied') + throw new SdpBulkDeniedError(results); + break; + } + } + return results; +} diff --git a/src/main/sdp/SdpChanges.test.ts b/src/main/sdp/SdpChanges.test.ts new file mode 100644 index 00000000..6176e0bd --- /dev/null +++ b/src/main/sdp/SdpChanges.test.ts @@ -0,0 +1,137 @@ +import { expect, it, vi } from 'vitest'; +import { readChanges } from './SdpChanges'; +import { SdpProvider } from './SdpProvider'; +it('reads a bounded scheduled window and projects only change correlation fields', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ + changes: [ + { + id: '12', + display_id: { display_value: 'CH 12' }, + title: 'Patch', + description: '

db01

', + status: { name: 'Open' }, + site: { name: 'Production' }, + scheduled_start_time: { value: '1000' }, + scheduled_end_time: { value: null }, + assets: [{ name: 'db01', secret: 'hidden' }], + services: [{ name: 'Payments' }], + udf_fields: { private: 'hidden' }, + }, + ], + list_info: { has_more_rows: true }, + }); + const page = await readChanges(provider, 'token', new AbortController().signal, { + action: 'readChanges', + problemStart: 800000000, + page: 1, + }); + expect(page).toMatchObject({ + page: 1, + hasMore: true, + changes: [ + { + id: '12', + number: 'CH 12', + scheduledStart: 1000, + scheduledEnd: null, + assets: ['db01'], + services: ['Payments'], + }, + ], + }); + expect(JSON.stringify(page)).not.toContain('hidden'); + const url = new URL(json.mock.calls[0]![0]); + expect(url.pathname).toBe('/app/itdesk/api/v3/changes'); + expect(JSON.parse(url.searchParams.get('input_data')!)).toMatchObject({ + list_info: { + start_index: 51, + row_count: 50, + search_criteria: { + field: 'scheduled_start_time', + value: '195200000', + children: [{ value: '800000000' }], + }, + }, + }); + expect(json.mock.calls[0]![2]).toMatchObject({ + headers: { Authorization: 'Zoho-oauthtoken token' }, + }); +}); +it('rejects malformed records and unknown pagination rather than claiming complete coverage', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json'); + for (const value of [ + { changes: [] }, + { changes: [{ id: 'bad' }], list_info: { has_more_rows: false } }, + { changes: [{ id: '1', assets: {} }], list_info: { has_more_rows: false } }, + ]) { + json.mockResolvedValue(value); + await expect( + readChanges(provider, 'token', new AbortController().signal, { + action: 'readChanges', + problemStart: 10000, + page: 0, + }), + ).rejects.toThrow(); + } +}); + +it('hydrates omitted affected systems from bounded detail reads and labels incomplete coverage', async () => { + const provider = new SdpProvider(); + const start = 10000000; + const rows = Array.from({ length: 12 }, (_, i) => ({ + id: String(i + 1), + title: 'Patch', + scheduled_start_time: { value: String(start - 1000) }, + scheduled_end_time: { value: String(start + 1000) }, + })); + const json = vi.spyOn(provider, 'json').mockImplementation(async (url) => { + const id = new URL(url).pathname.split('/').at(-1)!; + if (id === 'changes') return { changes: rows, list_info: { has_more_rows: false } }; + return { + change: { + ...rows[Number(id) - 1], + assets: [{ name: 'db01.prod.test' }], + services: [{ name: 'Payments' }], + configuration_items: [{ name: 'db02.prod.test' }], + }, + }; + }); + const result = await readChanges(provider, 'token', new AbortController().signal, { + action: 'readChanges', + problemStart: start, + page: 0, + }); + expect(json).toHaveBeenCalledTimes(11); + expect(result.detailsComplete).toBe(false); + expect(result.changes[0]).toMatchObject({ + assets: ['db01.prod.test'], + services: ['Payments'], + configurationItems: ['db02.prod.test'], + }); + expect(result.changes[11]?.assets).toEqual([]); + expect( + json.mock.calls + .slice(1) + .every(([url]) => + url.startsWith('https://support.campingworld.com/app/itdesk/api/v3/changes/'), + ), + ).toBe(true); +}); +it('rejects a detail returned for another change', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json') + .mockResolvedValueOnce({ + changes: [{ id: '1', scheduled_start_time: { value: '1000' } }], + list_info: { has_more_rows: false }, + }) + .mockResolvedValueOnce({ change: { id: '2' } }); + await expect( + readChanges(provider, 'token', new AbortController().signal, { + action: 'readChanges', + problemStart: 2000, + page: 0, + }), + ).rejects.toThrow(); +}); diff --git a/src/main/sdp/SdpChanges.ts b/src/main/sdp/SdpChanges.ts new file mode 100644 index 00000000..7bce87b6 --- /dev/null +++ b/src/main/sdp/SdpChanges.ts @@ -0,0 +1,141 @@ +import { + SDP_CHANGE_LOOKBACK_MS, + SDP_CHANGE_GRACE_MS, + SdpChangeRecordSchema, + type SdpChangeRecord, + SdpChangesPageSchema, + type SdpChangesCommand, + type SdpChangesPage, +} from '@shared/sdpChanges'; +import { isObject, SdpProviderError, type SdpProvider } from './SdpProvider'; +import { resourceHeaders } from './SdpResources'; + +function label(value: unknown, max = 200): string { + if (isObject(value)) return label(value.display_value ?? value.name ?? value.value, max); + return typeof value === 'string' || typeof value === 'number' ? String(value).slice(0, max) : ''; +} +function time(value: unknown): number | null { + if (!isObject(value) || value.value === null || value.value === '') return null; + const result = Number(value.value); + return Number.isSafeInteger(result) && result > 0 && result <= 8640000000000000 ? result : null; +} +function names(value: unknown): string[] { + if (value == null) return []; + if (!Array.isArray(value) || value.length > 500) throw new SdpProviderError('invalid'); + return value.map((entry) => label(entry, 500)).filter(Boolean); +} +/** Account-bound, read-only projection; never persisted in snapshots or shared collections. */ +export async function readChanges( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: SdpChangesCommand, +): Promise { + const url = new URL('https://support.campingworld.com/app/itdesk/api/v3/changes'); + url.searchParams.set( + 'input_data', + JSON.stringify({ + list_info: { + start_index: command.page * 50 + 1, + row_count: 50, + sort_field: 'scheduled_start_time', + sort_order: 'desc', + search_criteria: { + field: 'scheduled_start_time', + condition: 'greater or equal', + value: String(Math.max(0, command.problemStart - SDP_CHANGE_LOOKBACK_MS)), + children: [ + { + field: 'scheduled_start_time', + condition: 'lesser or equal', + value: String(command.problemStart), + logical_operator: 'AND', + }, + ], + }, + }, + }), + ); + const value = await provider.json(url.toString(), signal, { headers: resourceHeaders(token) }); + if ( + !isObject(value) || + !Array.isArray(value.changes) || + !isObject(value.list_info) || + typeof value.list_info.has_more_rows !== 'boolean' + ) + throw new SdpProviderError('invalid'); + const rawChanges = value.changes; + const page = SdpChangesPageSchema.parse({ + page: command.page, + hasMore: value.list_info.has_more_rows, + detailsComplete: true, + changes: rawChanges.map(project), + }); + // Cloud list responses omit affected systems even with fields_required. Only hydrate + // time-compatible records, in bounded batches; never silently report full coverage. + const candidates = page.changes.filter((change, index) => { + const raw = rawChanges[index]; + return ( + needsDetails(change, command.problemStart) && + isObject(raw) && + (!('assets' in raw) || !('services' in raw) || !('configuration_items' in raw)) + ); + }); + page.detailsComplete = candidates.length <= 10; + const details = new Map(); + for (let offset = 0; offset < Math.min(candidates.length, 10); offset += 3) { + const batch = candidates.slice(offset, Math.min(offset + 3, 10)); + await Promise.all( + batch.map(async (candidate) => { + const response = await provider.json( + `${url.origin}${url.pathname}/${candidate.id}`, + signal, + { headers: resourceHeaders(token) }, + ); + if ( + !isObject(response) || + !isObject(response.change) || + String(response.change.id) !== candidate.id + ) + throw new SdpProviderError('invalid'); + if ( + !('assets' in response.change) || + !('services' in response.change) || + !('configuration_items' in response.change) + ) + page.detailsComplete = false; + details.set(candidate.id, project(response.change)); + }), + ); + } + page.changes = page.changes.map((change) => details.get(change.id) ?? change); + return page; +} +function needsDetails(change: SdpChangeRecord, at: number): boolean { + const start = change.scheduledStart; + return ( + start !== null && + start <= at && + at - start <= SDP_CHANGE_LOOKBACK_MS && + at <= (change.scheduledEnd ?? start) + SDP_CHANGE_GRACE_MS && + (change.scheduledEnd === null || change.scheduledEnd >= start) && + !/\b(cancelled|canceled|rejected)\b/i.test(change.status) + ); +} +function project(row: unknown): SdpChangeRecord { + if (!isObject(row)) throw new SdpProviderError('invalid'); + return SdpChangeRecordSchema.parse({ + id: String(row.id), + number: label(row.display_id, 100) || String(row.id), + title: label(row.title, 500), + description: label(row.description, 20000), + status: label(row.status), + stage: label(row.stage), + site: label(row.site), + scheduledStart: time(row.scheduled_start_time), + scheduledEnd: time(row.scheduled_end_time), + assets: names(row.assets), + services: names(row.services), + configurationItems: names(row.configuration_items), + }); +} diff --git a/src/main/sdp/SdpConversationQuery.ts b/src/main/sdp/SdpConversationQuery.ts new file mode 100644 index 00000000..67cd7c01 --- /dev/null +++ b/src/main/sdp/SdpConversationQuery.ts @@ -0,0 +1,23 @@ +/** SDP's Emails filter excludes notes, approval comments, and system-user notifications. + * Filter before paging: notification type or sender display name cannot identify automation. + */ +export function emailConversationCriteria(includeAutoNotifications = false) { + return { + field: 'type', + condition: 'neq', + values: ['NOTES'], + children: [ + ...(!includeAutoNotifications + ? [ + { + field: 'created_by.user_type', + condition: 'neq', + values: ['1'], + logical_operator: 'and', + }, + ] + : []), + { field: 'type', condition: 'neq', values: ['ApprovalComments'], logical_operator: 'and' }, + ], + }; +} diff --git a/src/main/sdp/SdpFieldCatalog.test.ts b/src/main/sdp/SdpFieldCatalog.test.ts new file mode 100644 index 00000000..586d745f --- /dev/null +++ b/src/main/sdp/SdpFieldCatalog.test.ts @@ -0,0 +1,79 @@ +import { expect, it, vi } from 'vitest'; +import { readCustomFieldCatalog } from './SdpFieldCatalog'; +import { SdpProvider } from './SdpProvider'; + +const definition = (field_key: string, type: string, field_type: string, extra = {}) => ({ + field_key, + type, + field_type, + name: 'Human label', + module: { name: 'request' }, + ...extra, +}); +it('reads paginated Cloud definitions with constraints, ignores other modules and never follows metadata URLs', async () => { + const provider = new SdpProvider(); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValueOnce({ + udf_fields: [ + definition('udf_char23', 'string', 'Single Line', { + constraints: [{ constraint_name: 'max_length', constraint_value: '25' }], + href: 'https://untrusted.invalid', + }), + definition('udf_char23', 'string', 'Single Line', { module: { name: 'change' } }), + definition('udf_char130', 'string', 'Multi Line'), + definition('udf_char102', 'multi_select', 'Check Box'), + ], + list_info: { has_more_rows: true }, + }) + .mockResolvedValueOnce({ + udf_fields: [ + definition('ref_people', 'refered_field', 'Multi Select Reference Entity', { + constraints: [{ constraint_name: 'collection', constraint_value: true }], + }), + definition('udf_datestamp1', 'datestamp', 'Datestamp'), + definition('udf_long1', 'long', 'Numeric Field'), + definition('udf_boolean1', 'boolean', 'Decision Box'), + definition('auto_number', 'sequence_number', 'Auto Number Field'), + definition('unknown', 'future_type', 'Future Field'), + ], + list_info: { has_more_rows: false }, + }); + const fields = await readCustomFieldCatalog(provider, 'token', AbortSignal.timeout(1000)); + expect(fields.udf_char23).toMatchObject({ + display_name: 'Human label', + constraints: { max_length: '25' }, + }); + expect(fields.udf_char130).toMatchObject({ display_type: 'Multi Line' }); + expect(fields.udf_char102).toMatchObject({ multiple: true }); + expect(fields.ref_people).toMatchObject({ type: 'lookup', multiple: true }); + expect(fields.udf_datestamp1).toMatchObject({ type: 'datestamp' }); + expect(fields.auto_number).toMatchObject({ editable: false }); + expect(fields.unknown).toMatchObject({ editable: false }); + for (const [index, [url]] of json.mock.calls.entries()) { + expect(new URL(url).origin).toBe('https://support.campingworld.com'); + expect(new URL(url).pathname).toBe('/app/itdesk/api/v3/udf_fields'); + expect(JSON.parse(new URL(url).searchParams.get('input_data')!).list_info).toMatchObject({ + start_index: index * 100 + 1, + search_criteria: { field: 'module.name', condition: 'is', value: 'request' }, + fields_required: expect.arrayContaining(['constraints']), + }); + } +}); +it.each([ + { udf_fields: [], list_info: { has_more_rows: true } }, + { udf_fields: [], list_info: {} }, + { + udf_fields: [ + definition('same', 'string', 'Single Line'), + definition('same', 'string', 'Single Line'), + ], + list_info: { has_more_rows: false }, + }, +])('rejects incomplete or ambiguous definitions', async (raw) => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue(raw); + await expect( + readCustomFieldCatalog(provider, 'token', AbortSignal.timeout(1000)), + ).rejects.toMatchObject({ kind: 'invalid' }); +}); diff --git a/src/main/sdp/SdpFieldCatalog.ts b/src/main/sdp/SdpFieldCatalog.ts new file mode 100644 index 00000000..22d38a62 --- /dev/null +++ b/src/main/sdp/SdpFieldCatalog.ts @@ -0,0 +1,161 @@ +import { scalarText, isObject, SdpProvider, SdpProviderError } from './SdpProvider'; + +// Public request API types. Values and permissions always come from the live ticket. +// Cloud exposes custom definitions through the setup API, not the UI-only _metainfo. +export async function readCustomFieldCatalog( + provider: SdpProvider, + token: string, + signal: AbortSignal, +): Promise> { + const fields: Record = {}; + for (let page = 0; page < 20; page++) { + const url = new URL('https://support.campingworld.com/app/itdesk/api/v3/udf_fields'); + url.searchParams.set( + 'input_data', + JSON.stringify({ + list_info: { + start_index: page * 100 + 1, + row_count: 100, + fields_required: [ + 'id', + 'field_key', + 'name', + 'type', + 'field_type', + 'constraints', + 'module', + 'reference_entity', + 'field_config', + ], + search_criteria: { field: 'module.name', condition: 'is', value: 'request' }, + }, + }), + ); + const raw = await provider.json( + url.href, + signal, + { + headers: { + Authorization: `Zoho-oauthtoken ${token}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', + }, + }, + 1048576, + ); + if (!isObject(raw) || !Array.isArray(raw.udf_fields) || !isObject(raw.list_info)) + throw new SdpProviderError('invalid'); + for (const field of raw.udf_fields) { + if (!isObject(field) || !isObject(field.module) || field.module.name !== 'request') continue; + const key = scalarText(field.field_key); + if (!/^[a-z][a-z0-9_]{0,88}$/.test(key) || Object.hasOwn(fields, key)) + throw new SdpProviderError('invalid'); + fields[key] = customDefinition(field); + } + if (raw.list_info.has_more_rows === false) return fields; + if (raw.list_info.has_more_rows !== true || !raw.udf_fields.length) + throw new SdpProviderError('invalid'); + } + throw new SdpProviderError('invalid'); +} +function customDefinition(field: Record): Record { + const constraints = Object.fromEntries( + (Array.isArray(field.constraints) ? field.constraints : []) + .filter(isObject) + .filter((c) => typeof c.constraint_name === 'string') + .map((c) => [String(c.constraint_name), c.constraint_value]), + ); + const reference = field.type === 'refered_field'; + const multiple = field.type === 'multi_select' || constraints.collection === true; + const type = reference ? 'lookup' : field.type; + const supported = [ + 'string', + 'multi_select', + 'lookup', + 'datetime', + 'datestamp', + 'long', + 'double', + 'boolean', + 'sequence_number', + ].includes(String(type)); + return { + type, + display_name: field.name, + display_type: field.field_type, + multiple, + constraints, + editable: supported && type !== 'sequence_number', + }; +} +const lookups: Record = { + requester: [], + on_behalf_of: [], + status: [], + priority: [], + site: [], + group: ['site'], + technician: ['site', 'group'], + request_type: [], + category: [], + subcategory: ['category'], + item: ['subcategory'], + impact: [], + urgency: [], + mode: [], + level: [], + assets: ['site'], + configuration_items: [], +}; +const labels: Record = { + request_type: 'Request type', + on_behalf_of: 'On behalf of', + due_by_time: 'Due by', + first_response_due_by_time: 'First response due by', + created_time: 'Created', + 'resolution.content': 'Resolution', +}; +export function publicFieldInfo( + key: string, + current: unknown, + templateDefault: unknown, + options: unknown, +): Record { + const display_name = + labels[key] ?? key.replaceAll('_', ' ').replace(/^./, (c) => c.toUpperCase()); + const multiple = Array.isArray(current) || Array.isArray(templateDefault); + if (key in lookups) + return { + type: 'lookup', + display_name, + depends_on: lookups[key], + multiple: ['assets', 'configuration_items'].includes(key), + }; + if (['description', 'resolution.content'].includes(key)) return { type: 'html', display_name }; + if (['subject', 'update_reason'].includes(key)) return { type: 'string', display_name }; + if (key.endsWith('_time')) return { type: 'datetime', display_name }; + if (!key.startsWith('udf_fields.')) return {}; + const name = key.slice(11); + return customFieldInfo(name, current ?? templateDefault, options, multiple); +} +function customFieldInfo( + name: string, + sample: unknown, + options: unknown, + multiple: boolean, +): Record { + const display_name = name; + if (Array.isArray(options) && options.length) + return { + type: isObject(options[0]) && options[0].id ? 'lookup' : 'string', + display_name, + multiple, + }; + if (/^(udf_date\d+|dt_|date_)/.test(name)) return { type: 'datetime', display_name }; + if (/^(udf_(?:long|double)\d+|num_|dbl_)/.test(name)) return { type: 'double', display_name }; + if (/^udf_boolean\d+$/.test(name)) return { type: 'boolean', display_name }; + if (isObject(sample) && typeof sample.id === 'string') + return { type: 'lookup', display_name, multiple }; + if (/^(udf_char\d+|txt_)/.test(name)) return { type: 'string', display_name, multiple }; + // Unknown custom types remain untouched rather than guessing a writable representation. + return {}; +} diff --git a/src/main/sdp/SdpForms.test.ts b/src/main/sdp/SdpForms.test.ts new file mode 100644 index 00000000..8a2900da --- /dev/null +++ b/src/main/sdp/SdpForms.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SdpProvider, SdpProviderError } from './SdpProvider'; +import { + editedRequest, + readForm, + readOptions, + readReplyContext, + readStandardOptions, + validateFormMutation, +} from './SdpForms'; +import { submitMutation } from './SdpMutations'; +import { SdpOptionsCommandSchema, type SdpFieldValue } from '@shared/sdpForm'; +function setup() { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockImplementation(async (url, _signal, init) => { + const path = new URL(url).pathname; + if (init?.method) + return { response_status: { status_code: 2000 }, request: { id: '123', display_id: '7' } }; + if (path.endsWith('/notifications/_links')) + return { _links: [{ name: 'add', method: 'post' }] }; + if (path.endsWith('/_links')) + return { + _links: { links: [{ name: 'edit', method: 'put', non_editable_fields: ['created_time'] }] }, + }; + if (path.endsWith('/udf_fields')) + return { + udf_fields: [ + { + field_key: 'txt_major_incident', + name: 'Major incident', + type: 'multi_select', + field_type: 'Check Box', + module: { name: 'request' }, + constraints: [], + }, + { + field_key: 'udf_char1', + name: 'Location', + type: 'string', + field_type: 'Single Line', + module: { name: 'request' }, + constraints: [], + }, + { + field_key: 'secret_not_in_template', + name: 'Hidden', + type: 'string', + field_type: 'Single Line', + module: { name: 'request' }, + constraints: [], + }, + ], + list_info: { has_more_rows: false }, + }; + if (path.endsWith('/_get_template_with_layout')) + return { + request_template: { + layouts: [ + { + name: 'technician_layout', + sections: [ + { + name: 'Details', + fields: [ + 'subject', + 'description', + 'status', + 'group', + 'technician', + 'created_time', + 'udf_fields.txt_major_incident', + 'udf_fields.udf_char1', + ].map((name) => ({ name, mandatory: name === 'subject' })), + }, + ], + }, + ], + }, + }; + if (path.endsWith('/_allowed_values_for_fields')) + return { + allowed_values: { + status: [ + { id: '1', name: 'Open' }, + { id: '2', name: 'Closed' }, + ], + udf_fields: { txt_major_incident: [{ value: 'Yes', display_value: 'Yes' }] }, + }, + }; + if (path.endsWith('/group')) + return { groups: [{ id: '9', name: 'Example group' }], list_info: { has_more_rows: true } }; + if (path.endsWith('/technician')) + return { technician: [{ id: '11', name: 'Operator' }], list_info: { has_more_rows: false } }; + return { + request: { + id: '123', + template: { id: '7', name: 'Example incident' }, + subject: 'Test', + description: '

Unchanged formatting

', + requester: { email_id: 'requester@example.test' }, + status: { id: '1', name: 'Open' }, + udf_fields: { txt_major_incident: [], udf_char1: 'A' }, + }, + }; + }); + return { provider, json, signal: new AbortController().signal }; +} +describe('native SDP template forms', () => { + it('scopes technician choices to associated sites and groups using the Cloud lookup keys', async () => { + const { provider, json, signal } = setup(); + const form = await readForm(provider, 'token', signal, '123'); + await readOptions( + provider, + 'token', + signal, + { + action: 'readOptions', + id: '123', + field: 'technician', + page: 0, + search: '', + dependencies: { site: { id: '3' }, group: { id: '9' } }, + }, + form, + ); + const input = JSON.parse(new URL(json.mock.lastCall![0]).searchParams.get('input_data')!); + expect(input.list_info.search_criteria).toEqual([ + { field: 'associated_sites.id', condition: 'is', value: '3', logical_operator: 'and' }, + { field: 'groups.id', condition: 'is', value: '9', logical_operator: 'and' }, + { field: 'name', condition: 'like', values: [''], logical_operator: 'and' }, + ]); + }); + it('projects only active template fields and preserves provider edit restrictions', async () => { + const { provider, signal } = setup(); + const form = await readForm(provider, 'token', signal, '123'); + expect(form.fields.find((f) => f.key === 'created_time')?.readOnly).toBe(true); + expect(form.fields.some((f) => f.key.includes('secret'))).toBe(false); + expect(form.fields.find((f) => f.key === 'status')?.choices).toEqual([ + { label: 'Open', value: { id: '1', name: 'Open' } }, + { label: 'Closed', value: { id: '2', name: 'Closed' } }, + ]); + expect(form.fields.find((f) => f.key === 'udf_fields.txt_major_incident')).toMatchObject({ + kind: 'choice', + multiple: true, + }); + }); + it('uses a fixed tenant endpoint, scoped dependencies and paginated lookup search', async () => { + const { provider, json, signal } = setup(); + const form = await readForm(provider, 'token', signal, '123'); + const command = SdpOptionsCommandSchema.parse({ + action: 'readOptions', + id: '123', + field: 'group', + page: 1, + search: 'Example', + dependencies: { site: { id: '3' } }, + }); + expect(await readOptions(provider, 'token', signal, command, form)).toMatchObject({ + hasMore: true, + choices: [{ label: 'Example group' }], + }); + const url = new URL(json.mock.lastCall![0]); + expect(url.origin).toBe('https://support.campingworld.com'); + expect(JSON.parse(url.searchParams.get('input_data')!)).toMatchObject({ + list_info: { + start_index: 51, + search_criteria: [ + { field: 'site.id', condition: 'is', value: '3' }, + { field: 'name', values: ['Example'] }, + ], + }, + }); + await expect( + readOptions( + provider, + 'token', + signal, + { ...command, dependencies: { requester: { id: '4' } } }, + form, + ), + ).rejects.toThrow(); + expect(SdpOptionsCommandSchema.safeParse({ ...command, field: '../../users' }).success).toBe( + false, + ); + }); + it('rejects read-only, unknown and invalid values before any live write', async () => { + const { provider, json, signal } = setup(); + const invalidFields: Record[] = [ + { created_time: 10 }, + { 'udf_fields.secret_not_in_template': 'bad' }, + { subject: null }, + { status: { id: '999' } }, + { 'udf_fields.txt_major_incident': 'Yes' }, + ]; + for (const fields of invalidFields) + await expect( + validateFormMutation(provider, 'token', signal, { kind: 'edit', id: '123', fields }), + ).rejects.toThrow(); + expect(json.mock.calls.every((c) => !c[2]?.method)).toBe(true); + }); + it('writes only changed fields using IDs and retains checkbox arrays and explicit clears', async () => { + const { provider, json, signal } = setup(); + await submitMutation(provider, 'token', signal, { + kind: 'edit', + id: '123', + fields: { + status: { id: '2', name: 'Closed' }, + group: null, + 'udf_fields.txt_major_incident': ['Yes'], + 'udf_fields.udf_char1': null, + }, + }); + const call = json.mock.lastCall!; + expect(call[2]?.method).toBe('PUT'); + expect(JSON.parse(new URLSearchParams(call[2]?.body as string).get('input_data')!)).toEqual({ + request: { + status: { id: '2' }, + group: null, + udf_fields: { txt_major_incident: ['Yes'], udf_char1: null }, + }, + }); + }); + it('sends a real notification payload, with reviewed recipients and escaped operator text', async () => { + const { provider, json, signal } = setup(); + await submitMutation(provider, 'token', signal, { + kind: 'reply', + id: '123', + to: ['requester@example.test'], + cc: [], + bcc: [], + subject: 'Re: Test', + body: '\nHello', + isPublic: true, + }); + const call = json.mock.lastCall!; + expect(call[0]).toMatch(/\/123\/notifications$/); + expect(call[2]?.method).toBe('POST'); + expect(JSON.parse(new URLSearchParams(call[2]?.body as string).get('input_data')!)).toEqual({ + notification: { + to: ['requester@example.test'], + cc: [], + bcc: [], + subject: 'Re: Test', + description: '<script>unsafe</script>
Hello', + is_public: true, + type: 'REQREPLY', + in_reply_to: { id: '123' }, + }, + }); + }); +}); + +it.each([401, 403, 404])( + 'loads and saves supported template fields when optional metadata returns %s', + async (status) => { + const { provider, json, signal } = setup(); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => { + if (new URL(args[0]).pathname.endsWith('/udf_fields')) + throw new SdpProviderError('denied', 0, 'http', status); + return original(...args); + }); + const form = await readForm(provider, 'token', signal, '123'); + expect(form).toMatchObject({ canEdit: true, metadataAvailable: false }); + expect(form.fields.find((f) => f.key === 'technician')).toMatchObject({ + kind: 'lookup', + dependencies: ['site', 'group'], + }); + expect(form.fields.find((f) => f.key === 'created_time')?.readOnly).toBe(true); + expect(form.fields.find((f) => f.key === 'udf_fields.txt_major_incident')).toMatchObject({ + multiple: true, + kind: 'choice', + }); + await submitMutation(provider, 'token', signal, { + kind: 'edit', + id: '123', + fields: { subject: 'Updated', technician: null }, + }); + expect(json.mock.lastCall![2]?.method).toBe('PUT'); + }, +); + +it('does not fall back for an outage or authorize an edit without live permissions', async () => { + const { provider, json, signal } = setup(); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => { + if (new URL(args[0]).pathname.endsWith('/udf_fields')) throw new SdpProviderError('outage'); + return original(...args); + }); + await expect(readForm(provider, 'token', signal, '123')).rejects.toMatchObject({ + kind: 'outage', + }); + json.mockImplementation(async (...args) => { + if (new URL(args[0]).pathname.endsWith('/_links')) return { _links: [] }; + return original(...args); + }); + await expect( + validateFormMutation(provider, 'token', signal, { + kind: 'edit', + id: '123', + fields: { subject: 'No' }, + }), + ).rejects.toThrow(); + expect(json.mock.calls.some((c) => c[2]?.method)).toBe(false); +}); + +it('uses live labels and limits, preserves date-only values and writes metadata-defined dates correctly', async () => { + const { provider, json, signal } = setup(); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => { + const raw = (await original(...args)) as Record; + const path = new URL(args[0]).pathname; + if (path.endsWith('/udf_fields')) + return { + udf_fields: [ + { + field_key: 'udf_char1', + name: 'Provider', + type: 'string', + field_type: 'Single Line', + module: { name: 'request' }, + constraints: [ + { constraint_name: 'max_length', constraint_value: '25' }, + { constraint_name: 'min_length', constraint_value: '2' }, + ], + }, + { + field_key: 'custom_date', + name: 'Date of hire', + type: 'datestamp', + field_type: 'Datestamp', + module: { name: 'request' }, + }, + ], + list_info: { has_more_rows: false }, + }; + if (path.endsWith('/_get_template_with_layout')) + return { + request_template: { + layouts: [ + { + name: 'technician_layout', + sections: [ + { + name: 'Details', + fields: [{ name: 'udf_fields.udf_char1' }, { name: 'udf_fields.custom_date' }], + }, + ], + }, + ], + }, + }; + if (path.endsWith('/123') && !args[2]?.method) + return { + request: { + ...(raw.request as object), + udf_fields: { custom_date: { value: '2026-09-18' } }, + }, + }; + return raw; + }); + const form = await readForm(provider, 'token', signal, '123'); + expect(form.fields[0]).toMatchObject({ label: 'Provider', maxLength: 25, minLength: 2 }); + expect(form.fields[1]).toMatchObject({ + label: 'Date of hire', + kind: 'date', + dateOnly: true, + value: '2026-09-18', + }); + const invalid: Record[] = [ + { 'udf_fields.udf_char1': 'a' }, + { 'udf_fields.udf_char1': 'x'.repeat(26) }, + { 'udf_fields.custom_date': '2026-02-31' }, + ]; + for (const fields of invalid) + await expect( + validateFormMutation(provider, 'token', signal, { kind: 'edit', id: '123', fields }), + ).rejects.toThrow(); + await submitMutation(provider, 'token', signal, { + kind: 'edit', + id: '123', + fields: { 'udf_fields.custom_date': '2026-09-19' }, + }); + expect( + JSON.parse(new URLSearchParams(json.mock.lastCall![2]?.body as string).get('input_data')!), + ).toEqual({ request: { udf_fields: { custom_date: { value: '2026-09-19' } } } }); +}); + +it('propagates a real ticket denial after setup metadata is denied', async () => { + const { provider, json, signal } = setup(); + const original = json.getMockImplementation()!; + let reads = 0; + json.mockImplementation(async (...args) => { + const path = new URL(args[0]).pathname; + if (path.endsWith('/udf_fields')) throw new SdpProviderError('denied', 0, 'http', 401); + if (path.endsWith('/123') && ++reads > 1) throw new SdpProviderError('denied', 0, 'http', 403); + return original(...args); + }); + await expect(readForm(provider, 'token', signal, '123')).rejects.toMatchObject({ + kind: 'denied', + httpStatus: 403, + }); +}); + +it('anchors message forwarding to the authorized request and starts without recipients', async () => { + const { provider, json, signal } = setup(); + const original = json.getMockImplementation()!; + const body = '

' + 'Message content '.repeat(100) + '

'; + json.mockImplementation(async (...args) => { + if (new URL(args[0]).pathname.endsWith('/123/notifications/77')) + return { notification: { id: '77', subject: 'Selected message', description: body } }; + return original(...args); + }); + const context = await readReplyContext(provider, 'token', signal, '123', true, '77'); + expect(context).toMatchObject({ to: [], cc: [], body, canReply: true }); + expect(context.subject).toContain('Selected message'); + expect(context.subject).toMatch(/^Fwd:/); + json.mockImplementation(async (...args) => { + if (new URL(args[0]).pathname.endsWith('/123/notifications/77')) + return { notification: { id: '78', description: 'Different message' } }; + return original(...args); + }); + await expect(readReplyContext(provider, 'token', signal, '123', true, '77')).rejects.toThrow(); +}); + +it('loads bounded account-scoped choices and filters technicians by the selected group', async () => { + const { provider, json, signal } = setup(); + json.mockResolvedValue({ + technician: [ + { id: '9', name: 'Example' }, + { id: '10', name: 'Deleted', deleted: true }, + ], + list_info: { has_more_rows: true }, + }); + const result = await readStandardOptions(provider, 'token', signal, { + action: 'readStandardOptions', + field: 'technician', + groupId: '4', + search: 'Example', + page: 1, + }); + const url = new URL(json.mock.lastCall![0]); + expect(url.pathname).toBe('/app/itdesk/api/v3/requests/technician'); + expect(JSON.parse(url.searchParams.get('input_data')!)).toMatchObject({ + list_info: { + start_index: 51, + row_count: 50, + search_criteria: [ + { field: 'name', values: ['Example'] }, + { field: 'groups.id', value: '4' }, + ], + }, + }); + expect(result).toEqual({ + field: 'technician', + hasMore: true, + choices: [{ label: 'Example', value: { id: '9', name: 'Example' } }], + }); + json.mockResolvedValue({ unexpected: [] }); + await expect( + readStandardOptions(provider, 'token', signal, { + action: 'readStandardOptions', + field: 'group', + search: '', + page: 0, + }), + ).rejects.toThrow(); +}); + +describe('rich-text mutation boundary', () => { + it('retains explicit clears and passes only text to HTML encoding', () => { + const html = vi.fn((value: string) => `encoded:${value}`); + expect(editedRequest({ description: null, 'resolution.content': 'fixed' }, html)).toEqual({ + description: null, + resolution: { content: 'encoded:fixed' }, + }); + expect(html).toHaveBeenCalledExactlyOnceWith('fixed'); + for (const value of [true, 123, ['text'], { id: '123' }]) { + expect(() => editedRequest({ description: value }, html)).toThrow(SdpProviderError); + } + }); +}); diff --git a/src/main/sdp/SdpForms.ts b/src/main/sdp/SdpForms.ts new file mode 100644 index 00000000..1e3a4ef0 --- /dev/null +++ b/src/main/sdp/SdpForms.ts @@ -0,0 +1,505 @@ +import { z } from 'zod'; +import { + SdpFormSchema, + SdpOptionsSchema, + SdpReplyContextSchema, + type SdpStandardOptionsCommand, + type SdpForm, + type SdpFormField, + type SdpFieldValue, +} from '@shared/sdpForm'; +import type { SdpBrokerCommand } from '@shared/sdpAccount'; +import type { SdpMutation } from '@shared/sdpMutation'; +import { scalarText, SdpProvider, SdpProviderError, isObject } from './SdpProvider'; +import { loggers } from '../logger'; +import { publicFieldInfo, readCustomFieldCatalog } from './SdpFieldCatalog'; +import { validateRelation } from './SdpTicketRelations'; +const BASE = 'https://support.campingworld.com/app/itdesk/api/v3/requests'; +const headers = (token: string) => ({ + Authorization: `Zoho-oauthtoken ${token}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', +}); +export class SdpFormUnavailableError extends Error { + constructor() { + super( + 'SDP did not authorize this editor request. Your account is still connected. Cancel to return to the ticket.', + ); + } +} +const object = (v: unknown): Record => (isObject(v) ? v : {}); +const array = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +const label = (v: unknown, fallback = '') => (typeof v === 'string' ? v.slice(0, 250) : fallback); +const at = (v: unknown, key: string): unknown => + key.split('.').reduce((value, part) => object(value)[part], v); +const writable = new Set([ + 'subject', + 'description', + 'requester', + 'on_behalf_of', + 'status', + 'priority', + 'group', + 'technician', + 'request_type', + 'category', + 'subcategory', + 'item', + 'impact', + 'urgency', + 'site', + 'mode', + 'level', + 'assets', + 'configuration_items', + 'due_by_time', + 'first_response_due_by_time', + 'created_time', + 'resolution.content', + 'update_reason', +]); +// SDP values deliberately retain their schema-defined scalar, reference or list type. +// eslint-disable-next-line sonarjs/function-return-type +function value(v: unknown, type: string): SdpFieldValue { + if (v == null) return null; + if (Array.isArray(v)) + return v + .slice(0, 200) + .map((item) => value(item, type)) + .filter( + (item): item is Exclude => + item !== null && !Array.isArray(item), + ); + if (type === 'datestamp') + return typeof object(v).value === 'string' ? String(object(v).value) : null; + if (type === 'datetime') { + const n = Number(object(v).value); + return Number.isFinite(n) ? n : null; + } + if (isObject(v)) + return /^\d{1,30}$/.test(String(v.id)) ? { id: String(v.id), name: label(v.name) } : null; + if (typeof v === 'string') return v.slice(0, 100000); + return typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) ? v : null; +} +function choices(v: unknown) { + return array(v) + .filter((item) => object(item).deleted !== true) + .flatMap((item) => { + const row = object(item); + const id = scalarText(row.id); + if (/^\d{1,30}$/.test(id) && typeof row.name === 'string') + return [{ label: label(row.name), value: { id, name: label(row.name) } }]; + if (typeof row.value === 'string') + return [{ label: label(row.display_value, row.value), value: row.value }]; + return []; + }); +} +async function get( + provider: SdpProvider, + token: string, + signal: AbortSignal, + path: string, + input?: unknown, + parentId?: string, +) { + const url = new URL(`${BASE}/${path}`); + if (input) url.searchParams.set('input_data', JSON.stringify(input)); + try { + return object(await provider.json(url.href, signal, { headers: headers(token) }, 1048576)); + } catch (error) { + loggers.main.warn('SDP form read failed', { + resource: path.replace(/\d+/g, ':id'), + kind: error instanceof SdpProviderError ? error.kind : 'unknown', + status: error instanceof SdpProviderError ? error.httpStatus : undefined, + }); + if ( + parentId && + error instanceof SdpProviderError && + error.kind === 'denied' && + [401, 403, 404].includes(error.httpStatus ?? 0) + ) { + // UI metadata can reject OAuth while the public ticket API still authorizes this user. + // Revalidate live access; never infer it from a cached ticket or ignore a real denial. + const parent = await get(provider, token, signal, parentId); + if (String(object(parent.request).id) !== parentId) throw new SdpProviderError('invalid'); + throw new SdpFormUnavailableError(); + } + throw error; + } +} +function links(raw: Record): Record[] { + return array(Array.isArray(raw._links) ? raw._links : object(raw._links).links).map(object); +} +function fieldInfo(metadata: Record, key: string) { + if (key.startsWith('udf_fields.')) + return object(object(object(metadata.udf_fields).fields)[key.slice(11)]); + return object(metadata[key === 'resolution.content' ? 'description' : key]); +} +function fieldKind( + info: Record, + options: SdpFormField['choices'], +): SdpFormField['kind'] { + if (info.type === 'lookup') return 'lookup'; + if ( + options.length || + /Pick List|Check Box|Radio Button|Multi Select/.test(String(info.display_type)) + ) + return 'choice'; + if (info.type === 'datetime' || info.type === 'datestamp') return 'date'; + if (info.type === 'boolean') return 'boolean'; + if (['long', 'double', 'integer', 'decimal'].includes(String(info.type))) return 'number'; + if (info.type === 'html' || info.display_type === 'Multi Line') return 'multiline'; + return 'text'; +} +function projectField( + field: Record, + section: Record, + metadata: Record, + allowed: Record, + request: Record, + defaults: Record, + permissions: { canEdit: boolean; blocked: unknown[] }, +): SdpFormField[] { + const key = label(field.name); + if (!writable.has(key) && !/^udf_fields\.[a-z][a-z0-9_]*$/.test(key)) return []; + const declared = fieldInfo(metadata, key); + const info = Object.keys(declared).length + ? declared + : publicFieldInfo(key, at(request, key), at(defaults, key), at(allowed, key)); + if (!Object.keys(info).length) return []; + const options = choices(at(allowed, key)); + const kind = fieldKind(info, options); + const constraints = object(info.constraints); + const fallbackLength = kind === 'multiline' ? 100000 : 250; + return [ + { + key, + label: key === 'resolution.content' ? 'Resolution' : label(info.display_name, key), + section: label(section.name, 'Details'), + kind, + required: field.mandatory === true, + readOnly: + !permissions.canEdit || + info.read_only === true || + info.editable === false || + permissions.blocked.includes(key), + multiple: info.multiple === true, + dateOnly: info.type === 'datestamp', + integer: info.type === 'long', + minLength: Math.max(0, Number(constraints.min_length) || 0), + maxLength: Math.min(100000, Math.max(1, Number(constraints.max_length) || fallbackLength)), + dependencies: array(info.depends_on).filter((v): v is string => typeof v === 'string'), + choices: options, + value: value(at(request, key), String(info.type)), + }, + ]; +} +export async function readForm( + provider: SdpProvider, + token: string, + signal: AbortSignal, + id: string, +): Promise { + const [parent, permissions] = await Promise.all([ + get(provider, token, signal, id), + get(provider, token, signal, `${id}/_links`, undefined, id), + ]); + const request = object(parent.request); + if (String(request.id) !== id) throw new SdpProviderError('invalid'); + let customFields: Record | undefined; + try { + customFields = await readCustomFieldCatalog(provider, token, signal); + } catch (error) { + if ( + !(error instanceof SdpProviderError) || + error.kind !== 'denied' || + ![401, 403, 404].includes(error.httpStatus ?? 0) + ) + throw error; + // Old grants may lack setup.READ. A metadata denial is not a ticket/session denial. + const authorized = await get(provider, token, signal, id); + if (String(object(authorized.request).id) !== id) throw new SdpProviderError('invalid'); + } + const template = object(request.template); + const templateId = scalarText(template.id); + if (!/^\d{1,30}$/.test(templateId)) throw new SdpProviderError('invalid'); + const [layoutRaw, allowedRaw] = await Promise.all([ + get( + provider, + token, + signal, + `${id}/template/${templateId}/_get_template_with_layout`, + undefined, + id, + ), + get( + provider, + token, + signal, + `${id}/_allowed_values_for_fields`, + { + list_info: { for: templateId }, + }, + id, + ), + ]); + const metadata = { udf_fields: { fields: customFields ?? {} } }; + const allowed = object(allowedRaw.allowed_values); + const layout = array(object(layoutRaw.request_template).layouts) + .map(object) + .find((l) => l.name === 'technician_layout'); + if (!layout) throw new SdpProviderError('invalid'); + const edit = links(permissions).find((l) => l.name === 'edit' && l.method === 'put'); + const blocked = array(edit?.non_editable_fields); + const fields = array(layout.sections) + .map(object) + .flatMap((section) => + array(section.fields) + .map(object) + .flatMap((field) => + projectField( + field, + section, + metadata, + allowed, + request, + object(object(layoutRaw.request_template).request), + { canEdit: !!edit, blocked }, + ), + ), + ); + return SdpFormSchema.parse({ + id, + template: { id: templateId, name: label(template.name) }, + fields, + canEdit: !!edit, + metadataAvailable: customFields !== undefined, + unavailableFields: array(layout.sections) + .map(object) + .flatMap((s) => array(s.fields).map(object)) + .map((f) => label(f.name)) + .filter( + (key) => /^udf_fields\.[a-z][a-z0-9_]*$/.test(key) && !fields.some((f) => f.key === key), + ), + }); +} +export async function readOptions( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: Extract, + form: SdpForm, +) { + const field = form.fields.find((f) => f.key === command.field); + if (!field || !['lookup', 'choice'].includes(field.kind) || field.readOnly) + throw new SdpProviderError('invalid'); + const criteria: unknown[] = []; + for (const [key, v] of Object.entries(command.dependencies)) { + if (!field.dependencies.includes(key)) throw new SdpProviderError('invalid'); + const lookupKey = + field.key === 'technician' + ? (({ site: 'associated_sites', group: 'groups' } as Record)[key] ?? key) + : key; + if (v !== null) + criteria.push({ + field: isObject(v) ? `${lookupKey}.id` : lookupKey, + condition: 'is', + value: isObject(v) ? v.id : v, + logical_operator: 'and', + }); + } + criteria.push({ + field: 'name', + condition: 'like', + values: [command.search], + logical_operator: 'and', + }); + const data = await get( + provider, + token, + signal, + `${command.id}/${field.key.replaceAll('.', '/')}`, + { list_info: { start_index: command.page * 50 + 1, row_count: 50, search_criteria: criteria } }, + command.id, + ); + const rows = Object.entries(data).find( + ([key, v]) => key !== 'response_status' && Array.isArray(v), + )?.[1]; + if (!rows) throw new SdpProviderError('invalid'); + return SdpOptionsSchema.parse({ + field: field.key, + choices: choices(rows), + hasMore: object(data.list_info).has_more_rows === true, + }); +} +/** Request-scoped Cloud lookup catalogs, also available before a ticket is created. */ +export async function readStandardOptions( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: SdpStandardOptionsCommand, +) { + const criteria: unknown[] = [{ field: 'name', condition: 'like', values: [command.search] }]; + if (command.groupId) + criteria.push({ + field: 'groups.id', + condition: 'is', + value: command.groupId, + logical_operator: 'and', + }); + const data = await get(provider, token, signal, command.field, { + list_info: { start_index: command.page * 50 + 1, row_count: 50, search_criteria: criteria }, + }); + const rows = data[command.field]; + if (!Array.isArray(rows)) throw new SdpProviderError('invalid'); + return SdpOptionsSchema.parse({ + field: command.field, + choices: choices(rows), + hasMore: object(data.list_info).has_more_rows === true, + }); +} +export async function readReplyContext( + provider: SdpProvider, + token: string, + signal: AbortSignal, + id: string, + forward = false, + sourceId?: string, +) { + const [raw, permissions] = await Promise.all([ + get(provider, token, signal, id), + get( + provider, + token, + signal, + `${id}/notifications/_links`, + { operations_required: ['add'] }, + id, + ), + ]); + const request = object(raw.request); + if (String(request.id) !== id) throw new SdpProviderError('invalid'); + const source = + forward && sourceId + ? object( + (await get(provider, token, signal, `${id}/notifications/${sourceId}`, undefined, id)) + .notification, + ) + : request; + if (forward && sourceId && String(source.id) !== sourceId) throw new SdpProviderError('invalid'); + const email = label(object(request.requester).email_id); + const emails = (v: unknown) => + array(v).filter((v): v is string => z.email().safeParse(v).success); + return SdpReplyContextSchema.parse({ + id, + subject: + `${forward ? 'Fwd' : 'Re'}: [Request ID :##${label(object(request.display_key).display_value, scalarText(request.display_id, id))}##] : ${label(source.subject)}`.slice( + 0, + 250, + ), + to: forward ? [] : emails([email]), + cc: forward ? [] : emails(request.email_cc), + ...(forward + ? { body: typeof source.description === 'string' ? source.description.slice(0, 12000) : '' } + : {}), + canReply: links(permissions).some((l) => l.method === 'post' && l.name === 'add'), + }); +} +function validateScalar(field: SdpFormField, part: unknown): void { + let valid = true; + if (field.kind === 'lookup') valid = isObject(part) && /^\d{1,30}$/.test(String(part.id)); + else if (['text', 'multiline', 'choice'].includes(field.kind)) + valid = + typeof part === 'string' && + part.length <= field.maxLength && + part.length >= (field.minLength ?? 0); + else if (field.kind === 'date' && field.dateOnly) + valid = + typeof part === 'string' && + /^\d{4}-\d{2}-\d{2}$/.test(part) && + Number.isFinite(Date.parse(part)) && + new Date(part).toISOString().slice(0, 10) === part; + else if (['date', 'number'].includes(field.kind)) + valid = + typeof part === 'number' && + Number.isFinite(part) && + (!field.integer || Number.isSafeInteger(part)); + else if (field.kind === 'boolean') valid = typeof part === 'boolean'; + if (!valid) throw new SdpProviderError('invalid'); + const key = (v: unknown) => (isObject(v) ? v.id : v); + if (field.choices.length && !field.choices.some((c) => key(c.value) === key(part))) + throw new SdpProviderError('invalid'); +} +function validateFieldValue(field: SdpFormField, v: SdpFieldValue): void { + const empty = v === null || v === '' || (Array.isArray(v) && !v.length); + if (empty) { + if (field.required) throw new SdpProviderError('invalid'); + return; + } + if (field.multiple !== Array.isArray(v)) throw new SdpProviderError('invalid'); + for (const part of Array.isArray(v) ? v : [v]) validateScalar(field, part); +} +export async function validateFormMutation( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpMutation, +) { + if (mutation.kind === 'relation') { + await validateRelation(provider, token, signal, mutation); + return; + } + if (mutation.kind === 'reply' || mutation.kind === 'forward') { + if ( + !( + await readReplyContext( + provider, + token, + signal, + mutation.id, + mutation.kind === 'forward', + mutation.kind === 'forward' ? mutation.sourceId : undefined, + ) + ).canReply + ) + throw new SdpProviderError('denied'); + return; + } + if (mutation.kind !== 'edit') return; + const form = await readForm(provider, token, signal, mutation.id); + for (const [key, v] of Object.entries(mutation.fields)) { + const field = form.fields.find((f) => f.key === key); + if (!field || field.readOnly) throw new SdpProviderError('invalid'); + validateFieldValue(field, v); + } + return form; +} +function editedHtml(value: SdpFieldValue, html: (text: string) => string): string | null { + if (value === null) return null; + if (typeof value !== 'string') throw new SdpProviderError('invalid'); + return html(value); +} +export function editedRequest( + fields: Record, + html: (text: string) => string, + form?: SdpForm, +) { + const request: Record = {}; + for (const [key, v] of Object.entries(fields)) { + let result: unknown = v; + if (key === 'description' || key === 'resolution.content') result = editedHtml(v, html); + else if ( + (form?.fields.find((f) => f.key === key)?.kind === 'date' || + /(?:_time$|^udf_fields\.(?:udf_date\d+$|dt_|date_))/.test(key)) && + (typeof v === 'number' || typeof v === 'string') + ) + result = { value: String(v) }; + else if (isObject(v)) result = { id: v.id }; + else if (Array.isArray(v)) result = v.map((item) => (isObject(item) ? { id: item.id } : item)); + const [parent = '', child] = key.split('.'); + if (child) { + request[parent] ??= {}; + (request[parent] as Record)[child] = result; + } else request[parent] = result; + } + return request; +} diff --git a/src/main/sdp/SdpGatewayClient.test.ts b/src/main/sdp/SdpGatewayClient.test.ts new file mode 100644 index 00000000..f966ef59 --- /dev/null +++ b/src/main/sdp/SdpGatewayClient.test.ts @@ -0,0 +1,120 @@ +import { createServer } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; +import type PocketBase from 'pocketbase'; +import { WEB_RUNTIME } from '@shared/runtime'; +import { RELAY_WEB_API_PREFIX } from '@shared/webApi'; +import { SdpGatewayClient } from './SdpGatewayClient'; +import type { SdpBroker } from './SdpBroker'; +import { RelayWebGateway } from '../web/RelayWebGateway'; +import { RelayWebServer } from '../web/RelayWebServer'; +async function freePort() { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} +describe('SDP native client through authenticated private Relay gateway', () => { + it('isolates logical sessions, enforces CSRF and hides saved results when Relay is unavailable', async () => { + const port = await freePort(); + const invoke = vi.fn(async (_id: string, _command: unknown) => ({ + view: { configured: true, status: 'disconnected' as const }, + })); + const disconnect = vi.fn(); + const origin = `http://127.0.0.1:${port}`; + const config = { + mode: 'server' as const, + port: 8090, + bindHost: '127.0.0.1' as const, + secret: 'fixture-passphrase', + web: { enabled: true, port }, + }; + const gateway = new RelayWebGateway({ + config, + getSdpBroker: () => ({ invoke, disconnect }) as unknown as SdpBroker, + hostname: '127.0.0.1', + getInterfaceAddresses: () => [], + authenticate: async (passphrase) => + passphrase === 'fixture-passphrase' + ? { + pbUrl: 'http://127.0.0.1:8090', + auth: { token: 'fixture-passphrase', record: null }, + publicConfig: { + mode: 'server', + port: 8090, + bindHost: '127.0.0.1', + lanIp: '127.0.0.1', + }, + runtime: WEB_RUNTIME, + refresh: async () => ({ token: 'fixture-passphrase', record: null }), + } + : null, + }); + const server = new RelayWebServer({ host: '127.0.0.1', port, staticRoot: '/missing', gateway }); + await server.start(); + const getOne = vi.fn().mockResolvedValue({ enabled: true, gatewayPort: port }); + const context = () => ({ + config: { + mode: 'client' as const, + serverUrl: 'http://127.0.0.1:8090', + secret: 'fixture-passphrase', + }, + pb: { collection: () => ({ getOne }) } as unknown as PocketBase, + }); + try { + const alice = new SdpGatewayClient(context); + const bob = new SdpGatewayClient(context); + expect((await alice.invoke({ action: 'status' })).view.configured).toBe(true); + const aliceId = invoke.mock.calls[0]![0]; + await bob.invoke({ action: 'status' }); + const bobId = invoke.mock.calls[1]![0]; + expect(aliceId).not.toBe(bobId); + await alice.invoke({ action: 'status' }); + expect(invoke.mock.calls[2]![0]).toBe(aliceId); + const draft = { + action: 'prepareChange', + mutation: { + kind: 'create', + fields: { subject: 'Synthetic request', description: 'x'.repeat(12000) }, + majorIncident: false, + }, + } as const; + await alice.invoke(draft); + expect(invoke).toHaveBeenLastCalledWith(aliceId, draft); + const confirmation = { + action: 'confirmChange', + confirmationId: 'f6d1a214-87d9-45ef-9bce-b1a850e5d301', + } as const; + await alice.invoke(confirmation); + expect(invoke).toHaveBeenLastCalledWith(aliceId, confirmation); + const login = await fetch(`${origin}${RELAY_WEB_API_PREFIX}/session/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Synthetic gateway login. + body: JSON.stringify({ passphrase: 'fixture-passphrase' }), + }); + const cookie = login.headers.get('set-cookie')!.split(';')[0]!; + await login.json(); + const rejected = await fetch(`${origin}${RELAY_WEB_API_PREFIX}/sdp/account`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin, Cookie: cookie }, + body: JSON.stringify(confirmation), + }); + expect(rejected.status).toBe(403); + await rejected.text(); + const anonymous = await fetch(`${origin}${RELAY_WEB_API_PREFIX}/sdp/account`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ action: 'status' }), + }); + expect(anonymous.status).toBe(401); + await anonymous.text(); + await server.stop(); + await expect(alice.invoke({ action: 'status' })).rejects.toThrow('Relay server connection'); + } finally { + await server.stop(); + await gateway.dispose(); + } + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/main/sdp/SdpGatewayClient.ts b/src/main/sdp/SdpGatewayClient.ts new file mode 100644 index 00000000..5075a67b --- /dev/null +++ b/src/main/sdp/SdpGatewayClient.ts @@ -0,0 +1,112 @@ +import type PocketBase from 'pocketbase'; +import { + SdpBrokerReplySchema, + SDP_DISCOVERY_COLLECTION, + SDP_DISCOVERY_ID, + type SdpBackend, + type SdpBrokerCommand, + type SdpBrokerReply, +} from '@shared/sdpAccount'; +import type { ClientConfig } from '../config/AppConfig'; +import { RELAY_WEB_API_PREFIX } from '@shared/webApi'; + +/** Native transport uses the existing workspace connection, never an SDP credential. */ +export class SdpGatewayClient implements SdpBackend { + private pending: Promise = Promise.resolve(); + private cookie = ''; + private csrf = ''; + private origin = ''; + private owner = ''; + constructor( + private readonly context: () => { config: ClientConfig; pb: PocketBase }, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + invoke(command: SdpBrokerCommand): Promise { + const result = this.pending.then(() => this.perform(command)); + this.pending = result.catch(() => undefined); + return result; + } + private async perform(command: SdpBrokerCommand): Promise { + const { config, pb } = this.context(); + const owner = `${config.serverUrl}\0${config.secret}`; + if (owner !== this.owner) { + this.cookie = ''; + this.csrf = ''; + this.owner = owner; + } + try { + const discovery = await pb + .collection(SDP_DISCOVERY_COLLECTION) + .getOne(SDP_DISCOVERY_ID, { requestKey: null }); + if (!discovery.enabled) { + this.cookie = ''; + return { view: { configured: false, status: 'disconnected' } }; + } + const port: unknown = discovery.gatewayPort; + if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) + throw new Error('Invalid gateway configuration.'); + const url = new URL(config.serverUrl); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) + throw new Error('Invalid Relay address.'); + url.port = String(port); + if (this.origin !== url.origin) { + this.cookie = ''; + this.csrf = ''; + this.origin = url.origin; + } + if (!this.cookie) { + const login = await this.request('/session/login', { passphrase: config.secret }); + const body = (await this.json(login)) as { session?: { csrfToken?: string } }; + const cookie = login.headers.get('set-cookie')?.split(';')[0]; + if (!cookie?.startsWith('relay_web_session=') || !body.session?.csrfToken) + throw new Error('Relay session unavailable.'); + this.cookie = cookie; + this.csrf = body.session.csrfToken; + } + const response = await this.request('/sdp/account', command); + return SdpBrokerReplySchema.parse(await this.json(response)); + } catch { + // Do not silently reauthenticate and reuse a previous user's cached projection. + this.cookie = ''; + this.csrf = ''; + throw new Error('The Relay server connection is unavailable. Reconnect and sign in again.'); + } + } + private async json(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('Empty Relay response.'); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 15 * 1024 * 1024) throw new Error('Relay response too large.'); + chunks.push(part.value); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown; + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + } + private async request(path: string, body: unknown): Promise { + const response = await this.fetchImpl(`${this.origin}${RELAY_WEB_API_PREFIX}${path}`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(90_000), + headers: { + 'Content-Type': 'application/json', + Origin: this.origin, + ...(this.cookie ? { Cookie: this.cookie, 'X-Relay-CSRF': this.csrf } : {}), + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('Relay request failed.'); + } + return response; + } +} diff --git a/src/main/sdp/SdpHistory.test.ts b/src/main/sdp/SdpHistory.test.ts new file mode 100644 index 00000000..134fbc69 --- /dev/null +++ b/src/main/sdp/SdpHistory.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from 'vitest'; +import { readHistory } from './SdpHistory'; +import { SdpProvider } from './SdpProvider'; +it('reads paginated Cloud history without following provider links or losing changes', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ + history: [ + { + id: '7', + by: { name: 'Operator', photo_url: 'https://untrusted.test' }, + time: { value: '1789752022731' }, + operation: 'edit', + description: '', + diff: [ + { field: 'status', previous_value: { name: 'Open' }, current_value: { name: 'Closed' } }, + { field: 'technician', previous_value: null, current_value: { name: 'Example' } }, + ], + }, + ], + list_info: { has_more_rows: true }, + }); + const result = await readHistory(provider, 'token', new AbortController().signal, { + action: 'readHistory', + id: '123', + page: 2, + }); + const url = new URL(json.mock.calls[0]![0]); + expect(url.pathname).toBe('/app/itdesk/api/v3/requests/123/_history'); + expect(JSON.parse(url.searchParams.get('input_data')!).list_info.start_index).toBe(101); + expect(result.entries[0]!.changes).toEqual([ + { field: 'status', before: 'Open', after: 'Closed' }, + { field: 'technician', before: '', after: 'Example' }, + ]); + expect(JSON.stringify(result)).not.toContain('untrusted.test'); + expect(result.hasMore).toBe(true); +}); +it('rejects malformed history instead of reporting an empty successful page', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue({ history: {} }); + await expect( + readHistory(provider, 'token', new AbortController().signal, { + action: 'readHistory', + id: '123', + page: 0, + }), + ).rejects.toThrow(); +}); + +it('omits invalid provider timestamps so history remains renderable', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue({ + history: [{ id: '1', time: { value: '1e30' } }], + }); + const result = await readHistory(provider, 'token', new AbortController().signal, { + action: 'readHistory', + id: '123', + page: 0, + }); + expect(result.entries[0]!.at).toBeNull(); +}); + +it('formats structured history values without leaking object coercion', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue({ + history: [ + { + id: '1', + diff: [ + { + field: 'values', + previous_value: [true, 3, { name: 'NOC' }], + current_value: { unrelated: 'hidden' }, + }, + ], + }, + ], + }); + const result = await readHistory(provider, 'token', new AbortController().signal, { + action: 'readHistory', + id: '123', + page: 0, + }); + expect(result.entries[0]!.changes).toEqual([ + { field: 'values', before: 'true, 3, NOC', after: '' }, + ]); +}); diff --git a/src/main/sdp/SdpHistory.ts b/src/main/sdp/SdpHistory.ts new file mode 100644 index 00000000..de2bd4ca --- /dev/null +++ b/src/main/sdp/SdpHistory.ts @@ -0,0 +1,58 @@ +import { SdpHistorySchema, type SdpHistoryCommand, type SdpHistory } from '@shared/sdpHistory'; +import { isObject, SdpProviderError, type SdpProvider } from './SdpProvider'; +import { resourceHeaders } from './SdpResources'; +const BASE = 'https://support.campingworld.com/app/itdesk/api/v3/requests'; +function display(value: unknown): string { + if (value === null || value === undefined || value === 'null') return ''; + if (Array.isArray(value)) return value.map(display).join(', ').slice(0, 12000); + if (isObject(value)) return display(value.display_value ?? value.name ?? value.value ?? value.id); + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value).slice(0, 12000) + : ''; +} +/** Cloud sandbox contract: GET requests/{id}/_history, independently paginated. */ +export async function readHistory( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: SdpHistoryCommand, +): Promise { + const url = new URL(`${BASE}/${command.id}/_history`); + url.searchParams.set( + 'input_data', + JSON.stringify({ + list_info: { + start_index: command.page * 50 + 1, + row_count: 50, + sort_field: 'time', + sort_order: 'desc', + }, + }), + ); + const value = await provider.json(url.toString(), signal, { headers: resourceHeaders(token) }); + if (!isObject(value) || !Array.isArray(value.history)) throw new SdpProviderError('invalid'); + return SdpHistorySchema.parse({ + id: command.id, + page: command.page, + hasMore: isObject(value.list_info) && value.list_info.has_more_rows === true, + entries: value.history.map((row) => { + if (!isObject(row)) throw new SdpProviderError('invalid'); + const at = isObject(row.time) ? Number(row.time.value) : Number.NaN; + return { + id: String(row.id), + author: isObject(row.by) ? display(row.by.name).slice(0, 250) : '', + at: Number.isFinite(at) && at >= 0 && at <= 8640000000000000 ? at : null, + operation: display(row.operation).slice(0, 200), + description: display(row.description), + changes: (Array.isArray(row.diff) ? row.diff : []).map((change) => { + if (!isObject(change)) throw new SdpProviderError('invalid'); + return { + field: display(change.field).slice(0, 250), + before: display(change.previous_value), + after: display(change.current_value), + }; + }), + }; + }), + }); +} diff --git a/src/main/sdp/SdpMutations.test.ts b/src/main/sdp/SdpMutations.test.ts new file mode 100644 index 00000000..f9086841 --- /dev/null +++ b/src/main/sdp/SdpMutations.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SdpProvider } from './SdpProvider'; +import { submitMutation } from './SdpMutations'; +import { SdpMutationSchema, SDP_DEFAULT_INCIDENT_TEMPLATE } from '@shared/sdpMutation'; +describe('SDP mutation boundary', () => { + it('only submits supported fields and encodes operator text as text', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ + response_status: { status_code: 2000 }, + request: { id: '123', display_id: '7' }, + }); + await submitMutation(provider, 'token', new AbortController().signal, { + kind: 'create', + majorIncident: true, + fields: { subject: 'Test', description: '\nTest', group: 'NOC' }, + templateId: SDP_DEFAULT_INCIDENT_TEMPLATE.id, + }); + const input = JSON.parse( + new URLSearchParams(json.mock.calls[0]![2]?.body as string).get('input_data')!, + ); + expect(input).toEqual({ + request: { + subject: 'Test', + description: '<img src=x>
Test', + group: { name: 'NOC' }, + template: { id: SDP_DEFAULT_INCIDENT_TEMPLATE.id }, + udf_fields: { txt_major_incident: ['Yes'] }, + }, + }); + expect( + SdpMutationSchema.safeParse({ kind: 'update', id: '../anything', fields: { status: 'Open' } }) + .success, + ).toBe(false); + expect( + SdpMutationSchema.safeParse({ kind: 'update', id: '1', fields: { arbitrary: true } }).success, + ).toBe(false); + }); + it('keeps notes private unless explicitly reviewed as requester-visible', async () => { + const provider = new SdpProvider(); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValue({ response_status: { status_code: 2000 } }); + await submitMutation(provider, 'token', new AbortController().signal, { + kind: 'note', + id: '123', + body: 'Internal', + showToRequester: false, + }); + const input = JSON.parse( + new URLSearchParams(json.mock.calls[0]![2]?.body as string).get('input_data')!, + ); + expect(input.request_note).toMatchObject({ + show_to_requester: false, + notify_technician: false, + add_to_linked_requests: false, + }); + }); + it('preserves full-length subjects and only checks Major Incident for an explicit major create', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ + response_status: { status_code: 2000 }, + request: { id: '123' }, + }); + const subject = 'A'.repeat(250); + for (const majorIncident of [true, false]) { + const mutation = SdpMutationSchema.parse({ + kind: 'create', + fields: { subject }, + majorIncident, + }); + await submitMutation(provider, 'token', new AbortController().signal, mutation); + const input = JSON.parse( + new URLSearchParams(json.mock.lastCall![2]?.body as string).get('input_data')!, + ); + expect(input.request.subject).toBe(subject); + expect(input.request.udf_fields).toEqual( + majorIncident ? { txt_major_incident: ['Yes'] } : undefined, + ); + expect(input.request.template).toEqual( + majorIncident ? { id: SDP_DEFAULT_INCIDENT_TEMPLATE.id } : undefined, + ); + } + expect( + SdpMutationSchema.safeParse({ + kind: 'create', + fields: { subject }, + majorIncident: true, + templateId: '42', + }).success, + ).toBe(false); + }); +}); + +it('forwards with explicit recipients, private visibility and the Cloud forward type', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockImplementation(async (url, _signal, init) => { + if (init?.method) return { response_status: { status_code: 2000 } }; + if (url.includes('/notifications/_links')) return { _links: [{ name: 'add', method: 'post' }] }; + return { + request: { + id: '123', + subject: 'Synthetic', + requester: { email_id: 'requester@example.test' }, + }, + }; + }); + await submitMutation(provider, 'token', new AbortController().signal, { + kind: 'forward', + id: '123', + to: ['recipient@example.test'], + cc: [], + bcc: [], + subject: 'Fwd: Synthetic', + body: '', + isPublic: false, + }); + const call = json.mock.calls.find((c) => c[2]?.method)!; + expect(call[0]).toMatch(/\/requests\/123\/notifications$/); + expect( + JSON.parse(new URLSearchParams(call[2]!.body as string).get('input_data')!).notification, + ).toMatchObject({ + type: 'REQFORWARD', + in_reply_to: { id: '123' }, + to: ['recipient@example.test'], + is_public: false, + description: '<script>not executable</script>', + }); +}); diff --git a/src/main/sdp/SdpMutations.ts b/src/main/sdp/SdpMutations.ts new file mode 100644 index 00000000..eece7d45 --- /dev/null +++ b/src/main/sdp/SdpMutations.ts @@ -0,0 +1,222 @@ +import { editedRequest, validateFormMutation } from './SdpForms'; +import type { SdpForm } from '@shared/sdpForm'; +import { attachmentBody } from './SdpAttachments'; +import { createHash } from 'node:crypto'; +import { relationBaseline } from './SdpTicketRelations'; +import { + SDP_DEFAULT_INCIDENT_TEMPLATE, + type SdpMutation, + type SdpChangeResult, + type SdpRequestFields, +} from '@shared/sdpMutation'; +import { + scalarText, + SdpProvider, + SdpProviderError, + SdpValidationError, + validationFields, +} from './SdpProvider'; + +import { resourceBaseline, resourceInput, resourcePath } from './SdpResources'; +const BASE = 'https://support.campingworld.com/app/itdesk/api/v3/requests'; +const object = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const headers = (token: string) => ({ + Authorization: `Zoho-oauthtoken ${token}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', +}); +const html = (text: string): string => + text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('\n', '
'); +function requestFields(fields: SdpRequestFields): Record { + const request: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (key === 'subject') request.subject = value; + else if (key === 'description') request.description = html(String(value)); + else if (key === 'resolution') request.resolution = { content: html(String(value)) }; + else + request[key === 'requestType' ? 'request_type' : key] = + value === null ? null : { name: value }; + } + return request; +} +/** Compare a fresh upstream record immediately before updating; no raw record leaves main. */ +export async function mutationBaseline( + provider: SdpProvider, + token: string, + signal: AbortSignal, + id: string, + mutation?: SdpMutation, +): Promise { + const value = await provider.json(`${BASE}/${id}`, signal, { headers: headers(token) }); + if (!object(value) || !object(value.request) || String(value.request.id) !== id) + throw new SdpProviderError('invalid'); + // Stable key ordering prevents JSON property order from producing false conflicts. + const canonical = (item: unknown): unknown => { + if (Array.isArray(item)) return item.map(canonical); + if (!object(item)) return item; + return Object.fromEntries( + Object.keys(item) + .sort((a, b) => a.localeCompare(b)) + .map((key) => [key, canonical(item[key])]), + ); + }; + const resource = mutation?.kind === 'resource' ? mutation : undefined; + let snapshot: unknown = value.request; + if (mutation?.kind === 'relation') + snapshot = { + request: value.request, + target: await relationBaseline(provider, token, signal, mutation), + }; + else if (resource) + snapshot = { + request: value.request, + resource: await resourceBaseline(provider, token, signal, resource), + }; + return createHash('sha256') + .update(JSON.stringify(canonical(snapshot))) + .digest('hex'); +} +function mutationInput( + mutation: Exclude, + form?: SdpForm, +): Record { + if (mutation.kind === 'relation') + return mutation.operation === 'merge' + ? { merge_requests: [{ id: mutation.targetId }] } + : { link_requests: [{ linked_request: { id: mutation.targetId } }] }; + if (mutation.kind === 'edit') return { request: editedRequest(mutation.fields, html, form) }; + if (mutation.kind === 'reply' || mutation.kind === 'forward') + return { + notification: { + to: mutation.to, + cc: mutation.cc, + bcc: mutation.bcc, + subject: mutation.subject, + description: html(mutation.body), + is_public: mutation.isPublic, + type: mutation.kind === 'forward' ? 'REQFORWARD' : 'REQREPLY', + in_reply_to: { + id: mutation.kind === 'forward' ? (mutation.sourceId ?? mutation.id) : mutation.id, + }, + }, + }; + if (mutation.kind === 'attachment') return {}; + if (mutation.kind === 'resource') return resourceInput(mutation); + if (mutation.kind === 'note') + return { + request_note: { + description: html(mutation.body), + show_to_requester: mutation.showToRequester, + notify_technician: false, + mark_first_response: false, + add_to_linked_requests: false, + }, + }; + return requestInput(mutation); +} +function requestInput( + mutation: Extract, +): Record { + const request = requestFields(mutation.fields); + if (mutation.kind === 'create') { + if (mutation.templateId) request.template = { id: mutation.templateId }; + if (mutation.requesterEmail) request.requester = { email_id: mutation.requesterEmail }; + if (mutation.majorIncident) { + request.template = { id: SDP_DEFAULT_INCIDENT_TEMPLATE.id }; + // This tenant's Major Incident checkbox is a multi-choice custom field. + request.udf_fields = { txt_major_incident: ['Yes'] }; + } + } + return { request }; +} + +export async function submitMutation( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpMutation, +): Promise { + if (mutation.kind === 'bulk') throw new Error('Bulk changes require individual result handling.'); + const form = await validateFormMutation(provider, token, signal, mutation); + const { path, method } = mutationRequest(mutation); + const input = mutationInput(mutation, form); + let value: unknown; + try { + value = await provider.json(path, signal, { + method, + headers: + mutation.kind === 'attachment' + ? headers(token) + : { ...headers(token), 'Content-Type': 'application/x-www-form-urlencoded' }, + ...(method !== 'DELETE' || mutation.kind === 'relation' + ? { + body: + mutation.kind === 'attachment' + ? attachmentBody(mutation) + : new URLSearchParams({ input_data: JSON.stringify(input) }).toString(), + } + : {}), + }); + } catch (error) { + if (error instanceof SdpValidationError) throw error; + if (error instanceof SdpProviderError && error.kind === 'denied') throw error; + // A timed-out write may already have succeeded. Never automatically replay it. + throw new Error( + 'SDP did not confirm the change. Check the ticket in SDP before trying again; Relay will not retry automatically.', + ); + } + checkMutationStatus(value); + const request = object(value) && object(value.request) ? value.request : undefined; + const id = mutation.kind === 'create' ? scalarText(request?.id) : mutation.id; + if (!/^\d{1,30}$/.test(id)) + throw new Error( + 'SDP returned an incomplete confirmation. Check SDP before creating another ticket.', + ); + return { id, number: scalarText(request?.display_id, id).slice(0, 50), kind: mutation.kind }; +} +function checkMutationStatus(value: unknown) { + const status = object(value) ? value.response_status : undefined; + if (object(status) && status.status_code === 3000) { + const fields = validationFields(value); + const detail = fields.length ? ' Check required fields: ' + fields.join(', ') + '.' : ''; + throw new Error( + `SDP applied only part of the change. Refresh the ticket before trying again.${detail}`, + ); + } + if (!object(status) || status.status_code !== 2000) + throw new Error( + 'SDP rejected the change. Check your permissions, field names, template and required fields in SDP before preparing a new change.', + ); +} + +function mutationRequest(mutation: Exclude): { + path: string; + method: string; +} { + if (mutation.kind === 'relation') + return { + path: `${BASE}/${mutation.id}/_${mutation.operation === 'merge' ? 'merge_requests' : 'link_requests'}`, + method: { link: 'POST', unlink: 'DELETE', merge: 'PUT' }[mutation.operation], + }; + let path = BASE; + if (mutation.kind !== 'create') path += `/${mutation.id}`; + if (mutation.kind === 'note') path += '/notes'; + if (mutation.kind === 'reply' || mutation.kind === 'forward') path += '/notifications'; + if (mutation.kind === 'attachment') path += '/_uploads'; + if (mutation.kind === 'resource') { + path = resourcePath(mutation); + if (mutation.operation === 'approve' || mutation.operation === 'reject') + path += `/_${mutation.operation}`; + } + let method = mutation.kind === 'update' || mutation.kind === 'edit' ? 'PUT' : 'POST'; + if (mutation.kind === 'resource') { + method = { create: 'POST', update: 'PUT', delete: 'DELETE', approve: 'PUT', reject: 'PUT' }[ + mutation.operation + ]; + } + return { path, method }; +} diff --git a/src/main/sdp/SdpProvider.test.ts b/src/main/sdp/SdpProvider.test.ts new file mode 100644 index 00000000..348be3ff --- /dev/null +++ b/src/main/sdp/SdpProvider.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + scalarText, + projectProperties, + SdpProvider, + projectTestTicket, + projectQueue, +} from './SdpProvider'; +import { loggers } from '../logger'; +const fixture = { + requests: [ + { + display_id: '810129', + status: { name: 'Open' }, + priority: { name: 'Low' }, + group: { name: 'NOC' }, + }, + ], +}; +describe('SDP provider boundary', () => { + it('keeps scalar identifiers and rejects structured values without coercing them', () => { + expect(scalarText('00123')).toBe('00123'); + expect(scalarText(123)).toBe('123'); + for (const value of [null, undefined, true, [], {}, NaN, Infinity, { toString: () => '123' }]) { + expect(scalarText(value)).toBe(''); + expect(scalarText(value, 'fallback')).toBe('fallback'); + } + }); + it('returns bounded field identifiers for rejected writes without exposing provider error text', async () => { + const remote = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + response_status: { + status_code: 4000, + messages: [ + { + fields: ['category', 'udf_char23', '', + ownerEmail: 'user@example.test', + status: 'Open', + scheduledStart: '2026-09-17T10:00:00Z', + }, + }); + expect(resourceInput(task)).toEqual({ + task: { + title: 'Investigate', + description: '<script>test</script>', + owner: { email_id: 'user@example.test' }, + status: { name: 'Open' }, + scheduled_start_time: { value: String(Date.parse('2026-09-17T10:00:00Z')) }, + }, + }); + const worklog = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'worklogs', + operation: 'create', + fields: { ownerEmail: 'user@example.test', hours: '1', minutes: '30' }, + }); + expect(resourceInput(worklog)).toEqual({ + worklog: { + owner: { email_id: 'user@example.test' }, + time_spent: { hours: '1', minutes: '30' }, + mark_first_response: false, + }, + }); + }); + it('projects only supported fields and paginates within the selected request', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockResolvedValue({ + tasks: [ + { + id: '4', + title: 'Review', + status: { name: 'Open' }, + owner: { email_id: 'user@example.test', private_profile: 'omit' }, + unknown: 'omit', + }, + ], + list_info: { has_more_rows: true }, + }); + const page = await readResources(provider, 'token', signal, { + action: 'readResources', + id: '123', + resource: 'tasks', + page: 1, + }); + expect(page).toMatchObject({ + page: 1, + hasMore: true, + rows: [ + { + id: '4', + title: 'Review', + fields: { title: 'Review', status: 'Open', ownerEmail: 'user@example.test' }, + }, + ], + }); + expect(page.rows[0]?.fields.scheduledStart).toBeUndefined(); + expect(JSON.stringify(page)).not.toContain('omit'); + expect(new URL(json.mock.calls[0]![0]).searchParams.get('input_data')).toContain('51'); + }); + it('includes child-record changes in the review conflict check', async () => { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json'); + const mutation = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'tasks', + recordId: '4', + operation: 'update', + fields: { status: 'Closed' }, + }); + json + .mockResolvedValueOnce({ request: { id: '123' } }) + .mockResolvedValueOnce({ task: { id: '4', title: 'Before' } }); + const before = await mutationBaseline(provider, 'token', signal, '123', mutation); + json + .mockResolvedValueOnce({ request: { id: '123' } }) + .mockResolvedValueOnce({ task: { id: '4', title: 'After' } }); + expect(await mutationBaseline(provider, 'token', signal, '123', mutation)).not.toBe(before); + }); + it.each(['approve', 'reject', 'delete'] as const)( + 'uses the documented %s endpoint once', + async (operation) => { + const provider = new SdpProvider(); + const json = vi + .spyOn(provider, 'json') + .mockResolvedValue({ response_status: { status_code: 2000 } }); + const mutation = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'approvals', + levelId: '4', + recordId: '9', + operation, + fields: operation === 'delete' ? {} : { comments: 'Reviewed' }, + }); + expect(await submitMutation(provider, 'token', signal, mutation)).toMatchObject({ + id: '123', + kind: 'resource', + }); + expect(json).toHaveBeenCalledTimes(1); + expect(json.mock.calls[0]![0]).toBe( + resourcePath(mutation) + (operation === 'delete' ? '' : `/_${operation}`), + ); + expect(json.mock.calls[0]![2]?.method).toBe(operation === 'delete' ? 'DELETE' : 'PUT'); + }, + ); +}); + +it('encodes checklists, nested answers and personal reminders using the Cloud contracts', () => { + const checklist = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'checklists', + operation: 'create', + fields: { name: 'Verify recovery', checklistTemplateId: '55' }, + }); + expect(resourceInput(checklist)).toEqual({ + checklist: { name: 'Verify recovery', checklist_template: { id: '55' } }, + }); + const item = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'checklistitems', + checklistId: '55', + recordId: '66', + operation: 'update', + fields: { completed: 'true', value: 'Verified' }, + }); + expect(resourcePath(item)).toMatch(/\/requests\/123\/checklists\/55\/checklistitems\/66$/); + expect(resourceInput(item)).toEqual({ + checklist_item: { is_completed: true, cl_value: 'Verified' }, + }); + const reminder = SdpResourceMutationSchema.parse({ + kind: 'resource', + id: '123', + resource: 'reminders', + operation: 'create', + fields: { summary: 'Follow up', reminderDate: '2026-09-21T12:00:00Z', remindBefore: '30' }, + }); + expect(resourceInput(reminder)).toEqual({ + reminder: { + summary: 'Follow up', + date: { value: String(Date.parse('2026-09-21T12:00:00Z')) }, + remind_before: { minutes: 30 }, + }, + }); +}); +it('rejects missing checklist parents, unsupported deletion and invalid reminder fields', () => { + for (const mutation of [ + { + kind: 'resource', + id: '123', + resource: 'checklistitems', + operation: 'create', + fields: { itemId: '77' }, + }, + { + kind: 'resource', + id: '123', + resource: 'checklistitems', + checklistId: '55', + recordId: '66', + operation: 'delete', + fields: {}, + }, + { + kind: 'resource', + id: '123', + resource: 'reminders', + operation: 'create', + fields: { summary: 'Follow up', reminderDate: 'tomorrow' }, + }, + { + kind: 'resource', + id: '123', + resource: 'reminders', + recordId: '66', + operation: 'update', + fields: { remindBefore: '-5' }, + }, + ]) + expect(SdpResourceMutationSchema.safeParse(mutation).success).toBe(false); +}); +it('reads checklist answers without dropping false completion values', async () => { + const provider = new SdpProvider(); + vi.spyOn(provider, 'json').mockResolvedValue({ + checklistitems: [ + { + id: '66', + item: { id: '77', name: 'Verify backup' }, + is_completed: false, + cl_value: 'Pending', + order: 1, + }, + ], + list_info: { has_more_rows: false }, + }); + const result = await readResources(provider, 'token', new AbortController().signal, { + action: 'readResources', + id: '123', + resource: 'checklistitems', + checklistId: '55', + page: 0, + }); + expect(result.rows[0]).toMatchObject({ + title: 'Verify backup', + fields: { itemId: '77', completed: 'false', value: 'Pending' }, + }); +}); diff --git a/src/main/sdp/SdpResources.ts b/src/main/sdp/SdpResources.ts new file mode 100644 index 00000000..440d2801 --- /dev/null +++ b/src/main/sdp/SdpResources.ts @@ -0,0 +1,273 @@ +import { + SDP_RESOURCE_FIELDS, + SdpResourcePageSchema, + SdpResourceChoicesSchema, + type SdpResourceCommand, + type SdpResourceMutation, + type SdpResourceName, + type SdpResourcePage, +} from '@shared/sdpResources'; +import { SdpProvider, SdpProviderError, isObject } from './SdpProvider'; +const BASE = 'https://support.campingworld.com/app/itdesk/api/v3/requests'; +export const resourceHeaders = (token: string) => ({ + Authorization: `Zoho-oauthtoken ${token}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', +}); +export function resourcePath(value: { + id: string; + resource: SdpResourceName; + levelId?: string; + checklistId?: string; + recordId?: string; +}): string { + let nested: string = value.resource; + if (value.resource === 'approvals') nested = `approval_levels/${value.levelId}/approvals`; + if (value.resource === 'checklistitems') + nested = `checklists/${value.checklistId}/checklistitems`; + const suffix = value.recordId ? '/' + value.recordId : ''; + return `${BASE}/${value.id}/${nested}${suffix}`; +} +const wrappers: Record = { + tasks: 'task', + worklogs: 'worklog', + approval_levels: 'approval_level', + approvals: 'approval', + checklists: 'checklist', + reminders: 'reminder', + checklistitems: 'checklist_item', +}; +type Codec = + | 'name' + | 'date' + | 'email' + | 'id' + | 'scalar' + | 'html' + | 'boolean' + | 'value' + | 'hours' + | 'minutes' + | 'reminderMinutes'; +const fieldMap: Record = { + summary: ['summary', 'scalar'], + reminderDate: ['date', 'date'], + remindBefore: ['remind_before', 'reminderMinutes'], + reminderStatus: ['status', 'scalar'], + name: ['name', 'scalar'], + checklistTemplateId: ['checklist_template', 'id'], + itemId: ['item', 'id'], + order: ['order', 'scalar'], + completed: ['is_completed', 'boolean'], + value: ['cl_value', 'scalar'], + title: ['title', 'scalar'], + description: ['description', 'html'], + comments: ['comments', 'scalar'], + status: ['status', 'name'], + priority: ['priority', 'name'], + taskType: ['task_type', 'name'], + scheduledStart: ['scheduled_start_time', 'date'], + scheduledEnd: ['scheduled_end_time', 'date'], + actualStart: ['actual_start_time', 'date'], + actualEnd: ['actual_end_time', 'date'], + startTime: ['start_time', 'date'], + endTime: ['end_time', 'date'], + completion: ['percentage_completion', 'scalar'], + effortDays: ['estimated_effort_days', 'scalar'], + effortHours: ['estimated_effort_hours', 'scalar'], + effortMinutes: ['estimated_effort_minutes', 'scalar'], + additionalCost: ['additional_cost', 'scalar'], + exchangeRate: ['exchange_rate', 'scalar'], + level: ['level', 'scalar'], + ownerEmail: ['owner', 'email'], + approverEmail: ['approver', 'email'], + templateId: ['template', 'id'], + currencyId: ['currency', 'id'], + includeNonoperational: ['include_nonoperational_hours', 'boolean'], + techCharge: ['tech_charge', 'value'], + otherCharge: ['other_charge', 'value'], + hours: ['time_spent', 'hours'], + minutes: ['time_spent', 'minutes'], +}; +const html = (text: string) => + text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('\n', '
'); +function encode(value: string, codec: Codec): unknown { + switch (codec) { + case 'reminderMinutes': + return { minutes: Number(value) }; + case 'name': + return { name: value }; + case 'email': + return { email_id: value }; + case 'id': + return { id: value }; + case 'date': + return { value: String(Date.parse(value)) }; + case 'value': + return { value }; + case 'html': + return html(value); + case 'boolean': + return value === 'true'; + default: + return value; + } +} +export function resourceInput(value: SdpResourceMutation): Record { + const result: Record = {}; + for (const [key, field] of Object.entries(value.fields)) { + if (key === 'hours' || key === 'minutes') continue; + const [target, codec] = fieldMap[key]!; + result[target] = encode(field, codec); + } + if (value.resource === 'worklogs') { + result.mark_first_response = false; + if (value.fields.hours !== undefined || value.fields.minutes !== undefined) + result.time_spent = { + hours: value.fields.hours ?? '0', + minutes: value.fields.minutes ?? '0', + }; + } + return { [wrappers[value.resource]]: result }; +} +function scalar(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return String(value).slice(0, 12000); + return ''; +} +function nested(value: unknown, key: string): string { + return isObject(value) ? scalar(value[key]) : ''; +} +function decode(value: unknown, codec: Codec): string { + switch (codec) { + case 'name': + return nested(value, 'name'); + case 'email': + return nested(value, 'email_id'); + case 'id': + return nested(value, 'id'); + case 'value': + return nested(value, 'value'); + case 'hours': + return nested(value, 'hours'); + case 'reminderMinutes': + case 'minutes': + return nested(value, 'minutes'); + case 'date': { + const stamp = nested(value, 'value'); + if (!stamp) return ''; + const date = new Date(Number(stamp)); + return Number.isFinite(date.getTime()) ? date.toISOString() : ''; + } + default: + return scalar(value); + } +} +function projectFields( + row: Record, + resource: SdpResourceName, +): Record { + const fields: Record = {}; + for (const key of Object.keys(SDP_RESOURCE_FIELDS[resource])) { + const [source, codec] = fieldMap[key]!; + const value = decode(row[source], codec); + if (value) fields[key] = value; + } + return fields; +} +export async function readResources( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: SdpResourceCommand, +): Promise { + const url = new URL(resourcePath(command)); + url.searchParams.set( + 'input_data', + JSON.stringify({ list_info: { start_index: command.page * 50 + 1, row_count: 50 } }), + ); + const value = await provider.json(url.toString(), signal, { headers: resourceHeaders(token) }); + if (!isObject(value) || !Array.isArray(value[command.resource])) + throw new SdpProviderError('invalid'); + const rows = (value[command.resource] as unknown[]).map((row) => { + if (!isObject(row)) throw new SdpProviderError('invalid'); + const fields = projectFields(row, command.resource); + return { + id: scalar(row.id), + title: scalar( + row.title || + row.summary || + row.name || + nested(row.item, 'name') || + fields.approverEmail || + fields.description || + fields.level || + row.id, + ).slice(0, 500), + status: + nested(row.status, 'name') || nested(row.approval_status, 'name') || scalar(row.status), + fields, + }; + }); + return SdpResourcePageSchema.parse({ + id: command.id, + resource: command.resource, + levelId: command.levelId, + checklistId: command.checklistId, + page: command.page, + rows, + hasMore: isObject(value.list_info) && value.list_info.has_more_rows === true, + }); +} +export async function resourceBaseline( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpResourceMutation, +): Promise { + if (!mutation.recordId) return null; + const value = await provider.json(resourcePath(mutation), signal, { + headers: resourceHeaders(token), + }); + const record = isObject(value) ? value[wrappers[mutation.resource]] : undefined; + if (!isObject(record) || String(record.id) !== mutation.recordId) + throw new SdpProviderError('invalid'); + return record; +} + +/** Read catalog definitions only; Relay never modifies checklist setup. */ +export async function readResourceChoices( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: import('@shared/sdpResources').SdpResourceChoicesCommand, +) { + const url = new URL(`https://support.campingworld.com/app/itdesk/api/v3/${command.catalog}`); + url.searchParams.set( + 'input_data', + JSON.stringify({ + list_info: { + row_count: 50, + start_index: command.page * 50 + 1, + ...(command.search + ? { search_criteria: { field: 'name', condition: 'contains', value: command.search } } + : {}), + }, + }), + ); + const value = await provider.json(url.toString(), signal, { headers: resourceHeaders(token) }); + if (!isObject(value) || !Array.isArray(value[command.catalog])) + throw new SdpProviderError('invalid'); + return SdpResourceChoicesSchema.parse({ + catalog: command.catalog, + page: command.page, + hasMore: isObject(value.list_info) && value.list_info.has_more_rows === true, + choices: (value[command.catalog] as unknown[]).map((row) => { + if (!isObject(row)) throw new SdpProviderError('invalid'); + return { id: String(row.id), name: scalar(row.name).slice(0, 250) }; + }), + }); +} diff --git a/src/main/sdp/SdpRuntime.ts b/src/main/sdp/SdpRuntime.ts new file mode 100644 index 00000000..a75c9c40 --- /dev/null +++ b/src/main/sdp/SdpRuntime.ts @@ -0,0 +1,126 @@ +import { app, safeStorage } from 'electron'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import type PocketBase from 'pocketbase'; +import type { AppConfig } from '../config/AppConfig'; +import { + SDP_DISCOVERY_COLLECTION, + SDP_DISCOVERY_ID, + type SdpBackend, + type SdpServerCommand, + type SdpServerView, +} from '@shared/sdpAccount'; +import { SdpBroker } from './SdpBroker'; +import { SdpServerStore } from './SdpServerStore'; +import { SdpGatewayClient } from './SdpGatewayClient'; + +type Context = { getConfig: () => AppConfig | null; getPb: () => PocketBase | null }; +let context: Context | undefined; +let closed = false; +let broker: SdpBroker | undefined; +let timer: ReturnType | undefined; +let published = ''; +let publishing = false; +const localId = `desktop:${randomUUID()}`; +let remote: SdpGatewayClient | undefined; +export function initializeSdpRuntime(value: Context): void { + context = value; + closed = false; + timer = setInterval(() => { + void publishSdpDiscovery().catch(() => undefined); + }, 15_000); + timer.unref(); + app.once('before-quit', () => { + closed = true; + clearInterval(timer); + broker?.dispose(); + broker = undefined; + }); +} +export function getSdpBroker(): SdpBroker | null { + if (closed) return null; + if (context?.getConfig()?.load()?.mode !== 'server') { + broker?.dispose(); + broker = undefined; + return null; + } + broker ??= new SdpBroker( + new SdpServerStore(join(app.getPath('userData'), 'sdp-server'), { + isEncryptionAvailable: () => + safeStorage.isEncryptionAvailable() && + (process.platform !== 'linux' || safeStorage.getSelectedStorageBackend() !== 'basic_text'), + encryptString: (value) => safeStorage.encryptString(value), + decryptString: (value) => safeStorage.decryptString(value), + }), + ); + return broker; +} +export async function publishSdpDiscovery(): Promise { + if (publishing) return; + const config = context?.getConfig()?.load(); + const pb = context?.getPb(); + if (config?.mode !== 'server') { + getSdpBroker(); + published = ''; + return; + } + if (!pb?.authStore.isValid || pb.authStore.record?.collectionName !== '_superusers') return; + publishing = true; + try { + const settings = getSdpBroker()?.store.settings(); + const body = { + enabled: !!settings && config.web?.enabled === true, + gatewayPort: config.web?.port ?? 8091, + revision: settings?.revision ?? '', + }; + const fingerprint = `${pb.baseURL}:${JSON.stringify(body)}`; + if (published === fingerprint) return; + const records = pb.collection(SDP_DISCOVERY_COLLECTION); + try { + await records.update(SDP_DISCOVERY_ID, body, { requestKey: null }); + } catch (error) { + if ((error as { status?: number }).status !== 404) throw error; + await records.create({ id: SDP_DISCOVERY_ID, ...body }, { requestKey: null }); + } + published = fingerprint; + } finally { + publishing = false; + } +} +export const sdpBackend: SdpBackend = { + async invoke(command) { + const config = context?.getConfig()?.load(); + if (config?.mode === 'server') return getSdpBroker()!.invoke(localId, command); + if (config?.mode !== 'client') return { view: { configured: false, status: 'disconnected' } }; + remote ??= new SdpGatewayClient(() => { + const current = context?.getConfig()?.load(); + const pb = context?.getPb(); + if (current?.mode !== 'client' || !pb) throw new Error('Relay connection unavailable.'); + return { config: current, pb }; + }); + return remote.invoke(command); + }, +}; +export async function sdpServerCommand(command: SdpServerCommand): Promise { + const config = context?.getConfig()?.load(); + if (config?.mode !== 'server') throw new Error('Configure SDP on the Relay server computer.'); + const current = getSdpBroker()!; + if (command.action !== 'status') { + current.store.save( + command.action === 'save' ? command.client : null, + command.action === 'save' ? command.cacheMinutes : 60, + command.expectedRevision, + ); + current.reset(); + published = ''; + } + await publishSdpDiscovery(); + const settings = current.store.settings(); + return { + configured: !!settings, + revision: settings?.revision ?? '', + cacheMinutes: settings?.cacheMinutes ?? 60, + gatewayEnabled: config.web?.enabled === true, + gatewayPort: config.web?.port ?? 8091, + }; +} diff --git a/src/main/sdp/SdpServerStore.ts b/src/main/sdp/SdpServerStore.ts new file mode 100644 index 00000000..1c1b2180 --- /dev/null +++ b/src/main/sdp/SdpServerStore.ts @@ -0,0 +1,232 @@ +import Database from 'better-sqlite3'; +import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'; +import { join } from 'node:path'; +import { + SdpClientSchema, + SdpDetailSchema, + type SdpDetail, + type SdpClient, + type SdpQueuePage, + SdpQueuePageSchema, + type SdpTestTicket, +} from '@shared/sdpAccount'; + +export type SdpDetailSnapshot = { detail: SdpDetail; fetchedAt: number; expiresAt: number }; +export type SdpSettings = { client: SdpClient; cacheMinutes: number; revision: string }; +export type SdpQueueSnapshot = { queuePage: SdpQueuePage; fetchedAt: number; expiresAt: number }; +export type SdpSnapshot = { ticket: SdpTestTicket; fetchedAt: number; expiresAt: number }; +export type SdpKeyProtection = { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; +}; + +/** Server-private storage; it is never a PocketBase collection or a desktop offline cache. */ +export class SdpServerStore { + private readonly key: Buffer; + private readonly db: Database.Database; + private readonly configPath: string; + constructor(root: string, protection: SdpKeyProtection) { + if (!protection.isEncryptionAvailable()) + throw new Error('OS-protected SDP storage is unavailable.'); + mkdirSync(root, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); + const keyPath = join(root, 'key.bin'); + if (existsSync(keyPath)) + this.key = Buffer.from(protection.decryptString(readFileSync(keyPath)), 'base64'); + else { + this.key = randomBytes(32); + writeFileSync(keyPath, protection.encryptString(this.key.toString('base64')), { + mode: 0o600, + flag: 'wx', + }); + } + if (this.key.length !== 32) throw new Error('Invalid SDP storage key.'); + this.configPath = join(root, 'connection.enc'); + const dbPath = join(root, 'outage-cache.sqlite'); + this.db = new Database(dbPath); + chmodSync(dbPath, 0o600); + this.db.pragma('secure_delete = ON'); + this.db.pragma('journal_mode = DELETE'); + this.db.exec( + 'CREATE TABLE IF NOT EXISTS snapshots (owner TEXT PRIMARY KEY, expires INTEGER NOT NULL, body BLOB NOT NULL)', + ); + this.db.exec( + 'CREATE TABLE IF NOT EXISTS queue_snapshots (owner TEXT NOT NULL, page_key TEXT NOT NULL, expires INTEGER NOT NULL, body BLOB NOT NULL, PRIMARY KEY(owner, page_key))', + ); + this.db.exec( + 'CREATE TABLE IF NOT EXISTS detail_snapshots (owner TEXT NOT NULL, detail_key TEXT NOT NULL, expires INTEGER NOT NULL, body BLOB NOT NULL, PRIMARY KEY(owner, detail_key))', + ); + this.prune(); + } + private seal(value: unknown, context: string): Buffer { + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, nonce); + cipher.setAAD(Buffer.from(context)); + const body = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]); + return Buffer.concat([nonce, cipher.getAuthTag(), body]); + } + private open(value: Buffer, context: string): unknown { + const decipher = createDecipheriv('aes-256-gcm', this.key, value.subarray(0, 12)); + decipher.setAAD(Buffer.from(context)); + decipher.setAuthTag(value.subarray(12, 28)); + return JSON.parse( + Buffer.concat([decipher.update(value.subarray(28)), decipher.final()]).toString('utf8'), + ) as unknown; + } + settings(): SdpSettings | null { + if (!existsSync(this.configPath)) return null; + const value = this.open(readFileSync(this.configPath), 'sdp-connection') as SdpSettings | null; + if (!value) return null; + const client = SdpClientSchema.parse(value.client); + if ( + !Number.isInteger(value.cacheMinutes) || + value.cacheMinutes < 5 || + value.cacheMinutes > 240 || + typeof value.revision !== 'string' + ) + throw new Error('Invalid SDP configuration.'); + return { ...value, client }; + } + save(client: SdpClient | null, cacheMinutes: number, expectedRevision: string): void { + if ((this.settings()?.revision ?? '') !== expectedRevision) + throw new Error('SDP settings changed. Refresh before saving.'); + const value = client + ? { client: SdpClientSchema.parse(client), cacheMinutes, revision: randomUUID() } + : null; + if (client && (!Number.isInteger(cacheMinutes) || cacheMinutes < 5 || cacheMinutes > 240)) + throw new Error('Invalid cache duration.'); + const temp = `${this.configPath}.tmp`; + writeFileSync(temp, this.seal(value, 'sdp-connection'), { mode: 0o600 }); + renameSync(temp, this.configPath); + this.db.prepare('DELETE FROM snapshots').run(); + this.db.prepare('DELETE FROM queue_snapshots').run(); + this.db.prepare('DELETE FROM detail_snapshots').run(); + } + owner(zuid: string, revision: string): string { + return createHmac('sha256', this.key) + .update(`support.campingworld.com/itdesk\0${revision}\0${zuid}`) + .digest('hex'); + } + put(owner: string, snapshot: SdpSnapshot): void { + this.prune(); + this.db + .prepare('INSERT OR REPLACE INTO snapshots (owner,expires,body) VALUES (?,?,?)') + .run(owner, snapshot.expiresAt, this.seal(snapshot, owner)); + // The restricted test has one snapshot per user; bound even unattended server storage. + this.db + .prepare( + 'DELETE FROM snapshots WHERE owner IN (SELECT owner FROM snapshots ORDER BY expires DESC LIMIT -1 OFFSET 1000)', + ) + .run(); + } + get(owner: string): SdpSnapshot | null { + this.prune(); + const row = this.db.prepare('SELECT body FROM snapshots WHERE owner = ?').get(owner) as + { body: Buffer } | undefined; + if (!row) return null; + try { + const value = this.open(row.body, owner) as SdpSnapshot; + if (!value || value.expiresAt <= Date.now()) return null; + return value; + } catch { + this.remove(owner); + return null; + } + } + putQueue(owner: string, snapshot: SdpQueueSnapshot): void { + this.prune(); + const key = `${snapshot.queuePage.queue}:${snapshot.queuePage.page}`; + this.db + .prepare( + 'INSERT OR REPLACE INTO queue_snapshots (owner,page_key,expires,body) VALUES (?,?,?,?)', + ) + .run(owner, key, snapshot.expiresAt, this.seal(snapshot, `${owner}:${key}`)); + this.db + .prepare( + 'DELETE FROM queue_snapshots WHERE rowid IN (SELECT rowid FROM queue_snapshots ORDER BY expires DESC LIMIT -1 OFFSET 1000)', + ) + .run(); + } + getQueue(owner: string, queue: string, page: number): SdpQueueSnapshot | null { + this.prune(); + const key = `${queue}:${page}`; + const row = this.db + .prepare('SELECT body FROM queue_snapshots WHERE owner=? AND page_key=?') + .get(owner, key) as { body: Buffer } | undefined; + if (!row) return null; + try { + const value = this.open(row.body, `${owner}:${key}`) as SdpQueueSnapshot; + const queuePage = SdpQueuePageSchema.parse(value.queuePage); + if ( + queuePage.queue !== queue || + queuePage.page !== page || + !Number.isFinite(value.expiresAt) || + value.expiresAt <= Date.now() + ) + return null; + return { ...value, queuePage }; + } catch { + this.remove(owner); + return null; + } + } + putDetail(owner: string, snapshot: SdpDetailSnapshot): void { + this.prune(); + const key = `v2:${snapshot.detail.id}:${snapshot.detail.page}:${!!snapshot.detail.includeAutoNotifications}`; + this.db + .prepare( + 'INSERT OR REPLACE INTO detail_snapshots (owner,detail_key,expires,body) VALUES (?,?,?,?)', + ) + .run(owner, key, snapshot.expiresAt, this.seal(snapshot, `${owner}:detail:${key}`)); + this.db + .prepare( + 'DELETE FROM detail_snapshots WHERE rowid IN (SELECT rowid FROM detail_snapshots ORDER BY expires DESC LIMIT -1 OFFSET 200)', + ) + .run(); + } + getDetail( + owner: string, + id: string, + page: number, + includeAutoNotifications = false, + ): SdpDetailSnapshot | null { + this.prune(); + const key = `v2:${id}:${page}:${includeAutoNotifications}`; + const row = this.db + .prepare('SELECT body FROM detail_snapshots WHERE owner=? AND detail_key=?') + .get(owner, key) as { body: Buffer } | undefined; + if (!row) return null; + try { + const value = this.open(row.body, `${owner}:detail:${key}`) as SdpDetailSnapshot; + const detail = SdpDetailSchema.parse(value.detail); + if ( + detail.id !== id || + detail.page !== page || + !!detail.includeAutoNotifications !== includeAutoNotifications || + !Number.isFinite(value.expiresAt) || + value.expiresAt <= Date.now() + ) + return null; + return { ...value, detail }; + } catch { + this.remove(owner); + return null; + } + } + remove(owner: string): void { + this.db.prepare('DELETE FROM snapshots WHERE owner = ?').run(owner); + this.db.prepare('DELETE FROM queue_snapshots WHERE owner = ?').run(owner); + this.db.prepare('DELETE FROM detail_snapshots WHERE owner = ?').run(owner); + } + prune(): void { + this.db.prepare('DELETE FROM snapshots WHERE expires <= ?').run(Date.now()); + this.db.prepare('DELETE FROM queue_snapshots WHERE expires <= ?').run(Date.now()); + this.db.prepare('DELETE FROM detail_snapshots WHERE expires <= ?').run(Date.now()); + } + close(): void { + this.db.close(); + this.key.fill(0); + } +} diff --git a/src/main/sdp/SdpTicketRelations.test.ts b/src/main/sdp/SdpTicketRelations.test.ts new file mode 100644 index 00000000..1761b223 --- /dev/null +++ b/src/main/sdp/SdpTicketRelations.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi } from 'vitest'; +import { SdpProvider } from './SdpProvider'; +import { readTicketRelations } from './SdpTicketRelations'; +import { mutationBaseline, submitMutation } from './SdpMutations'; +import { SdpMutationSchema } from '@shared/sdpMutation'; +import { SdpBrokerReplySchema } from '@shared/sdpAccount'; +const signal = new AbortController().signal; +function setup() { + const provider = new SdpProvider(); + const json = vi.spyOn(provider, 'json').mockImplementation(async (url, _signal, init) => { + if (init?.method) return { response_status: { status_code: 2000 } }; + const path = new URL(url).pathname; + if (path.endsWith('/_links')) + return { + _links: [ + { name: 'link_requests', method: 'get' }, + { name: 'link_requests', method: 'post' }, + { name: 'link_requests', method: 'delete' }, + { name: 'merge_requests', method: 'put' }, + ], + }; + if (path.endsWith('/_link_requests')) + return { + link_requests: [{ linked_request: { id: '456', display_id: '2', subject: 'Duplicate' } }], + list_info: { has_more_rows: false }, + }; + if (path.endsWith('/requests')) + return { requests: [{ id: '456', display_id: '2', subject: 'Duplicate' }] }; + return { request: { id: path.split('/').at(-1), subject: 'Example' } }; + }); + return { provider, json }; +} +it.each(['link', 'unlink', 'merge'] as const)( + 'uses the verified Cloud %s payload after checking permissions and the target', + async (operation) => { + const { provider, json } = setup(); + await submitMutation(provider, 'token', signal, { + kind: 'relation', + id: '123', + targetId: '456', + operation, + }); + const [url, , init] = json.mock.lastCall!; + expect(url).toBe( + `https://support.campingworld.com/app/itdesk/api/v3/requests/123/_${operation === 'merge' ? 'merge_requests' : 'link_requests'}`, + ); + expect(init?.method).toBe({ link: 'POST', unlink: 'DELETE', merge: 'PUT' }[operation]); + const input = JSON.parse(new URLSearchParams(init?.body as string).get('input_data')!); + expect(input).toEqual( + operation === 'merge' + ? { merge_requests: [{ id: '456' }] } + : { link_requests: [{ linked_request: { id: '456' } }] }, + ); + expect(json.mock.calls.some(([u]) => u.endsWith('/456'))).toBe(true); + }, +); +it('projects links and searches by ticket number through the strict gateway schema', async () => { + const { provider, json } = setup(); + const ticketRelations = await readTicketRelations(provider, 'token', signal, { + action: 'readTicketRelations', + id: '123', + number: 'IN-2', + page: 0, + }); + expect(ticketRelations.candidate).toEqual({ id: '456', number: '2', subject: 'Duplicate' }); + expect(ticketRelations.linked).toHaveLength(1); + expect(json.mock.calls.find(([url]) => url.includes('_link_requests'))?.[0]).not.toContain('?'); + const input = JSON.parse(new URL(json.mock.lastCall![0]).searchParams.get('input_data')!); + expect(input.list_info.search_criteria).toEqual({ + field: 'display_id', + condition: 'is', + value: '2', + }); + expect( + SdpBrokerReplySchema.safeParse({ + view: { configured: true, status: 'connected', ticketRelations }, + }).success, + ).toBe(true); +}); +it('rejects self-merges, unauthorized operations, and detects target changes in the baseline', async () => { + const { provider, json } = setup(); + const mutation = { kind: 'relation', id: '123', targetId: '456', operation: 'merge' } as const; + expect(SdpMutationSchema.safeParse({ ...mutation, targetId: '123' }).success).toBe(false); + const before = await mutationBaseline(provider, 'token', signal, '123', mutation); + const original = json.getMockImplementation()!; + json.mockImplementation(async (...args) => + args[0].endsWith('/456') ? { request: { id: '456', subject: 'Changed' } } : original(...args), + ); + expect(await mutationBaseline(provider, 'token', signal, '123', mutation)).not.toBe(before); + json.mockResolvedValue({ _links: [] }); + await expect(submitMutation(provider, 'token', signal, mutation)).rejects.toMatchObject({ + kind: 'denied', + }); + expect(json.mock.calls.some((c) => c[2]?.method)).toBe(false); +}); +it('does not report an HTTP 200 partial close as success', async () => { + const { provider, json } = setup(); + json.mockResolvedValue({ + response_status: { status_code: 3000, messages: [{ fields: ['category', 'subcategory'] }] }, + }); + await expect( + submitMutation(provider, 'token', signal, { + kind: 'update', + id: '123', + fields: { status: 'Closed' }, + }), + ).rejects.toThrow('Check required fields: category, subcategory'); +}); diff --git a/src/main/sdp/SdpTicketRelations.ts b/src/main/sdp/SdpTicketRelations.ts new file mode 100644 index 00000000..9172a3fb --- /dev/null +++ b/src/main/sdp/SdpTicketRelations.ts @@ -0,0 +1,122 @@ +import { + SdpTicketRelationsSchema, + type SdpRelationMutation, + type SdpRelatedTicket, +} from '@shared/sdpTicketRelations'; +import type { SdpBrokerCommand } from '@shared/sdpAccount'; +import { scalarText, SdpProvider, SdpProviderError, isObject } from './SdpProvider'; + +const BASE = 'https://support.campingworld.com/app/itdesk/api/v3/requests'; +const object = (v: unknown): Record => (isObject(v) ? v : {}); +const rows = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); +async function get( + provider: SdpProvider, + token: string, + signal: AbortSignal, + path: string, + input?: unknown, +) { + const url = new URL(`${BASE}${path}`); + if (input) url.searchParams.set('input_data', JSON.stringify(input)); + return object( + await provider.json( + url.href, + signal, + { + headers: { + Authorization: `Zoho-oauthtoken ${token}`, + Accept: 'application/vnd.manageengine.sdp.v3+json', + }, + }, + 1048576, + ), + ); +} +function project(raw: unknown): SdpRelatedTicket { + const r = object(raw); + if (!/^\d{1,30}$/.test(String(r.id))) throw new SdpProviderError('invalid'); + return { + id: String(r.id), + number: String(object(r.display_key).display_value ?? r.display_id ?? r.id).slice(0, 50), + subject: scalarText(r.subject).slice(0, 250), + }; +} +async function permissions(provider: SdpProvider, token: string, signal: AbortSignal, id: string) { + const raw = await get(provider, token, signal, `/${id}/_links`); + return rows(Array.isArray(raw._links) ? raw._links : object(raw._links).links).map(object); +} +export async function readTicketRelations( + provider: SdpProvider, + token: string, + signal: AbortSignal, + command: Extract, +) { + const links = await permissions(provider, token, signal, command.id); + const can = (name: string, method: string) => + links.some((l) => l.name === name && l.method === method); + // This Cloud operation rejects input_data/list_info. Page the bounded response locally. + const canRead = can('link_requests', 'get'); + const raw = canRead ? await get(provider, token, signal, `/${command.id}/_link_requests`) : {}; + if (canRead && !Array.isArray(raw.link_requests)) throw new SdpProviderError('invalid'); + const linked = rows(raw.link_requests); + let candidate: SdpRelatedTicket | null = null; + if (command.number) { + const result = await get(provider, token, signal, '', { + list_info: { + row_count: 2, + start_index: 1, + search_criteria: { + field: 'display_id', + condition: 'is', + value: command.number.replace(/^[A-Za-z]+-/, ''), + }, + }, + }); + if (!Array.isArray(result.requests)) throw new SdpProviderError('invalid'); + const matches = rows(result.requests).filter( + (r) => String(object(r).display_id) === command.number!.replace(/^[A-Za-z]+-/, ''), + ); + if (matches.length === 1) candidate = project(matches[0]); + } + return SdpTicketRelationsSchema.parse({ + id: command.id, + linked: linked + .slice(command.page * 50, (command.page + 1) * 50) + .map((r) => project(object(r).linked_request)), + candidate, + canLink: can('link_requests', 'post'), + canUnlink: can('link_requests', 'delete'), + canMerge: can('merge_requests', 'put'), + hasMore: linked.length > (command.page + 1) * 50, + }); +} +export async function validateRelation( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpRelationMutation, +) { + if (mutation.id === mutation.targetId) throw new SdpProviderError('invalid'); + const links = await permissions(provider, token, signal, mutation.id); + const method = { link: 'post', unlink: 'delete', merge: 'put' }[mutation.operation]; + const name = mutation.operation === 'merge' ? 'merge_requests' : 'link_requests'; + if (!links.some((l) => l.name === name && l.method === method)) + throw new SdpProviderError('denied'); + const target = await get(provider, token, signal, `/${mutation.targetId}`); + if ( + String(object(target.request).id) !== mutation.targetId || + object(target.request).is_trashed === true + ) + throw new SdpProviderError('invalid'); +} +export async function relationBaseline( + provider: SdpProvider, + token: string, + signal: AbortSignal, + mutation: SdpRelationMutation, +) { + const target = await get(provider, token, signal, `/${mutation.targetId}`); + if (String(object(target.request).id) !== mutation.targetId) + throw new SdpProviderError('invalid'); + return target.request; +} diff --git a/src/main/web/RelayWebGateway.ts b/src/main/web/RelayWebGateway.ts index 7c8d5da0..435e67ff 100644 --- a/src/main/web/RelayWebGateway.ts +++ b/src/main/web/RelayWebGateway.ts @@ -1,7 +1,9 @@ +import { SdpBrokerCommandSchema } from '@shared/sdpAccount'; +import { RELAY_WEB_API_PREFIX, WebRadarSnapshotSchema } from '@shared/webApi'; +import type { SdpBroker } from '../sdp/SdpBroker'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { hostname as getHostname, networkInterfaces } from 'node:os'; import type { ServerConfig } from '../config/AppConfig'; -import { WebRadarSnapshotSchema } from '@shared/webApi'; import type { WebSessionCreateInput } from './WebSessionStore'; import { WebSessionStore } from './WebSessionStore'; import { WebRequestSecurity } from './WebRequestSecurity'; @@ -21,6 +23,7 @@ function startPreparingKnowledgeRoot(rootDir: string): void { } type RelayWebGatewayOptions = { + getSdpBroker?: () => SdpBroker | null; config: ServerConfig; authenticate: (passphrase: string) => Promise; hostname?: string; @@ -80,6 +83,30 @@ export class RelayWebGateway { options.authorizeCapability?.(logicalSessionId, capability) ?? false, }); + if (options.getSdpBroker) { + this.sessions.onDestroyed((id) => { + options.getSdpBroker?.()?.disconnect(id); + }); + this.router.register({ + method: 'POST', + path: `${RELAY_WEB_API_PREFIX}/sdp/account`, + authenticated: true, + csrf: true, + bodySchema: SdpBrokerCommandSchema, + maxBodyBytes: 15 * 1024 * 1024, + rateLimit: { bucket: 'sdp-account', key: 'session', limit: 60, windowMs: 60_000 }, + handler: async ({ logicalSessionId, body }) => { + try { + const broker = options.getSdpBroker?.(); + if (!broker || !logicalSessionId) + return { status: 503, body: { error: 'SDP is unavailable.' } }; + return { status: 200, body: await broker.invoke(logicalSessionId, body) }; + } catch { + return { status: 502, body: { error: 'SDP could not complete this action.' } }; + } + }, + }); + } registerWebSessionRoutes(this.router, { sessions: this.sessions, authenticate: options.authenticate, diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 6c0d03c4..f88d1aea 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -38,6 +38,25 @@ describe('preload Knowledge web link bridge', () => { api = exposeCall[1] as BridgeAPI; }); + it('validates desktop notification destinations and removes the click listener', () => { + const callback = vi.fn(); + const unsubscribe = api.onNotificationClick!(callback); + const handler = electronMocks.on.mock.calls.find( + ([channel]) => channel === 'ticket:notify', + )![1]; + handler({}, { source: 'Tickets', ticketId: '123' }); + expect(callback).toHaveBeenCalledWith({ source: 'Tickets', ticketId: '123' }); + handler({}, { source: 'Tickets', ticketId: 'https://invalid.test' }); + expect(callback).toHaveBeenCalledOnce(); + unsubscribe(); + expect(electronMocks.removeListener).toHaveBeenCalledWith('ticket:notify', handler); + }); + + it('forwards typed SDP commands through the desktop account channel', async () => { + await api.sdpAccount?.({ action: 'status' }); + expect(electronMocks.invoke).toHaveBeenCalledWith('sdp:account', { action: 'status' }); + }); + it('invokes the dedicated Knowledge web link channel with the URL', async () => { const url = 'https://docs.example.com/runbook?incident=123'; diff --git a/src/preload/index.ts b/src/preload/index.ts index c0c948fb..78e36af9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { isNotificationTarget } from '@shared/notificationTarget'; import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS, @@ -168,6 +169,16 @@ const api: BridgeAPI = { optimizeAlertImage: (dataUrl) => ipcRenderer.invoke(IPC_CHANNELS.OPTIMIZE_ALERT_IMAGE, dataUrl), // Alerts playAlertSound: () => ipcRenderer.invoke(IPC_CHANNELS.ALERT_PLAY_SOUND), + notifyTicket: (payload) => ipcRenderer.invoke(IPC_CHANNELS.TICKET_NOTIFY, payload), + onNotificationClick: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, target: unknown) => { + if (isNotificationTarget(target)) callback(target); + }; + ipcRenderer.on(IPC_CHANNELS.TICKET_NOTIFY, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.TICKET_NOTIFY, handler); + }, + sdpServer: (command) => ipcRenderer.invoke(IPC_CHANNELS.SDP_SERVER, command), + sdpAccount: (command) => ipcRenderer.invoke(IPC_CHANNELS.SDP_ACCOUNT, command), selectReminderSound: () => ipcRenderer.invoke(IPC_CHANNELS.ALERT_SELECT_REMINDER_SOUND), saveAlertImage: (dataUrl, suggestedName) => ipcRenderer.invoke(IPC_CHANNELS.SAVE_ALERT_IMAGE, dataUrl, suggestedName), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e77c2a26..def6f435 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,3 +1,11 @@ +import { + NotificationProvider, + NOTIFICATION_NAVIGATION_EVENT, + useOperationalToast, +} from './features/notifications/NotificationProvider'; +import { NotificationCenter } from './features/notifications/NotificationCenter'; +import { NotificationTargetSchema } from '@shared/notifications'; +import type { SdpBridgeContext } from '@shared/sdpLinks'; import { NotesProvider, PrivilegedAccessProvider, SearchProvider } from './contexts'; import { Activity, @@ -13,6 +21,11 @@ import { import { Sidebar } from './components/Sidebar'; import { WorldClock } from './components/WorldClock'; import { AssemblerTab } from './tabs/AssemblerTab'; +import { + TICKET_NAVIGATION_EVENT, + type TicketNavigation, +} from './features/tickets/ticketNavigation'; +import type { TicketOpenRequest } from './tabs/TicketsTab'; import { WindowControls } from './components/WindowControls'; import { ToastProvider, NoopToastProvider, useToast } from './components/Toast'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -94,6 +107,7 @@ const KnowledgeWorkspace = lazyTab( 'KnowledgeWorkspace', ); const CloudStatusTab = lazyTab(() => import('./tabs/CloudStatusTab'), 'CloudStatusTab'); +const TicketsTab = lazyTab(() => import('./tabs/TicketsTab'), 'TicketsTab'); const DynatraceProblemsTab = lazyTab( () => import('./tabs/DynatraceProblemsTab'), 'DynatraceProblemsTab', @@ -158,7 +172,21 @@ function withStartupTimeout(promise: Promise, timeoutMs: number): Promise< }); } -export function MainApp({ +type MainAppProps = { + readonly onReconfigure?: () => void; + readonly relayConfig?: PublicRelayConfig | null; + readonly launchIntent?: 'recovery'; +}; +export function MainApp(props: MainAppProps = {}) { + if (new URLSearchParams(globalThis.location.search).has('popout')) + return ; + return ( + + + + ); +} +function MainAppContent({ onReconfigure, relayConfig = null, launchIntent, @@ -168,6 +196,7 @@ export function MainApp({ readonly launchIntent?: 'recovery'; } = {}) { const { showToast } = useToast(); + const showStatusNotification = useOperationalToast('Status'); useErrorNotifications(showToast); const searchParams = new URLSearchParams(globalThis.location.search); @@ -226,7 +255,7 @@ export function MainApp({ statusData: cloudStatusData, loading: cloudStatusLoading, refetch: cloudStatusRefetch, - } = useAppCloudStatus(showToast, handleOpenCloudStatusProvider); + } = useAppCloudStatus(showStatusNotification, handleOpenCloudStatusProvider); const [knowledgeDestination, setKnowledgeDestination] = useState('home'); const nextKnowledgeRecordRequestId = useRef(0); const [knowledgeRecordOpenRequest, setKnowledgeRecordOpenRequest] = @@ -286,6 +315,40 @@ export function MainApp({ // Track which tabs have been mounted at least once const [mountedTabs, setMountedTabs] = useState>(new Set([activeTab])); + const [ticketRequest, setTicketRequest] = useState(); + const [ticketProblemRequest, setTicketProblemRequest] = useState<{ + problemId: string; + sequence: number; + }>(); + const [ticketBridge, setTicketBridge] = useState(); + const ticketSequence = useRef(0); + useEffect(() => { + const navigate = (event: Event) => { + const result = NotificationTargetSchema.safeParse((event as CustomEvent).detail); + if (result.success && result.data.source !== 'Tickets') setActiveTab(result.data.source); + }; + globalThis.addEventListener(NOTIFICATION_NAVIGATION_EVENT, navigate); + return () => globalThis.removeEventListener(NOTIFICATION_NAVIGATION_EVENT, navigate); + }, [setActiveTab]); + useEffect(() => { + const navigate = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + ticketSequence.current += 1; + if (detail.destination === 'ticket') { + setTicketRequest({ ...detail, sequence: ticketSequence.current }); + setActiveTab('Tickets'); + } else if (detail.destination === 'problem') { + setTicketProblemRequest({ problemId: detail.problemId, sequence: ticketSequence.current }); + setActiveTab('Problems'); + } else if (detail.destination === 'bridge') { + setTicketBridge(detail.bridge); + setActiveTab('Compose'); + } + }; + globalThis.addEventListener(TICKET_NAVIGATION_EVENT, navigate); + return () => globalThis.removeEventListener(TICKET_NAVIGATION_EVENT, navigate); + }, [setActiveTab]); const [loadedReminderAlert, setLoadedReminderAlert] = useState( null, ); @@ -413,6 +476,7 @@ export function MainApp({ Knowledge: 'Knowledge', Status: 'Service Status', Problems: 'Dynatrace Problems', + Tickets: 'Tickets', Radar: 'Dispatcher Radar', Alerts: 'Alerts', Settings: 'Settings', @@ -444,6 +508,11 @@ export function MainApp({ />
+ {!isPopout && ( + + + + )} @@ -456,6 +525,8 @@ export function MainApp({ setTicketBridge(undefined)} groups={data.groups} contacts={data.contacts} onCall={data.onCall} @@ -528,6 +599,7 @@ export function MainApp({ }> @@ -535,6 +607,15 @@ export function MainApp({ )} + {mountedTabs.has('Tickets') && ( + + + }> + + + + + )} {mountedTabs.has('Radar') && ( @@ -605,9 +686,11 @@ export function MainApp({ - - - + {!isPopout && ( + + + + )} {!isPopout && ( diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 8c57a418..23db8e9a 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -131,6 +131,15 @@ vi.mock('../components/Toast', () => ({ useToast: () => ({ showToast: mockShowToast }), })); +vi.mock('../features/notifications/NotificationProvider', () => ({ + NotificationProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useOperationalToast: () => mockShowToast, + NOTIFICATION_NAVIGATION_EVENT: 'relay:notification-navigation', +})); +vi.mock('../features/notifications/NotificationCenter', () => ({ + NotificationCenter: () => , +})); + // ── mock heavy sub-components ──────────────────────────────────────────────── vi.mock('../components/Sidebar', () => ({ Sidebar: ({ @@ -896,13 +905,14 @@ describe('MainApp', () => { ['4', 'Knowledge'], ['5', 'Status'], ['6', 'Problems'], + ['8', 'Tickets'], ])('navigates on Cmd+%s to %s', (key, destination) => { renderApp(); fireEvent.keyDown(window, { key, metaKey: true }); expect(mockSetActiveTab).toHaveBeenCalledWith(destination); }); - it.each(['8', '9'])('does not assign Cmd+%s', (key) => { + it.each(['9'])('does not assign Cmd+%s', (key) => { renderApp(); fireEvent.keyDown(window, { key, metaKey: true }); expect(mockSetActiveTab).not.toHaveBeenCalled(); diff --git a/src/renderer/src/components/DynatraceProblemNotificationManager.tsx b/src/renderer/src/components/DynatraceProblemNotificationManager.tsx index 7e04e2a4..158465d7 100644 --- a/src/renderer/src/components/DynatraceProblemNotificationManager.tsx +++ b/src/renderer/src/components/DynatraceProblemNotificationManager.tsx @@ -6,7 +6,7 @@ import { type DynatraceProblemSeverity, } from '@shared/dynatraceProblems'; import { useCollection } from '../hooks/useCollection'; -import { useToast } from './Toast'; +import { useOperationalToast } from '../features/notifications/NotificationProvider'; const SEVERITY_ORDER: Record = { AVAILABILITY: 0, @@ -41,7 +41,7 @@ function notificationMessage(problems: DynatraceProblemRecord[]): string { export function DynatraceProblemNotificationManager({ onOpenProblems, }: Readonly<{ onOpenProblems: () => void }>) { - const { showToast } = useToast(); + const showToast = useOperationalToast('Problems'); const { data: problems, loading } = useCollection( DYNATRACE_PROBLEMS_COLLECTION, { sort: '-startTime', filter: 'scopeExcluded=false && status="OPEN"' }, @@ -65,7 +65,6 @@ export function DynatraceProblemNotificationManager({ delivery: 'dynatrace-problem', action: { label: 'Open Problems', onClick: onOpenProblems }, }); - void globalThis.api?.playAlertSound?.().catch(() => undefined); }, [onOpenProblems, showToast]); useEffect(() => { diff --git a/src/renderer/src/components/RadarQueueNotificationManager.tsx b/src/renderer/src/components/RadarQueueNotificationManager.tsx index 70eb4526..509e5c6e 100644 --- a/src/renderer/src/components/RadarQueueNotificationManager.tsx +++ b/src/renderer/src/components/RadarQueueNotificationManager.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import type { RadarSnapshot, RadarStatusColor } from '@shared/ipc'; import { useRadarSnapshot } from '../hooks/useRadarSnapshot'; -import { useToast } from './Toast'; +import { useOperationalToast } from '../features/notifications/NotificationProvider'; export type RadarTargetKey = 'prod01' | 'prod02' | 'transactionalEmails'; @@ -71,7 +71,7 @@ export function RadarQueueNotificationManager({ onOpenRadar, }: Readonly<{ onOpenRadar: () => void }>) { const { snapshot } = useRadarSnapshot(); - const { showToast } = useToast(); + const showToast = useOperationalToast('Radar'); const previousTonesRef = useRef | null>(null); useEffect(() => { diff --git a/src/renderer/src/components/ShortcutsModal.tsx b/src/renderer/src/components/ShortcutsModal.tsx index 37dc3f27..4fff579d 100644 --- a/src/renderer/src/components/ShortcutsModal.tsx +++ b/src/renderer/src/components/ShortcutsModal.tsx @@ -19,6 +19,7 @@ function getShortcuts(modKey: string, isWeb: boolean) { { keys: `${modKey} + 5`, description: 'Go to Service Status' }, { keys: `${modKey} + 6`, description: 'Go to Dynatrace Problems' }, { keys: `${modKey} + 7`, description: 'Go to Dispatcher Radar' }, + { keys: `${modKey} + 8`, description: 'Go to Tickets' }, ], }, { diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index a379d8b7..812bd5ec 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -41,6 +41,24 @@ const navItems: { label: string; tab: TabName; icon: React.ReactNode }[] = [ { label: 'Status', tab: 'Status', icon: }, { label: 'Problems', tab: 'Problems', icon: }, { label: 'Radar', tab: 'Radar', icon: }, + { + label: 'Tickets', + tab: 'Tickets', + icon: ( + + ), + }, ]; export const Sidebar: React.FC = ({ diff --git a/src/renderer/src/components/Toast.tsx b/src/renderer/src/components/Toast.tsx index 26692940..54bfb225 100644 --- a/src/renderer/src/components/Toast.tsx +++ b/src/renderer/src/components/Toast.tsx @@ -12,7 +12,12 @@ import { createClientId } from '../utils/clientId'; export type ToastType = 'success' | 'error' | 'info' | 'warning'; export type ToastDelivery = - 'routine' | 'cloud-degradation' | 'cloud-outage' | 'radar-critical' | 'dynatrace-problem'; + | 'routine' + | 'ticket' + | 'cloud-degradation' + | 'cloud-outage' + | 'radar-critical' + | 'dynatrace-problem'; export type ToastOptions = { title?: string; @@ -36,6 +41,7 @@ interface ToastMessage { interface ToastContextType { showToast: ShowToast; + dismissDelivery?: (delivery: ToastDelivery) => void; } type ToastAction = @@ -54,6 +60,7 @@ function isOperationalToast(toast: ToastMessage): boolean { function deliveryPriority(delivery: ToastDelivery): number { switch (delivery) { + case 'ticket': case 'dynatrace-problem': return 4; case 'radar-critical': @@ -122,6 +129,7 @@ function findNextOperationalId(toasts: ToastMessage[]): string | null { const queued = toasts.filter((toast) => toast.state === 'queued'); return ( queued.find((toast) => deliveryOf(toast) === 'dynatrace-problem')?.id ?? + queued.find((toast) => deliveryOf(toast) === 'ticket')?.id ?? queued.find((toast) => deliveryOf(toast) === 'radar-critical')?.id ?? queued.find((toast) => deliveryOf(toast) === 'cloud-outage')?.id ?? queued.find((toast) => deliveryOf(toast) === 'cloud-degradation')?.id ?? @@ -271,7 +279,17 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) = }; }, []); - const toastContextValue = useMemo(() => ({ showToast }), [showToast]); + const dismissDelivery = useCallback( + (delivery: ToastDelivery) => { + for (const toast of toastsRef.current) + if (deliveryOf(toast) === delivery) finalizeToastRemoval(toast.id); + }, + [finalizeToastRemoval], + ); + const toastContextValue = useMemo( + () => ({ showToast, dismissDelivery }), + [showToast, dismissDelivery], + ); const visibleToasts = toasts.filter((toast) => toast.state !== 'queued'); const orderedToasts = [ ...visibleToasts.filter(isOperationalToast), diff --git a/src/renderer/src/components/__tests__/Sidebar.test.tsx b/src/renderer/src/components/__tests__/Sidebar.test.tsx index 3dd2699f..473339a3 100644 --- a/src/renderer/src/components/__tests__/Sidebar.test.tsx +++ b/src/renderer/src/components/__tests__/Sidebar.test.tsx @@ -93,7 +93,7 @@ describe('Sidebar', () => { Reflect.deleteProperty(globalThis as Record, 'api'); }); - it('renders all seven shared destinations in their shortcut order', () => { + it('renders all eight shared destinations in their shortcut order', () => { stubRuntime('web'); const { container } = render(); @@ -105,6 +105,7 @@ describe('Sidebar', () => { 'Status', 'Problems', 'Radar', + 'Tickets', ]); expect(screen.queryByTestId('sidebar-btn-notes')).not.toBeInTheDocument(); expect(screen.queryByTestId('sidebar-btn-people')).not.toBeInTheDocument(); @@ -123,6 +124,7 @@ describe('Sidebar', () => { 'Status', 'Problems', 'Radar', + 'Tickets', ]); }); diff --git a/src/renderer/src/components/settings/administration/RelayServerPanel.tsx b/src/renderer/src/components/settings/administration/RelayServerPanel.tsx index 0542d0f9..586a21e4 100644 --- a/src/renderer/src/components/settings/administration/RelayServerPanel.tsx +++ b/src/renderer/src/components/settings/administration/RelayServerPanel.tsx @@ -1,3 +1,4 @@ +import { SdpServerSettings } from './SdpServerSettings'; import React, { useMemo, useState } from 'react'; import { DynatraceConnectionSettings } from './DynatraceConnectionSettings'; import { DynatraceProblemScopeEditor } from './DynatraceProblemScopeEditor'; @@ -32,6 +33,7 @@ export function RelayServerPanel({ snapshot, execute }: Readonly
+
Local-only maintenance boundary diff --git a/src/renderer/src/components/settings/administration/SdpServerSettings.test.tsx b/src/renderer/src/components/settings/administration/SdpServerSettings.test.tsx new file mode 100644 index 00000000..3cd8f9ff --- /dev/null +++ b/src/renderer/src/components/settings/administration/SdpServerSettings.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpServerSettings } from './SdpServerSettings'; +const original = globalThis.api; +afterEach(() => { + globalThis.api = original; +}); +it('keeps setup in server administration and clears the submitted secret after saving', async () => { + const invoke = vi.fn().mockResolvedValue({ + success: true, + data: { + configured: false, + revision: '', + cacheMinutes: 60, + gatewayEnabled: true, + gatewayPort: 8091, + }, + }); + globalThis.api = { ...original, sdpServer: invoke } as BridgeAPI; + render(); + fireEvent.change(await screen.findByLabelText('Client ID'), { + target: { value: '1000.FIXTURE' }, + }); + fireEvent.change(screen.getByLabelText('Client secret'), { target: { value: 'dummy-secret' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save server setup' })); + expect(await screen.findByText(/Server setup saved/)).toBeInTheDocument(); + expect(screen.getByLabelText('Client secret')).toHaveValue(''); + expect(invoke).toHaveBeenLastCalledWith({ + action: 'save', + expectedRevision: '', + client: { clientId: '1000.FIXTURE', clientSecret: 'dummy-secret' }, + cacheMinutes: 60, + }); +}); +it('does not show configuration inputs when the main process denies access', async () => { + globalThis.api = { + ...original, + sdpServer: vi + .fn() + .mockResolvedValue({ success: false, error: 'Server administration required.' }), + } as BridgeAPI; + render(); + expect(await screen.findByText('Server administration required.')).toBeInTheDocument(); + expect(screen.queryByLabelText('Client secret')).not.toBeInTheDocument(); +}); diff --git a/src/renderer/src/components/settings/administration/SdpServerSettings.tsx b/src/renderer/src/components/settings/administration/SdpServerSettings.tsx new file mode 100644 index 00000000..bda0a731 --- /dev/null +++ b/src/renderer/src/components/settings/administration/SdpServerSettings.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { SDP_CALLBACK, type SdpServerCommand, type SdpServerView } from '@shared/sdpAccount'; +import { TactileButton } from '../../TactileButton'; + +export function SdpServerSettings() { + const [view, setView] = useState(); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [minutes, setMinutes] = useState(60); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState(''); + const invoke = globalThis.api?.sdpServer; + useEffect(() => { + let active = true; + if (invoke) + void invoke({ action: 'status' }) + .then((result) => { + if (!active) return; + if (result.success && result.data) { + setView(result.data); + setMinutes(result.data.cacheMinutes); + } else setFeedback(result.error ?? 'Open these settings on the Relay server computer.'); + }) + .catch(() => { + if (active) setFeedback('Server settings are unavailable.'); + }); + return () => { + active = false; + }; + }, [invoke]); + async function run(command: SdpServerCommand) { + setBusy(true); + setFeedback(''); + try { + const result = await invoke!(command); + if (!result.success || !result.data) + throw new Error(result.error ?? 'Could not save SDP settings.'); + setView(result.data); + setMinutes(result.data.cacheMinutes); + setClientId(''); + setClientSecret(''); + setFeedback( + command.action === 'clear' + ? 'SDP disconnected. Saved copies removed.' + : 'Server setup saved. Users can connect their work accounts.', + ); + } catch (error) { + setFeedback(error instanceof Error ? error.message : 'SDP settings unavailable.'); + } finally { + setBusy(false); + } + } + return ( +
+ ServiceDesk Plus +

One server setup. Each person signs in with their own work account.

+ {!invoke &&

Configure this on the Relay server computer.

} + {view && ( +
{ + event.preventDefault(); + void run({ + action: 'save', + expectedRevision: view.revision, + client: { clientId, clientSecret }, + cacheMinutes: minutes, + }); + }} + > +

+ {view.configured + ? 'Configured. Replacing setup signs everyone out and removes saved copies.' + : 'Create one Zoho server-based application.'}{' '} + Register callback {SDP_CALLBACK}. +

+ + + +

+ Encrypted copies stay on this server and are available only during an SDP outage, to the + same signed-in person. No ticket copies are saved on client computers. Live testing + remains limited to ticket 810129 metadata. +

+ {!view.gatewayEnabled && ( +

+ Enable Relay Web in the server connection settings so desktop clients can reach the + sign-in service over your trusted LAN or VPN. +

+ )} + + Save server setup + + {view.configured && ( + void run({ action: 'clear', expectedRevision: view.revision })} + > + Disconnect SDP and clear saved copies + + )} +
+ )} + {feedback && ( +

+ {feedback} +

+ )} +
+ ); +} diff --git a/src/renderer/src/features/notifications/NotificationCenter.test.tsx b/src/renderer/src/features/notifications/NotificationCenter.test.tsx new file mode 100644 index 00000000..b5f2dc75 --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationCenter.test.tsx @@ -0,0 +1,103 @@ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { NotificationCenter } from './NotificationCenter'; +import { NotificationProvider, useNotifications } from './NotificationProvider'; + +vi.mock('../../services/pocketbase', () => ({ getPb: () => ({ baseURL: 'http://center.test' }) })); +vi.mock('../../components/Toast', () => ({ useToast: () => ({ showToast: vi.fn() }) })); +vi.mock('../tickets/SdpAlerts', () => ({ + useSdpAlerts: () => ({ attention: false }), + SdpAlertControls: () => , +})); +let state: NonNullable>; +function Capture() { + state = useNotifications()!; + return ; +} +function setup() { + render( + + + , + ); +} +beforeEach(() => localStorage.clear()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +it('labels severity, scopes both bulk actions to the source, and offers Undo', () => { + setup(); + act(() => { + state.publish({ + id: 'problem', + source: 'Problems', + title: 'Problem event', + message: 'Review problem', + type: 'error', + target: { source: 'Problems' }, + }); + state.publish({ + id: 'radar', + source: 'Radar', + title: 'Radar event', + message: 'Review Radar', + type: 'warning', + target: { source: 'Radar' }, + }); + }); + fireEvent.click(screen.getByRole('button', { name: /Notifications/ })); + expect(screen.getByRole('button', { name: /Problem event/ })).toHaveTextContent('Error'); + expect(screen.getByRole('button', { name: /Radar event/ })).toHaveTextContent('Warning'); + fireEvent.click(screen.getByRole('button', { name: 'Radar' })); + fireEvent.click(screen.getByRole('button', { name: 'Mark radar read' })); + expect(state.notices.find((n) => n.id === 'problem')?.read).toBe(false); + expect(screen.getByRole('button', { name: 'Mark radar read' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Clear radar' })); + expect(screen.queryByRole('button', { name: /Radar event/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Undo clear' })); + expect(screen.getByRole('button', { name: /Radar event/ })).toBeInTheDocument(); +}); +it('shows persistent snooze and quiet status and updates at their time boundaries', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 19, 22, 59)); + setup(); + act(() => + state.savePreferences({ + ...state.preferences, + snoozeUntil: Date.now() + 30000, + quietHoursEnabled: true, + quietStart: '22:00', + quietEnd: '23:00', + }), + ); + expect(screen.getByRole('button', { name: /Notifications.*Snoozed/ })).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByRole('button', { name: /Notifications.*Quiet hours/ })).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); +}); +it('collapses per-source options and preserves times when quiet hours are toggled', () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: 'Notifications' })); + fireEvent.click(screen.getByRole('button', { name: 'Preferences' })); + const summary = screen.getByText('Tickets', { selector: 'summary' }); + expect(summary.closest('details')).not.toHaveAttribute('open'); + fireEvent.click(summary); + expect( + within(summary.closest('details')!).getByRole('button', { name: 'Ticket rules' }), + ).toBeVisible(); + const toggle = screen.getByRole('checkbox', { name: 'Enable quiet hours' }); + expect(toggle).not.toBeChecked(); + fireEvent.click(toggle); + fireEvent.change(screen.getByLabelText('Quiet hours start'), { target: { value: '21:30' } }); + fireEvent.click(toggle); + expect(screen.getByLabelText('Quiet hours start')).toBeDisabled(); + fireEvent.click(toggle); + expect(screen.getByLabelText('Quiet hours start')).toHaveValue('21:30'); +}); diff --git a/src/renderer/src/features/notifications/NotificationCenter.tsx b/src/renderer/src/features/notifications/NotificationCenter.tsx new file mode 100644 index 00000000..18380a0a --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationCenter.tsx @@ -0,0 +1,401 @@ +import { useEffect, useState } from 'react'; +import { quietNow } from '@shared/serviceDesk'; +import { + NOTIFICATION_SOURCES, + type NotificationPreferences, + type NotificationSource, +} from '@shared/notifications'; +import { Modal } from '../../components/Modal'; +import { TactileButton } from '../../components/TactileButton'; +import { useSdpAlerts, SdpAlertControls } from '../tickets/SdpAlerts'; +import { TicketNotificationRules } from '../tickets/TicketNotifications'; +import { openNotificationTarget, useNotifications } from './NotificationProvider'; +import '../tickets/tickets.css'; +import './notifications.css'; + +function useInterruptionStatus(preferences?: NotificationPreferences) { + const [now, setNow] = useState(Date.now); + const snoozeUntil = preferences?.snoozeUntil ?? 0; + useEffect(() => { + const remaining = snoozeUntil - Date.now(); + const timer = setTimeout( + () => setNow(Date.now()), + Math.min(60000 - (Date.now() % 60000), remaining > 0 ? remaining : Infinity), + ); + return () => clearTimeout(timer); + }, [now, snoozeUntil]); + const snoozed = snoozeUntil > now; + const quiet = + !!preferences?.quietHoursEnabled && + quietNow( + { + ...preferences, + snoozeUntil: 0, + rules: [], + warningMinutes: 30, + }, + now, + ); + let pauseLabel = quiet ? 'Quiet hours' : ''; + if (snoozed) pauseLabel = 'Snoozed'; + return { snoozed, quiet, pauseLabel }; +} + +export function NotificationCenter() { + const notifications = useNotifications(); + const sdp = useSdpAlerts(); + const [open, setOpen] = useState(false); + const [rules, setRules] = useState(false); + const [section, setSection] = useState<'Inbox' | 'Preferences'>('Inbox'); + const [source, setSource] = useState('All'); + const { snoozed, quiet, pauseLabel } = useInterruptionStatus(notifications?.preferences); + if (!notifications) return null; + const { + notices, + preferences, + savePreferences, + storageError, + markRead, + clear, + undoClear, + clearedCount, + } = notifications; + const unread = notices.filter((notice) => !notice.read).length; + const visible = notices.filter((notice) => source === 'All' || notice.source === source); + const desktop = globalThis.api?.runtime.kind === 'electron'; + return ( + <> + setOpen(true)} + > + Notifications{unread > 0 ? ` (${unread} unread)` : ''} + {pauseLabel && · {pauseLabel}} + {sdp.attention && !} + + {rules && ( + { + setRules(false); + setOpen(true); + }} + /> + )} + {open && ( + setOpen(false)} + footer={ + <> + + savePreferences({ + ...preferences, + snoozeUntil: snoozed ? 0 : Date.now() + 3600000, + }) + } + > + {snoozed ? 'Resume alerts' : 'Snooze 1h'} + + setOpen(false)}> + Done + + + } + > + + {snoozed && ( +

+ + Interruptions paused until{' '} + {new Date(preferences.snoozeUntil).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + })} + . Inbox entries continue. + +

+ )} + {quiet && !snoozed && ( +

+ + Quiet hours active until {preferences.quietEnd}. Inbox entries continue. + +

+ )} + {storageError &&

{storageError}

} + {section === 'Inbox' ? ( + <> + +
+ !notice.read)} + onClick={() => markRead(undefined, source === 'All' ? undefined : source)} + > + {source === 'All' ? 'Mark all read' : `Mark ${source.toLowerCase()} read`} + + clear(source === 'All' ? undefined : source, true)} + > + Clear {source === 'All' ? 'inbox' : source.toLowerCase()} + +
+ {clearedCount > 0 && ( +
+ + Cleared {clearedCount} {clearedCount === 1 ? 'notification' : 'notifications'}. + + + Undo clear + +
+ )} + {!visible.length && ( +

+ No notifications{source === 'All' ? '' : ` from ${source.toLowerCase()}`} yet. +

+ )} +
+ {visible.map((notice) => ( + + ))} +
+ + ) : ( +
+
+ Delivery + {(['toast', 'desktop', 'sound'] as const).map((channel) => ( + + ))} + {!desktop && ( +

+ Desktop notifications and sounds are available in Relay desktop. +

+ )} +
+
+ Quiet hours + +
+ + +
+

+ Uses this device’s time zone. Quiet hours silence interruptions; inbox entries + continue. +

+
+ {NOTIFICATION_SOURCES.map((name) => ( +
+ + {name} + {preferences.sources[name].enabled ? 'Enabled' : 'Off'} + +
+ {name} delivery + + {name === 'Tickets' ? ( + { + setOpen(false); + setRules(true); + }} + /> + ) : ( +
+ {(['info', 'warning', 'error', 'sound'] as const).map((level) => ( + + ))} +
+ )} +
+
+ ))} +
+ )} +
+ )} + + ); +} diff --git a/src/renderer/src/features/notifications/NotificationProvider.test.tsx b/src/renderer/src/features/notifications/NotificationProvider.test.tsx new file mode 100644 index 00000000..06609ab2 --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationProvider.test.tsx @@ -0,0 +1,244 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + NotificationProvider, + useNotifications, + type NotificationInput, +} from './NotificationProvider'; +import { TICKET_NAVIGATION_EVENT } from '../tickets/ticketNavigation'; +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + sound: vi.fn(async () => true), + desktop: vi.fn(async () => true), +})); +vi.mock('../../components/Toast', () => ({ useToast: () => ({ showToast: mocks.toast }) })); +vi.mock('../../services/pocketbase', () => ({ + getPb: () => ({ baseURL: 'http://notifications.test' }), +})); +const original = globalThis.api; +let state: NonNullable>; +function Capture() { + state = useNotifications()!; + return null; +} +function setup() { + return render( + + + , + ); +} +const notice: NotificationInput = { + id: 'reply-1', + source: 'Tickets', + title: 'Ticket #42', + message: 'New reply', + type: 'info', + target: { source: 'Tickets', ticketId: '123' }, + sound: true, + options: { delivery: 'ticket' }, +}; +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + globalThis.api = { + runtime: { kind: 'electron' }, + playAlertSound: mocks.sound, + notifyTicket: mocks.desktop, + } as never; +}); +afterEach(() => { + cleanup(); + globalThis.api = original; + vi.useRealTimers(); +}); +it('deduplicates delivery, keeps a session inbox, and opens the exact ticket from a banner', () => { + const navigate = vi.fn(); + window.addEventListener(TICKET_NAVIGATION_EVENT, navigate); + setup(); + act(() => { + state.publish(notice); + state.publish(notice); + }); + expect(state.notices).toHaveLength(1); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(mocks.sound).toHaveBeenCalledOnce(); + act(() => { + void mocks.toast.mock.calls[0]![2].action.onClick(); + }); + expect(state.notices[0]!.read).toBe(true); + expect(navigate.mock.calls[0]![0].detail).toMatchObject({ + destination: 'ticket', + ticketId: '123', + }); + expect(localStorage.getItem('relay:notifications:http://notifications.test')).toBeNull(); + window.removeEventListener(TICKET_NAVIGATION_EVENT, navigate); +}); +it('records all sources during snooze without banners, desktop notifications or sounds', () => { + setup(); + act(() => + state.savePreferences({ ...state.preferences, desktop: true, snoozeUntil: Date.now() + 60000 }), + ); + act(() => { + state.publish(notice); + state.publish({ ...notice, id: 'radar-1', source: 'Radar', target: { source: 'Radar' } }); + }); + expect(state.notices).toHaveLength(2); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.desktop).not.toHaveBeenCalled(); + expect(mocks.sound).not.toHaveBeenCalled(); +}); +it('applies shared quiet hours across midnight and resumes delivery after the interval', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 19, 23, 30)); + setup(); + act(() => + state.savePreferences({ + ...state.preferences, + quietHoursEnabled: true, + quietStart: '22:00', + quietEnd: '07:00', + }), + ); + act(() => state.publish(notice)); + expect(mocks.toast).not.toHaveBeenCalled(); + vi.setSystemTime(new Date(2026, 8, 20, 8, 0)); + act(() => state.publish({ ...notice, id: 'reply-2' })); + expect(mocks.toast).toHaveBeenCalledOnce(); +}); +it('keeps quiet-hour times when disabled and continues delivery', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 19, 23, 30)); + setup(); + act(() => + state.savePreferences({ + ...state.preferences, + quietHoursEnabled: false, + quietStart: '22:00', + quietEnd: '07:00', + }), + ); + act(() => state.publish(notice)); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(state.preferences.quietStart).toBe('22:00'); +}); +it('honors quiet hours saved before the explicit toggle existed', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 19, 23, 30)); + const first = setup(); + const legacy = { ...state.preferences, quietHoursEnabled: undefined }; + localStorage.setItem( + 'relay:notifications:http://notifications.test', + JSON.stringify({ ...legacy, quietStart: '22:00', quietEnd: '07:00' }), + ); + first.unmount(); + setup(); + act(() => state.publish(notice)); + expect(mocks.toast).not.toHaveBeenCalled(); +}); +it('scopes mark-read and clear, restores without redelivery, and retains new entries', () => { + setup(); + act(() => { + state.publish({ ...notice, at: 1 }); + state.publish({ ...notice, id: 'radar', at: 2, source: 'Radar', target: { source: 'Radar' } }); + }); + act(() => state.markRead(undefined, 'Radar')); + expect(state.notices.find((n) => n.id === notice.id)?.read).toBe(false); + act(() => state.clear('Radar', true)); + expect(state.clearedCount).toBe(1); + act(() => state.publish({ ...notice, id: 'new', at: 3 })); + mocks.toast.mockClear(); + act(() => state.undoClear()); + expect(state.notices.map((n) => n.id)).toEqual(['new', 'radar', 'reply-1']); + expect(state.notices[1]?.read).toBe(true); + expect(state.clearedCount).toBe(0); + expect(mocks.toast).not.toHaveBeenCalled(); +}); +it('never restores ticket notices purged by sign-out or an account reset', () => { + setup(); + act(() => { + state.publish(notice); + state.publish({ ...notice, id: 'radar', source: 'Radar', target: { source: 'Radar' } }); + }); + act(() => state.clear(undefined, true)); + act(() => state.clear('Tickets')); + act(() => state.undoClear()); + expect(state.notices.map((n) => n.source)).toEqual(['Radar']); +}); +it('preserves rule channel choices and filters sources without changing other sources', () => { + setup(); + act(() => + state.savePreferences({ + ...state.preferences, + desktop: true, + sources: { + ...state.preferences.sources, + Radar: { ...state.preferences.sources.Radar, warning: false }, + }, + }), + ); + act(() => { + state.publish({ ...notice, toast: false, desktop: false, sound: false }); + state.publish({ + ...notice, + id: 'radar-2', + type: 'warning', + source: 'Radar', + target: { source: 'Radar' }, + }); + }); + expect(state.notices).toHaveLength(1); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.desktop).not.toHaveBeenCalled(); + act(() => + state.publish({ + ...notice, + id: 'problem-1', + source: 'Problems', + target: { source: 'Problems' }, + }), + ); + act(() => state.clear('Tickets')); + expect(state.notices.map((n) => n.source)).toEqual(['Problems']); + expect(mocks.desktop).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'New activity needs attention. Open Relay to review.', + target: { source: 'Problems' }, + }), + ); +}); +it('keeps desktop capabilities unavailable to the browser and bounds inbox history', () => { + globalThis.api = { + runtime: { kind: 'web' }, + playAlertSound: mocks.sound, + notifyTicket: mocks.desktop, + } as never; + setup(); + act(() => state.savePreferences({ ...state.preferences, desktop: true, toast: false })); + act(() => { + for (let i = 0; i < 205; i++) state.publish({ ...notice, id: `n-${i}` }); + }); + expect(state.notices).toHaveLength(200); + expect(mocks.desktop).not.toHaveBeenCalled(); + expect(mocks.sound).not.toHaveBeenCalled(); +}); +it('validates native notification navigation and unregisters its listener', () => { + let callback: (value: unknown) => void = () => {}; + const off = vi.fn(); + globalThis.api!.onNotificationClick = ((cb: typeof callback) => { + callback = cb; + return off; + }) as never; + const navigate = vi.fn(); + window.addEventListener(TICKET_NAVIGATION_EVENT, navigate); + const view = setup(); + act(() => state.publish(notice)); + act(() => callback({ source: 'Tickets', ticketId: 'javascript:bad' })); + expect(navigate).not.toHaveBeenCalled(); + act(() => callback({ source: 'Tickets', ticketId: '123' })); + expect(navigate).toHaveBeenCalledOnce(); + expect(state.notices[0]!.read).toBe(true); + view.unmount(); + expect(off).toHaveBeenCalledOnce(); + window.removeEventListener(TICKET_NAVIGATION_EVENT, navigate); +}); diff --git a/src/renderer/src/features/notifications/NotificationProvider.tsx b/src/renderer/src/features/notifications/NotificationProvider.tsx new file mode 100644 index 00000000..e2a56167 --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationProvider.tsx @@ -0,0 +1,283 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, +} from 'react'; +import { + defaultNotificationPreferences, + NotificationPreferencesSchema, + NotificationTargetSchema, + type NotificationPreferences, + type NotificationSource, + type NotificationTarget, +} from '@shared/notifications'; +import { quietNow, TicketPreferencesSchema } from '@shared/serviceDesk'; +import { + useToast, + type ShowToast, + type ToastOptions, + type ToastType, +} from '../../components/Toast'; +import { getPb } from '../../services/pocketbase'; +import { createClientId } from '../../utils/clientId'; +import { navigateTicketWorkspace } from '../tickets/ticketNavigation'; + +export const NOTIFICATION_NAVIGATION_EVENT = 'relay:notification-navigation'; +export function openNotificationTarget(target: NotificationTarget): void { + if (target.source === 'Tickets') { + navigateTicketWorkspace({ destination: 'ticket', source: 'sdp', ticketId: target.ticketId }); + } else window.dispatchEvent(new CustomEvent(NOTIFICATION_NAVIGATION_EVENT, { detail: target })); +} +export type NotificationInput = { + id?: string; + source: NotificationSource; + title: string; + message: string; + type: ToastType; + target: NotificationTarget; + at?: number; + inbox?: boolean; + toast?: boolean; + desktop?: boolean; + sound?: boolean; + interrupt?: boolean; + options?: ToastOptions; +}; +export type RelayNotice = NotificationInput & { id: string; at: number; read: boolean }; +type NotificationContextValue = { + notices: RelayNotice[]; + preferences: NotificationPreferences; + savePreferences: (next: NotificationPreferences) => void; + storageError: string; + publish: (input: NotificationInput) => void; + markRead: (id?: string, source?: NotificationSource) => void; + clear: (source?: NotificationSource, undoable?: boolean) => void; + undoClear: () => void; + clearedCount: number; +}; +const NotificationContext = createContext(undefined); +export const useNotifications = () => useContext(NotificationContext); + +export function NotificationProvider({ children }: Readonly) { + const { showToast, dismissDelivery } = useToast(); + const storageKey = `relay:notifications:${getPb().baseURL}`; + const [preferences, setPreferences] = useState(() => { + try { + return NotificationPreferencesSchema.parse( + JSON.parse(localStorage.getItem(storageKey) ?? 'null'), + ); + } catch { + const defaults = defaultNotificationPreferences(); + try { + const old = TicketPreferencesSchema.parse( + JSON.parse(localStorage.getItem(`relay:sdp-alert-rules:${getPb().baseURL}`) ?? 'null'), + ); + return { + ...defaults, + quietHoursEnabled: !!old.quietStart && !!old.quietEnd, + quietStart: old.quietStart, + quietEnd: old.quietEnd, + snoozeUntil: old.snoozeUntil, + }; + } catch { + return defaults; + } + } + }); + const [{ notices, cleared }, setInbox] = useState<{ + notices: RelayNotice[]; + cleared: RelayNotice[]; + }>({ notices: [], cleared: [] }); + const setNotices = useCallback((update: (old: RelayNotice[]) => RelayNotice[]) => { + setInbox((old) => ({ ...old, notices: update(old.notices) })); + }, []); + const [storageError, setStorageError] = useState(''); + const seen = useRef(new Set()); + const lastSound = useRef(0); + const current = useRef({ preferences, showToast, dismissDelivery }); + current.current = { preferences, showToast, dismissDelivery }; + const savePreferences = useCallback( + (next: NotificationPreferences) => { + const parsed = NotificationPreferencesSchema.parse(next); + setPreferences(parsed); + try { + localStorage.setItem(storageKey, JSON.stringify(parsed)); + setStorageError(''); + } catch { + setStorageError('Preferences apply to this session; device storage is unavailable.'); + } + }, + [storageKey], + ); + const clear = useCallback((source?: NotificationSource, undoable = false) => { + if (!source || source === 'Tickets') current.current.dismissDelivery?.('ticket'); + setInbox((old) => ({ + notices: source ? old.notices.filter((notice) => notice.source !== source) : [], + // Account resets must also purge any ticket entries waiting for Undo. + cleared: undoable + ? old.notices.filter((notice) => !source || notice.source === source) + : old.cleared.filter((notice) => source && notice.source !== source), + })); + }, []); + const undoClear = useCallback(() => { + setInbox((old) => { + const existingIds = new Set(old.notices.map((notice) => notice.id)); + return { + notices: [...old.notices, ...old.cleared.filter((notice) => !existingIds.has(notice.id))] + .sort((a, b) => b.at - a.at) + .slice(0, 200), + cleared: [], + }; + }); + }, []); + const markRead = useCallback( + (id?: string, source?: NotificationSource) => { + setNotices((old) => + old.map((notice) => + (!id || notice.id === id) && (!source || notice.source === source) + ? { ...notice, read: true } + : notice, + ), + ); + }, + [setNotices], + ); + const publish = useCallback( + (input: NotificationInput) => { + const { preferences: prefs, showToast: toast } = current.current; + const rule = prefs.sources[input.source]; + const level = input.type === 'success' ? 'info' : input.type; + if (!rule.enabled || !rule[level]) return; + const id = input.id ?? createClientId(); + if (seen.current.has(id)) return; + seen.current.add(id); + if (seen.current.size > 1000) seen.current.delete(seen.current.values().next().value!); + const notice: RelayNotice = { ...input, id, at: input.at ?? Date.now(), read: false }; + if (input.inbox !== false) setNotices((old) => [notice, ...old].slice(0, 200)); + if ( + input.interrupt === false || + quietNow( + { + ...prefs, + quietStart: prefs.quietHoursEnabled ? prefs.quietStart : '', + rules: [], + warningMinutes: 30, + }, + Date.now(), + ) + ) + return; + const open = () => { + markRead(id); + if (input.options?.action) input.options.action.onClick(); + else openNotificationTarget(input.target); + }; + if (prefs.toast && input.toast !== false) + toast(input.message, input.type, { + ...input.options, + title: input.title, + durationMs: input.options?.durationMs ?? 8000, + action: { label: input.options?.action?.label ?? `Open ${input.source}`, onClick: open }, + }); + const desktop = globalThis.api?.runtime.kind === 'electron'; + if (desktop && prefs.desktop && input.desktop !== false) + void globalThis.api + ?.notifyTicket?.({ + title: `Relay — ${input.source.toLowerCase()}`, + body: 'New activity needs attention. Open Relay to review.', + target: input.target, + }) + .catch(() => undefined); + if ( + desktop && + prefs.sound && + (input.sound ?? rule.sound) && + Date.now() - lastSound.current >= 1000 + ) { + lastSound.current = Date.now(); + void globalThis.api?.playAlertSound?.().catch(() => undefined); + } + }, + [markRead, setNotices], + ); + useEffect( + () => + globalThis.api?.onNotificationClick?.((target) => { + const parsed = NotificationTargetSchema.safeParse(target); + if (!parsed.success) return; + const destination = parsed.data; + setNotices((old) => readTarget(old, destination)); + openNotificationTarget(destination); + }), + [setNotices], + ); + const value = useMemo( + () => ({ + notices, + preferences, + savePreferences, + storageError, + publish, + markRead, + clear, + undoClear, + clearedCount: cleared.length, + }), + [ + notices, + preferences, + savePreferences, + storageError, + publish, + markRead, + clear, + undoClear, + cleared.length, + ], + ); + return {children}; +} + +/** Preserve each source's detector and action; deliver operational events through one policy. */ +export function useOperationalToast(source: Exclude): ShowToast { + const context = useNotifications(); + const publish = context?.publish; + const { showToast } = useToast(); + return useCallback( + (message, type, options) => { + if (!publish || !options?.delivery || options.delivery === 'routine') { + showToast(message, type, options); + if (!publish && source === 'Problems') + void globalThis.api?.playAlertSound?.().catch(() => undefined); + return; + } + publish({ + source, + title: options.title ?? source, + message, + type, + target: { source }, + options, + }); + }, + [publish, source, showToast], + ); +} + +function readTarget(notices: RelayNotice[], destination: NotificationTarget): RelayNotice[] { + return notices.map((notice) => { + if (notice.target.source !== destination.source) return notice; + if ( + destination.source === 'Tickets' && + notice.target.source === 'Tickets' && + notice.target.ticketId !== destination.ticketId + ) + return notice; + return { ...notice, read: true }; + }); +} diff --git a/src/renderer/src/features/notifications/notifications.css b/src/renderer/src/features/notifications/notifications.css new file mode 100644 index 00000000..ba9b15f9 --- /dev/null +++ b/src/renderer/src/features/notifications/notifications.css @@ -0,0 +1,128 @@ +.notification-attention { + color: var(--alarm-bright); + font-weight: var(--weight-semibold); +} +.notification-sources, +.notification-inbox-actions, +.notification-source-options, +.notification-quiet-hours { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.notification-sources { + margin-top: 16px; +} +.notification-inbox-actions { + justify-content: flex-end; + border-bottom: 1px solid var(--color-border-subtle); + padding-block: 8px; +} +.notification-empty { + color: var(--color-text-secondary); + padding-block: 24px; +} +.notification-entry { + display: grid; + gap: 6px; + width: 100%; + padding: 16px 12px; + text-align: left; + font: inherit; + color: var(--color-text-secondary); + background: transparent; + border: 0; + border-bottom: 1px solid var(--color-border-subtle); + cursor: pointer; + overflow-wrap: anywhere; +} +.notification-entry:hover { + background: var(--color-bg-card-hover); +} +.notification-entry:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.notification-entry.is-unread { + box-shadow: inset 2px 0 var(--accent); +} +.notification-entry strong { + color: var(--color-text-primary); +} +.notification-meta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + font-size: var(--text-xs); + color: var(--color-text-tertiary); +} +.notification-preferences { + display: grid; + gap: 20px; + padding-top: 20px; +} +.notification-preferences fieldset { + display: grid; + gap: 12px; + min-width: 0; + margin: 0; + padding: 12px 0 0; + border: 0; + border-top: 1px solid var(--color-border-subtle); +} +.notification-preferences legend { + padding-right: 12px; + font-weight: var(--weight-semibold); +} +.notification-quiet-hours label { + display: grid; + gap: 6px; + flex: 1; + min-width: 150px; +} + +.notification-source-details { + border-top: 1px solid var(--color-border-subtle); +} +.notification-source-details summary { + padding-block: 12px; + font-weight: var(--weight-semibold); + cursor: pointer; +} +.notification-source-details summary span { + float: right; + margin-left: 12px; + color: var(--color-text-secondary); + font-weight: var(--weight-regular); +} +.notification-source-details summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.notification-source-details fieldset { + border: 0; + padding-bottom: 12px; +} +.notification-undo { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} +.notification-pause, +.notification-severity { + color: var(--color-text-secondary); +} +.notification-severity--warning { + color: var(--color-warning); +} +.notification-severity--error { + color: var(--alarm-bright); +} + +.notification-dialog input[type='checkbox'] { + accent-color: var(--accent); +} diff --git a/src/renderer/src/features/tickets/LiveSdpNotificationNavigation.test.tsx b/src/renderer/src/features/tickets/LiveSdpNotificationNavigation.test.tsx new file mode 100644 index 00000000..9ea79f93 --- /dev/null +++ b/src/renderer/src/features/tickets/LiveSdpNotificationNavigation.test.tsx @@ -0,0 +1,68 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ELECTRON_RUNTIME } from '@shared/runtime'; +import { LiveSdpQueues } from './LiveSdpQueues'; +vi.mock('../../services/pocketbase', () => ({ getPb: () => ({ baseURL: 'http://draft.test' }) })); +vi.mock('./SdpNativeEditor', () => ({ + SdpNativeEditor: ({ onClose }: Readonly<{ onClose: () => void }>) => ( + <> + + + + ), +})); +const original = globalThis.api; +afterEach(() => { + cleanup(); + globalThis.api = original; +}); +it('defers notification navigation while a draft is open and opens it after the draft ends', async () => { + let view: unknown; + const invoke = vi.fn().mockImplementation(async (command) => { + if (command.action === 'status' && view) return { success: true, data: view }; + const id = command.id ?? '123'; + view = { + configured: true, + status: 'connected', + replyActivity: { + id, + number: id, + subject: `Ticket ${id}`, + status: 'Open', + priority: 'Low', + group: 'NOC', + technician: '', + createdAt: 1000, + dueAt: null, + }, + detail: { id, description: '', conversations: [], page: 0, hasMore: false }, + snapshot: { source: 'live', fetchedAt: Date.now(), expiresAt: Date.now() + 60000 }, + detailSnapshot: { source: 'live', fetchedAt: Date.now(), expiresAt: Date.now() + 60000 }, + }; + return { success: true, data: view }; + }); + globalThis.api = { ...original, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as never; + const rendered = render( + , + ); + await screen.findByRole('heading', { name: 'Ticket 123' }); + fireEvent.click(screen.getByRole('button', { name: 'Reply' })); + fireEvent.change(screen.getByLabelText('Draft'), { target: { value: 'Keep this reply' } }); + rendered.rerender( + , + ); + expect(screen.getByLabelText('Draft')).toHaveValue('Keep this reply'); + expect(invoke).not.toHaveBeenCalledWith({ action: 'readDetail', id: '999', page: 0 }); + expect( + screen.getByText('Finish or cancel your draft to open the notified ticket.'), + ).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Finish draft' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith({ action: 'readDetail', id: '999', page: 0 }), + ); + await screen.findByRole('heading', { name: 'Ticket 999' }); +}); diff --git a/src/renderer/src/features/tickets/LiveSdpQueues.test.tsx b/src/renderer/src/features/tickets/LiveSdpQueues.test.tsx new file mode 100644 index 00000000..25920462 --- /dev/null +++ b/src/renderer/src/features/tickets/LiveSdpQueues.test.tsx @@ -0,0 +1,361 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { ELECTRON_RUNTIME } from '@shared/runtime'; +import { SdpTicketContent } from './SdpTicketContent'; +import { LiveSdpQueues, SdpBody } from './LiveSdpQueues'; +vi.mock('../../services/pocketbase', () => ({ getPb: () => ({ baseURL: 'http://relay.test' }) })); +vi.mock('../../hooks/useCollection', () => ({ + useCollection: () => ({ data: [], error: null, refetch: vi.fn() }), +})); +const original = globalThis.api; +afterEach(() => { + globalThis.api = original; + vi.useRealTimers(); +}); +const ticket = { + id: '123', + number: '810129', + subject: 'Synthetic live subject', + status: 'Open', + priority: 'Low', + group: 'NOC', + technician: 'Example technician', + createdAt: 1000, + dueAt: null, +}; +it('keeps queue controls separate and clears ticket details after deleting saved data', async () => { + const invoke = vi.fn().mockImplementation(async (command) => ({ + success: true, + data: { + configured: true, + testControls: true, + status: 'connected', + ...(['readQueue', 'readDetail'].includes(command.action) + ? { + queuePage: { + queue: command.queue ?? 'NOC', + page: command.page, + hasMore: true, + tickets: [{ ...ticket, group: command.queue ?? 'NOC' }], + }, + ...(command.action === 'readDetail' + ? { + detail: { + id: command.id, + includeAutoNotifications: command.includeAutoNotifications ?? false, + properties: [{ label: 'Impact', value: 'Single user' }], + notes: [ + { + id: '7', + body: '

Example internal note

', + author: 'Example technician', + createdAt: 1000, + }, + ], + description: '

Example description

', + page: 0, + hasMore: false, + conversations: [ + { + id: '5', + author: 'Example author', + subject: 'Reply', + body: '

Example reply

', + createdAt: 1000, + }, + ], + }, + } + : {}), + snapshot: { source: 'live', fetchedAt: Date.now(), expiresAt: Date.now() + 60000 }, + } + : {}), + }, + })); + globalThis.api = { ...original, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + await waitFor(() => expect(screen.getByRole('button', { name: 'NOC' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'NOC' })); + fireEvent.click(await screen.findByRole('button', { name: /Synthetic live subject/ })); + expect(screen.getByRole('complementary', { name: 'Ticket 810129' })).toHaveTextContent( + 'Example technician', + ); + await screen.findByText('Example description'); + expect(screen.getByText('Example description')).not.toBeVisible(); + fireEvent.click(screen.getByText('Original request', { selector: 'summary' })); + expect(screen.getByText('Example description')).toBeVisible(); + fireEvent.click(screen.getByRole('checkbox', { name: 'Show automatic notifications' })); + await waitFor(() => + expect(screen.getByRole('checkbox', { name: 'Show automatic notifications' })).toBeChecked(), + ); + expect(invoke).toHaveBeenCalledWith({ + action: 'readDetail', + id: '123', + page: 0, + includeAutoNotifications: true, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Show automatic notifications' })); + await waitFor(() => + expect( + screen.getByRole('checkbox', { name: 'Show automatic notifications' }), + ).not.toBeChecked(), + ); + fireEvent.click(screen.getByRole('button', { name: 'Details' })); + expect(screen.getByText('Single user')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Notes' })); + expect(screen.getByText('Example internal note')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Conversation' })); + expect(screen.getByText('Example reply')).toBeVisible(); + expect(invoke).toHaveBeenCalledWith({ action: 'readDetail', id: '123', page: 0 }); + const more = screen.getByRole('button', { name: 'More actions' }); + more.focus(); + fireEvent.click(more); + expect(screen.getByRole('menuitem', { name: 'Refresh ticket' })).toBeVisible(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(more).toHaveFocus(); + fireEvent.click(screen.getByRole('button', { name: 'Back to queue' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith({ action: 'readQueue', queue: 'NOC', page: 1 }), + ); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Clear my saved SDP data' })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole('button', { name: 'Clear my saved SDP data' })); + await waitFor(() => + expect( + screen.queryByRole('button', { name: /Synthetic live subject/ }), + ).not.toBeInTheDocument(), + ); + expect(invoke).toHaveBeenCalledWith({ action: 'clearCopies' }); + expect(screen.getByRole('button', { name: 'New ticket' })).toBeEnabled(); +}); +it('replaces unusable queues with an account connection action when signed out', async () => { + globalThis.api = { + ...original, + runtime: ELECTRON_RUNTIME, + sdpAccount: vi + .fn() + .mockResolvedValue({ success: true, data: { configured: true, status: 'disconnected' } }), + } as BridgeAPI; + render(); + const connect = await screen.findByRole('button', { name: 'Connect work account' }); + await waitFor(() => expect(connect).toBeEnabled()); + expect(screen.queryByLabelText('Search tickets')).not.toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New ticket' })).not.toBeInTheDocument(); + fireEvent.click(connect); + expect(await screen.findByRole('dialog', { name: 'Your SDP connection' })).toBeVisible(); + expect(await screen.findByRole('button', { name: 'Sign in with work account' })).toBeEnabled(); +}); + +it('removes live rows when a refresh cannot reach Relay', async () => { + const invoke = vi.fn().mockResolvedValue({ + success: true, + data: { + configured: true, + status: 'connected', + queuePage: { queue: 'NOC', page: 0, hasMore: false, tickets: [ticket] }, + }, + }); + globalThis.api = { ...original, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + await screen.findByRole('button', { name: /Synthetic live subject/ }); + invoke.mockResolvedValue({ success: false, error: 'offline' }); + fireEvent.click(screen.getByRole('button', { name: 'Refresh queue' })); + await screen.findByRole('alert'); + expect(screen.queryByRole('button', { name: /Synthetic live subject/ })).not.toBeInTheDocument(); +}); + +it('renders provider HTML as inert text without scripts, remote media or clickable URLs', () => { + const { container } = render( + Hello & welcome

Reply' + } + />, + ); + expect(container).toHaveTextContent('Hello & welcome'); + expect(container).toHaveTextContent('Reply'); + expect(container.querySelector('script,img,a,iframe')).toBeNull(); + expect(container.querySelector('p')).not.toBeNull(); + expect(container).not.toHaveTextContent('bad()'); +}); + +it('preserves complete form tables while discarding source styles and event handlers', () => { + const { container } = render( + ResponsibilitiesExample accessApproval limit1500' + } + />, + ); + expect(container.querySelectorAll('tr')).toHaveLength(2); + expect(container).toHaveTextContent('Approval limit'); + expect(container).toHaveTextContent('1500'); + expect(container.querySelector('[style],[onclick]')).toBeNull(); +}); + +it('never shows demo controls and hides cache clearing without local test capability', async () => { + globalThis.api = { + ...original, + runtime: ELECTRON_RUNTIME, + sdpAccount: vi.fn().mockResolvedValue({ + success: true, + data: { configured: true, status: 'connected', testControls: false }, + }), + } as BridgeAPI; + render(); + await waitFor(() => expect(screen.getByRole('button', { name: 'New ticket' })).toBeEnabled()); + expect(screen.queryByRole('button', { name: 'Synthetic workspace' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clear my saved SDP data' })).not.toBeInTheDocument(); +}); + +it('only offers activity paging when the active feed has another page or a previous page', () => { + const onPage = vi.fn(); + const detail = { + id: '123', + description: '', + conversations: [], + notes: [], + page: 0, + hasMore: false, + notesHasMore: true, + }; + const props = { detail, busy: false, onPage, setSection: vi.fn() }; + const view = render(); + expect(screen.queryByRole('button', { name: 'Next activity' })).not.toBeInTheDocument(); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Next activity' })); + expect(onPage).toHaveBeenLastCalledWith(1); + view.rerender( + , + ); + expect(screen.getByRole('button', { name: 'Next activity' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Previous activity' })); + expect(onPage).toHaveBeenLastCalledWith(0); +}); + +it('opens a notified ticket outside the current queue from the authorized detail summary', async () => { + const notified = { + ...ticket, + id: '999', + number: '99', + subject: 'Notified SOX ticket', + group: 'SOX', + }; + const invoke = vi.fn().mockImplementation(async (command) => ({ + success: true, + data: { + configured: true, + status: 'connected', + queuePage: { queue: 'NOC', page: 0, hasMore: false, tickets: [] }, + snapshot: { source: 'live', fetchedAt: Date.now(), expiresAt: Date.now() + 60000 }, + ...(command.action === 'readDetail' + ? { + replyActivity: notified, + detail: { + id: '999', + description: '

Notified conversation

', + page: 0, + hasMore: false, + conversations: [], + }, + detailSnapshot: { + source: 'live', + fetchedAt: Date.now(), + expiresAt: Date.now() + 60000, + }, + } + : {}), + }, + })); + globalThis.api = { ...original, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render( + , + ); + await screen.findByRole('complementary', { name: 'Ticket 99' }); + expect(invoke).toHaveBeenCalledWith({ action: 'readDetail', id: '999', page: 0 }); + expect(screen.getByRole('heading', { name: 'Notified SOX ticket' })).toBeVisible(); +}); + +it('groups ticket navigation into six sections without losing detail views', () => { + const detail = { + id: '123', + description: 'Original request', + conversations: [], + page: 0, + hasMore: false, + }; + const setSection = vi.fn(); + const props = { detail, busy: false, onPage: vi.fn(), setSection }; + const view = render(); + const navigation = screen.getByRole('navigation', { name: 'Ticket sections' }); + expect(within(navigation).getAllByRole('button')).toHaveLength(6); + expect(within(navigation).queryByRole('button', { name: 'Messages' })).toBeNull(); + fireEvent.click(within(navigation).getByRole('button', { name: 'Related' })); + expect(setSection).toHaveBeenLastCalledWith('Links & bridge'); + view.rerender(); + expect(within(navigation).getByRole('button', { name: 'Details' })).toHaveAttribute( + 'aria-current', + 'page', + ); + const details = screen.getByRole('navigation', { name: 'Ticket details views' }); + fireEvent.click(within(details).getByRole('button', { name: 'Resolution' })); + expect(setSection).toHaveBeenLastCalledWith('Resolution'); +}); + +it('refreshes the visible workspace in the background and pauses for the account dialog', async () => { + vi.useFakeTimers(); + let refreshed = false; + const invoke = vi.fn(async (command: { action: string }) => { + if (command.action === 'refreshVisible') refreshed = true; + return { + success: true as const, + data: { + configured: true, + status: 'connected' as const, + queuePage: { + queue: 'NOC' as const, + page: 0, + hasMore: false, + tickets: [ + { + ...ticket, + subject: refreshed ? 'Refreshed subject' : ticket.subject, + }, + ], + }, + snapshot: { + source: 'live' as const, + fetchedAt: Date.now(), + expiresAt: Date.now() + 300_000, + }, + }, + }; + }); + globalThis.api = { ...original, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText(ticket.subject)).toBeVisible(); + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(invoke).toHaveBeenCalledWith({ action: 'refreshVisible' }); + expect(screen.getByText('Refreshed subject')).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: /^Work account$/ })); + const calls = invoke.mock.calls.filter(([command]) => command.action === 'refreshVisible').length; + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(invoke.mock.calls.filter(([command]) => command.action === 'refreshVisible')).toHaveLength( + calls, + ); +}); diff --git a/src/renderer/src/features/tickets/LiveSdpQueues.tsx b/src/renderer/src/features/tickets/LiveSdpQueues.tsx new file mode 100644 index 00000000..2223d380 --- /dev/null +++ b/src/renderer/src/features/tickets/LiveSdpQueues.tsx @@ -0,0 +1,717 @@ +import { SdpBulkDialog, SdpBulkControls } from './SdpBulkDialog'; +import { SdpReplyStatus } from './SdpReplyStatus'; +import { resetSdpNotifications } from './SdpAlerts'; +import type { BridgeGroup } from '@shared/ipc'; +import type { TicketOpenRequest } from '../../tabs/TicketsTab'; +import { SdpChangeDialog, type SdpChangeMode } from './SdpChangeDialog'; +import { linkSdpProblem } from '../../services/sdpLinkService'; +import { useEffect, useEffectEvent, useRef, useState } from 'react'; +import { + SDP_QUEUES, + type SdpAccountCommand, + type SdpAccountView, + type SdpQueue, +} from '@shared/sdpAccount'; +import { + TabCommandBar, + TabCommandGroup, + TabPageHeader, +} from '../../components/tab-chrome/TabChrome'; +import { TactileButton } from '../../components/TactileButton'; +import type { SdpQueueFilters } from '@shared/sdpQueueFilters'; +import { SdpAccountPanel } from './SdpAccountPanel'; +import type { SdpDetailSection } from './SdpTicketContent'; +import { SdpTicketWorkspace } from './SdpTicketWorkspace'; +export { SdpBody } from './SdpTicketContent'; +const date = (value: number | null): string => + value === null ? 'Not set' : new Date(value).toLocaleString(); + +export function LiveSdpQueues({ + groups = [], + request, +}: Readonly<{ groups?: BridgeGroup[]; request?: TicketOpenRequest }>) { + const [nativeEditor, setNativeEditor] = useState<'edit' | 'reply' | 'forward'>(); + const [filters, setFilters] = useState({ status: '', priority: '', technician: '', due: '' }); + const [editor, setEditor] = useState<{ + mode: SdpChangeMode; + ticket?: import('@shared/sdpAccount').SdpQueueTicket; + problem?: NonNullable; + }>(); + const handledRequest = useRef(0); + const openedFrom = useRef(null); + const [view, setView] = useState(); + const [queue, setQueue] = useState('NOC'); + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(''); + const [openedTicket, setOpenedTicket] = useState(); + const [detailSection, setDetailSection] = useState('Conversations'); + const [account, setAccount] = useState(false); + const [error, setError] = useState(''); + const [requestBusy, setRequestBusy] = useState(false); + const [bulkIds, setBulkIds] = useState([]); + const [bulkTickets, setBulkTickets] = useState(); + const busy = requestBusy || !!bulkTickets; + const pending = useRef(false); + const epoch = useRef(0); + const alive = useRef(true); + const refreshing = useRef(false); + const invoke = globalThis.api?.sdpAccount; + const available = globalThis.api?.runtime.kind === 'electron' && !!invoke; + const connected = view?.status === 'connected'; + const openNotifiedTicket = useEffectEvent( + (id: string) => void run({ action: 'readDetail', id, page: 0 }), + ); + useEffect(() => { + if ( + !connected || + request?.source !== 'sdp' || + request.sequence === handledRequest.current || + nativeEditor || + editor || + requestBusy + ) + return; + handledRequest.current = request.sequence; + if (request.major) setEditor({ mode: 'major', problem: request.problem }); + else if (request.ticketId) openNotifiedTicket(request.ticketId); + }, [connected, request, nativeEditor, editor, requestBusy]); + useEffect(() => { + if (!connected && !requestBusy) { + setEditor(undefined); + setNativeEditor(undefined); + setSelected(''); + setOpenedTicket(undefined); + } + }, [connected, requestBusy]); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + function toggleBulk(id: string, checked: boolean) { + setBulkIds((ids) => (checked ? [...ids, id] : ids.filter((value) => value !== id))); + } + async function run(command: SdpAccountCommand) { + if (command.action !== 'readDetail') setBulkIds([]); + if (!invoke || pending.current || bulkTickets) return; + const current = ++epoch.current; + pending.current = true; + setRequestBusy(true); + setError(''); + if (command.action === 'readDetail') { + if (selected !== command.id) setDetailSection('Conversations'); + setView((old) => + old ? { ...old, detail: undefined, detailSnapshot: undefined, message: undefined } : old, + ); + setOpenedTicket( + (old) => view?.queuePage?.tickets.find((item) => item.id === command.id) ?? old, + ); + setSelected(command.id); + } else { + setView((old) => + old + ? { + ...old, + queuePage: undefined, + detail: undefined, + snapshot: undefined, + detailSnapshot: undefined, + } + : old, + ); + setSelected(''); + setOpenedTicket(undefined); + } + if (command.action === 'clearCopies') { + setSearch(''); + resetSdpNotifications(); + } + try { + const result = await invoke(command); + if (!result.success || !result.data) + throw new Error(result.error ?? 'SDP could not complete this action.'); + if (alive.current && current === epoch.current) setView(result.data); + } catch { + if (alive.current && current === epoch.current) { + setView(undefined); + setError( + 'Could not load SDP. Check the server connection and your work sign-in, then retry.', + ); + } + } finally { + pending.current = false; + if (alive.current) setRequestBusy(false); + } + } + useEffect(() => { + if (!available) return; + let active = true; + let checking = false; + const check = async () => { + if (checking || pending.current || refreshing.current) return; + checking = true; + const current = epoch.current; + try { + const result = await invoke!({ action: 'status' }); + if (!active || current !== epoch.current) return; + if (result.success && result.data) { + setView(result.data); + setError(''); + } else { + setView(undefined); + setError('Relay is unavailable. Live ticket data has been hidden.'); + } + } catch { + if (active && current === epoch.current) { + setView(undefined); + setError('Relay is unavailable. Live ticket data has been hidden.'); + } + } finally { + checking = false; + } + }; + void check(); + const timer = setInterval(() => void check(), 5000); + return () => { + active = false; + clearInterval(timer); + }; + }, [available, invoke]); + const refreshVisible = useEffectEvent(async () => { + if ( + !invoke || + !connected || + pending.current || + refreshing.current || + nativeEditor || + editor || + bulkTickets || + account + ) + return; + const current = ++epoch.current; + refreshing.current = true; + try { + const result = await invoke({ action: 'refreshVisible' }); + if (alive.current && current === epoch.current && result.success && result.data) + setView(result.data); + } finally { + refreshing.current = false; + } + }); + useEffect(() => { + if (!available) return; + const timer = setInterval(() => { + void refreshVisible().catch(() => undefined); + }, 30_000); + return () => clearInterval(timer); + }, [available]); + useEffect(() => { + // Opening a draft invalidates any older background response without resetting the draft. + if (nativeEditor || editor || bulkTickets || account) epoch.current++; + }, [nativeEditor, editor, bulkTickets, account]); + function applyResult(next: SdpAccountView) { + epoch.current++; + setView(next); + } + useEffect(() => { + if (!view?.snapshot) return; + const timer = setTimeout( + () => { + setView((old) => + old ? { ...old, queuePage: undefined, ticket: undefined, snapshot: undefined } : old, + ); + setSelected(''); + setOpenedTicket(undefined); + }, + Math.max(0, view.snapshot.expiresAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [view?.snapshot]); + useEffect(() => { + if (!view?.detailSnapshot) return; + const timer = setTimeout( + () => + setView((old) => (old ? { ...old, detail: undefined, detailSnapshot: undefined } : old)), + Math.max(0, view.detailSnapshot.expiresAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [view?.detailSnapshot]); + const result = + view?.queuePage?.queue === queue && view.queuePage.page === page ? view.queuePage : undefined; + const tickets = + result?.tickets.filter( + (ticket) => + `${ticket.number} ${ticket.subject} ${ticket.technician}` + .toLowerCase() + .includes(search.toLowerCase()) && + (!filters.status || ticket.status === filters.status) && + (!filters.priority || ticket.priority === filters.priority) && + (!filters.technician || ticket.technician === filters.technician) && + (!filters.due || + (ticket.dueAt !== null && + (filters.due === 'overdue' + ? ticket.dueAt < Date.now() + : ticket.dueAt >= Date.now() && ticket.dueAt < Date.now() + 86400000))), + ) ?? []; + const queueTicket = selectedTicket(view, selected, openedTicket); + const ticket = + queueTicket && view?.replyActivity?.id === selected + ? { + ...queueTicket, + ...view.replyActivity, + } + : queueTicket; + function load(nextQueue: SdpQueue, nextPage = 0) { + if (nextQueue === queue && result?.filters) { + setPage(nextPage); + void run({ action: 'readQueue', queue: nextQueue, page: nextPage, filters: result.filters }); + return; + } + setQueue(nextQueue); + setPage(nextPage); + setSearch(''); + setFilters({ status: '', priority: '', technician: '', due: '' }); + void run({ action: 'readQueue', queue: nextQueue, page: nextPage }); + } + const showWorkspace = connected || !!result || !!ticket || !!editor || !!nativeEditor; + const resultCaption = `${tickets.length} ${tickets.length === 1 ? 'ticket' : 'tickets'}`; + const filterCaption = result?.filters ? ' · Filtered' : ''; + return ( +
+ SDP · Your work account} + /> + {showWorkspace && ( + <> + + + setAccount(true)}> + Work account + + load(queue, page)} + > + {busy ? 'Loading…' : 'Refresh queue'} + + {view?.testControls === true && ( + void run({ action: 'clearCopies' })} + > + Clear my saved SDP data + + )} + + + + setEditor({ mode: 'major' })} + > + Major incident + + setEditor({ mode: 'create' })} + > + New ticket + + + +
+ + {result && view?.snapshot && ( + + {view.snapshot.source === 'outage-cache' + ? 'SDP unavailable · Saved copy · Read only' + : 'Live from SDP'} + + )} +
+ + )} + + {request?.ticketId && + request.sequence !== handledRequest.current && + (nativeEditor || editor) && ( +

+ Finish or cancel your draft to open the notified ticket. +

+ )} + {bulkTickets && ( + { + setBulkTickets(undefined); + setBulkIds([]); + }} + onResult={applyResult} + /> + )} + {error && ( +

+ {error} +

+ )} + {view?.message && ( +

+ {view.message} +

+ )} + {!showWorkspace && ( + setAccount(true)} + /> + )} + {showWorkspace && ( + <> + {!ticket && result && ( +
+
+ {queue} queue + + {result.tickets.length} + on this page + +
+
+ {[...new Set(result.tickets.map((item) => item.status))].map((status) => ( +
+
{status}
+
{result.tickets.filter((item) => item.status === status).length}
+
+ ))} +
+
+ Unread replies + {result.tickets.filter((item) => item.replyUnread).length} +
+
+ )} +
+ + {( + [ + ['status', 'Status'], + ['priority', 'Priority'], + ['technician', 'Technician'], + ] as const + ).map(([key, label]) => ( + + ))} + + { + const filter = Object.fromEntries( + Object.entries({ ...filters, search }).filter(([, v]) => v), + ) as SdpQueueFilters; + setPage(0); + void run({ + action: 'readQueue', + queue, + page: 0, + ...(Object.keys(filter).length ? { filters: filter } : {}), + }); + }} + > + Apply filters + + { + setFilters({ status: '', priority: '', technician: '', due: '' }); + setSearch(''); + setPage(0); + if (connected) void run({ action: 'readQueue', queue, page: 0 }); + }} + > + Clear filters + +
+
+
+
+ {result ? `${resultCaption}${filterCaption}` : 'Queue not loaded'} +
+ + + + + + + + + + + + + + + + + {tickets.map((item) => ( + + + + + + + ))} + +
TicketPriority / statusGroup / technicianDue
+ + + + {item.priority} + {item.status} + + {item.group} + {item.technician} + {date(item.dueAt)}
+ {result && tickets.length === 0 && ( +

+ {search ? 'No matches in these results.' : 'No tickets returned for this queue.'} +

+ )} + {!result && connected && !busy && ( +

+ Choose a queue or Refresh queue to load tickets. +

+ )} +
+ load(queue, page - 1)} + > + Previous + + = 19} + onClick={() => load(queue, page + 1)} + > + Next + + Page {page + 1} +
+
+ {ticket && ( + setEditor({ mode, ticket })} + onResult={applyResult} + onRefresh={(nextPage, includeAutoNotifications) => + void run({ + action: 'readDetail', + id: ticket.id, + page: nextPage, + ...((includeAutoNotifications ?? view?.detail?.includeAutoNotifications) + ? { includeAutoNotifications: true } + : {}), + }) + } + onClose={() => { + setSelected(''); + setOpenedTicket(undefined); + requestAnimationFrame(() => openedFrom.current?.focus()); + }} + /> + )} +
+ + )} + {editor && ( + setEditor(undefined)} + onResult={(next) => { + applyResult(next); + if (next.changeResult?.kind === 'create' && editor.problem && editor.mode === 'major') { + void linkSdpProblem({ + ticketId: next.changeResult.id, + ticketNumber: next.changeResult.number, + problemId: editor.problem.problemId, + environment: editor.problem.environmentUrl, + }).catch(() => + setError( + 'Ticket created, but the problem link was not saved. Open the ticket to link it.', + ), + ); + } + }} + /> + )} + {account && setAccount(false)} />} +
+ ); +} + +function SdpConnectionPrompt({ + available, + view, + busy, + error, + onConnect, +}: Readonly<{ + available: boolean; + view: SdpAccountView | undefined; + busy: boolean; + error: string; + onConnect: () => void; +}>) { + let title = 'Live tickets are available on desktop'; + if (available) + title = + view?.status === 'expired' ? 'Reconnect your work account' : 'Connect your work account'; + return ( +
+

{title}

+

+ {available + ? 'Sign in to view your SDP queues and work on tickets. Your work account determines access.' + : 'Open Relay desktop to connect your SDP account. Web sign-in is not available yet.'} +

+ {available && !view && !error && ( +

+ Checking connection… +

+ )} + {available && ( + + {view?.status === 'connecting' ? 'Continue work sign-in' : 'Connect work account'} + + )} +
+ ); +} + +function selectedTicket( + view: SdpAccountView | undefined, + selected: string, + opened?: import('@shared/sdpAccount').SdpQueueTicket, +) { + return ( + view?.queuePage?.tickets.find((item) => item.id === selected) ?? + (view?.replyActivity?.id === selected ? view.replyActivity : undefined) ?? + (opened?.id === selected ? opened : undefined) + ); +} diff --git a/src/renderer/src/features/tickets/SdpAccountPanel.test.tsx b/src/renderer/src/features/tickets/SdpAccountPanel.test.tsx new file mode 100644 index 00000000..30b44b88 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAccountPanel.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ELECTRON_RUNTIME, WEB_RUNTIME } from '@shared/runtime'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpAccountPanel } from './SdpAccountPanel'; + +const originalApi = globalThis.api; +afterEach(() => { + globalThis.api = originalApi; +}); +describe('SDP account panel', () => { + it('never offers desktop credentials or sign-in to Relay Web', () => { + const invoke = vi.fn(); + globalThis.api = { ...originalApi, runtime: WEB_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + expect(screen.getByText(/Open Relay desktop/)).toBeInTheDocument(); + expect(screen.queryByLabelText('Client secret')).not.toBeInTheDocument(); + expect(invoke).not.toHaveBeenCalled(); + }); + it('offers only work sign-in after server setup', async () => { + const invoke = vi + .fn() + .mockResolvedValue({ success: true, data: { configured: true, status: 'disconnected' } }); + globalThis.api = { ...originalApi, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Sign in with work account' })); + expect(invoke).toHaveBeenLastCalledWith({ action: 'connect' }); + expect(screen.queryByLabelText('Client secret')).not.toBeInTheDocument(); + }); + it('directs an unconfigured connection to server administration', async () => { + const invoke = vi + .fn() + .mockResolvedValue({ success: true, data: { configured: false, status: 'disconnected' } }); + globalThis.api = { ...originalApi, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + expect(await screen.findByText(/one-time setup/)).toBeInTheDocument(); + expect(screen.queryByLabelText('Client secret')).not.toBeInTheDocument(); + }); + it('keeps the account panel focused on sign-in and disconnect', async () => { + const invoke = vi + .fn() + .mockResolvedValueOnce({ + success: true, + data: { + configured: true, + status: 'connected', + ticket: { number: '810129', status: 'Open', priority: 'Low', group: 'NOC' }, + }, + }) + .mockResolvedValue({ success: true, data: { configured: true, status: 'disconnected' } }); + globalThis.api = { ...originalApi, runtime: ELECTRON_RUNTIME, sdpAccount: invoke } as BridgeAPI; + render(); + await screen.findByRole('button', { name: 'Disconnect' }); + expect(screen.queryByRole('button', { name: /Read ticket/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + await screen.findByRole('button', { name: 'Sign in with work account' }); + expect(screen.queryByRole('region', { name: 'Live SDP test ticket' })).not.toBeInTheDocument(); + expect(invoke).toHaveBeenLastCalledWith({ action: 'disconnect' }); + }); +}); diff --git a/src/renderer/src/features/tickets/SdpAccountPanel.tsx b/src/renderer/src/features/tickets/SdpAccountPanel.tsx new file mode 100644 index 00000000..7aaec883 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAccountPanel.tsx @@ -0,0 +1,168 @@ +import { resetSdpNotifications } from './SdpAlerts'; +import { useEffect, useRef, useState } from 'react'; +import { type SdpAccountCommand, type SdpAccountView } from '@shared/sdpAccount'; +import { Modal } from '../../components/Modal'; +import { TactileButton } from '../../components/TactileButton'; + +export function SdpAccountPanel({ onClose }: Readonly<{ onClose: () => void }>) { + const [view, setView] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const actionEpoch = useRef(0); + const actionPending = useRef(false); + const invoke = globalThis.api?.sdpAccount; + const available = globalThis.api?.runtime.kind === 'electron' && !!invoke; + useEffect(() => { + if (!available) return; + let active = true; + let pending = false; + const poll = async () => { + if (pending || actionPending.current) return; + const epoch = actionEpoch.current; + pending = true; + try { + const result = await invoke!({ action: 'status' }); + if (epoch !== actionEpoch.current) return; + if (active && result.success && result.data) { + setView(result.data); + setError(''); + } else if (active) { + setView(undefined); + setError(result.error ?? 'Could not read SDP connection status.'); + } + } catch { + if (active && epoch === actionEpoch.current) { + setView(undefined); + setError('Could not read SDP connection status.'); + } + } finally { + pending = false; + } + }; + void poll(); + const timer = setInterval(() => void poll(), 5000); + return () => { + active = false; + clearInterval(timer); + }; + }, [available, invoke]); + + useEffect(() => { + if (!view?.snapshot) return; + const timer = setTimeout( + () => + setView((current) => + current ? { ...current, ticket: undefined, snapshot: undefined } : current, + ), + Math.max(0, view.snapshot.expiresAt - Date.now()), + ); + return () => clearTimeout(timer); + }, [view?.snapshot]); + + async function run(command: SdpAccountCommand) { + if (command.action === 'disconnect' || command.action === 'connect') resetSdpNotifications(); + actionEpoch.current++; + actionPending.current = true; + setBusy(true); + setError(''); + try { + const result = await invoke!(command); + if (!result.success || !result.data) + throw new Error(result.error ?? 'SDP connection failed.'); + setView(result.data); + } catch (reason) { + setView(undefined); + setError(reason instanceof Error ? reason.message : 'SDP connection failed.'); + } finally { + actionPending.current = false; + setBusy(false); + } + } + return ( + Done} + > +
+

+ Your work account determines access in SDP. This connection belongs to this desktop + session. +

+ {!available ? ( +

+ + Open Relay desktop to connect your SDP account. Web sign-in is not available yet. + +

+ ) : ( + <> + {!view && !error && ( +

+ Checking connection… +

+ )} + {view && ( + + + { + { + disconnected: 'Not connected', + connecting: 'Waiting for Zoho', + connected: 'Connected to SDP', + expired: 'Session expired', + }[view.status] + } + + + {view.message ?? + (view.status === 'connecting' + ? 'Complete sign-in in your browser, then return here.' + : 'Passwords and MFA stay with your work sign-in provider.')} + + + )} + {view && !view.configured && ( +

+ + SDP needs one-time setup by an administrator on the Relay server computer. + +

+ )} + {view?.configured && ( +
+ {(view.status === 'disconnected' || view.status === 'expired') && ( + void run({ action: 'connect' })} + > + Sign in with work account + + )} + {(view.status === 'connected' || view.status === 'connecting') && ( + void run({ action: 'disconnect' })}> + {view.status === 'connecting' ? 'Cancel sign-in' : 'Disconnect'} + + )} +
+ )} + {error && ( +

+ {error} +

+ )} + + )} +

+ SDP determines your permissions. Sign-in requests read, create, update and delete access; + every live change requires review and confirmation. Existing read-only sessions need a new + work sign-in to grant these permissions. Saved outage copies remain read-only. +

+
+
+ ); +} diff --git a/src/renderer/src/features/tickets/SdpAlerts.test.tsx b/src/renderer/src/features/tickets/SdpAlerts.test.tsx new file mode 100644 index 00000000..c4777814 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAlerts.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpAlertControls, useSdpAlerts } from './SdpAlerts'; +function SdpAlerts({ + connected, + resetKey = 0, +}: Readonly<{ connected: boolean; resetKey?: number }>) { + const state = useSdpAlerts(connected, resetKey); + return {}} />; +} +vi.mock('../../services/pocketbase', () => ({ getPb: () => ({ baseURL: 'http://monitor.test' }) })); +vi.mock('../../hooks/useCollection', () => ({ useCollection: () => ({ data: [] }) })); +const original = globalThis.api; +afterEach(() => { + cleanup(); + globalThis.api = original; + vi.useRealTimers(); +}); +it('starts automatically, heartbeats with a snapshot cursor, and unsubscribes on pause and disconnect', async () => { + vi.useFakeTimers(); + const invoke = vi.fn().mockResolvedValue({ + success: true, + data: { + configured: true, + status: 'connected', + monitoring: { state: 'live', nextCheckAt: 30000 }, + monitor: { tickets: [], fetchedAt: 1000, truncated: false, generation: 'a' }, + }, + }); + globalThis.api = { sdpAccount: invoke } as unknown as BridgeAPI; + const view = render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(invoke).toHaveBeenCalledWith({ action: 'monitorQueues', after: undefined }); + expect(screen.getByRole('button', { name: 'Stop monitoring' })).toBeEnabled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(invoke).toHaveBeenLastCalledWith({ action: 'monitorQueues', after: 1000 }); + fireEvent.click(screen.getByRole('button', { name: 'Stop monitoring' })); + expect(invoke).toHaveBeenLastCalledWith({ action: 'monitorQueues', enabled: false }); + const count = invoke.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(invoke).toHaveBeenCalledTimes(count); + fireEvent.click(screen.getByRole('button', { name: 'Monitor queues' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + view.rerender(); + expect(invoke).toHaveBeenLastCalledWith({ action: 'monitorQueues', enabled: false }); +}); +it.each([ + ['invalid', 'Relay could not read an SDP queue response.'], + ['throttled', 'SDP requested a pause before more API calls.'], + ['timeout', 'The queue scan took too long.'], + ['outage', 'SDP could not be reached.'], +])('shows %s backoff without losing the connection', async (failure, message) => { + vi.useFakeTimers(); + const invoke = vi.fn().mockResolvedValue({ + success: true, + data: { + configured: true, + status: 'connected', + monitoring: { state: 'backoff', failure, nextCheckAt: Date.now() + 120000 }, + }, + }); + globalThis.api = { sdpAccount: invoke } as unknown as BridgeAPI; + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByRole('status')).toHaveTextContent('Next check'); + expect(screen.getByRole('status')).toHaveTextContent(message); + expect(screen.getByRole('button', { name: 'Stop monitoring' })).toBeEnabled(); +}); +it('clearing saved data pauses monitoring and prevents automatic repopulation', async () => { + vi.useFakeTimers(); + const invoke = vi + .fn() + .mockResolvedValue({ success: true, data: { configured: true, status: 'connected' } }); + globalThis.api = { sdpAccount: invoke } as unknown as BridgeAPI; + const view = render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + view.rerender(); + expect(screen.getByRole('button', { name: 'Monitor queues' })).toBeEnabled(); + expect(invoke).toHaveBeenLastCalledWith({ action: 'monitorQueues', enabled: false }); + const count = invoke.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(90000); + }); + expect(invoke).toHaveBeenCalledTimes(count); +}); diff --git a/src/renderer/src/features/tickets/SdpAlerts.tsx b/src/renderer/src/features/tickets/SdpAlerts.tsx new file mode 100644 index 00000000..364823ff --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAlerts.tsx @@ -0,0 +1,292 @@ +import type { SdpMonitor } from '@shared/sdpAccount'; +import type { DynatraceProblemRecord } from '@shared/dynatraceProblems'; +import { linkSdpProblem } from '../../services/sdpLinkService'; +import { SdpWorkflowAutoLink } from './sdpWorkflowAutoLink'; +import { useEffect, useRef, useState } from 'react'; +import { + defaultTicketPreferences, + TicketPreferencesSchema, + type TicketPreferences, +} from '@shared/serviceDesk'; +import { SDP_LINK_COLLECTION, type SdpLink } from '@shared/sdpLinks'; +import { getPb } from '../../services/pocketbase'; +import { useCollection } from '../../hooks/useCollection'; +import { TactileButton } from '../../components/TactileButton'; +import { useNotifications } from '../notifications/NotificationProvider'; +import { SdpAlertEngine } from './sdpAlertEngine'; + +export const SDP_NOTIFICATIONS_RESET = 'relay:sdp-notifications-reset'; +export function resetSdpNotifications(): void { + window.dispatchEvent(new Event(SDP_NOTIFICATIONS_RESET)); +} +function useSdpConnection(override?: boolean): boolean { + const [connected, setConnected] = useState(false); + useEffect(() => { + if ( + override !== undefined || + globalThis.api?.runtime.kind !== 'electron' || + !globalThis.api?.sdpAccount + ) + return; + let active = true; + let pending = false; + const check = async () => { + if (pending) return; + pending = true; + try { + const result = await globalThis.api!.sdpAccount!({ action: 'status' }); + if (active) setConnected(result.success && result.data?.status === 'connected'); + } catch { + if (active) setConnected(false); + } finally { + pending = false; + } + }; + void check(); + const timer = setInterval(() => void check(), 5000); + return () => { + active = false; + clearInterval(timer); + }; + }, [override]); + return override ?? connected; +} +export function useSdpAlerts(connectedOverride?: boolean, resetKey = 0) { + const connected = useSdpConnection(connectedOverride); + const notifications = useNotifications(); + const publish = notifications?.publish; + const clear = notifications?.clear; + const [attention, setAttention] = useState(false); + const previousReset = useRef(resetKey); + const key = `relay:sdp-alert-rules:${getPb().baseURL}`; + const [preferences, setPreferences] = useState(() => { + try { + return TicketPreferencesSchema.parse(JSON.parse(localStorage.getItem(key) ?? 'null')); + } catch { + return { ...defaultTicketPreferences(), rules: [] }; + } + }); + const [enabled, setEnabled] = useState(false); + const [message, setMessage] = useState('Monitoring off'); + const engine = useRef(new SdpAlertEngine()); + const links = useCollection(SDP_LINK_COLLECTION); + const problems = useCollection('dynatrace_problems'); + const current = useRef({ + preferences, + linked: new Set(links.data.filter((link) => !link.suppressed).map((link) => link.ticketId)), + links: links.data, + problems: problems.data, + }); + current.current = { + preferences, + linked: new Set(links.data.filter((link) => !link.suppressed).map((link) => link.ticketId)), + links: links.data, + problems: problems.data, + }; + useEffect(() => { + if (!connected) { + engine.current.reset(); + clear?.('Tickets'); + } + setAttention(false); + setEnabled(connected); + setMessage(connected ? 'Starting queue monitoring…' : 'Monitoring off'); + }, [connected, clear]); + useEffect(() => { + if (previousReset.current === resetKey) return; + previousReset.current = resetKey; + setAttention(false); + setEnabled(false); + engine.current.reset(); + clear?.('Tickets'); + setMessage('Monitoring off · Saved data cleared'); + }, [resetKey, clear]); + useEffect(() => { + const reset = () => { + setEnabled(false); + engine.current.reset(); + clear?.('Tickets'); + setMessage('Monitoring paused'); + }; + window.addEventListener(SDP_NOTIFICATIONS_RESET, reset); + return () => window.removeEventListener(SDP_NOTIFICATIONS_RESET, reset); + }, [clear]); + useEffect(() => { + if (!enabled || !connected || !globalThis.api?.sdpAccount) return; + const evaluator = engine.current; + const autoLink = new SdpWorkflowAutoLink(); + let active = true; + let running = false; + let after: number | undefined; + let generation: string | undefined; + const linkWorkflowTickets = async (monitor: SdpMonitor, coverage: string) => { + try { + const linked = await autoLink.scan( + monitor.tickets, + current.current.problems, + current.current.links, + async (input) => { + const result = await globalThis.api!.sdpAccount!({ + action: 'verifyWorkflowTicket', + id: input.ticketId, + problemId: input.problemId, + environment: input.environment, + }); + if (!result.success || result.data?.status !== 'connected') + throw new Error('SdpAlerts: SDP operation did not return the expected result.'); + return result.data.workflowTicketMatch === true; + }, + (input) => linkSdpProblem(input, true), + () => active, + ); + if (active && linked) + setMessage(`${coverage} · ${linked} workflow ticket${linked === 1 ? '' : 's'} linked`); + } catch { + if (active) { + setAttention(true); + setMessage( + `${coverage} · Automatic linking will retry; check the SDP and Relay connections.`, + ); + } + } + }; + const check = async () => { + if (running) return; + running = true; + try { + const result = await globalThis.api!.sdpAccount!({ action: 'monitorQueues', after }); + if (!active) return; + if (!result.success || !result.data) + throw new Error('SdpAlerts: SDP operation did not return the expected result.'); + if (result.data.monitoring?.state === 'backoff') { + setAttention(true); + evaluator.reset(); + clear?.('Tickets'); + after = undefined; + setMessage( + `${monitorFailure(result.data.monitoring.failure)} Next check ${new Date(result.data.monitoring.nextCheckAt).toLocaleTimeString()}.`, + ); + return; + } + const { monitor } = result.data; + if (!monitor) return; + setAttention(false); + if (generation && generation !== monitor.generation) clear?.('Tickets'); + generation = monitor.generation; + after = monitor.fetchedAt; + const deliveries = evaluator.evaluate( + monitor, + { ...current.current.preferences, quietStart: '', quietEnd: '', snoozeUntil: 0 }, + current.current.linked, + ); + for (const delivery of deliveries) { + const { notice, rule } = delivery; + publish?.({ + id: `${monitor.generation}:${notice.id}`, + source: 'Tickets', + title: `Ticket #${notice.number}`, + message: `${rule.name} · ${notice.event}`, + type: notice.event === 'sla-breached' ? 'error' : 'info', + target: { source: 'Tickets', ticketId: notice.ticketId }, + at: notice.at, + inbox: rule.inbox, + toast: rule.toast, + desktop: rule.desktop, + sound: rule.sound, + options: { delivery: 'ticket', action: undefined }, + }); + } + const coverage = monitor.truncated + ? 'Partial coverage: newest 1,000 per queue' + : `${monitor.tickets.length} tickets checked`; + setMessage(`${coverage} · ${new Date(monitor.fetchedAt).toLocaleTimeString()}`); + await linkWorkflowTickets(monitor, coverage); + } catch { + if (active) { + setAttention(true); + engine.current.reset(); + clear?.('Tickets'); + after = undefined; + setMessage('Waiting for the Relay server. Reconnecting automatically…'); + } + } finally { + running = false; + } + }; + void check(); + const timer = setInterval(() => void check(), 5000); + return () => { + active = false; + clearInterval(timer); + evaluator.reset(); + void globalThis.api + ?.sdpAccount?.({ action: 'monitorQueues', enabled: false }) + .catch(() => undefined); + }; + }, [enabled, connected, publish, clear]); + function savePreferences(next: TicketPreferences) { + setPreferences(next); + try { + localStorage.setItem(key, JSON.stringify(next)); + } catch { + setMessage('Rules apply to this session; device storage is unavailable.'); + } + } + return { connected, enabled, setEnabled, attention, message, preferences, savePreferences }; +} +export function SdpAlertControls({ + state, + onRules, +}: Readonly<{ + state: ReturnType; + onRules: () => void; +}>) { + return ( +
+
+ state.setEnabled(!state.enabled)} + > + {state.enabled ? 'Stop monitoring' : 'Monitor queues'} + + + Ticket rules + +
+

+ {state.enabled ? state.message : 'Monitoring paused'} +

+
+ How ticket monitoring works +

+ Queues are checked every 30 seconds while Relay is running and your SDP account is + connected, including when you use another tab. A full check runs every five minutes. + Coverage is limited to 1,000 tickets per queue; the first scan establishes a baseline. + Busy queues may require another scan for replies. Matching NOC workflow tickets are + automatically linked after their description confirms the exact Dynatrace problem URL. Up + to five candidates are checked per scan; ambiguous matches stay unlinked. Unlinking + prevents automatic relinking across the workspace. Ticket rules are opt-in. +

+
+
+ ); +} + +function monitorFailure(kind?: string): string { + switch (kind) { + case 'timeout': + return 'The queue scan took too long.'; + case 'invalid': + return 'Relay could not read an SDP queue response.'; + case 'throttled': + return 'SDP requested a pause before more API calls.'; + case 'denied': + return 'SDP access needs to be verified again.'; + case 'outage': + return 'SDP could not be reached.'; + default: + return 'Queue monitoring could not complete.'; + } +} diff --git a/src/renderer/src/features/tickets/SdpAttachmentsPanel.test.tsx b/src/renderer/src/features/tickets/SdpAttachmentsPanel.test.tsx new file mode 100644 index 00000000..14f878f1 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAttachmentsPanel.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpAttachmentsPanel } from './SdpAttachmentsPanel'; +const original = globalThis.api; +afterEach(() => { + globalThis.api = original; +}); +it('requires a separate confirmation after selecting an attachment and never retries an uncertain upload', async () => { + const invoke = vi.fn().mockImplementation(async (command) => { + if (command.action === 'confirmChange') throw new Error('connection lost'); + return { + success: true, + data: { + configured: true, + status: 'connected', + review: { + confirmationId: 'f6d1a214-87d9-45ef-9bce-b1a850e5d301', + expiresAt: Date.now() + 300000, + mutation: { ...command.mutation, data: '' }, + }, + }, + }; + }); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + render(); + const file = new File(['dummy bytes'], 'example.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new TextEncoder().encode('dummy bytes').buffer, + }); + fireEvent.change(screen.getByLabelText('Add attachment (up to 10 MB)'), { + target: { files: [file] }, + }); + const confirm = await screen.findByRole('button', { name: 'Upload attachment' }); + expect(screen.getByText('11 B · Ticket 900123')).toBeVisible(); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke.mock.calls[0]![0].action).toBe('prepareChange'); + fireEvent.click(confirm); + await screen.findByText(/result is uncertain/); + expect(invoke).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('button', { name: 'Upload attachment' })).not.toBeInTheDocument(); +}); +it('downloads only the chosen attachment and disables file access for an outage copy', async () => { + const invoke = vi + .fn() + .mockResolvedValue({ success: true, data: { message: 'Download cancelled.' } }); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + const props = { + id: '123', + files: [{ id: '4', name: 'example.txt', size: 20, contentType: 'text/plain' }], + onResult: vi.fn(), + }; + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Save example.txt' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith({ + action: 'downloadAttachment', + id: '123', + attachmentId: '4', + }), + ); + await screen.findByText('Download cancelled.'); + rerender(); + expect(screen.getByRole('button', { name: 'Save example.txt' })).toBeDisabled(); + expect(screen.getByLabelText('Add attachment (up to 10 MB)')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add attachment' })).toBeDisabled(); + expect(screen.getByText('Reconnect to SDP to upload or save files.')).toBeVisible(); +}); + +it('keeps long filenames out of button text and explains unavailable downloads', () => { + const longName = + 'Database-diagnostics-for-primary-cluster-after-scheduled-maintenance-2026-09-20.log'; + render( + , + ); + expect(screen.getByText(longName)).toBeVisible(); + expect(screen.getByRole('button', { name: `Save ${longName}` })).toHaveTextContent('Save file'); + expect(screen.getByText('2 KB')).toBeVisible(); + expect(screen.getByText('11 MB · Over the 10 MB download limit')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Save large.zip' })).toBeDisabled(); +}); + +it('restores keyboard focus to Add attachment when upload review is cancelled', async () => { + const invoke = vi.fn().mockImplementation(async (command) => ({ + success: true, + data: { + configured: true, + status: 'connected', + review: + command.action === 'prepareChange' + ? { + confirmationId: 'f6d1a214-87d9-45ef-9bce-b1a850e5d301', + expiresAt: Date.now() + 300000, + mutation: { ...command.mutation, data: '' }, + } + : undefined, + }, + })); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + render(); + const file = new File(['sample'], 'evidence.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new TextEncoder().encode('sample').buffer, + }); + fireEvent.change(screen.getByLabelText('Add attachment (up to 10 MB)'), { + target: { files: [file] }, + }); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add attachment' })).toHaveFocus()); + expect(invoke).toHaveBeenLastCalledWith({ action: 'cancelChange' }); + expect(invoke.mock.calls.some(([command]) => command.action === 'confirmChange')).toBe(false); +}); diff --git a/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx b/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx new file mode 100644 index 00000000..ae73467d --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx @@ -0,0 +1,242 @@ +import { useId, useRef, useState } from 'react'; +import { SDP_ATTACHMENT_MAX_BYTES, type SdpAttachment } from '@shared/sdpAttachments'; +import type { SdpReview } from '@shared/sdpMutation'; +import type { SdpAccountView } from '@shared/sdpAccount'; +import { TactileButton } from '../../components/TactileButton'; +import { Modal } from '../../components/Modal'; +import { SdpIcon } from './SdpIcon'; + +function fileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toLocaleString(undefined, { maximumFractionDigits: 1 })} MB`; +} + +export function SdpAttachmentsPanel({ + id, + number = id, + files, + enabled, + onResult, +}: Readonly<{ + id: string; + number?: string; + files: SdpAttachment[]; + enabled: boolean; + onResult: (view: SdpAccountView) => void; +}>) { + const uploadInput = useRef(null); + const uploadButton = useRef(null); + const helpId = useId(); + const [activity, setActivity] = useState(); + const [review, setReview] = useState(); + const [size, setSize] = useState(0); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const locked = useRef(false); + async function upload(file: File) { + if (locked.current) return; + if (!file.size || file.size > SDP_ATTACHMENT_MAX_BYTES) { + setMessage('Choose a file between 1 byte and 10 MB.'); + return; + } + locked.current = true; + setBusy(true); + setActivity('prepare'); + setMessage(''); + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) + binary += String.fromCodePoint(...bytes.subarray(offset, offset + 8192)); + const result = await globalThis.api!.sdpAccount!({ + action: 'prepareChange', + mutation: { + kind: 'attachment', + id, + name: file.name, + contentType: file.type || 'application/octet-stream', + data: btoa(binary), + }, + }); + if (!result.success || !result.data?.review) + throw new Error('SdpAttachmentsPanel: SDP operation did not return the expected result.'); + setSize(file.size); + setReview(result.data.review); + } catch { + setMessage('Could not prepare the attachment. Check the file, connection and permissions.'); + } finally { + locked.current = false; + setBusy(false); + setActivity(undefined); + } + } + async function confirm() { + if (!review || locked.current) return; + locked.current = true; + setBusy(true); + setActivity('upload'); + const confirmationId = review.confirmationId; + setReview(undefined); + try { + const result = await globalThis.api!.sdpAccount!({ action: 'confirmChange', confirmationId }); + if (!result.success || !result.data) + throw new Error('SdpAttachmentsPanel: SDP operation did not return the expected result.'); + setMessage(result.data.message ?? 'Check SDP for the upload result.'); + onResult(result.data); + } catch { + setMessage( + 'The result is uncertain. Check SDP before uploading again. Relay will not retry automatically.', + ); + } finally { + locked.current = false; + setBusy(false); + setActivity(undefined); + } + } + async function download(attachmentId: string) { + if (locked.current) return; + locked.current = true; + setBusy(true); + setActivity(attachmentId); + setMessage(''); + try { + const result = await globalThis.api!.sdpAccount!({ + action: 'downloadAttachment', + id, + attachmentId, + }); + setMessage( + result.success + ? (result.data?.message ?? 'Download finished.') + : 'The attachment could not be downloaded.', + ); + } catch { + setMessage('The attachment could not be downloaded. Check your connection and permissions.'); + } finally { + locked.current = false; + setBusy(false); + setActivity(undefined); + } + } + function close() { + if (!busy) { + setReview(undefined); + void globalThis.api?.sdpAccount?.({ action: 'cancelChange' }); + requestAnimationFrame(() => uploadButton.current?.focus()); + } + } + let uploadLabel = 'Add attachment'; + if (activity === 'prepare') uploadLabel = 'Preparing…'; + if (activity === 'upload') uploadLabel = 'Uploading…'; + return ( +
+
+

+ Attachments {files.length} +

+ } + loading={activity === 'prepare' || activity === 'upload'} + disabled={!enabled || busy || !!review} + aria-describedby={helpId} + onClick={() => uploadInput.current?.click()} + > + {uploadLabel} + + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) void upload(file); + }} + /> +
+

+ {enabled + ? 'Up to 10 MB per file. Review before uploading.' + : 'Reconnect to SDP to upload or save files.'} +

+ {!files.length &&

No attachments yet.

} + {!!files.length && ( +
    + {files.map((file) => ( +
  • + +
    + {file.name} + + {fileSize(file.size)} + {file.size > SDP_ATTACHMENT_MAX_BYTES && ' · Over the 10 MB download limit'} + +
    + } + aria-label={`Save ${file.name}`} + loading={activity === file.id} + disabled={!enabled || busy || !!review || file.size > SDP_ATTACHMENT_MAX_BYTES} + onClick={() => void download(file.id)} + > + {activity === file.id ? 'Saving…' : 'Save file'} + +
  • + ))} +
+ )} + {message && ( +

+ {message} +

+ )} + {review?.mutation.kind === 'attachment' && ( + + + Cancel + + void confirm()} + > + Upload attachment + + + } + > +
+ +
+ {review.mutation.name} + + {fileSize(size)} · Ticket {number} + +
+
+

+ The file becomes a ticket attachment and may be visible to the requester according to + SDP permissions. +

+
+ )} +
+ ); +} diff --git a/src/renderer/src/features/tickets/SdpBulkDialog.test.tsx b/src/renderer/src/features/tickets/SdpBulkDialog.test.tsx new file mode 100644 index 00000000..733abafb --- /dev/null +++ b/src/renderer/src/features/tickets/SdpBulkDialog.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpBulkDialog } from './SdpBulkDialog'; +const original = globalThis.api; +afterEach(() => { + globalThis.api = original; +}); +const tickets = ['123', '456'].map((id) => ({ + id, + number: id, + subject: 'Sample ' + id, + status: 'Open', + priority: 'Low', + group: 'NOC' as const, + technician: 'Example', + createdAt: 0, + dueAt: null, +})); +it('reviews explicit targets and displays partial results without retrying', async () => { + const invoke = vi.fn().mockImplementation(async (c) => ({ + success: true, + data: { + configured: true, + status: 'connected', + ...(c.action === 'readStandardOptions' + ? { + options: { + field: c.field, + hasMore: false, + choices: [{ label: 'Closed', value: { id: '2', name: 'Closed' } }], + }, + } + : {}), + ...(c.action === 'prepareChange' + ? { + review: { + confirmationId: '00000000-0000-4000-8000-000000000001', + expiresAt: Date.now() + 60000, + mutation: c.mutation, + }, + } + : { + bulkResult: [ + { id: '123', status: 'confirmed' }, + { id: '456', status: 'uncertain' }, + ], + message: 'Batch stopped', + }), + }, + })); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + render(); + fireEvent.focus(screen.getByLabelText('Status', { exact: true })); + await screen.findByRole('option', { name: 'Closed' }); + fireEvent.change(screen.getByLabelText('Status', { exact: true }), { + target: { value: 'Closed' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Review bulk changes' })); + const confirm = await screen.findByRole('button', { name: 'Confirm 2 live changes' }); + expect(invoke).toHaveBeenCalledWith({ + action: 'prepareChange', + mutation: { kind: 'bulk', ids: ['123', '456'], fields: { status: 'Closed' } }, + }); + expect(invoke.mock.calls.some(([c]) => c.action === 'confirmChange')).toBe(false); + fireEvent.click(confirm); + await screen.findByText('Batch stopped'); + expect(screen.getByText(/Not confirmed — check SDP/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Confirm 2/ })).not.toBeInTheDocument(); + expect(invoke.mock.calls.filter(([c]) => c.action !== 'readStandardOptions')).toHaveLength(2); +}); diff --git a/src/renderer/src/features/tickets/SdpBulkDialog.tsx b/src/renderer/src/features/tickets/SdpBulkDialog.tsx new file mode 100644 index 00000000..f2541f43 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpBulkDialog.tsx @@ -0,0 +1,259 @@ +import { SdpStandardSelect, standardFieldKey } from './SdpStandardSelect'; +import { useRef, useState } from 'react'; +import type { SdpAccountView, SdpQueueTicket } from '@shared/sdpAccount'; +import { + SdpBulkMutationSchema, + type SdpBulkResult, + type SdpRequestFields, + type SdpReview, +} from '@shared/sdpMutation'; +import { Modal } from '../../components/Modal'; +import { TactileButton } from '../../components/TactileButton'; +const fields = { + group: 'Support group', + technician: 'Technician', + status: 'Status', + priority: 'Priority', + requestType: 'Request type', + category: 'Category', + impact: 'Impact', + urgency: 'Urgency', + resolution: 'Resolution', +} as const; +const outcomes = { + confirmed: 'Confirmed by SDP', + conflict: 'Changed since review — not sent', + uncertain: 'Not confirmed — check SDP before retrying', + 'not-attempted': 'Not attempted', +}; +export function SdpBulkDialog({ + tickets, + onClose, + onResult, +}: Readonly<{ + tickets: SdpQueueTicket[]; + onClose: () => void; + onResult: (view: SdpAccountView) => void; +}>) { + const [patch, setPatch] = useState({}); + const [groupId, setGroupId] = useState(); + const [review, setReview] = useState(); + const [results, setResults] = useState(); + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + const [finished, setFinished] = useState(false); + const locked = useRef(false); + async function submit() { + if (locked.current || finished) return; + const parsed = SdpBulkMutationSchema.safeParse({ + kind: 'bulk', + ids: tickets.map((t) => t.id), + fields: patch, + }); + if (!parsed.success) { + setMessage('Choose at least one change for up to 20 tickets.'); + return; + } + const confirming = !!review; + const command = review + ? { action: 'confirmChange' as const, confirmationId: review.confirmationId } + : { action: 'prepareChange' as const, mutation: parsed.data }; + locked.current = true; + setBusy(true); + setMessage(''); + if (confirming) { + setReview(undefined); + setFinished(true); + } + try { + const result = await globalThis.api!.sdpAccount!(command); + if (!result.success || !result.data) + throw new Error('SdpBulkDialog: SDP operation did not return the expected result.'); + if (confirming) { + setResults(result.data.bulkResult); + setMessage(result.data.message ?? 'Check each ticket in SDP.'); + onResult(result.data); + } else if (result.data.review?.mutation.kind === 'bulk') setReview(result.data.review); + else throw new Error('SdpBulkDialog: SDP operation did not return the expected result.'); + } catch { + setMessage( + confirming + ? 'The result is uncertain. Check all selected tickets in SDP before trying again. Nothing will be retried automatically.' + : 'Could not prepare these changes. Refresh the queue and check your access.', + ); + } finally { + locked.current = false; + setBusy(false); + } + } + function close() { + if (busy) return; + void globalThis.api?.sdpAccount?.({ action: 'cancelChange' }); + onClose(); + } + return ( + + + {finished ? 'Done' : 'Cancel'} + + {!finished && ( + void submit()} + > + {review ? `Confirm ${tickets.length} live changes` : 'Review bulk changes'} + + )} + + } + > +

+ Each ticket is updated separately. A conflict or unconfirmed result stops the remaining + changes. SDP may send notifications. +

+
    + {tickets.map((ticket) => ( +
  • + #{ticket.number} — {ticket.subject} + {results && ( + + {' '} + · {outcomes[results.find((r) => r.id === ticket.id)?.status ?? 'not-attempted']} + + )} +
  • + ))} +
+ {message && ( +

+ {message} +

+ )} + {!finished && + (review ? ( +
+ {Object.entries(patch).map(([key, value]) => ( +
+
{fields[key as keyof typeof fields]}
+
{value ?? 'Unassigned'}
+
+ ))} +
+ ) : ( +
+

Leave fields blank to keep their existing values. Choose values from SDP.

+ {Object.entries(fields).map(([key, label]) => ( +
+ {standardFieldKey(key) ? ( + { + if (key === 'group') setGroupId(id); + setPatch((current) => { + const next = { ...current }; + if (value) next[key as keyof SdpRequestFields] = value; + else delete next[key as keyof SdpRequestFields]; + if (key === 'group') delete next.technician; + return next; + }); + }} + /> + ) : ( +