@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.
Files changed (289) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +3 -4
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  5. package/lib/public/assets/BugReportButton-C7OmS61B.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-fPxujqqE.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-5bCutWrs.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-owsBiDT1.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-DFhqzPSt.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-Bw-a-E-e.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-WtByf5bC.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-FpWWFNgT.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-BR7nOhpT.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-B_Va3V05.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-DxRFWvkN.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-CQf-9_V6.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-SoE1Kd7N.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-CgGXTlgn.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-uQ6WIwiC.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-C9RyfzXS.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-DFHi_8yG.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-Bii1t4qc.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-Bnv8Z9hi.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-_Y78wXiP.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-D4MqSOc2.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-uGHCXe-L.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-Dy1OqpzB.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-BUsldEli.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-D3Qq0tKp.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-B4IOD1DE.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-BfYfzvSg.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-DZwYE5Va.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CRG4Utt5.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-CCDYo_TN.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-DuLCmVFb.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-CtKNPqzb.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-3uxl3o7K.js} +43 -43
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-Zy1Op5C1.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-CzTWvUXZ.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CI-nYDFM.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-YZZGFyTX.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-L8tzWgDo.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-DBbX-fu8.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-AJwkOIsE.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-Di_66F2m.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-DiSumY-1.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-CwE4gXl5.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CfR4Sm9V.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-DOS8P9CQ.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-BYpHwQIH.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BRVJU_qV.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Bi3JvAFZ.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-Cqx7pEou.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C0DsncKj.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CAhpdilF.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CrCVeW2m.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-F_hwYqEt.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-C-s5pypL.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-DY5uzv6A.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-Dli7gwyg.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-ui4IhHNC.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV4hSmXJ.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-DkGy_J-L.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  69. package/lib/public/assets/{users-Drd7ifJx.js → users-BU0nXIVe.js} +1 -1
  70. package/lib/public/assets/{users-CEJ3fC78.js → users-Nb_rzRmF.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-CUNzD2kH.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-Ca3UFXZe.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-B2bH2vKX.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +11 -104
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  84. package/lib/src/app/ws/h264StreamWs.js +43 -12
  85. package/lib/src/app/ws/logcatWs.js +48 -14
  86. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  87. package/lib/src/app/ws/upgradeRouter.js +215 -0
  88. package/lib/src/dashboard/event-manager.js +7 -6
  89. package/lib/src/data-service/device-service.js +152 -49
  90. package/lib/src/data-service/device-store.js +92 -10
  91. package/lib/src/data-service/deviceClaims.js +116 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  93. package/lib/src/data-service/prisma-store.js +102 -31
  94. package/lib/src/device-managers/AndroidDeviceManager.js +40 -42
  95. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  96. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  97. package/lib/src/device-managers/NodeDevices.js +7 -2
  98. package/lib/src/device-managers/android/AndroidH264StreamService.js +13 -5
  99. package/lib/src/device-managers/android/AndroidStreamService.js +9 -6
  100. package/lib/src/device-managers/android/previewHold.js +2 -2
  101. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  102. package/lib/src/device-managers/ios/IOSStreamService.js +162 -40
  103. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  104. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  105. package/lib/src/device-utils.js +78 -24
  106. package/lib/src/gateway/createHandoff.js +73 -0
  107. package/lib/src/gateway/defaultGateway.js +80 -0
  108. package/lib/src/gateway/forwardToNode.js +147 -0
  109. package/lib/src/gateway/hubSessionToken.js +356 -0
  110. package/lib/src/gateway/internalCall.js +90 -0
  111. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  112. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  113. package/lib/src/gateway/sessionCreate.js +136 -0
  114. package/lib/src/gateway/sessionGateway.js +201 -0
  115. package/lib/src/gateway/sessionLocator.js +100 -0
  116. package/lib/src/generated/client/edge.js +7 -4
  117. package/lib/src/generated/client/index-browser.js +4 -1
  118. package/lib/src/generated/client/index.d.ts +112 -1
  119. package/lib/src/generated/client/index.js +7 -4
  120. package/lib/src/generated/client/package.json +1 -1
  121. package/lib/src/generated/client/schema.prisma +9 -0
  122. package/lib/src/generated/client/wasm.js +4 -1
  123. package/lib/src/helpers/index.js +5 -1
  124. package/lib/src/logger.js +5 -0
  125. package/lib/src/middleware/authMiddleware.js +80 -0
  126. package/lib/src/middleware/commandAuth.js +31 -17
  127. package/lib/src/middleware/csrfMiddleware.js +3 -2
  128. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  129. package/lib/src/middleware/sessionListingFilter.js +153 -0
  130. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  131. package/lib/src/plugin.js +27 -1
  132. package/lib/src/prisma.js +2 -1
  133. package/lib/src/services/DeviceReconciler.js +5 -2
  134. package/lib/src/services/OrphanSweeper.js +12 -19
  135. package/lib/src/services/PortAllocator.js +75 -16
  136. package/lib/src/services/ServerManager.js +63 -30
  137. package/lib/src/services/SessionLifecycleService.js +335 -76
  138. package/lib/src/services/ShutdownCoordinator.js +7 -1
  139. package/lib/src/services/SocketServer.js +11 -2
  140. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  141. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  142. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  143. package/lib/src/services/lease/LeaseOrphanSweeper.js +34 -6
  144. package/lib/src/services/lease/LeaseService.js +19 -5
  145. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  146. package/lib/src/services/session/sessionCredentials.js +7 -59
  147. package/lib/src/sessions/CloudSession.js +12 -0
  148. package/lib/src/sessions/LocalSession.js +52 -26
  149. package/lib/src/sessions/RemoteSession.js +116 -9
  150. package/lib/src/sessions/SessionManager.js +49 -6
  151. package/lib/src/sessions/appiumUmbrella.js +32 -0
  152. package/lib/test/helpers/appium-umbrella.js +84 -0
  153. package/lib/test/helpers/fake-device-table.js +97 -0
  154. package/lib/test/helpers/loki-stores.js +41 -0
  155. package/lib/test/helpers/loopbackRequest.js +77 -0
  156. package/lib/test/helpers/own-node-id.js +29 -0
  157. package/lib/test/helpers/scratch-database.js +100 -0
  158. package/lib/test/helpers/scratch-port-leases.js +119 -0
  159. package/lib/test/helpers/test-container.js +35 -27
  160. package/lib/test/integration/androidDevices.spec.js +5 -4
  161. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  162. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  163. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  164. package/lib/test/setup/scratch-default-database.js +32 -0
  165. package/lib/test/unit/AndroidDeviceManager.spec.js +56 -1
  166. package/lib/test/unit/DeviceModel.spec.js +9 -9
  167. package/lib/test/unit/HealingCascade.test.js +5 -1
  168. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  169. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  170. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  171. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  172. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  173. package/lib/test/unit/android-h264-service.spec.js +24 -0
  174. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  175. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  176. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  177. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  178. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  179. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  180. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  181. package/lib/test/unit/authMiddleware.test.js +3 -0
  182. package/lib/test/unit/bug-report/route.spec.js +9 -9
  183. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  184. package/lib/test/unit/clipboard-route.spec.js +22 -8
  185. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  186. package/lib/test/unit/command-auth.spec.js +31 -31
  187. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  188. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  189. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  190. package/lib/test/unit/create-error-message.spec.js +43 -0
  191. package/lib/test/unit/create-handoff.spec.js +86 -0
  192. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  193. package/lib/test/unit/device-access-guard.spec.js +66 -28
  194. package/lib/test/unit/device-service.spec.js +4 -0
  195. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  196. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  197. package/lib/test/unit/device-team-guard.spec.js +20 -20
  198. package/lib/test/unit/device-utils.spec.js +8 -0
  199. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  200. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  201. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  202. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  203. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  204. package/lib/test/unit/helpers.spec.js +27 -0
  205. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  206. package/lib/test/unit/hub-create-once.spec.js +190 -0
  207. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  208. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  209. package/lib/test/unit/hub-node-control-forward.spec.js +608 -0
  210. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  211. package/lib/test/unit/hub-node-race.spec.js +426 -0
  212. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  213. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  214. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  215. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  216. package/lib/test/unit/hub-session-token.spec.js +265 -0
  217. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  218. package/lib/test/unit/install-repository-app-team.spec.js +6 -2
  219. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  220. package/lib/test/unit/internal-call.spec.js +181 -0
  221. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  222. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  223. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  224. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  225. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  226. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  227. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  228. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  229. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +107 -0
  230. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  231. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  232. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  233. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  234. package/lib/test/unit/logcat-ws.spec.js +12 -1
  235. package/lib/test/unit/loopback-request.spec.js +81 -0
  236. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  237. package/lib/test/unit/node-create-caps.spec.js +54 -0
  238. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  239. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  240. package/lib/test/unit/node-phone-control.spec.js +131 -0
  241. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  242. package/lib/test/unit/omni-vision.spec.js +7 -3
  243. package/lib/test/unit/plugin.spec.js +45 -4
  244. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  245. package/lib/test/unit/profile-router.test.js +11 -11
  246. package/lib/test/unit/queue-requester.spec.js +5 -0
  247. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  248. package/lib/test/unit/queue-visibility.spec.js +1 -1
  249. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  250. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  251. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  252. package/lib/test/unit/recording-store.spec.js +9 -3
  253. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  254. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  255. package/lib/test/unit/remote-session-create.spec.js +130 -0
  256. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  257. package/lib/test/unit/reset-link.test.js +14 -14
  258. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  259. package/lib/test/unit/session-assets.spec.js +9 -9
  260. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  261. package/lib/test/unit/session-create-layer.spec.js +255 -0
  262. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  263. package/lib/test/unit/session-credentials.spec.js +0 -47
  264. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  265. package/lib/test/unit/session-locator.spec.js +250 -0
  266. package/lib/test/unit/session-node-calls.spec.js +174 -0
  267. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  268. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  269. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  270. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  271. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  272. package/lib/test/unit/stream-leave-route.spec.js +19 -7
  273. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  274. package/lib/test/unit/stream-stop-ownership.spec.js +17 -5
  275. package/lib/test/unit/stream-stop-recording.spec.js +17 -5
  276. package/lib/test/unit/stream-ticket-identity.spec.js +26 -6
  277. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  278. package/lib/test/unit/upgrade-router.spec.js +446 -0
  279. package/lib/test/unit/users-router.test.js +11 -8
  280. package/lib/test/unit/verify-credential.spec.js +4 -1
  281. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  282. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  283. package/lib/tsconfig.tsbuildinfo +1 -1
  284. package/package.json +3 -4
  285. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  286. package/prisma/schema.prisma +9 -0
  287. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  288. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  289. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -0,0 +1,608 @@
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
+ require("reflect-metadata");
49
+ const chai_1 = require("chai");
50
+ const fs_1 = __importDefault(require("fs"));
51
+ const os_1 = __importDefault(require("os"));
52
+ const path_1 = __importDefault(require("path"));
53
+ const sinon_1 = __importDefault(require("sinon"));
54
+ const express_1 = __importDefault(require("express"));
55
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
56
+ const jose = __importStar(require("jose"));
57
+ const typedi_1 = require("typedi");
58
+ const control_1 = __importDefault(require("../../src/app/routers/control"));
59
+ const auth_1 = require("../../src/app/routers/auth");
60
+ const authMiddleware_1 = require("../../src/middleware/authMiddleware");
61
+ const csrfMiddleware_1 = require("../../src/middleware/csrfMiddleware");
62
+ const hubSessionToken_1 = require("../../src/gateway/hubSessionToken");
63
+ const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
64
+ const PluginContext_1 = require("../../src/PluginContext");
65
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
66
+ const device_managers_1 = require("../../src/device-managers");
67
+ const device_store_1 = require("../../src/data-service/device-store");
68
+ const prisma_store_1 = require("../../src/data-service/prisma-store");
69
+ const SessionOwnerResolver_1 = require("../../src/services/device-access/SessionOwnerResolver");
70
+ const LiveSessionOwners_1 = require("../../src/services/device-access/LiveSessionOwners");
71
+ const config_1 = require("../../src/config");
72
+ const container_registration_1 = require("../helpers/container-registration");
73
+ const scratch_database_1 = require("../helpers/scratch-database");
74
+ const loopbackServer_1 = require("../helpers/loopbackServer");
75
+ const InspectorService_1 = require("../../src/services/InspectorService");
76
+ const http_1 = __importDefault(require("http"));
77
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
78
+ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
79
+ /**
80
+ * A hub forwards five of its /control actions on a node's phone (tap, swipe,
81
+ * text, keyevent, touchAndHold) to the phone's node. It sent them with no
82
+ * credential at all, so a node with auth enabled refused every one (its CSRF
83
+ * check, then its login), and device control of a node's phone only worked
84
+ * with the node's auth off.
85
+ *
86
+ * The hub now signs each forwarded call for the user it acts for: audience
87
+ * xenon-node-control, one minute, naming the user, whether they are an admin,
88
+ * the phone and its node. The node accepts it for /control on that phone
89
+ * only, then runs its own ownership guard with that user, whose session it
90
+ * knows (LiveSessionOwners).
91
+ */
92
+ // Named like the real manager: /control picks one by constructor.name.
93
+ // Hub and node share this process, so each call records the server it ran
94
+ // on: an action the hub ran on its own would show up as `hub-1:...`.
95
+ class AndroidDeviceManager {
96
+ constructor() {
97
+ this.taps = [];
98
+ this.calls = [];
99
+ this.failLogs = false;
100
+ this.tap = (udid) => __awaiter(this, void 0, void 0, function* () {
101
+ this.taps.push(udid);
102
+ });
103
+ this.getScreenshot = () => __awaiter(this, void 0, void 0, function* () {
104
+ this.at('getScreenshot');
105
+ return 'A'.repeat(200);
106
+ });
107
+ this.getClipboard = () => __awaiter(this, void 0, void 0, function* () {
108
+ this.at('getClipboard');
109
+ return 'copied';
110
+ });
111
+ this.setClipboard = () => __awaiter(this, void 0, void 0, function* () { return this.at('setClipboard'); });
112
+ this.lock = () => __awaiter(this, void 0, void 0, function* () { return this.at('lock'); });
113
+ this.unlock = () => __awaiter(this, void 0, void 0, function* () { return this.at('unlock'); });
114
+ this.getDisplayState = () => __awaiter(this, void 0, void 0, function* () {
115
+ this.at('getDisplayState');
116
+ return 'on';
117
+ });
118
+ this.uninstallApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('uninstallApp'); });
119
+ this.listApps = () => __awaiter(this, void 0, void 0, function* () {
120
+ this.at('listApps');
121
+ return [{ bundleId: 'com.example' }];
122
+ });
123
+ this.getLogs = () => __awaiter(this, void 0, void 0, function* () {
124
+ this.at('getLogs');
125
+ if (this.failLogs)
126
+ throw new Error('logcat failed on the node');
127
+ return 'a log line';
128
+ });
129
+ this.executeShell = () => __awaiter(this, void 0, void 0, function* () {
130
+ this.at('executeShell');
131
+ return 'uid=2000(shell)';
132
+ });
133
+ this.installApp = () => __awaiter(this, void 0, void 0, function* () { return this.at('installApp'); });
134
+ }
135
+ at(name) {
136
+ this.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:${name}`);
137
+ }
138
+ }
139
+ describe('the hub’s device control on a node’s phone', () => {
140
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
141
+ const loopback = (0, loopbackServer_1.loopbackServers)();
142
+ let dirs;
143
+ let hubKeys;
144
+ let manager;
145
+ let context;
146
+ let saved;
147
+ let restore;
148
+ let hubUrl;
149
+ let hubApp;
150
+ let nodeOrigin;
151
+ let nodePort;
152
+ let nodeArgs;
153
+ let nodeRequests;
154
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
155
+ dirs = [fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-control-keys-'))];
156
+ hubKeys = new JwtKeyService_1.JwtKeyService();
157
+ yield hubKeys.init(dirs[0]);
158
+ restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners, InspectorService_1.InspectorService);
159
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, hubKeys);
160
+ typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, new hubSessionToken_1.HubSessionTokenIssuer());
161
+ typedi_1.Container.set(SessionOwnerResolver_1.SessionOwnerResolver, new SessionOwnerResolver_1.SessionOwnerResolver());
162
+ typedi_1.Container.set(LiveSessionOwners_1.LiveSessionOwners, new LiveSessionOwners_1.LiveSessionOwners());
163
+ manager = new AndroidDeviceManager();
164
+ typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
165
+ typedi_1.Container.set(InspectorService_1.InspectorService, {
166
+ getSnapshot: () => __awaiter(void 0, void 0, void 0, function* () {
167
+ manager.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:getSnapshot`);
168
+ return { source: '<hierarchy/>' };
169
+ }),
170
+ });
171
+ nodeRequests = [];
172
+ context = typedi_1.Container.get(PluginContext_1.PluginContext);
173
+ saved = {
174
+ context: Object.assign({}, context),
175
+ store: device_store_1.DeviceStoreFactory._deviceStore,
176
+ authDisabled: config_1.config.authDisabled,
177
+ };
178
+ device_store_1.DeviceStoreFactory._deviceStore = new prisma_store_1.PrismaDeviceStore();
179
+ config_1.config.authDisabled = false;
180
+ // The hub: its public keys, and its own /control with a caller named by a header.
181
+ const hub = (0, express_1.default)();
182
+ hub.get('/xenon/api/auth/jwks.json', (_req, res) => res.json(hubKeys.jwks()));
183
+ hubUrl = `http://127.0.0.1:${(yield loopback.serve(hub)).address().port}`;
184
+ hubApp = (0, express_1.default)();
185
+ // One process plays both servers, and PluginContext is a singleton: each
186
+ // app takes its own identity per request, so the hub's forward decision
187
+ // (the phone's nodeId against this server's) sees the hub, not the node.
188
+ hubApp.use((_req, _res, next) => {
189
+ context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 1, 'hub-1', '');
190
+ next();
191
+ });
192
+ hubApp.use(express_1.default.json());
193
+ hubApp.use((req, _res, next) => {
194
+ var _a;
195
+ const user = String((_a = req.headers['x-test-user']) !== null && _a !== void 0 ? _a : '');
196
+ const admin = user.startsWith('admin-');
197
+ req.auth = {
198
+ kind: 'user-session',
199
+ userId: user,
200
+ role: admin ? 'ADMIN' : 'MEMBER',
201
+ scopes: admin ? 'admin,devices,sessions,read' : 'devices,sessions,read',
202
+ rateLimit: 300,
203
+ teamIds: undefined,
204
+ };
205
+ next();
206
+ });
207
+ const hubApi = express_1.default.Router();
208
+ control_1.default.register(hubApi);
209
+ hubApp.use('/xenon/api', hubApi);
210
+ // The node: its CSRF check, its login and its /control, as ServerManager mounts them.
211
+ const node = (0, express_1.default)();
212
+ node.use((req, _res, next) => {
213
+ nodeRequests.push({
214
+ method: req.method,
215
+ path: req.path,
216
+ url: req.originalUrl,
217
+ headers: Object.assign({}, req.headers),
218
+ });
219
+ next();
220
+ });
221
+ node.use((_req, _res, next) => {
222
+ context.setContext(nodeArgs, nodePort, 'node-1', '');
223
+ next();
224
+ });
225
+ node.use(express_1.default.json());
226
+ const nodeApi = express_1.default.Router();
227
+ nodeApi.use(csrfMiddleware_1.csrfMiddleware);
228
+ nodeApi.use(authMiddleware_1.authMiddleware);
229
+ nodeApi.get('/whoami', (req, res) => res.json(req.auth));
230
+ control_1.default.register(nodeApi);
231
+ node.use('/xenon/api', nodeApi);
232
+ nodePort = (yield loopback.serve(node)).address().port;
233
+ nodeOrigin = `http://127.0.0.1:${nodePort}`;
234
+ nodeArgs = Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { hub: hubUrl, bindHostOrIp: '127.0.0.1' });
235
+ context.setContext(nodeArgs, nodePort, 'node-1', '');
236
+ yield scratch.db.device.deleteMany({});
237
+ // alice's session runs on the node's phone.
238
+ for (const udid of ['phone-1', 'phone-2']) {
239
+ yield scratch.db.device.create({
240
+ data: {
241
+ udid,
242
+ host: nodeOrigin,
243
+ nodeId: 'node-1',
244
+ platform: 'android',
245
+ name: udid,
246
+ busy: udid === 'phone-1',
247
+ session_id: udid === 'phone-1' ? 's-1' : null,
248
+ },
249
+ });
250
+ }
251
+ typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).record('s-1', 'alice');
252
+ }));
253
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
254
+ Object.assign(context, saved.context);
255
+ device_store_1.DeviceStoreFactory._deviceStore = saved.store;
256
+ config_1.config.authDisabled = saved.authDisabled;
257
+ sinon_1.default.restore();
258
+ restore();
259
+ yield loopback.closeAll();
260
+ for (const dir of dirs)
261
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
262
+ }));
263
+ const tapThroughHub = (user) => (0, loopbackRequest_1.default)(hubApp)
264
+ .post('/xenon/api/control/phone-1/tap')
265
+ .set('x-test-user', user)
266
+ .send({ x: 10, y: 20 });
267
+ const controlToken = (grant = {}) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(Object.assign({ userId: 'alice', isAdmin: false, udid: 'phone-1', host: nodeOrigin }, grant));
268
+ const toNode = (method, url, token) => {
269
+ const req = (0, loopbackRequest_1.default)(nodeOrigin)[method](url);
270
+ if (token)
271
+ req.set(hubSessionToken_1.HUB_TOKEN_HEADER, token);
272
+ return method === 'post' ? req.send({ x: 1, y: 2 }) : req;
273
+ };
274
+ it('the owner’s tap through the hub reaches the node’s phone', () => __awaiter(void 0, void 0, void 0, function* () {
275
+ const res = yield tapThroughHub('alice');
276
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
277
+ (0, chai_1.expect)(manager.taps).to.deep.equal(['phone-1']);
278
+ }));
279
+ it('the hub signs the forwarded call for its user, that phone and its node', () => __awaiter(void 0, void 0, void 0, function* () {
280
+ const seen = [];
281
+ sinon_1.default.stub(typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer), 'controlTokenFor').callsFake((grant) => __awaiter(void 0, void 0, void 0, function* () {
282
+ seen.push(JSON.stringify(grant));
283
+ return hubKeys.sign({ sub: grant.userId, adm: grant.isAdmin, udid: grant.udid, host: grant.host }, { audience: hubSessionToken_1.HUB_CONTROL_AUDIENCE, ttlSeconds: 60 });
284
+ }));
285
+ yield tapThroughHub('alice').expect(200);
286
+ (0, chai_1.expect)(seen.map((g) => JSON.parse(g))).to.deep.equal([
287
+ { userId: 'alice', isAdmin: false, udid: 'phone-1', host: nodeOrigin },
288
+ ]);
289
+ }));
290
+ it('is a one-minute token /auth/token will never mint', () => __awaiter(void 0, void 0, void 0, function* () {
291
+ const claims = jose.decodeJwt(yield controlToken());
292
+ (0, chai_1.expect)(claims.aud).to.equal(hubSessionToken_1.HUB_CONTROL_AUDIENCE);
293
+ (0, chai_1.expect)(claims.exp - claims.iat).to.equal(60);
294
+ let refused;
295
+ yield (0, auth_1.issueToken)({ userId: 'alice', role: 'ADMIN', scopes: 'admin' }, { audience: hubSessionToken_1.HUB_CONTROL_AUDIENCE }).catch((error) => (refused = error));
296
+ (0, chai_1.expect)(refused === null || refused === void 0 ? void 0 : refused.message).to.include('unsupported audience');
297
+ }));
298
+ describe('which actions reach the node', () => {
299
+ const asAlice = (method, action, body) => {
300
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-1/${action}`);
301
+ req.set('x-test-user', 'alice');
302
+ return method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req;
303
+ };
304
+ const nodeSaw = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-1/${action}`);
305
+ // Each runs on the node and answers with the node's own reply.
306
+ const FORWARDED = [
307
+ {
308
+ method: 'get',
309
+ action: 'screenshot',
310
+ call: 'getScreenshot',
311
+ reply: { screenshot: 'A'.repeat(200) },
312
+ },
313
+ { method: 'get', action: 'clipboard', call: 'getClipboard', reply: { content: 'copied' } },
314
+ {
315
+ method: 'post',
316
+ action: 'clipboard',
317
+ send: { content: 'x' },
318
+ call: 'setClipboard',
319
+ reply: { success: true },
320
+ },
321
+ { method: 'post', action: 'lock', call: 'lock', reply: { success: true } },
322
+ { method: 'post', action: 'unlock', call: 'unlock', reply: { success: true } },
323
+ { method: 'get', action: 'display', call: 'getDisplayState', reply: { state: 'on' } },
324
+ {
325
+ method: 'post',
326
+ action: 'uninstall',
327
+ send: { bundleId: 'com.example' },
328
+ call: 'uninstallApp',
329
+ reply: { success: true },
330
+ },
331
+ { method: 'get', action: 'apps', call: 'listApps', reply: [{ bundleId: 'com.example' }] },
332
+ { method: 'get', action: 'logs', call: 'getLogs', reply: { logs: 'a log line' } },
333
+ {
334
+ method: 'post',
335
+ action: 'shell',
336
+ send: { command: 'id' },
337
+ call: 'executeShell',
338
+ reply: { output: 'uid=2000(shell)' },
339
+ },
340
+ {
341
+ method: 'get',
342
+ action: 'inspector/snapshot',
343
+ call: 'getSnapshot',
344
+ reply: { source: '<hierarchy/>' },
345
+ },
346
+ ];
347
+ for (const { method, action, send, call, reply } of FORWARDED) {
348
+ it(`${method.toUpperCase()} ${action} runs on the node and answers with the node's reply`, () => __awaiter(void 0, void 0, void 0, function* () {
349
+ const res = yield asAlice(method, action, send);
350
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
351
+ (0, chai_1.expect)(res.body).to.deep.equal(reply);
352
+ (0, chai_1.expect)(manager.calls).to.deep.equal([`node-1:${call}`]);
353
+ (0, chai_1.expect)(nodeSaw(action)).to.have.length(1);
354
+ }));
355
+ }
356
+ it('relays the node’s own error answer unchanged, sent once', () => __awaiter(void 0, void 0, void 0, function* () {
357
+ manager.failLogs = true;
358
+ const res = yield asAlice('get', 'logs');
359
+ (0, chai_1.expect)(res.status).to.equal(500);
360
+ (0, chai_1.expect)(res.body).to.deep.equal({ error: 'logcat failed on the node' });
361
+ (0, chai_1.expect)(nodeSaw('logs')).to.have.length(1);
362
+ }));
363
+ it('sends the node none of the caller’s credentials, only its own token', () => __awaiter(void 0, void 0, void 0, function* () {
364
+ yield (0, loopbackRequest_1.default)(hubApp)
365
+ .post('/xenon/api/control/phone-1/lock')
366
+ .set('x-test-user', 'alice')
367
+ .set('cookie', 'xenon_dashboard_session=secret-session')
368
+ .set('authorization', 'Bearer secret-jwt')
369
+ .set('x-xenon-access-key', 'secret-key')
370
+ .set('x-xenon-token', 'secret-token')
371
+ .send({})
372
+ .expect(200);
373
+ const [sent] = nodeSaw('lock');
374
+ for (const name of [
375
+ 'cookie',
376
+ 'authorization',
377
+ 'x-xenon-access-key',
378
+ 'x-xenon-token',
379
+ 'x-test-user',
380
+ ]) {
381
+ (0, chai_1.expect)(sent.headers, name).to.not.have.property(name);
382
+ }
383
+ (0, chai_1.expect)(sent.headers[hubSessionToken_1.HUB_TOKEN_HEADER]).to.be.a('string');
384
+ }));
385
+ it('answers appium-session itself: the session is routed through the hub', () => __awaiter(void 0, void 0, void 0, function* () {
386
+ const res = yield asAlice('get', 'appium-session');
387
+ (0, chai_1.expect)(res.status).to.equal(200);
388
+ (0, chai_1.expect)(res.body).to.include({ status: 'success', sessionId: 's-1' });
389
+ (0, chai_1.expect)(nodeSaw('appium-session')).to.have.length(0);
390
+ }));
391
+ // Not passed on yet: uploads need a relayed body, an app from the hub's
392
+ // library isn't on the node, and a path is a path on one machine.
393
+ // Omni's AI settings are the hub's. Anything added later is refused the
394
+ // same way until it is on the list.
395
+ const REFUSED = [
396
+ ['post', 'install', { appPath: '/tmp/app.apk' }],
397
+ ['post', 'install-repository-app', { appId: 'app-1' }],
398
+ ['post', 'upload-install'],
399
+ ['get', 'omni-scan'],
400
+ ['post', 'test-locator', { strategy: '-custom:ai-text', selector: 'OK' }],
401
+ ['post', 'an-action-added-later'],
402
+ ];
403
+ describe('the live preview', () => {
404
+ // phone-2 is free: a preview holds it on the node.
405
+ const preview = (method, action) => {
406
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/phone-2/${action}`);
407
+ req.set('x-test-user', 'alice');
408
+ return method === 'post' ? req.send({}) : req;
409
+ };
410
+ const nodeSawPreview = (action) => nodeRequests.filter((r) => r.path === `/xenon/api/control/phone-2/${action}`);
411
+ let started;
412
+ let stopped;
413
+ let mjpegPort;
414
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
415
+ started = [];
416
+ stopped = [];
417
+ // The camera the node's MJPEG fan-out reads: a frame every 50 ms.
418
+ const camera = (0, express_1.default)();
419
+ camera.get('/', (_req, res) => {
420
+ res.writeHead(200, { 'content-type': 'multipart/x-mixed-replace; boundary=f' });
421
+ const timer = setInterval(() => res.write('--f\r\nFAKE-JPEG-FRAME\r\n'), 50);
422
+ res.on('close', () => clearInterval(timer));
423
+ });
424
+ mjpegPort = (yield loopback.serve(camera)).address().port;
425
+ const mjpeg = typedi_1.Container.get(AndroidStreamService_1.default);
426
+ const h264 = typedi_1.Container.get(AndroidH264StreamService_1.default);
427
+ const where = () => typedi_1.Container.get(PluginContext_1.PluginContext).nodeId;
428
+ sinon_1.default.stub(mjpeg, 'startStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
429
+ started.push(`${where()}:${udid}`);
430
+ return { mjpegPort };
431
+ }));
432
+ sinon_1.default.stub(mjpeg, 'stopStream').callsFake((udid) => __awaiter(void 0, void 0, void 0, function* () {
433
+ stopped.push(`${where()}:${udid}`);
434
+ }));
435
+ sinon_1.default.stub(mjpeg, 'getStreamStatus').returns(undefined);
436
+ sinon_1.default.stub(mjpeg, 'updateViewerCount');
437
+ sinon_1.default.stub(h264, 'stop').resolves();
438
+ sinon_1.default.stub(h264, 'getMultiplexer').returns(undefined);
439
+ }));
440
+ it('starts on the node, which holds the phone, and the hub marks it busy for the node at once', () => __awaiter(void 0, void 0, void 0, function* () {
441
+ const res = yield preview('post', 'stream/start');
442
+ (0, chai_1.expect)(res.status, JSON.stringify(res.body)).to.equal(200);
443
+ (0, chai_1.expect)(res.body).to.include({ success: true, type: 'mjpeg' });
444
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
445
+ const row = yield scratch.db.device.findFirst({ where: { udid: 'phone-2' } });
446
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.session_id).to.equal('manual_alice_phone-2');
447
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.nodeBusy).to.equal(true);
448
+ (0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.busy).to.equal(true);
449
+ }));
450
+ it('status, leave and stop are the node’s answers', () => __awaiter(void 0, void 0, void 0, function* () {
451
+ yield preview('post', 'stream/start').expect(200);
452
+ const status = yield preview('get', 'stream/status');
453
+ (0, chai_1.expect)(status.status).to.equal(200);
454
+ (0, chai_1.expect)(status.body).to.include({ udid: 'phone-2', status: 'stopped' });
455
+ yield preview('post', 'stream/leave').expect(202);
456
+ yield preview('post', 'stream/stop').expect(200);
457
+ (0, chai_1.expect)(stopped).to.deep.equal(['node-1:phone-2']);
458
+ for (const action of ['stream/status', 'stream/leave', 'stream/stop']) {
459
+ (0, chai_1.expect)(nodeSawPreview(action), action).to.have.length(1);
460
+ }
461
+ }));
462
+ it('mints the viewer’s ticket here, where the team check ran', () => __awaiter(void 0, void 0, void 0, function* () {
463
+ const res = yield preview('post', 'stream/ticket');
464
+ (0, chai_1.expect)(res.status).to.equal(200);
465
+ (0, chai_1.expect)(res.body.ticket).to.be.a('string');
466
+ (0, chai_1.expect)(nodeSawPreview('stream/ticket')).to.have.length(0);
467
+ }));
468
+ it('relays the node’s MJPEG stream to the viewer, without the viewer’s ticket', () => __awaiter(void 0, void 0, void 0, function* () {
469
+ const hubPort = (yield loopback.serve(hubApp)).address().port;
470
+ const got = yield new Promise((resolve, reject) => {
471
+ const req = http_1.default.get({
472
+ host: '127.0.0.1',
473
+ port: hubPort,
474
+ path: '/xenon/api/control/phone-2/stream?ticket=spent&t=1',
475
+ headers: { 'x-test-user': 'alice' },
476
+ }, (res) => {
477
+ let body = '';
478
+ res.on('data', (chunk) => {
479
+ body += chunk.toString();
480
+ if (body.split('FAKE-JPEG-FRAME').length > 3) {
481
+ req.destroy();
482
+ resolve({ type: String(res.headers['content-type']), body });
483
+ }
484
+ });
485
+ });
486
+ req.on('error', (e) => (e.code === 'ECONNRESET' ? undefined : reject(e)));
487
+ });
488
+ (0, chai_1.expect)(got.type).to.include('multipart/x-mixed-replace');
489
+ (0, chai_1.expect)(started).to.deep.equal(['node-1:phone-2']);
490
+ (0, chai_1.expect)(nodeSawPreview('stream').map((r) => r.url)).to.deep.equal([
491
+ '/xenon/api/control/phone-2/stream?t=1',
492
+ ]);
493
+ yield preview('post', 'stream/stop').expect(200);
494
+ }));
495
+ });
496
+ it('refuses the rest, naming the node, and runs none of it on the hub', () => __awaiter(void 0, void 0, void 0, function* () {
497
+ for (const [method, action, body] of REFUSED) {
498
+ const res = yield asAlice(method, action, body);
499
+ (0, chai_1.expect)(res.status, `${method} ${action}`).to.equal(501);
500
+ (0, chai_1.expect)(res.body.error, `${method} ${action}`).to.equal('not_available_through_hub');
501
+ (0, chai_1.expect)(res.body.message, `${method} ${action}`).to.include(nodeOrigin);
502
+ }
503
+ (0, chai_1.expect)(nodeRequests).to.have.length(0);
504
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
505
+ }));
506
+ it('sends nothing to a cloud provider’s phone', () => __awaiter(void 0, void 0, void 0, function* () {
507
+ const provider = (0, express_1.default)();
508
+ let reached = 0;
509
+ provider.use((_req, res) => {
510
+ reached++;
511
+ res.status(200).json({});
512
+ });
513
+ const providerOrigin = `http://127.0.0.1:${(yield loopback.serve(provider)).address().port}`;
514
+ yield scratch.db.device.create({
515
+ data: {
516
+ udid: 'cloud-1',
517
+ host: providerOrigin,
518
+ platform: 'android',
519
+ name: 'cloud-1',
520
+ cloud: JSON.stringify({ cloudName: 'example-cloud' }),
521
+ },
522
+ });
523
+ for (const [method, action, body] of [
524
+ ['post', 'tap', { x: 1, y: 2 }],
525
+ ['get', 'screenshot'],
526
+ ['post', 'text', { text: 'a secret' }],
527
+ ]) {
528
+ const req = (0, loopbackRequest_1.default)(hubApp)[method](`/xenon/api/control/cloud-1/${action}`)
529
+ .set('x-test-user', 'alice');
530
+ const res = yield (method === 'post' ? req.send(body !== null && body !== void 0 ? body : {}) : req);
531
+ (0, chai_1.expect)(res.status, action).to.equal(501);
532
+ (0, chai_1.expect)(res.body.error, action).to.equal('not_available_for_cloud_phone');
533
+ }
534
+ (0, chai_1.expect)(reached).to.equal(0);
535
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
536
+ }));
537
+ it('a node that can’t be reached: 502, naming the node', () => __awaiter(void 0, void 0, void 0, function* () {
538
+ yield scratch.db.device.create({
539
+ data: {
540
+ udid: 'gone-1',
541
+ host: 'http://127.0.0.1:1',
542
+ nodeId: 'node-2',
543
+ platform: 'android',
544
+ name: 'gone-1',
545
+ },
546
+ });
547
+ const res = yield (0, loopbackRequest_1.default)(hubApp)
548
+ .get('/xenon/api/control/gone-1/screenshot')
549
+ .set('x-test-user', 'alice');
550
+ (0, chai_1.expect)(res.status).to.equal(502);
551
+ (0, chai_1.expect)(res.body.error).to.equal('node_unreachable');
552
+ (0, chai_1.expect)(res.body.message).to.include('http://127.0.0.1:1');
553
+ (0, chai_1.expect)(manager.calls).to.deep.equal([]);
554
+ }));
555
+ });
556
+ describe('on the node', () => {
557
+ it('the node’s own ownership guard judges the user the hub named', () => __awaiter(void 0, void 0, void 0, function* () {
558
+ const asBob = yield controlToken({ userId: 'bob' });
559
+ const bob = yield toNode('post', '/xenon/api/control/phone-1/tap', asBob);
560
+ (0, chai_1.expect)(bob.status).to.equal(409);
561
+ (0, chai_1.expect)(bob.body.error).to.equal('device_in_use_by_session');
562
+ const admin = yield controlToken({ userId: 'admin-carol', isAdmin: true });
563
+ yield toNode('post', '/xenon/api/control/phone-1/tap', admin).expect(200);
564
+ (0, chai_1.expect)(manager.taps).to.deep.equal(['phone-1']);
565
+ }));
566
+ it('is accepted for that phone’s /control only', () => __awaiter(void 0, void 0, void 0, function* () {
567
+ const token = yield controlToken();
568
+ const otherPhone = yield toNode('post', '/xenon/api/control/phone-2/tap', token);
569
+ (0, chai_1.expect)(otherPhone.status).to.equal(401);
570
+ const notControl = yield toNode('get', '/xenon/api/whoami', token);
571
+ (0, chai_1.expect)(notControl.status).to.equal(401);
572
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
573
+ }));
574
+ it('is refused when it names another node, or is another kind of hub token', () => __awaiter(void 0, void 0, void 0, function* () {
575
+ const issuer = typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer);
576
+ const tokens = [
577
+ yield controlToken({ host: 'http://10.9.9.9:4725' }),
578
+ (yield issuer.tokenFor('s-1')),
579
+ (yield issuer.createTokenFor({
580
+ userId: 'alice',
581
+ udid: 'phone-1',
582
+ host: nodeOrigin,
583
+ })),
584
+ yield hubKeys.sign({ sub: 'alice', adm: false, udid: 'phone-1', host: nodeOrigin }, { audience: hubSessionToken_1.HUB_CONTROL_AUDIENCE, ttlSeconds: -3600 }),
585
+ 'not-a-jwt',
586
+ ];
587
+ for (const token of tokens) {
588
+ const res = yield toNode('post', '/xenon/api/control/phone-1/tap', token);
589
+ (0, chai_1.expect)(res.status, token.slice(0, 20)).to.equal(401);
590
+ }
591
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
592
+ }));
593
+ it('cannot be checked while the hub’s keys cannot be fetched: 503', () => __awaiter(void 0, void 0, void 0, function* () {
594
+ const token = yield controlToken();
595
+ nodeArgs = Object.assign(Object.assign({}, nodeArgs), { hub: 'http://127.0.0.1:1' });
596
+ const res = yield toNode('post', '/xenon/api/control/phone-1/tap', token);
597
+ (0, chai_1.expect)(res.status).to.equal(503);
598
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
599
+ }));
600
+ it('a server that is not a node ignores it', () => __awaiter(void 0, void 0, void 0, function* () {
601
+ const token = yield controlToken();
602
+ nodeArgs = Object.assign(Object.assign({}, nodeArgs), { hub: undefined });
603
+ const res = yield toNode('post', '/xenon/api/control/phone-1/tap', token);
604
+ (0, chai_1.expect)(res.status).to.not.equal(200);
605
+ (0, chai_1.expect)(manager.taps).to.deep.equal([]);
606
+ }));
607
+ });
608
+ });