From 925b088c5e9463bd1ef3b6a3ac9fd94685915e23 Mon Sep 17 00:00:00 2001 From: Ryan Bledsoe Date: Sat, 19 Sep 2026 16:17:19 -0500 Subject: [PATCH 1/6] feat: add SDP ticket management and global notifications --- PRODUCT.md | 58 + docs/DESIGN.md | 68 + docs/SECURITY.md | 121 ++ docs/architecture.md | 199 ++- electron.vite.config.ts | 23 + playwright.electron.config.ts | 1 + src/main/__tests__/ipcHandlers.test.ts | 1 + src/main/handlers/radar/fetchRadar.test.ts | 36 +- src/main/handlers/radar/fetchRadar.ts | 11 +- src/main/handlers/sdpAccountHandlers.test.ts | 111 ++ src/main/handlers/sdpAccountHandlers.ts | 128 ++ .../window/ticketNotificationHandler.test.ts | 78 + .../window/ticketNotificationHandler.ts | 32 + src/main/handlers/windowHandlers.ts | 2 + src/main/index.ts | 3 + src/main/ipcHandlers.ts | 4 + .../__tests__/CollectionBootstrap.test.ts | 4 +- .../pocketbase/schema/collectionCatalog.ts | 3 + .../schema/serviceDeskCollections.ts | 44 + src/main/sdp/SdpAccountSession.test.ts | 86 + src/main/sdp/SdpAccountSession.ts | 155 ++ src/main/sdp/SdpAttachments.test.ts | 93 + src/main/sdp/SdpAttachments.ts | 77 + src/main/sdp/SdpBroker.test.ts | 909 ++++++++++ src/main/sdp/SdpBroker.ts | 874 ++++++++++ src/main/sdp/SdpConversationQuery.ts | 23 + src/main/sdp/SdpFieldCatalog.test.ts | 79 + src/main/sdp/SdpFieldCatalog.ts | 161 ++ src/main/sdp/SdpForms.test.ts | 396 +++++ src/main/sdp/SdpForms.ts | 449 +++++ src/main/sdp/SdpGatewayClient.test.ts | 120 ++ src/main/sdp/SdpGatewayClient.ts | 112 ++ src/main/sdp/SdpMutations.test.ts | 92 + src/main/sdp/SdpMutations.ts | 207 +++ src/main/sdp/SdpProvider.test.ts | 469 +++++ src/main/sdp/SdpProvider.ts | 697 ++++++++ src/main/sdp/SdpQueueMonitor.test.ts | 210 +++ src/main/sdp/SdpQueueMonitor.ts | 221 +++ src/main/sdp/SdpReplies.test.ts | 174 ++ src/main/sdp/SdpReplies.ts | 252 +++ src/main/sdp/SdpResources.test.ts | 151 ++ src/main/sdp/SdpResources.ts | 205 +++ src/main/sdp/SdpRuntime.ts | 128 ++ src/main/sdp/SdpServerStore.ts | 232 +++ src/main/sdp/SdpTicketRelations.test.ts | 108 ++ src/main/sdp/SdpTicketRelations.ts | 122 ++ src/main/web/RelayWebGateway.ts | 28 + src/preload/index.test.ts | 19 + src/preload/index.ts | 11 + src/renderer/src/App.tsx | 93 +- src/renderer/src/__tests__/App.test.tsx | 12 +- .../DynatraceProblemNotificationManager.tsx | 5 +- .../RadarQueueNotificationManager.tsx | 4 +- .../src/components/ShortcutsModal.tsx | 1 + src/renderer/src/components/Sidebar.tsx | 18 + src/renderer/src/components/Toast.tsx | 22 +- .../src/components/__tests__/Sidebar.test.tsx | 4 +- .../administration/RelayServerPanel.tsx | 2 + .../administration/SdpServerSettings.test.tsx | 46 + .../administration/SdpServerSettings.tsx | 141 ++ .../notifications/NotificationCenter.tsx | 298 ++++ .../NotificationProvider.test.tsx | 180 ++ .../notifications/NotificationProvider.tsx | 222 +++ .../features/notifications/notifications.css | 88 + .../LiveSdpNotificationNavigation.test.tsx | 68 + .../features/tickets/LiveSdpQueues.test.tsx | 265 +++ .../src/features/tickets/LiveSdpQueues.tsx | 585 +++++++ .../features/tickets/SdpAccountPanel.test.tsx | 60 + .../src/features/tickets/SdpAccountPanel.tsx | 160 ++ .../src/features/tickets/SdpAlerts.test.tsx | 98 ++ .../src/features/tickets/SdpAlerts.tsx | 246 +++ .../tickets/SdpAttachmentsPanel.test.tsx | 65 + .../features/tickets/SdpAttachmentsPanel.tsx | 170 ++ .../features/tickets/SdpChangeDialog.test.tsx | 102 ++ .../src/features/tickets/SdpChangeDialog.tsx | 379 +++++ .../features/tickets/SdpNativeEditor.test.tsx | 217 +++ .../src/features/tickets/SdpNativeEditor.tsx | 610 +++++++ .../src/features/tickets/SdpRelationships.tsx | 274 +++ .../features/tickets/SdpReplyStatus.test.tsx | 39 + .../src/features/tickets/SdpReplyStatus.tsx | 21 + .../tickets/SdpResourcesPanel.test.tsx | 73 + .../features/tickets/SdpResourcesPanel.tsx | 381 +++++ .../src/features/tickets/SdpTicketContent.tsx | 231 +++ .../tickets/SdpTicketRelationsPanel.test.tsx | 94 + .../tickets/SdpTicketRelationsPanel.tsx | 251 +++ .../features/tickets/SdpTicketWorkspace.tsx | 248 +++ .../features/tickets/TicketNotifications.tsx | 261 +++ .../features/tickets/sdpAlertEngine.test.ts | 132 ++ .../src/features/tickets/sdpAlertEngine.ts | 167 ++ .../src/features/tickets/ticketNavigation.ts | 16 + src/renderer/src/features/tickets/tickets.css | 1512 +++++++++++++++++ .../__tests__/useKeyboardShortcuts.test.ts | 3 +- .../src/hooks/useKeyboardShortcuts.ts | 2 + src/renderer/src/services/sdpLinkService.ts | 35 + src/renderer/src/tabs/AssemblerTab.tsx | 12 +- .../src/tabs/DynatraceProblemsTab.tsx | 28 +- src/renderer/src/tabs/TicketsTab.tsx | 14 + .../__tests__/DynatraceProblemsTab.test.tsx | 4 + src/renderer/src/tabs/assembler/types.ts | 3 + src/shared/ipc.ts | 19 + src/shared/notificationTarget.ts | 18 + src/shared/notifications.ts | 49 + src/shared/sdpAccount.ts | 344 ++++ src/shared/sdpAttachments.ts | 36 + src/shared/sdpForm.ts | 95 ++ src/shared/sdpLinks.ts | 21 + src/shared/sdpMutation.ts | 102 ++ src/shared/sdpQueueFilters.test.ts | 25 + src/shared/sdpQueueFilters.ts | 73 + src/shared/sdpReplies.ts | 10 + src/shared/sdpResources.ts | 181 ++ src/shared/sdpTicketRelations.ts | 43 + src/shared/serviceDesk.test.ts | 9 + src/shared/serviceDesk.ts | 120 ++ tests/e2e/radar-certificate.spec.ts | 54 +- tests/e2e/service-desk.spec.ts | 575 +++++++ tests/web/relay-web.spec.ts | 46 + verification/service-desk-real-pb.test.ts | 96 ++ 118 files changed, 17629 insertions(+), 36 deletions(-) create mode 100644 src/main/handlers/sdpAccountHandlers.test.ts create mode 100644 src/main/handlers/sdpAccountHandlers.ts create mode 100644 src/main/handlers/window/ticketNotificationHandler.test.ts create mode 100644 src/main/handlers/window/ticketNotificationHandler.ts create mode 100644 src/main/pocketbase/schema/serviceDeskCollections.ts create mode 100644 src/main/sdp/SdpAccountSession.test.ts create mode 100644 src/main/sdp/SdpAccountSession.ts create mode 100644 src/main/sdp/SdpAttachments.test.ts create mode 100644 src/main/sdp/SdpAttachments.ts create mode 100644 src/main/sdp/SdpBroker.test.ts create mode 100644 src/main/sdp/SdpBroker.ts create mode 100644 src/main/sdp/SdpConversationQuery.ts create mode 100644 src/main/sdp/SdpFieldCatalog.test.ts create mode 100644 src/main/sdp/SdpFieldCatalog.ts create mode 100644 src/main/sdp/SdpForms.test.ts create mode 100644 src/main/sdp/SdpForms.ts create mode 100644 src/main/sdp/SdpGatewayClient.test.ts create mode 100644 src/main/sdp/SdpGatewayClient.ts create mode 100644 src/main/sdp/SdpMutations.test.ts create mode 100644 src/main/sdp/SdpMutations.ts create mode 100644 src/main/sdp/SdpProvider.test.ts create mode 100644 src/main/sdp/SdpProvider.ts create mode 100644 src/main/sdp/SdpQueueMonitor.test.ts create mode 100644 src/main/sdp/SdpQueueMonitor.ts create mode 100644 src/main/sdp/SdpReplies.test.ts create mode 100644 src/main/sdp/SdpReplies.ts create mode 100644 src/main/sdp/SdpResources.test.ts create mode 100644 src/main/sdp/SdpResources.ts create mode 100644 src/main/sdp/SdpRuntime.ts create mode 100644 src/main/sdp/SdpServerStore.ts create mode 100644 src/main/sdp/SdpTicketRelations.test.ts create mode 100644 src/main/sdp/SdpTicketRelations.ts create mode 100644 src/renderer/src/components/settings/administration/SdpServerSettings.test.tsx create mode 100644 src/renderer/src/components/settings/administration/SdpServerSettings.tsx create mode 100644 src/renderer/src/features/notifications/NotificationCenter.tsx create mode 100644 src/renderer/src/features/notifications/NotificationProvider.test.tsx create mode 100644 src/renderer/src/features/notifications/NotificationProvider.tsx create mode 100644 src/renderer/src/features/notifications/notifications.css create mode 100644 src/renderer/src/features/tickets/LiveSdpNotificationNavigation.test.tsx create mode 100644 src/renderer/src/features/tickets/LiveSdpQueues.test.tsx create mode 100644 src/renderer/src/features/tickets/LiveSdpQueues.tsx create mode 100644 src/renderer/src/features/tickets/SdpAccountPanel.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpAccountPanel.tsx create mode 100644 src/renderer/src/features/tickets/SdpAlerts.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpAlerts.tsx create mode 100644 src/renderer/src/features/tickets/SdpAttachmentsPanel.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx create mode 100644 src/renderer/src/features/tickets/SdpChangeDialog.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpChangeDialog.tsx create mode 100644 src/renderer/src/features/tickets/SdpNativeEditor.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpNativeEditor.tsx create mode 100644 src/renderer/src/features/tickets/SdpRelationships.tsx create mode 100644 src/renderer/src/features/tickets/SdpReplyStatus.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpReplyStatus.tsx create mode 100644 src/renderer/src/features/tickets/SdpResourcesPanel.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpResourcesPanel.tsx create mode 100644 src/renderer/src/features/tickets/SdpTicketContent.tsx create mode 100644 src/renderer/src/features/tickets/SdpTicketRelationsPanel.test.tsx create mode 100644 src/renderer/src/features/tickets/SdpTicketRelationsPanel.tsx create mode 100644 src/renderer/src/features/tickets/SdpTicketWorkspace.tsx create mode 100644 src/renderer/src/features/tickets/TicketNotifications.tsx create mode 100644 src/renderer/src/features/tickets/sdpAlertEngine.test.ts create mode 100644 src/renderer/src/features/tickets/sdpAlertEngine.ts create mode 100644 src/renderer/src/features/tickets/ticketNavigation.ts create mode 100644 src/renderer/src/features/tickets/tickets.css create mode 100644 src/renderer/src/services/sdpLinkService.ts create mode 100644 src/renderer/src/tabs/TicketsTab.tsx create mode 100644 src/shared/notificationTarget.ts create mode 100644 src/shared/notifications.ts create mode 100644 src/shared/sdpAccount.ts create mode 100644 src/shared/sdpAttachments.ts create mode 100644 src/shared/sdpForm.ts create mode 100644 src/shared/sdpLinks.ts create mode 100644 src/shared/sdpMutation.ts create mode 100644 src/shared/sdpQueueFilters.test.ts create mode 100644 src/shared/sdpQueueFilters.ts create mode 100644 src/shared/sdpReplies.ts create mode 100644 src/shared/sdpResources.ts create mode 100644 src/shared/sdpTicketRelations.ts create mode 100644 src/shared/serviceDesk.test.ts create mode 100644 src/shared/serviceDesk.ts create mode 100644 tests/e2e/service-desk.spec.ts create mode 100644 verification/service-desk-real-pb.test.ts diff --git a/PRODUCT.md b/PRODUCT.md index 58f435fe..498579ca 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -8,6 +8,64 @@ 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. + +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 release target is full ticket/request-workspace parity in Relay's own design, using each +operator's work account and confirmation before live changes. This is not yet a parity-complete +replacement: replies/forwarding, template-driven custom-field editing, checklists, reminders, +request history, merge/duplicate/link operations, bulk actions and tenant-specific extensions +still require implementation and API validation. Assets, changes, purchasing and SDP administration +are outside this request-workspace target. + ## 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..e85ae426 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -115,6 +115,74 @@ 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 source, time, title and summary; opening an entry marks it read and navigates to its target. +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 provides shared banners, desktop delivery, sound, snooze and local-time quiet hours. +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. +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, +Work, Attachments and Links & bridge remain available as sections. + +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 Resolve, +Refresh and Open in SDP through the shared keyboard-accessible menu. 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. 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. + +--- + ## 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/SECURITY.md b/docs/SECURITY.md index 411bc3d6..326f0197 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -325,6 +325,127 @@ 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. +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 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. + +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. +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`. diff --git a/docs/architecture.md b/docs/architecture.md index 8398aaec..4da2dab0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -482,23 +482,210 @@ 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 + +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,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, reminders, forwarding and tenant-specific +workflow extensions still require additional API validation for full request-workspace parity. +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 only ticket ID/number, problem ID and environment. Ordinary renderer CRUD +maintains these immutable references, with uniqueness enforced server-side. 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. + +Background snapshots refresh each session's visible queue via its existing five-second status +check. Open ticket details and editor drafts stay intact; 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..1b41aea9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -91,6 +91,29 @@ 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/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/playwright.electron.config.ts b/playwright.electron.config.ts index ad2b8252..60b94bda 100644 --- a/playwright.electron.config.ts +++ b/playwright.electron.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'radar-certificate.spec.ts', 'setup-auth.spec.ts', 'redesign-screenshots.spec.ts', + 'service-desk.spec.ts', ], timeout: 60 * 1000, expect: { 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..b11443ba --- /dev/null +++ b/src/main/handlers/sdpAccountHandlers.test.ts @@ -0,0 +1,111 @@ +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('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, + }); +}); diff --git a/src/main/handlers/sdpAccountHandlers.ts b/src/main/handlers/sdpAccountHandlers.ts new file mode 100644 index 00000000..c488d684 --- /dev/null +++ b/src/main/handlers/sdpAccountHandlers.ts @@ -0,0 +1,128 @@ +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 'readQueue': + case 'readDetail': + case 'readForm': + case 'readOptions': + case 'readReplyContext': + case 'readTicketRelations': + case 'readResources': + case 'downloadAttachment': + case 'clearCopies': + case 'monitorQueues': + case 'prepareChange': + case 'confirmChange': + case 'cancelChange': + data = await account.invoke(command); + break; + case 'readTestTicket': + data = await account.readTestTicket(); + break; + default: + data = await account.status(); + } + 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..7a0ef434 --- /dev/null +++ b/src/main/pocketbase/schema/serviceDeskCollections.ts @@ -0,0 +1,44 @@ +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: null, + deleteRule: authenticated, + }, + fields: [ + { type: 'text', name: 'ticketId', required: true, max: 30, pattern: '^\\d{1,30}$' }, + { type: 'text', name: 'ticketNumber', required: true, max: 30, pattern: '^\\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..2ab0a09d --- /dev/null +++ b/src/main/sdp/SdpBroker.test.ts @@ -0,0 +1,909 @@ +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.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'); +}); diff --git a/src/main/sdp/SdpBroker.ts b/src/main/sdp/SdpBroker.ts new file mode 100644 index 00000000..2b9a865f --- /dev/null +++ b/src/main/sdp/SdpBroker.ts @@ -0,0 +1,874 @@ +import { latestReply, SdpReplyTracker } from './SdpReplies'; +import { + readForm, + readOptions, + 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 } from './SdpResources'; +import { readTicketRelations } from './SdpTicketRelations'; +import { mutationBaseline, submitMutation } from './SdpMutations'; +import type { SdpReview } from '@shared/sdpMutation'; +import { 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; + 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 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 === '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) => { + if (error instanceof SdpFormUnavailableError) { + ensureCurrent(); + active.form = undefined; + return { view: { ...this.view(active), message: error.message } }; + } + 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(), + 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' + | 'readReplyContext' + | 'readTicketRelations' + | 'readResources' + | 'readDetail' + | 'readQueue' + | 'readTestTicket'; + } + >, + ): Promise { + switch (command.action) { + case 'prepareChange': + case 'confirmChange': + case 'cancelChange': + return this.change(connection, ensureCurrent, command); + case 'downloadAttachment': + return this.download(connection, ensureCurrent, command); + case 'readForm': + case 'readOptions': + case 'readReplyContext': + case 'readTicketRelations': + return this.form(connection, ensureCurrent, command); + 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 form( + connection: Connection, + ensureCurrent: () => void, + command: Extract< + SdpBrokerCommand, + { action: 'readForm' | 'readOptions' | 'readReplyContext' | 'readTicketRelations' } + >, + ): 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 === 'readTicketRelations') + data = { ticketRelations: await readTicketRelations(...args, command) }; + else if (command.action === 'readReplyContext') + data = { replyContext: await readReplyContext(...args, command.id) }; + 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); + 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 { + // 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' && + (!this.authorizedTicket(connection, mutation.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(); + const baseline = + mutation.kind === 'create' + ? undefined + : 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.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 || + 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' && + 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 { + 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, + 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.', + }, + }; + } + 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/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..49dc1acb --- /dev/null +++ b/src/main/sdp/SdpFieldCatalog.ts @@ -0,0 +1,161 @@ +import { 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 = String(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..126e9791 --- /dev/null +++ b/src/main/sdp/SdpForms.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SdpProvider, SdpProviderError } from './SdpProvider'; +import { readForm, readOptions, 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, + }); +}); diff --git a/src/main/sdp/SdpForms.ts b/src/main/sdp/SdpForms.ts new file mode 100644 index 00000000..e29e61d4 --- /dev/null +++ b/src/main/sdp/SdpForms.ts @@ -0,0 +1,449 @@ +import { z } from 'zod'; +import { + SdpFormSchema, + SdpOptionsSchema, + SdpReplyContextSchema, + type SdpForm, + type SdpFormField, + type SdpFieldValue, +} from '@shared/sdpForm'; +import type { SdpBrokerCommand } from '@shared/sdpAccount'; +import type { SdpMutation } from '@shared/sdpMutation'; +import { 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 = String(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, + canEdit: boolean, + blocked: unknown[], +): SdpFormField[] { + const key = String(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: + !canEdit || info.read_only === true || info.editable === false || 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 = String(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), + !!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) => String(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, + }); +} +export async function readReplyContext( + provider: SdpProvider, + token: string, + signal: AbortSignal, + id: 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 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: + `Re: [Request ID :##${label(object(request.display_key).display_value, String(request.display_id ?? id))}##] : ${label(request.subject)}`.slice( + 0, + 250, + ), + to: emails([email]), + cc: emails(request.email_cc), + 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') { + if (!(await readReplyContext(provider, token, signal, mutation.id)).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; +} +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 = v === null ? null : html(String(v)); + 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/SdpMutations.test.ts b/src/main/sdp/SdpMutations.test.ts new file mode 100644 index 00000000..757ef1e6 --- /dev/null +++ b/src/main/sdp/SdpMutations.test.ts @@ -0,0 +1,92 @@ +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); + }); +}); diff --git a/src/main/sdp/SdpMutations.ts b/src/main/sdp/SdpMutations.ts new file mode 100644 index 00000000..05257da5 --- /dev/null +++ b/src/main/sdp/SdpMutations.ts @@ -0,0 +1,207 @@ +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 { 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: SdpMutation, 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') + return { + notification: { + to: mutation.to, + cc: mutation.cc, + bcc: mutation.bcc, + subject: mutation.subject, + description: html(mutation.body), + is_public: mutation.isPublic, + type: 'REQREPLY', + in_reply_to: { 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 { + 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' ? String(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: String(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: SdpMutation): { 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') 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..cd4d8f90 --- /dev/null +++ b/src/main/sdp/SdpProvider.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, vi } from 'vitest'; +import { 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('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'); + }, + ); +}); diff --git a/src/main/sdp/SdpResources.ts b/src/main/sdp/SdpResources.ts new file mode 100644 index 00000000..038d3adb --- /dev/null +++ b/src/main/sdp/SdpResources.ts @@ -0,0 +1,205 @@ +import { + SDP_RESOURCE_FIELDS, + SdpResourcePageSchema, + 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; + recordId?: string; +}): string { + const nested = + value.resource === 'approvals' ? `approval_levels/${value.levelId}/approvals` : value.resource; + 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', +}; +type Codec = + 'name' | 'date' | 'email' | 'id' | 'scalar' | 'html' | 'boolean' | 'value' | 'hours' | 'minutes'; +const fieldMap: Record = { + 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 '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 '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.name || + fields.approverEmail || + fields.description || + fields.level || + row.id, + ).slice(0, 500), + status: nested(row.status, 'name') || nested(row.approval_status, 'name'), + fields, + }; + }); + return SdpResourcePageSchema.parse({ + id: command.id, + resource: command.resource, + levelId: command.levelId, + 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; +} diff --git a/src/main/sdp/SdpRuntime.ts b/src/main/sdp/SdpRuntime.ts new file mode 100644 index 00000000..397e2c92 --- /dev/null +++ b/src/main/sdp/SdpRuntime.ts @@ -0,0 +1,128 @@ +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; + } + if (!broker) + 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..4dd3c062 --- /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 { 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: String(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..23112515 100644 --- a/src/main/web/RelayWebGateway.ts +++ b/src/main/web/RelayWebGateway.ts @@ -1,3 +1,6 @@ +import { SdpBrokerCommandSchema } from '@shared/sdpAccount'; +import { RELAY_WEB_API_PREFIX } 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'; @@ -21,6 +24,7 @@ function startPreparingKnowledgeRoot(rootDir: string): void { } type RelayWebGatewayOptions = { + getSdpBroker?: () => SdpBroker | null; config: ServerConfig; authenticate: (passphrase: string) => Promise; hostname?: string; @@ -80,6 +84,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..a8ff1bc3 --- /dev/null +++ b/src/renderer/src/components/settings/administration/SdpServerSettings.tsx @@ -0,0 +1,141 @@ +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.tsx b/src/renderer/src/features/notifications/NotificationCenter.tsx new file mode 100644 index 00000000..4680d1f5 --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationCenter.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { NOTIFICATION_SOURCES, 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'; + +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'); + if (!notifications) return null; + const { notices, preferences, savePreferences, storageError, markRead, clear } = 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'; + const snoozed = preferences.snoozeUntil > Date.now(); + return ( + <> + setOpen(true)} + > + Notifications{unread > 0 ? ` (${unread} unread)` : ''} + {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. +

+ )} + {storageError &&

{storageError}

} + {section === 'Inbox' ? ( + <> + +
+ markRead()} + > + Mark all read + + clear(source === 'All' ? undefined : source)} + > + Clear {source === 'All' ? 'inbox' : source.toLowerCase()} + +
+ {!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} + + {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..17acceec --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationProvider.test.tsx @@ -0,0 +1,180 @@ +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, 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('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..daa92921 --- /dev/null +++ b/src/renderer/src/features/notifications/NotificationProvider.tsx @@ -0,0 +1,222 @@ +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) => void; + clear: (source?: NotificationSource) => void; +}; +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, + quietStart: old.quietStart, + quietEnd: old.quietEnd, + snoozeUntil: old.snoozeUntil, + }; + } catch { + return defaults; + } + } + }); + const [notices, setNotices] = useState([]); + 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) => { + if (!source || source === 'Tickets') current.current.dismissDelivery?.('ticket'); + setNotices((old) => (source ? old.filter((notice) => notice.source !== source) : [])); + }, []); + const markRead = useCallback((id?: string) => { + setNotices((old) => + old.map((notice) => (!id || notice.id === id ? { ...notice, read: true } : notice)), + ); + }, []); + 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, 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], + ); + 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); + }), + [], + ); + const value = useMemo( + () => ({ notices, preferences, savePreferences, storageError, publish, markRead, clear }), + [notices, preferences, savePreferences, storageError, publish, markRead, clear], + ); + 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..f9b0c3e6 --- /dev/null +++ b/src/renderer/src/features/notifications/notifications.css @@ -0,0 +1,88 @@ +.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-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..a663b051 --- /dev/null +++ b/src/renderer/src/features/tickets/LiveSdpQueues.test.tsx @@ -0,0 +1,265 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } 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: 'Messages' })); + 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('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(); +}); diff --git a/src/renderer/src/features/tickets/LiveSdpQueues.tsx b/src/renderer/src/features/tickets/LiveSdpQueues.tsx new file mode 100644 index 00000000..84148f85 --- /dev/null +++ b/src/renderer/src/features/tickets/LiveSdpQueues.tsx @@ -0,0 +1,585 @@ +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'>(); + 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, setBusy] = useState(false); + const busy = requestBusy; + const pending = useRef(false); + const epoch = useRef(0); + const alive = useRef(true); + 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; + }; + }, []); + async function run(command: SdpAccountCommand) { + if (!invoke || pending.current) return; + const current = ++epoch.current; + pending.current = true; + setBusy(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) setBusy(false); + } + } + useEffect(() => { + if (!available) return; + let active = true; + let checking = false; + const check = async () => { + if (checking || pending.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]); + 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, + lastReply: view.replyActivity.lastReply, + replyUnread: view.replyActivity.replyUnread, + replyState: view.replyActivity.replyState, + } + : 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 resultCaption = `${tickets.length} ${tickets.length === 1 ? 'ticket' : 'tickets'}`; + const filterCaption = result?.filters ? ' · Filtered' : ''; + return ( +
+ SDP · Your work account} + /> + + + 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. +

+ )} + {error && ( +

+ {error} +

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

+ {view.message} +

+ )} + {!available &&

Open Relay desktop to use live tickets.

} + {available && !connected && !busy && ( +

Sign in through Work account to load your tickets.

+ )} + {!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={setView} + 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) => { + setView(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 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..e4207d46 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAccountPanel.tsx @@ -0,0 +1,160 @@ +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') 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..98027ab8 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAlerts.tsx @@ -0,0 +1,246 @@ +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 current = useRef({ + preferences, + linked: new Set(links.data.map((link) => link.ticketId)), + }); + current.current = { + preferences, + linked: new Set(links.data.map((link) => link.ticketId)), + }; + 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; + let active = true; + let running = false; + let after: number | undefined; + let generation: string | undefined; + 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(); + 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()}`); + } 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. 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..6f10f891 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAttachmentsPanel.test.tsx @@ -0,0 +1,65 @@ +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: 'Confirm live change' }); + 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: 'Confirm live change' })).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(); +}); diff --git a/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx b/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx new file mode 100644 index 00000000..efbb1dba --- /dev/null +++ b/src/renderer/src/features/tickets/SdpAttachmentsPanel.tsx @@ -0,0 +1,170 @@ +import { 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'; +export function SdpAttachmentsPanel({ + id, + files, + enabled, + onResult, +}: Readonly<{ + id: string; + files: SdpAttachment[]; + enabled: boolean; + onResult: (view: SdpAccountView) => void; +}>) { + 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); + setMessage(''); + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) + binary += String.fromCharCode(...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(); + 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); + } + } + async function confirm() { + if (!review || locked.current) return; + locked.current = true; + setBusy(true); + const confirmationId = review.confirmationId; + setReview(undefined); + try { + const result = await globalThis.api!.sdpAccount!({ action: 'confirmChange', confirmationId }); + if (!result.success || !result.data) throw new Error(); + 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); + } + } + async function download(attachmentId: string) { + if (locked.current) return; + locked.current = true; + setBusy(true); + 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); + } + } + function close() { + if (!busy) { + setReview(undefined); + void globalThis.api?.sdpAccount?.({ action: 'cancelChange' }); + } + } + return ( +
+

Attachments

+ {!files.length &&

No attachments.

} + {files.map((file) => ( +
+ + {file.name} · {Math.ceil(file.size / 1024)} KB + + SDP_ATTACHMENT_MAX_BYTES} + onClick={() => void download(file.id)} + > + Save {file.name} + +
+ ))} + + {message &&

{message}

} + {review?.mutation.kind === 'attachment' && ( + + + Cancel + + void confirm()} + > + Confirm live change + + + } + > +

+ Upload {review.mutation.name} ({Math.ceil(size / 1024)} KB) to ticket {id} in SDP using + your work account. +

+

+ 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/SdpChangeDialog.test.tsx b/src/renderer/src/features/tickets/SdpChangeDialog.test.tsx new file mode 100644 index 00000000..224fca98 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpChangeDialog.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { BridgeAPI } from '@shared/ipc'; +import { SdpChangeDialog } from './SdpChangeDialog'; +const original = globalThis.api; +afterEach(() => { + globalThis.api = original; +}); +it('requires the default template requester before reviewing a major incident', () => { + const invoke = vi.fn(); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + render(); + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test incident' } }); + fireEvent.click(screen.getByRole('button', { name: 'Review change' })); + expect(screen.getByRole('status')).toHaveTextContent('Enter a requester email'); + expect(invoke).not.toHaveBeenCalled(); +}); +it('reviews a real create before sending its one-use confirmation, then prevents resubmission', async () => { + const id = 'f6d1a214-87d9-45ef-9bce-b1a850e5d301'; + const invoke = vi.fn().mockImplementation(async (command) => ({ + success: true, + data: { + configured: true, + status: 'connected', + ...(command.action === 'prepareChange' + ? { + review: { + confirmationId: id, + expiresAt: Date.now() + 300000, + mutation: command.mutation, + }, + } + : { + changeResult: { id: '123', number: '789', kind: 'create' }, + message: 'Change confirmed by SDP.', + }), + }, + })); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + const result = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText('Subject'), { + target: { value: 'Synthetic major incident' }, + }); + fireEvent.change(screen.getByLabelText('Requester email'), { + target: { value: 'test@example.test' }, + }); + expect(screen.getByRole('checkbox', { name: 'Major Incident' })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'Review change' })); + const confirm = await screen.findByRole('button', { name: 'Confirm live change' }); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke.mock.calls[0]?.[0]).toMatchObject({ + action: 'prepareChange', + mutation: { + majorIncident: true, + templateId: '142866000146669084', + requesterEmail: 'test@example.test', + fields: { + subject: 'Synthetic major incident', + requestType: 'Incident', + impact: 'Single User', + urgency: 'Medium', + }, + }, + }); + expect(screen.getByText(/Major Incident: Yes/)).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Review live change' })).toHaveTextContent( + 'Synthetic major incident', + ); + fireEvent.click(confirm); + fireEvent.click(confirm); + await waitFor(() => expect(result).toHaveBeenCalledTimes(1)); + expect(invoke).toHaveBeenLastCalledWith({ action: 'confirmChange', confirmationId: id }); + expect(invoke).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('button', { name: 'Confirm live change' })).not.toBeInTheDocument(); +}); +it('does not claim success or offer an automatic retry after a lost write response', 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, + }, + }, + }; + }); + globalThis.api = { ...original, sdpAccount: invoke } as BridgeAPI; + const result = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Example' } }); + fireEvent.click(screen.getByRole('button', { name: 'Review change' })); + fireEvent.click(await screen.findByRole('button', { name: 'Confirm live change' })); + expect(await screen.findByRole('status')).toHaveTextContent('uncertain'); + expect(result).not.toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledTimes(2); +}); diff --git a/src/renderer/src/features/tickets/SdpChangeDialog.tsx b/src/renderer/src/features/tickets/SdpChangeDialog.tsx new file mode 100644 index 00000000..343e3ac9 --- /dev/null +++ b/src/renderer/src/features/tickets/SdpChangeDialog.tsx @@ -0,0 +1,379 @@ +import { useEffect, useRef, useState } from 'react'; +import type { SdpAccountView, SdpQueueTicket } from '@shared/sdpAccount'; +import { + SdpMutationSchema, + SDP_DEFAULT_INCIDENT_TEMPLATE, + type SdpMutation, + type SdpRequestFields, + type SdpReview, +} from '@shared/sdpMutation'; +import { Modal } from '../../components/Modal'; +import { TactileButton } from '../../components/TactileButton'; + +export type SdpChangeMode = 'create' | 'major' | 'update' | 'note' | 'resolve'; +export function SdpChangeDialog({ + mode, + ticket, + onClose, + onResult, +}: Readonly<{ + mode: SdpChangeMode; + ticket?: SdpQueueTicket; + onClose: () => void; + onResult: (view: SdpAccountView) => void; +}>) { + const [fields, setFields] = useState>((): Record => + mode === 'major' ? { requestType: 'Incident', impact: 'Single User', urgency: 'Medium' } : {}, + ); + const [review, setReview] = useState(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [finished, setFinished] = useState(false); + const alive = useRef(true); + const locked = useRef(false); + const creating = mode === 'create' || mode === 'major'; + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + function mutation(): SdpMutation { + if (mode === 'note') + return SdpMutationSchema.parse({ + kind: 'note', + id: ticket!.id, + body: fields.body ?? '', + showToRequester: fields.visibility === 'requester', + }); + const patch: SdpRequestFields = {}; + const editable = [ + 'subject', + 'description', + 'status', + 'priority', + 'group', + 'technician', + 'requestType', + 'category', + 'impact', + 'urgency', + 'resolution', + ] as const; + for (const key of editable) { + if (fields[key]?.trim()) patch[key] = fields[key].trim(); + } + if (fields.group === '(Unassigned)') patch.group = null; + if (fields.technician === '(Unassigned)') patch.technician = null; + if (creating) + return SdpMutationSchema.parse({ + kind: 'create', + fields: patch, + templateId: + mode === 'major' ? SDP_DEFAULT_INCIDENT_TEMPLATE.id : fields.templateId || undefined, + requesterEmail: fields.requesterEmail || undefined, + majorIncident: mode === 'major', + }); + return SdpMutationSchema.parse({ kind: 'update', id: ticket!.id, fields: patch }); + } + async function prepare() { + if (locked.current) return; + if (mode === 'resolve' && (!fields.resolution?.trim() || !fields.status?.trim())) { + setMessage('Enter the resolution and the exact target status from SDP.'); + return; + } + if ( + mode === 'major' && + ['requesterEmail', 'requestType', 'impact', 'urgency'].some((field) => !fields[field]?.trim()) + ) { + setMessage( + 'Enter a requester email, request type, impact and urgency for the default template.', + ); + return; + } + locked.current = true; + setBusy(true); + setMessage(''); + try { + const data = mutation(); + const result = await globalThis.api!.sdpAccount!({ action: 'prepareChange', mutation: data }); + if (!result.success || !result.data?.review) + throw new Error( + 'Could not prepare this change. Refresh the live ticket and check your connection.', + ); + if (alive.current) setReview(result.data.review); + } catch (error) { + if (alive.current) + setMessage( + error instanceof Error && !('issues' in error) + ? error.message + : 'Enter a subject or at least one change. Check the email, template ID and field lengths.', + ); + } finally { + locked.current = false; + if (alive.current) setBusy(false); + } + } + async function confirm() { + if (!review || locked.current) return; + locked.current = true; + setBusy(true); + setMessage(''); + const confirmationId = review.confirmationId; + setReview(undefined); + try { + const result = await globalThis.api!.sdpAccount!({ action: 'confirmChange', confirmationId }); + if (!result.success || !result.data) + throw new Error('The result is uncertain. Check the ticket in SDP before trying again.'); + if (alive.current) { + setMessage(result.data.message ?? 'Check SDP for the result.'); + onResult(result.data); + } + } catch { + if (alive.current) + setMessage( + 'The result is uncertain. Check the ticket in SDP before trying again. Relay will not retry this change.', + ); + } finally { + locked.current = false; + if (alive.current) { + setBusy(false); + setFinished(true); + } + } + } + function close() { + if (locked.current) return; + void globalThis.api?.sdpAccount?.({ action: 'cancelChange' }); + onClose(); + } + const title = { + major: 'Create major incident', + create: 'New SDP ticket', + note: 'Add note', + resolve: 'Resolve ticket', + update: 'Edit ticket', + }[mode]; + const createInputs = [ + 'subject', + 'description', + 'priority', + 'group', + 'templateId', + 'requesterEmail', + 'requestType', + 'impact', + 'urgency', + ]; + const inputs = { + create: createInputs, + major: createInputs.filter((field) => field !== 'templateId'), + resolve: ['resolution', 'status'], + note: ['body'], + update: [ + 'subject', + 'description', + 'status', + 'priority', + 'group', + 'technician', + 'requestType', + 'category', + 'impact', + 'urgency', + ], + }[mode]; + const labels: Record = { + subject: 'Subject', + description: 'Description', + status: 'Status', + priority: 'Priority', + group: 'Support group', + technician: 'Technician', + requestType: 'Request type', + category: 'Category', + impact: 'Impact', + urgency: 'Urgency', + resolution: 'Resolution', + templateId: 'SDP template ID (optional)', + requesterEmail: mode === 'major' ? 'Requester email' : 'Requester email (optional)', + body: 'Note', + }; + return ( + + + {finished ? 'Done' : 'Cancel'} + + {!finished && + (review ? ( + void confirm()} + > + Confirm live change + + ) : ( + void prepare()}> + Review change + + ))} + + } + > + {message && ( +

+ {message} +

+ )} + {!finished && + (review ? ( +
+

+ This will{' '} + {review.mutation.kind === 'create' + ? 'create a real ticket' + : `change ticket ${ticket?.number}`}{' '} + in SDP using your account. SDP workflows may send notifications. No automatic retry. +

+ {mode === 'major' && ( +

+ Template: {SDP_DEFAULT_INCIDENT_TEMPLATE.name}. Major Incident: Yes (checked in + SDP). +

+ )} + {review.mutation.kind === 'note' && ( +

+ Visibility:{' '} + {review.mutation.showToRequester ? 'Visible to requester' : 'Technicians only'}. No + email notification is requested. +

+ )} +
+ {Object.entries(fields) + .filter(([, value]) => value) + .map(([key, value]) => ( +
+
{labels[key] ?? key}
+
{value}
+
+ ))} +
+ { + setReview(undefined); + void globalThis.api?.sdpAccount?.({ action: 'cancelChange' }); + }} + > + Back to editing + +
+ ) : ( +
+

{formHint(mode)}

+ {mode === 'major' && ( +
+

Template: {SDP_DEFAULT_INCIDENT_TEMPLATE.name}

+ +
+ )} + {inputs.map((key) => ( + setFields({ ...fields, [key]: value })} + /> + ))} + + + + + + + + + {mode === 'note' && ( + + )} +
+ ))} +
+ ); +} + +function ChangeField({ + field, + label, + value, + ticket, + onChange, +}: Readonly<{ + field: string; + label: string; + value: string; + ticket?: SdpQueueTicket; + onChange: (value: string) => void; +}>) { + const multiline = ['description', 'resolution', 'body'].includes(field); + const placeholders: Record = { + status: ticket?.status, + priority: ticket?.priority, + group: ticket?.group, + }; + let length = 200; + if (field === 'subject') length = 250; + const choices = field === 'group' || field === 'technician' ? `sdp-${field}-choices` : undefined; + return ( +