@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,154 @@
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 lokijs_1 = __importDefault(require("lokijs"));
18
+ const sinon_1 = __importDefault(require("sinon"));
19
+ const LeaseOrphanSweeper_1 = require("../../../src/services/lease/LeaseOrphanSweeper");
20
+ const LeaseService_1 = require("../../../src/services/lease/LeaseService");
21
+ const leaseToken_1 = require("../../../src/services/lease/leaseToken");
22
+ const device_store_1 = require("../../../src/data-service/device-store");
23
+ const prisma_store_1 = require("../../../src/data-service/prisma-store");
24
+ const db_1 = require("../../../src/data-service/db");
25
+ const scratch_database_1 = require("../../helpers/scratch-database");
26
+ /**
27
+ * A lease locks its phone with `busy` alone. A session created on the lease
28
+ * claims the phone too (claimForSession). Ending the lease (a reap at
29
+ * expiresAt or on missed heartbeats, or its holder's release) wrote
30
+ * `busy: false` unconditionally, so a session that outlived its lease had
31
+ * its phone handed to another session mid-run.
32
+ *
33
+ * #375's rule (deviceClaims.ts, UNHELD) applies: clear `busy` only where
34
+ * nothing else holds the phone. The session's own release frees it later.
35
+ *
36
+ * Both stores run for real: Prisma on a scratch SQLite file, Loki on an
37
+ * in-memory collection.
38
+ */
39
+ const UDID = 'leased-phone-1';
40
+ const HOST = 'http://127.0.0.1:4723';
41
+ const TOKEN = 'd'.repeat(64);
42
+ function lokiStore() {
43
+ // The factory only makes a Loki store under NODE_ENV=test; take it without
44
+ // keeping it as the process's store.
45
+ const factory = device_store_1.DeviceStoreFactory;
46
+ const saved = factory._deviceStore;
47
+ const savedEnv = process.env.NODE_ENV;
48
+ factory._deviceStore = undefined;
49
+ process.env.NODE_ENV = 'test';
50
+ try {
51
+ return device_store_1.DeviceStoreFactory.getStore();
52
+ }
53
+ finally {
54
+ process.env.NODE_ENV = savedEnv;
55
+ factory._deviceStore = saved;
56
+ }
57
+ }
58
+ describe('ending a lease frees only a phone nothing else holds', () => {
59
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
60
+ let now;
61
+ function lease(over) {
62
+ return __awaiter(this, void 0, void 0, function* () {
63
+ yield scratch.db.lease.create({
64
+ data: Object.assign({ id: 'lse_held', tokenHash: (0, leaseToken_1.hashToken)(TOKEN), deviceUdid: UDID, deviceHost: HOST, actorId: 'usr_alice', status: 'active', heartbeatSeconds: 30, allocatedPorts: '{}', capabilityBag: '{}' }, over),
65
+ });
66
+ });
67
+ }
68
+ const cases = [
69
+ {
70
+ name: 'PrismaDeviceStore (scratch SQLite)',
71
+ make: () => __awaiter(void 0, void 0, void 0, function* () {
72
+ yield scratch.db.device.create({ data: { udid: UDID, host: HOST, busy: true } });
73
+ return {
74
+ store: new prisma_store_1.PrismaDeviceStore(),
75
+ busy: () => __awaiter(void 0, void 0, void 0, function* () {
76
+ var _a;
77
+ const where = { udid_host: { udid: UDID, host: HOST } };
78
+ return (_a = (yield scratch.db.device.findUnique({ where }))) === null || _a === void 0 ? void 0 : _a.busy;
79
+ }),
80
+ };
81
+ }),
82
+ },
83
+ {
84
+ name: 'LokiDeviceStore (in memory)',
85
+ make: () => __awaiter(void 0, void 0, void 0, function* () {
86
+ const devices = new lokijs_1.default('lease-reap-held-phone.json').addCollection('devices');
87
+ sinon_1.default.stub(db_1.XenonDatabase, 'DeviceModel').get(() => Promise.resolve(devices));
88
+ devices.insert({ udid: UDID, host: HOST, busy: true });
89
+ return {
90
+ store: lokiStore(),
91
+ busy: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = devices.findOne({ udid: UDID, host: HOST })) === null || _a === void 0 ? void 0 : _a.busy; }),
92
+ };
93
+ }),
94
+ },
95
+ ];
96
+ for (const { name, make } of cases) {
97
+ describe(name, () => {
98
+ let store;
99
+ let busy;
100
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
101
+ yield scratch.db.portLease.deleteMany({});
102
+ yield scratch.db.lease.deleteMany({});
103
+ yield scratch.db.device.deleteMany({});
104
+ now = Date.now();
105
+ ({ store, busy } = yield make());
106
+ }));
107
+ afterEach(() => sinon_1.default.restore());
108
+ /** A session created on the lease: it claims the leased phone. */
109
+ function sessionOnLease() {
110
+ return __awaiter(this, void 0, void 0, function* () {
111
+ (0, chai_1.expect)(yield store.claimForSession(UDID, HOST, null, 'sess-1', {})).to.equal(true);
112
+ });
113
+ }
114
+ const reap = () => new LeaseOrphanSweeper_1.LeaseOrphanSweeper(scratch.db, store).sweep();
115
+ const ended = () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = (yield scratch.db.lease.findUnique({ where: { id: 'lse_held' } }))) === null || _a === void 0 ? void 0 : _a.status; });
116
+ it('a reap at expiresAt leaves the phone busy while a session holds it', () => __awaiter(void 0, void 0, void 0, function* () {
117
+ yield lease({ expiresAt: now - 500, lastHeartbeatAt: now - 1000 });
118
+ yield sessionOnLease();
119
+ yield reap();
120
+ (0, chai_1.expect)(yield ended()).to.equal('expired');
121
+ (0, chai_1.expect)(yield busy(), "the session's phone").to.equal(true);
122
+ }));
123
+ it('a missed-heartbeat reap leaves the phone busy while a session holds it', () => __awaiter(void 0, void 0, void 0, function* () {
124
+ yield lease({ expiresAt: now + 60000, lastHeartbeatAt: now - 120000 });
125
+ yield sessionOnLease();
126
+ yield reap();
127
+ (0, chai_1.expect)(yield ended()).to.equal('expired');
128
+ (0, chai_1.expect)(yield busy()).to.equal(true);
129
+ }));
130
+ it('a reap frees the phone when nothing else holds it, as before', () => __awaiter(void 0, void 0, void 0, function* () {
131
+ yield lease({ expiresAt: now - 500, lastHeartbeatAt: now - 1000 });
132
+ yield reap();
133
+ (0, chai_1.expect)(yield ended()).to.equal('expired');
134
+ (0, chai_1.expect)(yield busy()).to.equal(false);
135
+ }));
136
+ it("once the session ends after the reap, the session's release frees the phone", () => __awaiter(void 0, void 0, void 0, function* () {
137
+ yield lease({ expiresAt: now - 500, lastHeartbeatAt: now - 1000 });
138
+ yield sessionOnLease();
139
+ yield reap();
140
+ (0, chai_1.expect)(yield store.releaseClaim(UDID, HOST, { sessionId: 'sess-1' }, {})).to.equal(true);
141
+ (0, chai_1.expect)(yield busy()).to.equal(false);
142
+ }));
143
+ it("the holder's release leaves the phone busy while a session holds it", () => __awaiter(void 0, void 0, void 0, function* () {
144
+ yield lease({ expiresAt: now + 60000, lastHeartbeatAt: now });
145
+ yield sessionOnLease();
146
+ const noAuth = { nodePairAuth: () => __awaiter(void 0, void 0, void 0, function* () { return ({}); }) };
147
+ const leases = new LeaseService_1.LeaseService(scratch.db, store, {}, noAuth);
148
+ yield leases.release('lse_held', TOKEN);
149
+ (0, chai_1.expect)(yield ended()).to.equal('released');
150
+ (0, chai_1.expect)(yield busy()).to.equal(true);
151
+ }));
152
+ });
153
+ }
154
+ });
@@ -0,0 +1,107 @@
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 LeaseOrphanSweeper_1 = require("../../../src/services/lease/LeaseOrphanSweeper");
19
+ const scratch_database_1 = require("../../helpers/scratch-database");
20
+ /**
21
+ * A lease ends at expiresAt. Since #366 heartbeat and extend refuse a lease
22
+ * past it, but the sweeper reaped only on missed heartbeats, so the lease
23
+ * stayed 'active', its phone busy and out of allocation, for up to
24
+ * 3 x heartbeatSeconds (15 minutes at the 300 s maximum) after it ended.
25
+ *
26
+ * The sweeper runs its real queries against a scratch SQLite file.
27
+ */
28
+ describe('LeaseOrphanSweeper: a lease ends at expiresAt (scratch DB)', () => {
29
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
30
+ let releaseLeaseLock;
31
+ let sweeper;
32
+ let now;
33
+ function lease(id, over) {
34
+ return __awaiter(this, void 0, void 0, function* () {
35
+ yield scratch.db.lease.create({
36
+ data: Object.assign({ id, tokenHash: `hash-${id}`, deviceUdid: `udid-${id}`, deviceHost: 'http://127.0.0.1:4723', actorId: 'usr_alice', status: 'active', heartbeatSeconds: 30, allocatedPorts: '{}', capabilityBag: '{}' }, over),
37
+ });
38
+ });
39
+ }
40
+ function portLease(port, leaseId) {
41
+ return __awaiter(this, void 0, void 0, function* () {
42
+ yield scratch.db.portLease.create({
43
+ data: {
44
+ port,
45
+ purpose: 'wdaLocalPort',
46
+ leasedToUdid: `udid-${leaseId}`,
47
+ leaseId,
48
+ leasedAt: now,
49
+ expiresAt: now + 3600000,
50
+ },
51
+ });
52
+ });
53
+ }
54
+ const statusOf = (id) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = (yield scratch.db.lease.findUnique({ where: { id } }))) === null || _a === void 0 ? void 0 : _a.status; });
55
+ const portsOf = (leaseId) => __awaiter(void 0, void 0, void 0, function* () { return (yield scratch.db.portLease.findMany({ where: { leaseId } })).map((p) => p.port); });
56
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
57
+ yield scratch.db.portLease.deleteMany({});
58
+ yield scratch.db.lease.deleteMany({});
59
+ now = Date.now();
60
+ releaseLeaseLock = sinon_1.default.stub().resolves(true);
61
+ sweeper = new LeaseOrphanSweeper_1.LeaseOrphanSweeper(scratch.db, { releaseLeaseLock });
62
+ }));
63
+ it('reaps a lease past expiresAt whose client is still heartbeating, like a missed-heartbeat reap', () => __awaiter(void 0, void 0, void 0, function* () {
64
+ yield lease('ended', { expiresAt: now - 500, lastHeartbeatAt: now - 1000 });
65
+ yield portLease(28301, 'ended');
66
+ yield sweeper.sweep();
67
+ (0, chai_1.expect)(yield statusOf('ended')).to.equal('expired');
68
+ (0, chai_1.expect)(yield portsOf('ended'), 'its ports are freed').to.deep.equal([]);
69
+ (0, chai_1.expect)(releaseLeaseLock.calledOnceWith('udid-ended', 'http://127.0.0.1:4723')).to.equal(true);
70
+ }));
71
+ it('leaves a heartbeating lease alone until expiresAt', () => __awaiter(void 0, void 0, void 0, function* () {
72
+ yield lease('live', { expiresAt: now + 60000, lastHeartbeatAt: now - 1000 });
73
+ yield portLease(28302, 'live');
74
+ yield sweeper.sweep();
75
+ (0, chai_1.expect)(yield statusOf('live')).to.equal('active');
76
+ (0, chai_1.expect)(yield portsOf('live')).to.deep.equal([28302]);
77
+ (0, chai_1.expect)(releaseLeaseLock.called).to.equal(false);
78
+ }));
79
+ it('still reaps on missed heartbeats before expiresAt', () => __awaiter(void 0, void 0, void 0, function* () {
80
+ yield lease('silent', { expiresAt: now + 60000, lastHeartbeatAt: now - 120000 });
81
+ yield sweeper.sweep();
82
+ (0, chai_1.expect)(yield statusOf('silent')).to.equal('expired');
83
+ (0, chai_1.expect)(releaseLeaseLock.calledOnceWith('udid-silent', 'http://127.0.0.1:4723')).to.equal(true);
84
+ }));
85
+ it('never touches a lease that was released', () => __awaiter(void 0, void 0, void 0, function* () {
86
+ yield lease('gone', { expiresAt: now - 500, lastHeartbeatAt: now - 1000 });
87
+ yield scratch.db.lease.update({ where: { id: 'gone' }, data: { status: 'released' } });
88
+ yield sweeper.sweep();
89
+ (0, chai_1.expect)(yield statusOf('gone')).to.equal('released');
90
+ (0, chai_1.expect)(releaseLeaseLock.called).to.equal(false);
91
+ }));
92
+ });
93
+ describe('leaseReapReason', () => {
94
+ // Past expiresAt, heartbeat and extend refuse the lease (410), so by the next
95
+ // sweep its heartbeats are missed too. The end time is the cause; the lab's
96
+ // log said "missed heartbeats" for every such lease.
97
+ it('names the end time for a lease past expiresAt, even when its heartbeats are missed too', () => {
98
+ const now = Date.now();
99
+ const reason = (0, LeaseOrphanSweeper_1.leaseReapReason)({ heartbeatSeconds: 10, lastHeartbeatAt: now - 60000, expiresAt: now - 1000 }, now);
100
+ (0, chai_1.expect)(reason).to.match(/^expired at /);
101
+ });
102
+ it('names missed heartbeats for a lease before its end time', () => {
103
+ const now = Date.now();
104
+ const reason = (0, LeaseOrphanSweeper_1.leaseReapReason)({ heartbeatSeconds: 10, lastHeartbeatAt: now - 60000, expiresAt: now + 60000 }, now);
105
+ (0, chai_1.expect)(reason).to.equal('missed heartbeats');
106
+ });
107
+ });
@@ -16,7 +16,7 @@ require("reflect-metadata");
16
16
  const chai_1 = require("chai");
17
17
  const sinon_1 = __importDefault(require("sinon"));
18
18
  const express_1 = __importDefault(require("express"));
19
- const supertest_1 = __importDefault(require("supertest"));
19
+ const loopbackRequest_1 = __importDefault(require("../../helpers/loopbackRequest"));
20
20
  const sdk_leases_1 = require("../../../src/app/routers/sdk-leases");
21
21
  const LeaseService_1 = require("../../../src/services/lease/LeaseService");
22
22
  const device_store_1 = require("../../../src/data-service/device-store");
@@ -120,7 +120,7 @@ describe('SDK leases respect the team boundary', () => {
120
120
  next();
121
121
  });
122
122
  app.use('/sdk/leases', (0, sdk_leases_1.makeRouter)({ leaseService: svc }));
123
- return (0, supertest_1.default)(app)
123
+ return (0, loopbackRequest_1.default)(app)
124
124
  .post('/sdk/leases')
125
125
  .send({ filters: Object.assign({ platform: 'android', filterByHost: HOST }, filters) });
126
126
  }
@@ -21,95 +21,273 @@ const yaml_1 = __importDefault(require("yaml"));
21
21
  const secure_values_preprocessor_1 = require("@appium/logger/build/lib/secure-values-preprocessor");
22
22
  const constants_1 = require("@appium/base-driver/build/lib/constants");
23
23
  const leaseToken_1 = require("../../../src/services/lease/leaseToken");
24
- // Appium logs the POST /session body before any plugin sees it, so Xenon
25
- // can't strip the lease token from that line; the documented Appium log
26
- // filter has to. This runs the filter exactly as documented — the JSON for
27
- // --log-filters and the YAML for a --config file, read straight out of the
24
+ // Appium logs every request body before any plugin sees it: the POST /session
25
+ // capabilities with their tokens, and the dashboard's login body with its
26
+ // password. Xenon can't strip them from those lines, so the documented Appium
27
+ // log filters have to. This runs the filters exactly as documented — the JSON
28
+ // for --log-filters and the YAML for a --config file, read straight out of the
28
29
  // doc — through Appium's own preprocessor, over the forms Appium logs.
29
- const DOC = path_1.default.resolve(__dirname, '../../../docs/superpowers/specs/2026-05-14-server-side-lease-api-design.md');
30
- const HEADING = "#### Keeping the lease token out of Appium's own log";
31
- function fencedBlock(language) {
32
- const doc = fs_1.default.readFileSync(DOC, 'utf8');
33
- const section = doc.slice(doc.indexOf(HEADING));
34
- (0, chai_1.expect)(doc.indexOf(HEADING), `${HEADING} in ${DOC}`).to.be.greaterThan(-1);
35
- const match = section.match(new RegExp('```' + language + '\\n([\\s\\S]*?)\\n```'));
36
- if (!match)
37
- throw new Error(`no ${language} block under ${HEADING}`);
38
- return match[1];
30
+ const ROOT = path_1.default.resolve(__dirname, '../../..');
31
+ const DOC = path_1.default.join(ROOT, 'docs/superpowers/specs/2026-05-14-server-side-lease-api-design.md');
32
+ const CHANGELOG = path_1.default.join(ROOT, 'CHANGELOG.md');
33
+ const HEADING = "#### Keeping tokens and passwords out of Appium's own log";
34
+ const REDACTED = '**REDACTED**';
35
+ // The text from `heading` to the next heading of any level.
36
+ function sectionAfter(text, heading) {
37
+ const start = text.indexOf(heading);
38
+ (0, chai_1.expect)(start, `${heading.trim()} found`).to.be.greaterThan(-1);
39
+ const rest = text.slice(start + heading.length);
40
+ const end = rest.search(/\n#+ /);
41
+ return end === -1 ? rest : rest.slice(0, end);
39
42
  }
43
+ // Every ```language block in `text`, dedented by its fence's indent.
44
+ function fencedBlocks(text, language) {
45
+ const fence = new RegExp('^( *)```' + language + '\\n([\\s\\S]*?)\\n\\1```', 'gm');
46
+ return [...text.matchAll(fence)].map(([, indent, body]) => body
47
+ .split('\n')
48
+ .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
49
+ .join('\n'));
50
+ }
51
+ // The doc's blocks: the token rule, then the password rule.
52
+ const docBlocks = (language) => fencedBlocks(sectionAfter(fs_1.default.readFileSync(DOC, 'utf8'), HEADING), language);
53
+ const RULE_INDEX = { token: 0, password: 1 };
54
+ function docBlock(language, rule) {
55
+ const block = docBlocks(language)[RULE_INDEX[rule]];
56
+ (0, chai_1.expect)(block, `the ${rule} rule's ${language} block under ${HEADING}`).to.be.a('string');
57
+ return block;
58
+ }
59
+ const RULE_FORMS = [
60
+ ['--log-filters JSON', 'json', (block) => JSON.parse(block)],
61
+ [
62
+ '--config YAML (server.log-filters)',
63
+ 'yaml',
64
+ (block) => yaml_1.default.parse(block).server['log-filters'],
65
+ ],
66
+ ];
40
67
  function filterFrom(rules) {
41
68
  return __awaiter(this, void 0, void 0, function* () {
42
69
  const pre = new secure_values_preprocessor_1.SecureValuesPreprocessor();
43
70
  (0, chai_1.expect)(yield pre.loadRules(rules)).to.deep.equal([]);
71
+ (0, chai_1.expect)(pre.rules).to.have.length(rules.length);
44
72
  return pre;
45
73
  });
46
74
  }
47
- const TOKEN = (0, leaseToken_1.generateToken)();
48
- function sessionBody(padding = 0) {
49
- return JSON.stringify({
75
+ // Appium's own cut: _.truncate to MAX_LOG_BODY_LENGTH, "..." included.
76
+ const appiumCut = (body) => lodash_1.default.truncate(body, { length: constants_1.MAX_LOG_BODY_LENGTH });
77
+ /**
78
+ * `body(padding)` cut by Appium `into` characters inside `secret` (the secret
79
+ * as the body spells it). Returns the cut body and where the secret starts.
80
+ */
81
+ function cutInside(body, secret, into) {
82
+ const at = body(0).indexOf(secret);
83
+ (0, chai_1.expect)(at).to.be.greaterThan(-1);
84
+ const start = constants_1.MAX_LOG_BODY_LENGTH - '...'.length - into;
85
+ const cut = appiumCut(body(start - at));
86
+ (0, chai_1.expect)(cut.slice(start)).to.equal(secret.slice(0, into) + '...');
87
+ return { cut, start };
88
+ }
89
+ // `raw` with `length` characters at `start` replaced by the redaction mark.
90
+ function redacted(raw, start, length) {
91
+ (0, chai_1.expect)(start).to.be.greaterThan(-1);
92
+ return { raw, expected: raw.slice(0, start) + REDACTED + raw.slice(start + length) };
93
+ }
94
+ // An [HTTP] request line: Appium logs the body, cut, after the method and URL.
95
+ const httpLine = (url, body) => `[HTTP] --> POST ${url} ${body}`;
96
+ // The same line as Appium colours it on a terminal.
97
+ const colouredHttpLine = (url, body) => `[HTTP] \u001b[37m-->\u001b[39m \u001b[37mPOST\u001b[39m \u001b[37m${url}\u001b[39m ` +
98
+ `\u001b[90m${body}\u001b[39m`;
99
+ // A line whose body Appium cut inside the secret: everything from the secret
100
+ // on, the cut's "..." included, is redacted, and nothing before it changes.
101
+ function cutLine(url, body, secret, into) {
102
+ const { cut, start } = cutInside(body, secret, into);
103
+ const raw = httpLine(url, cut);
104
+ const at = raw.length - cut.length + start;
105
+ return { raw, expected: raw.slice(0, at) + REDACTED };
106
+ }
107
+ const cutInto = (into) => `a body Appium cut ${into} character${into === 1 ? '' : 's'} into it`;
108
+ // ---------------------------------------------------------------------------
109
+ // Tokens: the API token, a session JWT and a lease token.
110
+ const ACCESS_KEY = 'xen_AbC123dEf456';
111
+ const TOKENS = [
112
+ {
113
+ key: 'token',
114
+ value: (0, leaseToken_1.generateToken)(),
115
+ options: { accessKey: ACCESS_KEY },
116
+ },
117
+ {
118
+ key: 'sessionToken',
119
+ value: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1In0.sig_-x',
120
+ options: {},
121
+ },
122
+ {
123
+ key: 'leaseToken',
124
+ value: (0, leaseToken_1.generateToken)(),
125
+ options: { accessKey: ACCESS_KEY, leaseId: 'lse_1' },
126
+ },
127
+ ];
128
+ function sessionRequest(options, padding = 0) {
129
+ return {
50
130
  capabilities: {
51
131
  alwaysMatch: {
52
132
  platformName: 'Android',
53
133
  'appium:padding': 'x'.repeat(padding),
54
- 'xe:options': { leaseId: 'lse_1', leaseToken: TOKEN },
134
+ 'xe:options': options,
55
135
  },
56
136
  firstMatch: [{}],
57
137
  },
58
- });
138
+ };
59
139
  }
60
- // Appium's own cut: _.truncate to MAX_LOG_BODY_LENGTH, "..." included.
61
- const appiumCut = (body) => lodash_1.default.truncate(body, { length: constants_1.MAX_LOG_BODY_LENGTH });
62
- // A body whose cut lands `into` characters inside the token.
63
- function truncatedInsideToken(into) {
64
- const at = sessionBody().indexOf(TOKEN);
65
- const cutAt = constants_1.MAX_LOG_BODY_LENGTH - '...'.length;
66
- const cut = appiumCut(sessionBody(cutAt - at - into));
67
- (0, chai_1.expect)(cut).to.include(TOKEN.slice(0, into));
68
- (0, chai_1.expect)(cut).to.not.include(TOKEN);
69
- return cut;
140
+ function tokenLines(key, token, others) {
141
+ const options = Object.assign(Object.assign({}, others), { [key]: token });
142
+ const body = (padding = 0) => JSON.stringify(sessionRequest(options, padding));
143
+ const once = (raw) => redacted(raw, raw.indexOf(token), token.length);
144
+ const lines = [
145
+ ['the JSON body', () => once(body())],
146
+ ['the [HTTP] request line', () => once(httpLine('/session', appiumCut(body())))],
147
+ ['the coloured [HTTP] request line', () => once(colouredHttpLine('/session', body()))],
148
+ [
149
+ 'the createSession args line',
150
+ () => once('Calling AppiumDriver.createSession() with args: ' +
151
+ appiumCut(JSON.stringify([null, null, sessionRequest(options).capabilities]))),
152
+ ],
153
+ ['util.inspect output', () => once(util_1.default.inspect(sessionRequest(options), { depth: 5 }))],
154
+ ['doubly-escaped JSON', () => once(JSON.stringify(body()))],
155
+ ];
156
+ for (const into of [1, 10, 30]) {
157
+ lines.push([cutInto(into), () => cutLine('/session', body, token, into)]);
158
+ }
159
+ return lines;
70
160
  }
71
- const LOGGED = [
72
- ['the full JSON body', () => `--> POST /session ${sessionBody()}`],
73
- [
74
- 'the colourised body Appium prints',
75
- () => `--> POST /session \u001b[90m${sessionBody()}\u001b[39m`,
76
- ],
77
- [
78
- 'the createSession args line',
79
- () => `Calling AppiumDriver.createSession() with args: ${appiumCut(JSON.stringify([null, null, JSON.parse(sessionBody()).capabilities]))}`,
80
- ],
81
- ['a util.inspect form', () => util_1.default.inspect(JSON.parse(sessionBody()), { depth: 5 })],
82
- ['doubly-escaped JSON', () => JSON.stringify(sessionBody())],
83
- ['a body Appium cut 30 characters into the token', () => truncatedInsideToken(30)],
84
- ['a body Appium cut 1 character into the token', () => truncatedInsideToken(1)],
161
+ // ---------------------------------------------------------------------------
162
+ // Passwords: the dashboard's POST /xenon/api/auth/login body.
163
+ const LOGIN_URL = '/xenon/api/auth/login';
164
+ // A password can hold any character. Most of these would get past a rule that
165
+ // stops at the first quote, or make one that doesn't read escapes eat the rest
166
+ // of the line.
167
+ const PASSWORDS = [
168
+ 'hunter2-Hunter2',
169
+ "it's mine", // util.inspect switches to "..."
170
+ 'say "hi", } then', // escaped quotes and JSON punctuation
171
+ 'it\'s "all" `three`', // util.inspect escapes its own quote
172
+ 'ends in a backslash\\',
173
+ '","password":"decoy', // reads like the next field
174
+ "password: 'decoy", // reads like the util.inspect key
175
+ 'my token: abc"def', // the token rule rewrites part of it first
176
+ 'tab\tnew\nline\u001bescape', // control characters are escaped
177
+ 'pässwörd 🔑 ok',
85
178
  ];
86
- describe("the documented Appium log filter keeps the lease token out of Appium's log", () => {
87
- const forms = [
88
- ['--log-filters JSON', () => JSON.parse(fencedBlock('json'))],
179
+ // Long enough to cut 30 characters in, with an escape early on. No astral
180
+ // characters: lodash counts those as one when it cuts.
181
+ const LONG_PASSWORD = 'c"x\\y it\'s, }] "correct horse" `battery` staple ä\n';
182
+ function loginBody(password, padding = 0) {
183
+ // Padding the address moves the password, keeping the body's shape.
184
+ return { email: `${'x'.repeat(padding)}qa@example.com`, password };
185
+ }
186
+ function passwordLines(password) {
187
+ const body = (padding = 0) => JSON.stringify(loginBody(password, padding));
188
+ const json = JSON.stringify(password).slice(1, -1);
189
+ const inspected = util_1.default.inspect(password);
190
+ // Anchored on what follows the password: it is the body's last field.
191
+ const at = (raw, spelled, after) => redacted(raw, raw.lastIndexOf(spelled + after), spelled.length);
192
+ const lines = [
193
+ ['the JSON body', () => at(body(), json, '"}')],
194
+ ['the [HTTP] request line', () => at(httpLine(LOGIN_URL, appiumCut(body())), json, '"}')],
195
+ ['the coloured [HTTP] request line', () => at(colouredHttpLine(LOGIN_URL, body()), json, '"}')],
196
+ [
197
+ 'util.inspect output',
198
+ () => at(util_1.default.inspect(loginBody(password)), inspected.slice(1, -1), inspected[0]),
199
+ ],
89
200
  [
90
- '--config YAML (server.log-filters)',
91
- () => yaml_1.default.parse(fencedBlock('yaml')).server['log-filters'],
201
+ 'doubly-escaped JSON',
202
+ () => at(JSON.stringify(body()), JSON.stringify(json).slice(1, -1), '\\"}"'),
92
203
  ],
93
204
  ];
94
- for (const [form, rules] of forms) {
205
+ return lines;
206
+ }
207
+ function passwordCutLines() {
208
+ const body = (padding = 0) => JSON.stringify(loginBody(LONG_PASSWORD, padding));
209
+ const json = JSON.stringify(LONG_PASSWORD).slice(1, -1);
210
+ return [1, 10, 30].map((into) => [
211
+ cutInto(into),
212
+ () => cutLine(LOGIN_URL, body, json, into),
213
+ ]);
214
+ }
215
+ describe("the documented Appium log filters keep tokens and passwords out of Appium's log", () => {
216
+ for (const [form, language, parse] of RULE_FORMS) {
95
217
  describe(form, () => {
96
- for (const [name, line] of LOGGED) {
97
- it(`redacts ${name}`, () => __awaiter(void 0, void 0, void 0, function* () {
98
- const pre = yield filterFrom(rules());
99
- const raw = line();
100
- const out = pre.preprocess(raw);
101
- // No run of the token's characters survives, not even its first one
102
- // beside the key.
103
- (0, chai_1.expect)(out).to.include('**LEASE TOKEN**');
104
- (0, chai_1.expect)(out).to.not.match(new RegExp(`leaseToken\\\\?["']?\\s*:\\s*\\\\?["']?${TOKEN[0]}`));
105
- (0, chai_1.expect)(out).to.not.include(TOKEN.slice(0, 8));
106
- // The lease id is not a secret, and the rest of the line is kept.
107
- (0, chai_1.expect)(out).to.include('lse_1');
108
- }));
218
+ const tokenRule = () => parse(docBlock(language, 'token'));
219
+ const passwordRule = () => parse(docBlock(language, 'password'));
220
+ // An operator may run the token rule alone (it shipped first) or both.
221
+ const withTokenRule = [
222
+ ['the token rule', tokenRule],
223
+ ['both rules', () => [...tokenRule(), ...passwordRule()]],
224
+ ];
225
+ for (const { key, value, options } of TOKENS) {
226
+ describe(`a ${key}`, () => {
227
+ for (const [name, line] of tokenLines(key, value, options)) {
228
+ it(`is redacted from ${name}`, () => __awaiter(void 0, void 0, void 0, function* () {
229
+ const { raw, expected } = line();
230
+ for (const [rules, load] of withTokenRule) {
231
+ const out = (yield filterFrom(load())).preprocess(raw);
232
+ // Only the token changes: the access key, the lease id and
233
+ // the rest of the line are kept.
234
+ (0, chai_1.expect)(out, rules).to.equal(expected);
235
+ (0, chai_1.expect)(out, rules).to.not.include(value.slice(0, 8));
236
+ (0, chai_1.expect)(out, rules).to.not.match(new RegExp(`${key}\\\\?["']?\\s*:\\s*\\\\?["']?${lodash_1.default.escapeRegExp(value[0])}`));
237
+ }
238
+ }));
239
+ }
240
+ });
109
241
  }
242
+ it('keeps the access key, which names a key without proving it', () => __awaiter(void 0, void 0, void 0, function* () {
243
+ const raw = JSON.stringify(sessionRequest({ accessKey: ACCESS_KEY }));
244
+ const pre = yield filterFrom([...tokenRule(), ...passwordRule()]);
245
+ (0, chai_1.expect)(pre.preprocess(raw)).to.equal(raw);
246
+ }));
247
+ describe('a password', () => {
248
+ for (const password of PASSWORDS) {
249
+ for (const [name, line] of passwordLines(password)) {
250
+ it(`${JSON.stringify(password)} is redacted from ${name}`, () => __awaiter(void 0, void 0, void 0, function* () {
251
+ const { raw, expected } = line();
252
+ for (const [rules, load] of [
253
+ ['the password rule', passwordRule],
254
+ ['both rules', () => [...tokenRule(), ...passwordRule()]],
255
+ ]) {
256
+ const out = (yield filterFrom(load())).preprocess(raw);
257
+ // Exactly the password goes: the address, the closing quote
258
+ // and everything after it stay.
259
+ (0, chai_1.expect)(out, rules).to.equal(expected);
260
+ (0, chai_1.expect)(out, rules).to.include('qa@example.com');
261
+ }
262
+ }));
263
+ }
264
+ }
265
+ for (const [name, line] of passwordCutLines()) {
266
+ it(`is redacted from ${name}`, () => __awaiter(void 0, void 0, void 0, function* () {
267
+ const { raw, expected } = line();
268
+ const out = (yield filterFrom([...tokenRule(), ...passwordRule()])).preprocess(raw);
269
+ (0, chai_1.expect)(out).to.equal(expected);
270
+ }));
271
+ }
272
+ });
110
273
  });
111
274
  }
112
- it('reads the same rule in both forms', () => {
113
- (0, chai_1.expect)(yaml_1.default.parse(fencedBlock('yaml')).server['log-filters']).to.deep.equal(JSON.parse(fencedBlock('json')));
275
+ it('shows each rule once in each form, and reads the same in both', () => {
276
+ (0, chai_1.expect)(docBlocks('json')).to.have.length(2);
277
+ (0, chai_1.expect)(docBlocks('yaml')).to.have.length(2);
278
+ for (const rule of ['token', 'password']) {
279
+ (0, chai_1.expect)(yaml_1.default.parse(docBlock('yaml', rule)).server['log-filters'], rule).to.deep.equal(JSON.parse(docBlock('json', rule)));
280
+ }
281
+ });
282
+ it('shows the token rule exactly as the 2.0.0 CHANGELOG publishes it', () => {
283
+ const changelog = fs_1.default.readFileSync(CHANGELOG, 'utf8');
284
+ const release = changelog.indexOf('\n## 2.0.0\n');
285
+ const entry = changelog.indexOf("**Keep tokens out of Appium's own request log.**", release);
286
+ (0, chai_1.expect)(release).to.be.greaterThan(-1);
287
+ (0, chai_1.expect)(entry).to.be.greaterThan(release);
288
+ for (const language of ['json', 'yaml']) {
289
+ const published = fencedBlocks(changelog.slice(entry), language)[0];
290
+ (0, chai_1.expect)(docBlock(language, 'token'), language).to.equal(published);
291
+ }
114
292
  });
115
293
  });