@xenon-device-management/xenon 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/package.json +3 -4
- package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-XpewX5nn.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
- package/lib/public/assets/BugReportButton-C7OmS61B.js +10 -0
- package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
- package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-fPxujqqE.js} +1 -1
- package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-5bCutWrs.js} +1 -1
- package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-owsBiDT1.js} +1 -1
- package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-DFhqzPSt.js} +1 -1
- package/lib/public/assets/{Modal-CceoMY7D.js → Modal-Bw-a-E-e.js} +1 -1
- package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-WtByf5bC.js} +1 -1
- package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-FpWWFNgT.js} +1 -1
- package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-Jzn9c7o8.js} +1 -1
- package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-BR7nOhpT.js} +1 -1
- package/lib/public/assets/{Table-LV7zFyZg.js → Table-B_Va3V05.js} +1 -1
- package/lib/public/assets/{activity-D17TUloi.js → activity-DxRFWvkN.js} +1 -1
- package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-CQf-9_V6.js} +1 -1
- package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-SoE1Kd7N.js} +1 -1
- package/lib/public/assets/{apps-Dbnzd0fW.js → apps-CgGXTlgn.js} +1 -1
- package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-uQ6WIwiC.js} +1 -1
- package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-C9RyfzXS.js} +1 -1
- package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-DFHi_8yG.js} +1 -1
- package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-Bii1t4qc.js} +2 -2
- package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-Bnv8Z9hi.js} +1 -1
- package/lib/public/assets/{button-xtAhYR-f.js → button-_Y78wXiP.js} +1 -1
- package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-D4MqSOc2.js} +1 -1
- package/lib/public/assets/{check-CyuEoKkd.js → check-uGHCXe-L.js} +1 -1
- package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-Dy1OqpzB.js} +1 -1
- package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-BUsldEli.js} +1 -1
- package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-D3Qq0tKp.js} +1 -1
- package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-B4IOD1DE.js} +1 -1
- package/lib/public/assets/{clock-Cd-t7xpe.js → clock-BfYfzvSg.js} +1 -1
- package/lib/public/assets/{copy-C6zerX3I.js → copy-DZwYE5Va.js} +1 -1
- package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CRG4Utt5.js} +3 -3
- package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
- package/lib/public/assets/{download-x9WP7mcL.js → download-CCDYo_TN.js} +1 -1
- package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-DuLCmVFb.js} +1 -1
- package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-CtKNPqzb.js} +1 -1
- package/lib/public/assets/{index-BR1wTIPC.js → index-3uxl3o7K.js} +43 -43
- package/lib/public/assets/{index-BvH_ekRy.js → index-Zy1Op5C1.js} +1 -1
- package/lib/public/assets/{input-DejcCiTI.js → input-CzTWvUXZ.js} +1 -1
- package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CI-nYDFM.js} +1 -1
- package/lib/public/assets/{layers-BKqYLiHE.js → layers-YZZGFyTX.js} +1 -1
- package/lib/public/assets/{lock-LFsFhc17.js → lock-L8tzWgDo.js} +1 -1
- package/lib/public/assets/{login-x2pkzt-t.js → login-DBbX-fu8.js} +1 -1
- package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-AJwkOIsE.js} +1 -1
- package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-Di_66F2m.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
- package/lib/public/assets/{network-Z_zBhpsD.js → network-DiSumY-1.js} +1 -1
- package/lib/public/assets/{overview-BY5uv08S.js → overview-CwE4gXl5.js} +1 -1
- package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CfR4Sm9V.js} +1 -1
- package/lib/public/assets/{plus-BTD0dnQ0.js → plus-DOS8P9CQ.js} +1 -1
- package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-BYpHwQIH.js} +1 -1
- package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BRVJU_qV.js} +1 -1
- package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Bi3JvAFZ.js} +1 -1
- package/lib/public/assets/{select-Uc9E5pye.js → select-Cqx7pEou.js} +1 -1
- package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C0DsncKj.js} +1 -1
- package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CAhpdilF.js} +1 -1
- package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CrCVeW2m.js} +1 -1
- package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-F_hwYqEt.js} +1 -1
- package/lib/public/assets/{settings-CkSWiZq8.js → settings-C-s5pypL.js} +1 -1
- package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-DY5uzv6A.js} +1 -1
- package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-Dli7gwyg.js} +1 -1
- package/lib/public/assets/{teams-PpoEQz31.js → teams-ui4IhHNC.js} +1 -1
- package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV4hSmXJ.js} +1 -1
- package/lib/public/assets/{upload-C3i7sAZl.js → upload-DkGy_J-L.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
- package/lib/public/assets/{users-Drd7ifJx.js → users-BU0nXIVe.js} +1 -1
- package/lib/public/assets/{users-CEJ3fC78.js → users-Nb_rzRmF.js} +1 -1
- package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-CUNzD2kH.js} +1 -1
- package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-Ca3UFXZe.js} +1 -1
- package/lib/public/assets/{zap-4yN8Jdfw.js → zap-B2bH2vKX.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/InternalHttpClient.js +5 -1
- package/lib/src/XenonCapabilityManager.js +15 -1
- package/lib/src/app/appiumBasePath.js +32 -0
- package/lib/src/app/index.js +12 -0
- package/lib/src/app/insertBeforeRoutes.js +24 -4
- package/lib/src/app/registerCommandAuth.js +152 -33
- package/lib/src/app/routers/control.js +11 -104
- package/lib/src/app/routers/grid.js +28 -8
- package/lib/src/app/routers/nodePhoneControl.js +301 -0
- package/lib/src/app/ws/h264StreamWs.js +43 -12
- package/lib/src/app/ws/logcatWs.js +48 -14
- package/lib/src/app/ws/nodeSocketRelay.js +167 -0
- package/lib/src/app/ws/upgradeRouter.js +215 -0
- package/lib/src/dashboard/event-manager.js +7 -6
- package/lib/src/data-service/device-service.js +152 -49
- package/lib/src/data-service/device-store.js +92 -10
- package/lib/src/data-service/deviceClaims.js +116 -0
- package/lib/src/data-service/deviceFieldOwners.js +32 -10
- package/lib/src/data-service/prisma-store.js +102 -31
- package/lib/src/device-managers/AndroidDeviceManager.js +40 -42
- package/lib/src/device-managers/HealthMonitorService.js +17 -2
- package/lib/src/device-managers/IOSDeviceManager.js +5 -1
- package/lib/src/device-managers/NodeDevices.js +7 -2
- package/lib/src/device-managers/android/AndroidH264StreamService.js +13 -5
- package/lib/src/device-managers/android/AndroidStreamService.js +9 -6
- package/lib/src/device-managers/android/previewHold.js +2 -2
- package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
- package/lib/src/device-managers/ios/IOSStreamService.js +162 -40
- package/lib/src/device-managers/localDeviceHosts.js +86 -0
- package/lib/src/device-managers/ownDeviceRow.js +38 -0
- package/lib/src/device-utils.js +78 -24
- package/lib/src/gateway/createHandoff.js +73 -0
- package/lib/src/gateway/defaultGateway.js +80 -0
- package/lib/src/gateway/forwardToNode.js +147 -0
- package/lib/src/gateway/hubSessionToken.js +356 -0
- package/lib/src/gateway/internalCall.js +90 -0
- package/lib/src/gateway/nodeSessionStatus.js +140 -0
- package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
- package/lib/src/gateway/sessionCreate.js +136 -0
- package/lib/src/gateway/sessionGateway.js +201 -0
- package/lib/src/gateway/sessionLocator.js +100 -0
- package/lib/src/generated/client/edge.js +7 -4
- package/lib/src/generated/client/index-browser.js +4 -1
- package/lib/src/generated/client/index.d.ts +112 -1
- package/lib/src/generated/client/index.js +7 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +9 -0
- package/lib/src/generated/client/wasm.js +4 -1
- package/lib/src/helpers/index.js +5 -1
- package/lib/src/logger.js +5 -0
- package/lib/src/middleware/authMiddleware.js +80 -0
- package/lib/src/middleware/commandAuth.js +31 -17
- package/lib/src/middleware/csrfMiddleware.js +3 -2
- package/lib/src/middleware/deviceAccessGuard.js +4 -1
- package/lib/src/middleware/sessionListingFilter.js +153 -0
- package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
- package/lib/src/plugin.js +27 -1
- package/lib/src/prisma.js +2 -1
- package/lib/src/services/DeviceReconciler.js +5 -2
- package/lib/src/services/OrphanSweeper.js +12 -19
- package/lib/src/services/PortAllocator.js +75 -16
- package/lib/src/services/ServerManager.js +63 -30
- package/lib/src/services/SessionLifecycleService.js +335 -76
- package/lib/src/services/ShutdownCoordinator.js +7 -1
- package/lib/src/services/SocketServer.js +11 -2
- package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
- package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
- package/lib/src/services/device-access/ticketActorAccess.js +3 -1
- package/lib/src/services/lease/LeaseOrphanSweeper.js +34 -6
- package/lib/src/services/lease/LeaseService.js +19 -5
- package/lib/src/services/session/nodeCreateCaps.js +55 -0
- package/lib/src/services/session/sessionCredentials.js +7 -59
- package/lib/src/sessions/CloudSession.js +12 -0
- package/lib/src/sessions/LocalSession.js +52 -26
- package/lib/src/sessions/RemoteSession.js +116 -9
- package/lib/src/sessions/SessionManager.js +49 -6
- package/lib/src/sessions/appiumUmbrella.js +32 -0
- package/lib/test/helpers/appium-umbrella.js +84 -0
- package/lib/test/helpers/fake-device-table.js +97 -0
- package/lib/test/helpers/loki-stores.js +41 -0
- package/lib/test/helpers/loopbackRequest.js +77 -0
- package/lib/test/helpers/own-node-id.js +29 -0
- package/lib/test/helpers/scratch-database.js +100 -0
- package/lib/test/helpers/scratch-port-leases.js +119 -0
- package/lib/test/helpers/test-container.js +35 -27
- package/lib/test/integration/androidDevices.spec.js +5 -4
- package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
- package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
- package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
- package/lib/test/setup/scratch-default-database.js +32 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +56 -1
- package/lib/test/unit/DeviceModel.spec.js +9 -9
- package/lib/test/unit/HealingCascade.test.js +5 -1
- package/lib/test/unit/OrphanSweeper.test.js +17 -13
- package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
- package/lib/test/unit/StreamTicketService.spec.js +4 -1
- package/lib/test/unit/TokenEndpoint.spec.js +17 -3
- package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
- package/lib/test/unit/android-h264-service.spec.js +24 -0
- package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
- package/lib/test/unit/apps-team-visibility.spec.js +16 -16
- package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
- package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
- package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
- package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
- package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
- package/lib/test/unit/authMiddleware.test.js +3 -0
- package/lib/test/unit/bug-report/route.spec.js +9 -9
- package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
- package/lib/test/unit/clipboard-route.spec.js +22 -8
- package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
- package/lib/test/unit/command-auth.spec.js +31 -31
- package/lib/test/unit/control-device-lookup.spec.js +9 -9
- package/lib/test/unit/control-forward-decision.spec.js +115 -0
- package/lib/test/unit/control-lookup-failure.spec.js +3 -3
- package/lib/test/unit/create-error-message.spec.js +43 -0
- package/lib/test/unit/create-handoff.spec.js +86 -0
- package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
- package/lib/test/unit/device-access-guard.spec.js +66 -28
- package/lib/test/unit/device-service.spec.js +4 -0
- package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
- package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
- package/lib/test/unit/device-team-guard.spec.js +20 -20
- package/lib/test/unit/device-utils.spec.js +8 -0
- package/lib/test/unit/extract-team-cap.spec.js +65 -0
- package/lib/test/unit/grid-admin-only.spec.js +3 -3
- package/lib/test/unit/healing-tier-gate.spec.js +5 -1
- package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
- package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
- package/lib/test/unit/helpers.spec.js +27 -0
- package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
- package/lib/test/unit/hub-create-once.spec.js +190 -0
- package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
- package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +608 -0
- package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
- package/lib/test/unit/hub-node-race.spec.js +426 -0
- package/lib/test/unit/hub-node-same-host.spec.js +356 -0
- package/lib/test/unit/hub-register-removal.spec.js +165 -0
- package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
- package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
- package/lib/test/unit/hub-session-token.spec.js +265 -0
- package/lib/test/unit/insert-before-routes.spec.js +5 -5
- package/lib/test/unit/install-repository-app-team.spec.js +6 -2
- package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
- package/lib/test/unit/internal-call.spec.js +181 -0
- package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
- package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
- package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
- package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
- package/lib/test/unit/is-xenon-running.spec.js +69 -0
- package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
- package/lib/test/unit/lease/LeaseService.spec.js +28 -1
- package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
- package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +107 -0
- package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
- package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
- package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
- package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
- package/lib/test/unit/logcat-ws.spec.js +12 -1
- package/lib/test/unit/loopback-request.spec.js +81 -0
- package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
- package/lib/test/unit/node-create-caps.spec.js +54 -0
- package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
- package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
- package/lib/test/unit/node-phone-control.spec.js +131 -0
- package/lib/test/unit/node-socket-relay.spec.js +357 -0
- package/lib/test/unit/omni-vision.spec.js +7 -3
- package/lib/test/unit/plugin.spec.js +45 -4
- package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
- package/lib/test/unit/profile-router.test.js +11 -11
- package/lib/test/unit/queue-requester.spec.js +5 -0
- package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
- package/lib/test/unit/queue-visibility.spec.js +1 -1
- package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
- package/lib/test/unit/recording-instrumentation.spec.js +5 -0
- package/lib/test/unit/recording-source-ended.spec.js +5 -0
- package/lib/test/unit/recording-store.spec.js +9 -3
- package/lib/test/unit/recordings-library-routes.spec.js +51 -51
- package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
- package/lib/test/unit/remote-session-create.spec.js +130 -0
- package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
- package/lib/test/unit/reset-link.test.js +14 -14
- package/lib/test/unit/session-app-resolution.spec.js +14 -10
- package/lib/test/unit/session-assets.spec.js +9 -9
- package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
- package/lib/test/unit/session-create-layer.spec.js +255 -0
- package/lib/test/unit/session-credential-strip.spec.js +25 -9
- package/lib/test/unit/session-credentials.spec.js +0 -47
- package/lib/test/unit/session-listing-auth.spec.js +345 -0
- package/lib/test/unit/session-locator.spec.js +250 -0
- package/lib/test/unit/session-node-calls.spec.js +174 -0
- package/lib/test/unit/session-owner-resolver.spec.js +107 -0
- package/lib/test/unit/session-recovery-routing.spec.js +118 -0
- package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
- package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
- package/lib/test/unit/socketServer.bearer.spec.js +3 -1
- package/lib/test/unit/stream-leave-route.spec.js +19 -7
- package/lib/test/unit/stream-own-device-row.spec.js +164 -0
- package/lib/test/unit/stream-stop-ownership.spec.js +17 -5
- package/lib/test/unit/stream-stop-recording.spec.js +17 -5
- package/lib/test/unit/stream-ticket-identity.spec.js +26 -6
- package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
- package/lib/test/unit/upgrade-router.spec.js +446 -0
- package/lib/test/unit/users-router.test.js +11 -8
- package/lib/test/unit/verify-credential.spec.js +4 -1
- package/lib/test/unit/webdriver-info-route.spec.js +64 -0
- package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -4
- package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
- package/prisma/schema.prisma +9 -0
- package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
- package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
- package/lib/src/proxy/wd-command-proxy.js +0 -221
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
const chai_1 = require("chai");
|
|
49
|
+
const http_1 = __importDefault(require("http"));
|
|
50
|
+
const net_1 = __importDefault(require("net"));
|
|
51
|
+
const path_1 = __importDefault(require("path"));
|
|
52
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
53
|
+
const ws_1 = __importStar(require("ws"));
|
|
54
|
+
const commandCaller_1 = require("../../src/middleware/commandCaller");
|
|
55
|
+
const sessionUpgradeGuard_1 = require("../../src/middleware/sessionUpgradeGuard");
|
|
56
|
+
const registerCommandAuth_1 = require("../../src/app/registerCommandAuth");
|
|
57
|
+
/**
|
|
58
|
+
* Session WebSockets under XENON_REQUIRE_COMMAND_AUTH.
|
|
59
|
+
*
|
|
60
|
+
* WebDriver BiDi (`<basePath>/bidi/<sessionId>`) and the endpoints drivers
|
|
61
|
+
* register (`/ws/session/<sessionId>/...`) are WebSocket upgrades, which never
|
|
62
|
+
* pass through the Express routes the per-command check guards. Without the
|
|
63
|
+
* guard, anyone with a session id can attach.
|
|
64
|
+
*
|
|
65
|
+
* Appium dispatches upgrades one of two ways (base-driver `server()`):
|
|
66
|
+
* - Node >= 22.21 / 24.9 (`shouldUpgradeCallback`): an `upgrade` listener on the
|
|
67
|
+
* http.Server, added before any plugin's updateServer runs.
|
|
68
|
+
* - older Node: an Express middleware (`handleUpgrade`), reached only while the
|
|
69
|
+
* server has no `upgrade` listener at all.
|
|
70
|
+
* These tests use Appium's own dispatch functions for both, on a real
|
|
71
|
+
* http.Server with a real WebSocket client.
|
|
72
|
+
*/
|
|
73
|
+
function appiumDir() {
|
|
74
|
+
return path_1.default.dirname(require.resolve('appium/package.json'));
|
|
75
|
+
}
|
|
76
|
+
function appiumExpress() {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
78
|
+
return require(require.resolve('express', { paths: [appiumDir()] }));
|
|
79
|
+
}
|
|
80
|
+
/** base-driver's upgrade dispatch: tryHandleWebSocketUpgrade and handleUpgrade. */
|
|
81
|
+
function appiumUpgradeDispatch() {
|
|
82
|
+
const baseDriverDir = path_1.default.dirname(require.resolve('@appium/base-driver/package.json', { paths: [appiumDir()] }));
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
84
|
+
return require(path_1.default.join(baseDriverDir, 'build/lib/express/middleware'));
|
|
85
|
+
}
|
|
86
|
+
const USERS = {
|
|
87
|
+
alice: { id: 'alice', role: 'MEMBER', status: 'ACTIVE' },
|
|
88
|
+
bob: { id: 'bob', role: 'MEMBER', status: 'ACTIVE' },
|
|
89
|
+
carol: { id: 'carol', role: 'ADMIN', status: 'ACTIVE' },
|
|
90
|
+
root: { id: 'root', role: 'SUPER_ADMIN', status: 'ACTIVE' },
|
|
91
|
+
};
|
|
92
|
+
const KEYS = {
|
|
93
|
+
'alice-token': ['alice', 'devices,sessions,read'],
|
|
94
|
+
'bob-token': ['bob', 'devices,sessions,read'],
|
|
95
|
+
'carol-ordinary': ['carol', 'devices,sessions,read'],
|
|
96
|
+
'carol-admin': ['carol', 'admin,devices,sessions,read'],
|
|
97
|
+
};
|
|
98
|
+
const JWTS = {
|
|
99
|
+
'alice-jwt': ['alice', 'devices,sessions,read'],
|
|
100
|
+
'root-jwt': ['root', 'sessions'],
|
|
101
|
+
};
|
|
102
|
+
const OWNERS = {
|
|
103
|
+
's-alice': 'alice',
|
|
104
|
+
's-bob': 'bob',
|
|
105
|
+
's-anon': null,
|
|
106
|
+
};
|
|
107
|
+
const as = (token) => ({
|
|
108
|
+
'x-xenon-access-key': `ak-${KEYS[token][0]}`,
|
|
109
|
+
'x-xenon-token': token,
|
|
110
|
+
});
|
|
111
|
+
const bearer = (jwt) => ({ authorization: `Bearer ${jwt}` });
|
|
112
|
+
/** Open a WebSocket and report how the server answered the upgrade. */
|
|
113
|
+
function connect(port, urlPath, headers = {}) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${port}${urlPath}`, { headers });
|
|
116
|
+
ws.on('open', () => {
|
|
117
|
+
resolve({ kind: 'open' });
|
|
118
|
+
ws.terminate();
|
|
119
|
+
});
|
|
120
|
+
ws.on('unexpected-response', (req, res) => {
|
|
121
|
+
var _a;
|
|
122
|
+
resolve({ kind: 'http', status: (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0 });
|
|
123
|
+
req.destroy();
|
|
124
|
+
});
|
|
125
|
+
ws.on('error', (err) => resolve({ kind: 'error', message: err.message }));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Send a raw upgrade request and collect everything until the server closes. */
|
|
129
|
+
function rawUpgrade(port, urlPath) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const socket = net_1.default.connect(port, '127.0.0.1', () => {
|
|
132
|
+
socket.write(`GET ${urlPath} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n` +
|
|
133
|
+
'Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' +
|
|
134
|
+
'Sec-WebSocket-Version: 13\r\n\r\n');
|
|
135
|
+
});
|
|
136
|
+
let data = '';
|
|
137
|
+
socket.on('data', (chunk) => (data += chunk.toString('latin1')));
|
|
138
|
+
socket.on('close', () => resolve(data));
|
|
139
|
+
socket.on('error', reject);
|
|
140
|
+
socket.setTimeout(5000, () => reject(new Error(`no close after: ${JSON.stringify(data)}`)));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const tick = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
+
const openSockets = new WeakMap();
|
|
145
|
+
/** Listen on 127.0.0.1, remembering every connection so shut() can end it. */
|
|
146
|
+
function listen(server) {
|
|
147
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
148
|
+
const sockets = new Set();
|
|
149
|
+
openSockets.set(server, sockets);
|
|
150
|
+
server.on('connection', (socket) => {
|
|
151
|
+
sockets.add(socket);
|
|
152
|
+
socket.on('close', () => sockets.delete(socket));
|
|
153
|
+
});
|
|
154
|
+
yield new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
155
|
+
return server.address().port;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Close the server and every connection. An upgraded socket leaves the HTTP
|
|
160
|
+
* server's own bookkeeping (closeAllConnections misses it), and one that no
|
|
161
|
+
* listener reads never sees the client's FIN, so it is destroyed here.
|
|
162
|
+
*/
|
|
163
|
+
function shut(server) {
|
|
164
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
165
|
+
var _a;
|
|
166
|
+
if (!server)
|
|
167
|
+
return;
|
|
168
|
+
for (const socket of (_a = openSockets.get(server)) !== null && _a !== void 0 ? _a : [])
|
|
169
|
+
socket.destroy();
|
|
170
|
+
server.closeAllConnections();
|
|
171
|
+
yield new Promise((resolve) => server.close(() => resolve()));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
describe('session WebSockets under per-command auth', () => {
|
|
175
|
+
describe('which upgrades belong to a session', () => {
|
|
176
|
+
const cases = [
|
|
177
|
+
// WebDriver BiDi, as Appium's main registers it under the base path.
|
|
178
|
+
['/wd/hub/bidi/abc', '/wd/hub', ['abc']],
|
|
179
|
+
['/wd/hub/bidi/abc/', '/wd/hub', ['abc']],
|
|
180
|
+
['/WD/HUB/BIDI/abc', '/wd/hub', ['abc']],
|
|
181
|
+
['/wd/hub/x/../bidi/abc', '/wd/hub', ['abc']],
|
|
182
|
+
['/wd/hub/bidi/%2e%2e/bidi/abc', '/wd/hub', ['abc']],
|
|
183
|
+
['http://elsewhere/wd/hub/bidi/abc', '/wd/hub', ['abc']],
|
|
184
|
+
// Appium reads the bidi session id from the raw URL, query and all; both count.
|
|
185
|
+
['/wd/hub/bidi/abc?x=1', '/wd/hub', ['abc', 'abc?x=1']],
|
|
186
|
+
['/wd/hub/bidi/ab%2Fc', '/wd/hub', ['ab%2Fc', 'ab/c']],
|
|
187
|
+
['/bidi/abc', '', ['abc']],
|
|
188
|
+
// The umbrella bidi socket belongs to no session.
|
|
189
|
+
['/wd/hub/bidi', '/wd/hub', []],
|
|
190
|
+
['/wd/hub/bidi/', '/wd/hub', []],
|
|
191
|
+
// Outside the base path Appium routes nothing to bidi.
|
|
192
|
+
['/bidi/abc', '/wd/hub', []],
|
|
193
|
+
// Driver endpoints (base-driver's DEFAULT_WS_PATHNAME_PREFIX), with or without the base path.
|
|
194
|
+
['/ws/session/abc/appium/device/logcat', '/wd/hub', ['abc']],
|
|
195
|
+
['/wd/hub/ws/session/abc/appium/device/syslog', '/wd/hub', ['abc']],
|
|
196
|
+
['/WS/SESSION/abc/appium/device/logcat/', '/wd/hub', ['abc']],
|
|
197
|
+
['/ws/session/abc', '', ['abc']],
|
|
198
|
+
// Not session WebSockets.
|
|
199
|
+
['/xenon/api/control/udid-1/logcat?ticket=t', '/wd/hub', []],
|
|
200
|
+
['/xenon/api/control/udid-1/stream/h264?ticket=t', '/wd/hub', []],
|
|
201
|
+
['/socket.io/?EIO=4&transport=websocket', '/wd/hub', []],
|
|
202
|
+
['/wd/hub/session/abc/url', '/wd/hub', []],
|
|
203
|
+
['/ws/other/abc', '/wd/hub', []],
|
|
204
|
+
];
|
|
205
|
+
for (const [url, basePath, ids] of cases) {
|
|
206
|
+
it(`${url} (base path ${JSON.stringify(basePath)}) -> ${JSON.stringify(ids)}`, () => {
|
|
207
|
+
(0, chai_1.expect)((0, sessionUpgradeGuard_1.sessionIdsOfUpgrade)(url, basePath)).to.deep.equal(ids);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
it("normalises the base path the way Appium does ('wd/hub/' -> '/wd/hub')", () => {
|
|
211
|
+
(0, chai_1.expect)((0, sessionUpgradeGuard_1.sessionIdsOfUpgrade)('/wd/hub/bidi/abc', 'wd/hub/')).to.deep.equal(['abc']);
|
|
212
|
+
(0, chai_1.expect)((0, sessionUpgradeGuard_1.sessionIdsOfUpgrade)('/wd/hub/bidi/abc', undefined)).to.deep.equal([]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('registration', () => {
|
|
216
|
+
const quiet = (errors) => ({
|
|
217
|
+
info: () => undefined,
|
|
218
|
+
warn: () => undefined,
|
|
219
|
+
error: (m) => errors.push(m),
|
|
220
|
+
});
|
|
221
|
+
it('refuses to start when the setting is on and there is no HTTP server to guard', () => {
|
|
222
|
+
const errors = [];
|
|
223
|
+
(0, chai_1.expect)(() => (0, registerCommandAuth_1.registerSessionUpgradeGuard)(undefined, appiumExpress()(), { basePath: '/wd/hub' }, {
|
|
224
|
+
enabled: () => true,
|
|
225
|
+
authDisabled: () => false,
|
|
226
|
+
logger: quiet(errors),
|
|
227
|
+
})).to.throw(/XENON_REQUIRE_COMMAND_AUTH/);
|
|
228
|
+
(0, chai_1.expect)(errors).to.have.length(1);
|
|
229
|
+
});
|
|
230
|
+
it('refuses to start when the setting is on and the Express guard cannot be placed', () => {
|
|
231
|
+
const errors = [];
|
|
232
|
+
(0, chai_1.expect)(() => (0, registerCommandAuth_1.registerSessionUpgradeGuard)(http_1.default.createServer(), { use: () => undefined }, { basePath: '/wd/hub' }, { enabled: () => true, authDisabled: () => false, logger: quiet(errors) })).to.throw(/XENON_REQUIRE_COMMAND_AUTH/);
|
|
233
|
+
});
|
|
234
|
+
it('logs an error but starts when the setting is off', () => {
|
|
235
|
+
const errors = [];
|
|
236
|
+
const result = (0, registerCommandAuth_1.registerSessionUpgradeGuard)(undefined, appiumExpress()(), {}, {
|
|
237
|
+
enabled: () => false,
|
|
238
|
+
authDisabled: () => false,
|
|
239
|
+
logger: quiet(errors),
|
|
240
|
+
});
|
|
241
|
+
(0, chai_1.expect)(result.placed).to.equal(false);
|
|
242
|
+
(0, chai_1.expect)(errors).to.have.length(1);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
// 'listener': Appium on Node >= 22.21 / 24.9, its upgrade listener added before plugins.
|
|
246
|
+
// 'express': Appium on older Node, its Express upgrade middleware, no upgrade listener.
|
|
247
|
+
for (const mode of ['listener', 'express']) {
|
|
248
|
+
describe(`Appium dispatching upgrades with its ${mode}`, () => {
|
|
249
|
+
let enabled;
|
|
250
|
+
let authDisabled;
|
|
251
|
+
let ownerOf;
|
|
252
|
+
let verifyKeyPair;
|
|
253
|
+
let verifyBearer;
|
|
254
|
+
let warnings;
|
|
255
|
+
let errors;
|
|
256
|
+
let server;
|
|
257
|
+
let port;
|
|
258
|
+
/** URLs Appium's WebSocket servers accepted. */
|
|
259
|
+
let accepted;
|
|
260
|
+
/** Calls into Appium's WebSocket servers' handleUpgrade, accepted or not. */
|
|
261
|
+
let appiumHandled;
|
|
262
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
263
|
+
enabled = true;
|
|
264
|
+
authDisabled = false;
|
|
265
|
+
warnings = [];
|
|
266
|
+
errors = [];
|
|
267
|
+
accepted = [];
|
|
268
|
+
verifyKeyPair = sinon_1.default.stub().callsFake((accessKey, token) => __awaiter(void 0, void 0, void 0, function* () {
|
|
269
|
+
const key = KEYS[token];
|
|
270
|
+
if (!key || accessKey !== `ak-${key[0]}`)
|
|
271
|
+
return null;
|
|
272
|
+
return {
|
|
273
|
+
row: { id: `key-${token}`, userId: key[0], scopes: key[1], expiresAt: null },
|
|
274
|
+
user: USERS[key[0]],
|
|
275
|
+
};
|
|
276
|
+
}));
|
|
277
|
+
verifyBearer = sinon_1.default.stub().callsFake((jwt) => __awaiter(void 0, void 0, void 0, function* () {
|
|
278
|
+
const t = JWTS[jwt];
|
|
279
|
+
if (!t)
|
|
280
|
+
return null;
|
|
281
|
+
return { payload: { sub: t[0], scopes: t[1] }, user: USERS[t[0]] };
|
|
282
|
+
}));
|
|
283
|
+
ownerOf = sinon_1.default.stub().callsFake((id) => __awaiter(void 0, void 0, void 0, function* () { return (id in OWNERS ? OWNERS[id] : null); }));
|
|
284
|
+
const dispatch = appiumUpgradeDispatch();
|
|
285
|
+
const bidi = new ws_1.WebSocketServer({ noServer: true });
|
|
286
|
+
const driverLogcat = new ws_1.WebSocketServer({ noServer: true });
|
|
287
|
+
for (const wss of [bidi, driverLogcat]) {
|
|
288
|
+
wss.on('connection', (ws, req) => {
|
|
289
|
+
accepted.push(String(req.url));
|
|
290
|
+
ws.on('error', () => undefined);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
appiumHandled = [
|
|
294
|
+
sinon_1.default.spy(bidi, 'handleUpgrade'),
|
|
295
|
+
sinon_1.default.spy(driverLogcat, 'handleUpgrade'),
|
|
296
|
+
];
|
|
297
|
+
// What Appium's main and a driver register.
|
|
298
|
+
const mapping = {
|
|
299
|
+
'/wd/hub/bidi': bidi,
|
|
300
|
+
'/wd/hub/bidi/:sessionId': bidi,
|
|
301
|
+
'/ws/session/s-alice/appium/device/logcat': driverLogcat,
|
|
302
|
+
};
|
|
303
|
+
const app = appiumExpress()();
|
|
304
|
+
if (mode === 'express')
|
|
305
|
+
app.use(dispatch.handleUpgrade(mapping));
|
|
306
|
+
app.use((_req, res) => res.status(404).json({ value: null }));
|
|
307
|
+
const httpServer = http_1.default.createServer(app);
|
|
308
|
+
if (mode === 'listener') {
|
|
309
|
+
// Appium's configureHttp, verbatim in effect.
|
|
310
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
311
|
+
if (!dispatch.tryHandleWebSocketUpgrade(req, socket, head, mapping))
|
|
312
|
+
socket.destroy();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const result = (0, registerCommandAuth_1.registerSessionUpgradeGuard)(httpServer, app, { basePath: 'wd/hub/' }, {
|
|
316
|
+
enabled: () => enabled,
|
|
317
|
+
authDisabled: () => authDisabled,
|
|
318
|
+
verifier: new commandCaller_1.CommandCallerVerifier({ verifyKeyPair, verifyBearer }),
|
|
319
|
+
ownerOf,
|
|
320
|
+
logger: {
|
|
321
|
+
info: () => undefined,
|
|
322
|
+
warn: (m) => warnings.push(m),
|
|
323
|
+
error: (m) => errors.push(m),
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
(0, chai_1.expect)(result.placed).to.equal(true);
|
|
327
|
+
server = httpServer;
|
|
328
|
+
port = yield listen(httpServer);
|
|
329
|
+
}));
|
|
330
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
331
|
+
const s = server;
|
|
332
|
+
server = undefined;
|
|
333
|
+
yield shut(s);
|
|
334
|
+
}));
|
|
335
|
+
const appiumSawAnything = () => appiumHandled.some((spy) => spy.called);
|
|
336
|
+
describe('who may attach', () => {
|
|
337
|
+
it("refuses a session upgrade without credentials with 404, before Appium's handler runs", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
338
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice')).to.deep.equal({
|
|
339
|
+
kind: 'http',
|
|
340
|
+
status: 404,
|
|
341
|
+
});
|
|
342
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
343
|
+
(0, chai_1.expect)(accepted).to.deep.equal([]);
|
|
344
|
+
(0, chai_1.expect)(verifyKeyPair.called || verifyBearer.called || ownerOf.called).to.equal(false);
|
|
345
|
+
(0, chai_1.expect)(warnings).to.have.length(1);
|
|
346
|
+
(0, chai_1.expect)(warnings[0]).to.include('s-alice');
|
|
347
|
+
(0, chai_1.expect)(warnings[0]).to.include('no credentials');
|
|
348
|
+
}));
|
|
349
|
+
it('answers the refusal with an HTTP 404 on the raw socket, then closes it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
350
|
+
const reply = yield rawUpgrade(port, '/wd/hub/bidi/s-alice');
|
|
351
|
+
(0, chai_1.expect)(reply).to.match(/^HTTP\/1\.1 404 Not Found\r\n/);
|
|
352
|
+
(0, chai_1.expect)(reply).not.to.include('101');
|
|
353
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
354
|
+
}));
|
|
355
|
+
it("lets the owner through to Appium's handler", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
356
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice', as('alice-token'))).to.deep.equal({
|
|
357
|
+
kind: 'open',
|
|
358
|
+
});
|
|
359
|
+
(0, chai_1.expect)(accepted).to.deep.equal(['/wd/hub/bidi/s-alice']);
|
|
360
|
+
(0, chai_1.expect)(warnings).to.deep.equal([]);
|
|
361
|
+
}));
|
|
362
|
+
it("lets the owner's Bearer token through", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
363
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi/s-alice', bearer('alice-jwt'))).kind).to.equal('open');
|
|
364
|
+
}));
|
|
365
|
+
it('lets an override admin attach to a session they do not own', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
366
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi/s-alice', as('carol-admin'))).kind).to.equal('open');
|
|
367
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi/s-anon', bearer('root-jwt'))).kind).to.equal('open');
|
|
368
|
+
}));
|
|
369
|
+
it("refuses another user, and an ADMIN's ordinary key, with 404", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
370
|
+
for (const who of ['bob-token', 'carol-ordinary']) {
|
|
371
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice', as(who)), who).to.deep.equal({
|
|
372
|
+
kind: 'http',
|
|
373
|
+
status: 404,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
377
|
+
(0, chai_1.expect)(warnings.join('\n')).not.to.include('bob-token');
|
|
378
|
+
}));
|
|
379
|
+
it('refuses everyone but override admins on a session nobody owns', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
380
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-anon', as('alice-token'))).to.deep.equal({
|
|
381
|
+
kind: 'http',
|
|
382
|
+
status: 404,
|
|
383
|
+
});
|
|
384
|
+
}));
|
|
385
|
+
it('refuses credentials that do not verify', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
386
|
+
const res = yield connect(port, '/wd/hub/bidi/s-alice', {
|
|
387
|
+
'x-xenon-access-key': 'ak-alice',
|
|
388
|
+
'x-xenon-token': 'guessed',
|
|
389
|
+
});
|
|
390
|
+
(0, chai_1.expect)(res).to.deep.equal({ kind: 'http', status: 404 });
|
|
391
|
+
(0, chai_1.expect)(ownerOf.called).to.equal(false);
|
|
392
|
+
}));
|
|
393
|
+
it("guards a driver's /ws/session/<id>/... endpoint the same way", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
394
|
+
const url = '/ws/session/s-alice/appium/device/logcat';
|
|
395
|
+
(0, chai_1.expect)(yield connect(port, url, as('bob-token'))).to.deep.equal({
|
|
396
|
+
kind: 'http',
|
|
397
|
+
status: 404,
|
|
398
|
+
});
|
|
399
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
400
|
+
(0, chai_1.expect)((yield connect(port, url, as('alice-token'))).kind).to.equal('open');
|
|
401
|
+
(0, chai_1.expect)(accepted).to.deep.equal([url]);
|
|
402
|
+
}));
|
|
403
|
+
it('answers a session the caller may not use exactly as one that does not exist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
404
|
+
const refused = yield rawUpgrade(port, '/wd/hub/bidi/s-alice');
|
|
405
|
+
const missing = yield rawUpgrade(port, '/wd/hub/bidi/no-such-session');
|
|
406
|
+
(0, chai_1.expect)(refused).to.equal(missing);
|
|
407
|
+
}));
|
|
408
|
+
});
|
|
409
|
+
describe('what it leaves alone', () => {
|
|
410
|
+
it('lets the umbrella /bidi socket, which belongs to no session, through untouched', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
411
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi')).kind).to.equal('open');
|
|
412
|
+
(0, chai_1.expect)(verifyKeyPair.called || ownerOf.called).to.equal(false);
|
|
413
|
+
(0, chai_1.expect)(warnings).to.deep.equal([]);
|
|
414
|
+
}));
|
|
415
|
+
it('does nothing at all when the setting is off (the default)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
416
|
+
enabled = false;
|
|
417
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi/s-alice')).kind).to.equal('open');
|
|
418
|
+
(0, chai_1.expect)(verifyKeyPair.called || verifyBearer.called || ownerOf.called).to.equal(false);
|
|
419
|
+
(0, chai_1.expect)(warnings.concat(errors)).to.deep.equal([]);
|
|
420
|
+
}));
|
|
421
|
+
it('does nothing when auth is disabled, even with the setting on', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
422
|
+
authDisabled = true;
|
|
423
|
+
(0, chai_1.expect)((yield connect(port, '/wd/hub/bidi/s-alice')).kind).to.equal('open');
|
|
424
|
+
(0, chai_1.expect)(ownerOf.called).to.equal(false);
|
|
425
|
+
}));
|
|
426
|
+
it('leaves ordinary HTTP requests to the same paths alone', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
427
|
+
const res = yield new Promise((resolve) => http_1.default.get(`http://127.0.0.1:${port}/wd/hub/bidi/s-alice`, (r) => {
|
|
428
|
+
var _a;
|
|
429
|
+
r.resume();
|
|
430
|
+
resolve((_a = r.statusCode) !== null && _a !== void 0 ? _a : 0);
|
|
431
|
+
}));
|
|
432
|
+
(0, chai_1.expect)(res).to.equal(404);
|
|
433
|
+
(0, chai_1.expect)(warnings).to.deep.equal([]);
|
|
434
|
+
(0, chai_1.expect)(ownerOf.called).to.equal(false);
|
|
435
|
+
}));
|
|
436
|
+
});
|
|
437
|
+
describe('failing closed', () => {
|
|
438
|
+
it('answers 503 when the owner lookup fails, and never hands the socket on', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
439
|
+
ownerOf.rejects(new Error('database is locked\nSELECT secret'));
|
|
440
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice', as('alice-token'))).to.deep.equal({
|
|
441
|
+
kind: 'http',
|
|
442
|
+
status: 503,
|
|
443
|
+
});
|
|
444
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
445
|
+
(0, chai_1.expect)(errors).to.have.length(1);
|
|
446
|
+
(0, chai_1.expect)(errors[0]).to.include('database is locked');
|
|
447
|
+
(0, chai_1.expect)(errors[0]).not.to.include('SELECT');
|
|
448
|
+
}));
|
|
449
|
+
it('answers 503 when the credential check fails', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
450
|
+
verifyKeyPair.rejects(new Error('database is locked'));
|
|
451
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice', as('alice-token'))).to.deep.equal({
|
|
452
|
+
kind: 'http',
|
|
453
|
+
status: 503,
|
|
454
|
+
});
|
|
455
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
456
|
+
}));
|
|
457
|
+
it("drops a client that leaves during the check without reaching Appium's handler or crashing", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
458
|
+
let release = () => undefined;
|
|
459
|
+
ownerOf.callsFake(() => new Promise((resolve) => (release = resolve)));
|
|
460
|
+
const socket = net_1.default.connect(port, '127.0.0.1');
|
|
461
|
+
yield new Promise((resolve) => socket.on('connect', () => resolve()));
|
|
462
|
+
socket.on('error', () => undefined);
|
|
463
|
+
socket.write('GET /wd/hub/bidi/s-alice HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\n' +
|
|
464
|
+
'Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' +
|
|
465
|
+
'Sec-WebSocket-Version: 13\r\nx-xenon-access-key: ak-alice\r\n' +
|
|
466
|
+
'x-xenon-token: alice-token\r\n\r\n');
|
|
467
|
+
yield tick(50);
|
|
468
|
+
(0, chai_1.expect)(ownerOf.called, 'the check is under way').to.equal(true);
|
|
469
|
+
// A reset, not a clean close: the server side sees ECONNRESET, which
|
|
470
|
+
// would crash the process if nothing were listening for it.
|
|
471
|
+
socket.resetAndDestroy();
|
|
472
|
+
yield tick(50);
|
|
473
|
+
release('alice');
|
|
474
|
+
yield tick(50);
|
|
475
|
+
(0, chai_1.expect)(appiumSawAnything()).to.equal(false);
|
|
476
|
+
(0, chai_1.expect)(accepted).to.deep.equal([]);
|
|
477
|
+
}));
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
describe("next to Xenon's own upgrade listeners (Appium's Express middleware unreachable)", () => {
|
|
482
|
+
// What the lab ran on Node < 22.21 before the upgrade router: Appium's
|
|
483
|
+
// upgrade middleware plus Xenon's h264 and logcat listeners. With any
|
|
484
|
+
// `upgrade` listener present, Node never hands an upgrade to Express, so
|
|
485
|
+
// Appium's handler is not reached at all; the guard's job here is only to
|
|
486
|
+
// refuse. The router's own wiring is in upgrade-router.spec.ts and
|
|
487
|
+
// ws-upgrade-routing-appium-server.spec.ts.
|
|
488
|
+
let server;
|
|
489
|
+
let port;
|
|
490
|
+
let seenByListeners;
|
|
491
|
+
let xenonAccepted;
|
|
492
|
+
let ownerOf;
|
|
493
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
494
|
+
seenByListeners = [];
|
|
495
|
+
xenonAccepted = [];
|
|
496
|
+
ownerOf = sinon_1.default.stub().callsFake((id) => __awaiter(void 0, void 0, void 0, function* () { return (id in OWNERS ? OWNERS[id] : null); }));
|
|
497
|
+
const dispatch = appiumUpgradeDispatch();
|
|
498
|
+
const bidi = new ws_1.WebSocketServer({ noServer: true });
|
|
499
|
+
const app = appiumExpress()();
|
|
500
|
+
app.use(dispatch.handleUpgrade({ '/wd/hub/bidi/:sessionId': bidi }));
|
|
501
|
+
const httpServer = http_1.default.createServer(app);
|
|
502
|
+
(0, registerCommandAuth_1.registerSessionUpgradeGuard)(httpServer, app, { basePath: '/wd/hub' }, {
|
|
503
|
+
enabled: () => true,
|
|
504
|
+
authDisabled: () => false,
|
|
505
|
+
verifier: new commandCaller_1.CommandCallerVerifier({
|
|
506
|
+
verifyKeyPair: sinon_1.default.stub().callsFake((_ak, token) => __awaiter(void 0, void 0, void 0, function* () {
|
|
507
|
+
return KEYS[token]
|
|
508
|
+
? {
|
|
509
|
+
row: {
|
|
510
|
+
id: 'k',
|
|
511
|
+
userId: KEYS[token][0],
|
|
512
|
+
scopes: KEYS[token][1],
|
|
513
|
+
expiresAt: null,
|
|
514
|
+
},
|
|
515
|
+
user: USERS[KEYS[token][0]],
|
|
516
|
+
}
|
|
517
|
+
: null;
|
|
518
|
+
})),
|
|
519
|
+
verifyBearer: sinon_1.default.stub().resolves(null),
|
|
520
|
+
}),
|
|
521
|
+
ownerOf,
|
|
522
|
+
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
523
|
+
});
|
|
524
|
+
// Xenon's attachLogcatWs / attachH264Ws shape: claim their own path, ignore the rest.
|
|
525
|
+
const xenonWss = new ws_1.WebSocketServer({ noServer: true });
|
|
526
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
527
|
+
seenByListeners.push({ url: String(req.url), alive: !socket.destroyed });
|
|
528
|
+
if (!/^\/xenon\/api\/control\/[^/]+\/(logcat|stream\/h264)\?/.test(String(req.url)))
|
|
529
|
+
return;
|
|
530
|
+
xenonWss.handleUpgrade(req, socket, head, (ws) => {
|
|
531
|
+
xenonAccepted.push(String(req.url));
|
|
532
|
+
ws.on('error', () => undefined);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
server = httpServer;
|
|
536
|
+
port = yield listen(httpServer);
|
|
537
|
+
}));
|
|
538
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
539
|
+
const s = server;
|
|
540
|
+
server = undefined;
|
|
541
|
+
yield shut(s);
|
|
542
|
+
}));
|
|
543
|
+
it("leaves Xenon's ticketed WebSockets alone, with no credentials and no lookups", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
544
|
+
for (const url of [
|
|
545
|
+
'/xenon/api/control/u1/logcat?ticket=t',
|
|
546
|
+
'/xenon/api/control/u1/stream/h264?ticket=t',
|
|
547
|
+
]) {
|
|
548
|
+
(0, chai_1.expect)((yield connect(port, url)).kind, url).to.equal('open');
|
|
549
|
+
}
|
|
550
|
+
(0, chai_1.expect)(xenonAccepted).to.have.length(2);
|
|
551
|
+
(0, chai_1.expect)(ownerOf.called).to.equal(false);
|
|
552
|
+
}));
|
|
553
|
+
it('refuses a session upgrade with 404 before any listener sees it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
554
|
+
(0, chai_1.expect)(yield connect(port, '/wd/hub/bidi/s-alice', as('bob-token'))).to.deep.equal({
|
|
555
|
+
kind: 'http',
|
|
556
|
+
status: 404,
|
|
557
|
+
});
|
|
558
|
+
(0, chai_1.expect)(seenByListeners).to.deep.equal([]);
|
|
559
|
+
}));
|
|
560
|
+
it("hands the owner's upgrade on to the listeners, exactly as without the guard", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
561
|
+
const ws = new ws_1.default(`ws://127.0.0.1:${port}/wd/hub/bidi/s-alice`, {
|
|
562
|
+
headers: as('alice-token'),
|
|
563
|
+
});
|
|
564
|
+
ws.on('error', () => undefined);
|
|
565
|
+
for (let i = 0; i < 50 && seenByListeners.length === 0; i++)
|
|
566
|
+
yield tick(10);
|
|
567
|
+
ws.terminate();
|
|
568
|
+
(0, chai_1.expect)(seenByListeners).to.deep.equal([{ url: '/wd/hub/bidi/s-alice', alive: true }]);
|
|
569
|
+
}));
|
|
570
|
+
});
|
|
571
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
require("reflect-metadata");
|
|
16
|
+
const chai_1 = require("chai");
|
|
17
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
18
|
+
const ShutdownCoordinator_1 = require("../../src/services/ShutdownCoordinator");
|
|
19
|
+
const SessionLifecycleService_1 = require("../../src/services/SessionLifecycleService");
|
|
20
|
+
const SessionManager_1 = require("../../src/sessions/SessionManager");
|
|
21
|
+
const SessionType_1 = __importDefault(require("../../src/enums/SessionType"));
|
|
22
|
+
/**
|
|
23
|
+
* A hub's graceful shutdown drains the sessions it knows. Its own (local)
|
|
24
|
+
* sessions die with the process, so they are finalized: video archived, the
|
|
25
|
+
* phone released, the row closed. A node's or a cloud provider's session keeps
|
|
26
|
+
* running there, and the restarted hub routes it again from its database. The
|
|
27
|
+
* drain used to finalize those too: the hub released the phone and closed the
|
|
28
|
+
* row, recovery then found nothing to route, and the node kept a session no
|
|
29
|
+
* client could reach, holding its phone until the idle timeout.
|
|
30
|
+
*/
|
|
31
|
+
describe('ShutdownCoordinator.drain', () => {
|
|
32
|
+
let stop;
|
|
33
|
+
const session = (id, type) => ({ getId: () => id, getType: () => type });
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
stop = sinon_1.default.stub(SessionLifecycleService_1.SessionLifecycleService.prototype, 'stopSessionForShutdown').resolves();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => sinon_1.default.restore());
|
|
38
|
+
it("finalizes this server's own sessions and leaves the ones other servers run", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
39
|
+
sinon_1.default
|
|
40
|
+
.stub(SessionManager_1.SESSION_MANAGER, 'getAllSessions')
|
|
41
|
+
.returns([
|
|
42
|
+
session('local-1', SessionType_1.default.LOCAL),
|
|
43
|
+
session('remote-1', SessionType_1.default.REMOTE),
|
|
44
|
+
session('cloud-1', SessionType_1.default.CLOUD),
|
|
45
|
+
]);
|
|
46
|
+
const result = yield new ShutdownCoordinator_1.ShutdownCoordinator().drain(1000, 500);
|
|
47
|
+
(0, chai_1.expect)(stop.args.map((args) => args[0])).to.deep.equal(['local-1']);
|
|
48
|
+
(0, chai_1.expect)(result).to.deep.equal({ attempted: 1, completed: 1 });
|
|
49
|
+
}));
|
|
50
|
+
it('drains nothing on a hub whose only sessions run on nodes', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
51
|
+
sinon_1.default
|
|
52
|
+
.stub(SessionManager_1.SESSION_MANAGER, 'getAllSessions')
|
|
53
|
+
.returns([session('remote-1', SessionType_1.default.REMOTE)]);
|
|
54
|
+
const result = yield new ShutdownCoordinator_1.ShutdownCoordinator().drain(1000, 500);
|
|
55
|
+
(0, chai_1.expect)(stop.called).to.equal(false);
|
|
56
|
+
(0, chai_1.expect)(result).to.deep.equal({ attempted: 0, completed: 0 });
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
@@ -41,7 +41,9 @@ describe('SocketServer — authenticate() bearer path', () => {
|
|
|
41
41
|
let keySvc;
|
|
42
42
|
let server;
|
|
43
43
|
let authenticate;
|
|
44
|
+
let restoreContainer;
|
|
44
45
|
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
46
|
+
restoreContainer = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, ApiKeyService_1.ApiKeyService);
|
|
45
47
|
dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-socket-bearer-'));
|
|
46
48
|
keySvc = new JwtKeyService_1.JwtKeyService();
|
|
47
49
|
yield keySvc.init(dir);
|
|
@@ -55,7 +57,7 @@ describe('SocketServer — authenticate() bearer path', () => {
|
|
|
55
57
|
}));
|
|
56
58
|
afterEach(() => {
|
|
57
59
|
sinon_1.default.restore();
|
|
58
|
-
|
|
60
|
+
restoreContainer();
|
|
59
61
|
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
60
62
|
});
|
|
61
63
|
it('1. valid xenon-rest bearer JWT for an ACTIVE user → principal dashboard', () => __awaiter(void 0, void 0, void 0, function* () {
|