@xenon-device-management/xenon 2.15.0 → 2.16.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 (183) hide show
  1. package/README.md +38 -26
  2. package/lib/package.json +2 -1
  3. package/lib/public/assets/{AnnotationOverlay-Btq3dZRI.js → AnnotationOverlay-2W4JvEyZ.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-C9OoAgg_.js → ApiKeyGate-GmckX_7r.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DBC_xczB.js → DeviceMosaicView-DqODzkyo.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DqWU2-vM.js → EmptyState-7hETKEWb.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CveQ3xrA.js → FieldGroup-GOgssFEP.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BfKDSfHz.js → FilterMenu-Yvf6RGMd.js} +1 -1
  9. package/lib/public/assets/{Menu-CtdCybzA.js → Menu-KrPnfdsf.js} +1 -1
  10. package/lib/public/assets/{Modal-Bsvj1tm2.js → Modal-C6WjZFeI.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CRofOiyx.js → RecordingPage-CSRIhZEh.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-jNQHb2IR.js → RecordingsPage-CB8ST3nE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-C8HtTha-.js → SegmentedControl-C9o90KId.js} +1 -1
  14. package/lib/public/assets/{SettingCard-D8K1LZNo.js → SettingCard-CPm3w9l2.js} +1 -1
  15. package/lib/public/assets/{Table-zNTX92Pn.js → Table-DwIgERRt.js} +1 -1
  16. package/lib/public/assets/{activity-CJ8LwZq9.js → activity-DrLbHifn.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CgxQZqrc.js → ai-settings-DU_Ls06P.js} +1 -1
  18. package/lib/public/assets/{api-keys-ECpo-zTo.js → api-keys-CWYdEzJ_.js} +1 -1
  19. package/lib/public/assets/{apps-BjGymyf3.js → apps-BqNVdln7.js} +1 -1
  20. package/lib/public/assets/{arrow-left-BWfCbeHP.js → arrow-left-BtB0_Jg_.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DC7UGf_n.js → arrow-right-C8NhdtyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-BOjwybDk.js → arrow-up-right-gMg0ffvv.js} +1 -1
  23. package/lib/public/assets/{auth-shell-CxMTo9aH.js → auth-shell-CiYZ3N4M.js} +2 -2
  24. package/lib/public/assets/{builds-page-DOCm4HkA.js → builds-page-EaaqDmLN.js} +1 -1
  25. package/lib/public/assets/{button-Cg08-EMT.js → button-DDau4Axv.js} +1 -1
  26. package/lib/public/assets/{calendar-CA1ikLJs.js → calendar-DQ83B1ed.js} +1 -1
  27. package/lib/public/assets/{check-BMTaH72W.js → check-K1ovTQam.js} +1 -1
  28. package/lib/public/assets/{chevron-right-B2QOv9Nx.js → chevron-right-Biq00pMt.js} +1 -1
  29. package/lib/public/assets/{circle-check-CYXBoUqT.js → circle-check-CBv88cH7.js} +1 -1
  30. package/lib/public/assets/{circle-x-DTwnSAnl.js → circle-x-Bmltailn.js} +1 -1
  31. package/lib/public/assets/{clock-C8_HViQP.js → clock-BPrld-3h.js} +1 -1
  32. package/lib/public/assets/{copy-Be7LNMXV.js → copy-t0QB14JI.js} +1 -1
  33. package/lib/public/assets/{device-explorer-BOqtfGXz.js → device-explorer-BgGZ8AvV.js} +1 -1
  34. package/lib/public/assets/{download-B56lwxWP.js → download-BroHxNOh.js} +1 -1
  35. package/lib/public/assets/{forgot-password-CCU6bzLn.js → forgot-password-Bjmf49i6.js} +1 -1
  36. package/lib/public/assets/{h264Stream-C-OtmIuk.js → h264Stream-DeD__6wa.js} +1 -1
  37. package/lib/public/assets/{index-CfxsUslp.js → index-72oF89LJ.js} +1 -1
  38. package/lib/public/assets/{index-B9KQ6LsY.js → index-B0JeuZbg.js} +1 -1
  39. package/lib/public/assets/index-B0gQkEg0.js +219 -0
  40. package/lib/public/assets/{input-CZHFjbSg.js → input-DIc4MSC7.js} +1 -1
  41. package/lib/public/assets/{line-chart-Gl_Dz3X_.js → line-chart-BcWm2vW8.js} +1 -1
  42. package/lib/public/assets/{list-checks-CCgjeyGY.js → list-checks-B06IJJad.js} +1 -1
  43. package/lib/public/assets/{lock-CkOwgDcx.js → lock-pVLvnN30.js} +1 -1
  44. package/lib/public/assets/{login-D5cc4w7M.js → login-CN77vy--.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-t7qr3fvX.js → maintenance-settings-CSdzQSkY.js} +1 -1
  46. package/lib/public/assets/{monitor-DEJixKQO.js → monitor-BtrN5IkI.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CtOrk4M8.js → mouse-pointer-2-CNsQfo6w.js} +1 -1
  48. package/lib/public/assets/{network-Dii9qdvk.js → network-BSfA8ZxG.js} +1 -1
  49. package/lib/public/assets/{overview-rolAACrO.js → overview-BfTebCK5.js} +1 -1
  50. package/lib/public/assets/{page-header-COQZxJfR.js → page-header-D8KUX6Qg.js} +1 -1
  51. package/lib/public/assets/{play-DrN1Se_R.js → play-Cvc8xzic.js} +1 -1
  52. package/lib/public/assets/{plus-5aq-9uRx.js → plus-C9Zj84UF.js} +1 -1
  53. package/lib/public/assets/{profile-page-6i5kY6kF.js → profile-page-Bi27STgs.js} +1 -1
  54. package/lib/public/assets/{reset-password-sp2IBrc7.js → reset-password-CPsOuwSu.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJDjeopU.js → runbook-page-DEQ-DBOX.js} +153 -102
  56. package/lib/public/assets/{select-Bpyn7rWO.js → select-BWpnhWXj.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BH74vmKv.js → selector-detail-redirect-BefSCSpe.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-Cw_g218H.js → selector-health-page-MSk96Zz7.js} +1 -1
  59. package/lib/public/assets/session-detail-page-DPyBNef_.js +4 -0
  60. package/lib/public/assets/{settings-C3uyGhyc.js → settings-V6x-cXwy.js} +1 -1
  61. package/lib/public/assets/{stat-tile-xVqAKIlI.js → stat-tile-Duex-GZD.js} +1 -1
  62. package/lib/public/assets/{tablet-BxUScAeO.js → tablet-BBcoqBmL.js} +1 -1
  63. package/lib/public/assets/{teams-B5ljGkY2.js → teams-ts8p5rEh.js} +1 -1
  64. package/lib/public/assets/{trash-2-B701i8sy.js → trash-2-CZ2ew6AZ.js} +1 -1
  65. package/lib/public/assets/{upload-C2gr44RS.js → upload-Dv99y_le.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-NeQOFEoy.js → use-builds-data-DrYC8JCF.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DEzJq5ef.js → use-password-reset-mode-C7QkBNkP.js} +1 -1
  68. package/lib/public/assets/{users-D_vv_yz4.js → users-D7tKMESH.js} +1 -1
  69. package/lib/public/assets/{users-MY6eNe6s.js → users-gJ5Y01yo.js} +1 -1
  70. package/lib/public/assets/{video-off-OK1lNZQn.js → video-off-CqZVuZGs.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-BU-C9lBf.js → webhook-settings-DI5DSW99.js} +1 -1
  72. package/lib/public/assets/{zap-DO9ry1iz.js → zap-ClcHFxfo.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +11 -7
  75. package/lib/src/InternalHttpClient.js +27 -5
  76. package/lib/src/XenonCapabilityManager.js +10 -3
  77. package/lib/src/app/openapi/sessions.yaml +7 -5
  78. package/lib/src/app/routers/auth.js +2 -1
  79. package/lib/src/app/routers/users.js +2 -1
  80. package/lib/src/app/ws/nodeSocketRelay.js +77 -11
  81. package/lib/src/dashboard/commandLogFields.js +42 -0
  82. package/lib/src/dashboard/event-manager.js +26 -154
  83. package/lib/src/dashboard/services/failure-analysis-service.js +8 -86
  84. package/lib/src/dashboard/services/failureCategories.js +264 -0
  85. package/lib/src/device-managers/AndroidDeviceManager.js +61 -23
  86. package/lib/src/device-managers/IOSDeviceManager.js +10 -0
  87. package/lib/src/device-utils.js +14 -6
  88. package/lib/src/gateway/forwardToNode.js +4 -11
  89. package/lib/src/gateway/hubSessionToken.js +27 -4
  90. package/lib/src/gateway/nodeWebDriverUrl.js +3 -5
  91. package/lib/src/helpers/outboundProxy.js +219 -0
  92. package/lib/src/index.js +21 -0
  93. package/lib/src/interceptors/CommandInterceptor.js +13 -15
  94. package/lib/src/interceptors/xenonScripts.js +28 -7
  95. package/lib/src/logger.js +3 -1
  96. package/lib/src/plugin.js +8 -3
  97. package/lib/src/scripts/database-check.js +3 -3
  98. package/lib/src/scripts/initialize-database.js +35 -33
  99. package/lib/src/scripts/run-migrations.js +1079 -27
  100. package/lib/src/services/ApiKeyService.js +16 -5
  101. package/lib/src/services/InterceptorService.js +6 -2
  102. package/lib/src/services/OrphanSweeper.js +6 -0
  103. package/lib/src/services/SelectorStateService.js +11 -7
  104. package/lib/src/services/SelectorVerificationJob.js +6 -2
  105. package/lib/src/services/SessionLifecycleService.js +32 -18
  106. package/lib/src/services/SocketClient.js +11 -0
  107. package/lib/src/services/SocketServer.js +361 -42
  108. package/lib/src/services/TeamService.js +5 -1
  109. package/lib/src/services/TracingService.js +171 -60
  110. package/lib/src/services/UserService.js +14 -3
  111. package/lib/src/services/UserSessionService.js +16 -4
  112. package/lib/src/services/healing/HealingOrchestrator.js +3 -1
  113. package/lib/src/services/identity/identityChanges.js +41 -0
  114. package/lib/src/services/logcat/SessionDeviceLogs.js +97 -10
  115. package/lib/src/services/logcat/deviceLogBook.js +5 -4
  116. package/lib/src/services/logcat/iosDriverLog.js +178 -0
  117. package/lib/src/services/selector-health/SelectorVisibilityResolver.js +121 -0
  118. package/lib/src/services/token/JwtKeyService.js +8 -2
  119. package/lib/src/services/token/mcpTokenTtl.js +27 -0
  120. package/lib/src/sessions/LocalSession.js +29 -1
  121. package/lib/src/sessions/RemoteSession.js +12 -2
  122. package/lib/src/sessions/SessionManager.js +5 -8
  123. package/lib/test/helpers/driver-log.js +38 -0
  124. package/lib/test/helpers/fake-http-proxy.js +106 -0
  125. package/lib/test/helpers/otlp-probe.js +107 -0
  126. package/lib/test/integration/androidDevices.spec.js +1 -1
  127. package/lib/test/integration/ios/01iOSSimulator.spec.js +1 -1
  128. package/lib/test/integration/ios/02iOSDevices.spec.js +1 -1
  129. package/lib/test/integration/selector-state-flow.spec.js +5 -5
  130. package/lib/test/unit/XenonCapabilityManager.spec.js +23 -0
  131. package/lib/test/unit/android-adb-retry.spec.js +112 -0
  132. package/lib/test/unit/command-log-network-capture.spec.js +191 -0
  133. package/lib/test/unit/dashboard-events-device-logs.spec.js +59 -11
  134. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  135. package/lib/test/unit/database-check.spec.js +1 -0
  136. package/lib/test/unit/device-utils.spec.js +13 -4
  137. package/lib/test/unit/event-log-session-data.spec.js +11 -4
  138. package/lib/test/unit/failure-analysis.spec.js +22 -8
  139. package/lib/test/unit/failure-categories.spec.js +24 -22
  140. package/lib/test/unit/failure-categorization.spec.js +393 -0
  141. package/lib/test/unit/healing-tier-gate.spec.js +3 -2
  142. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +72 -1
  143. package/lib/test/unit/ios-driver-log.spec.js +168 -0
  144. package/lib/test/unit/ios-simulator-session-readiness.spec.js +187 -0
  145. package/lib/test/unit/ios-simulator-session-teardown.spec.js +158 -0
  146. package/lib/test/unit/logcat-ws.spec.js +78 -37
  147. package/lib/test/unit/logger-json-levels.spec.js +44 -0
  148. package/lib/test/unit/mcp-token-ttl.spec.js +38 -0
  149. package/lib/test/unit/observability-example-loki.spec.js +25 -0
  150. package/lib/test/unit/openapi-for-site.spec.js +80 -0
  151. package/lib/test/unit/outbound-proxy.spec.js +320 -0
  152. package/lib/test/unit/port-exhaustion.spec.js +2 -1
  153. package/lib/test/unit/proxy-option.spec.js +358 -0
  154. package/lib/test/unit/run-migrations-database.spec.js +807 -0
  155. package/lib/test/unit/run-migrations-fail-fast.test.js +41 -27
  156. package/lib/test/unit/run-migrations.test.js +864 -0
  157. package/lib/test/unit/selector-health-actions.spec.js +3 -1
  158. package/lib/test/unit/selector-state-service.spec.js +72 -23
  159. package/lib/test/unit/selector-verification-clean-build.spec.js +1 -1
  160. package/lib/test/unit/selector-verification-job.spec.js +15 -6
  161. package/lib/test/unit/selector-verification-missed-regression.spec.js +2 -2
  162. package/lib/test/unit/selector-visibility-resolver.spec.js +209 -0
  163. package/lib/test/unit/session-device-logs-ios.spec.js +180 -0
  164. package/lib/test/unit/session-device-logs.spec.js +11 -2
  165. package/lib/test/unit/session-end-failure-analysis.spec.js +5 -5
  166. package/lib/test/unit/session-log-indexes.spec.js +1 -1
  167. package/lib/test/unit/socket-team-scope.integration.spec.js +749 -0
  168. package/lib/test/unit/socketServer.scopedEmit.spec.js +237 -5
  169. package/lib/test/unit/tracing-export-routing.spec.js +137 -0
  170. package/lib/test/unit/tracing-session-spans.spec.js +337 -0
  171. package/lib/test/unit/unexpected-shutdown-reason.spec.js +2 -2
  172. package/lib/tsconfig.tsbuildinfo +1 -1
  173. package/package.json +2 -1
  174. package/schema.json +11 -7
  175. package/scripts/dev/readme-screenshots.js +276 -23
  176. package/scripts/dev/screenshot-fixtures/logcat.js +157 -0
  177. package/scripts/dev/screenshot-fixtures/phone-screen.html +469 -0
  178. package/scripts/dev/screenshot-fixtures/session.js +242 -0
  179. package/scripts/export-openapi.js +32 -0
  180. package/scripts/lib/openapi-for-site.js +79 -0
  181. package/lib/public/assets/index-BOxe43p1.js +0 -219
  182. package/lib/public/assets/session-detail-page-AZJE6XXE.js +0 -4
  183. package/lib/src/scripts/prepare-prisma.js +0 -89
@@ -16,6 +16,7 @@ require("reflect-metadata");
16
16
  const chai_1 = require("chai");
17
17
  const sinon_1 = __importDefault(require("sinon"));
18
18
  const http_1 = __importDefault(require("http"));
19
+ const express_1 = __importDefault(require("express"));
19
20
  const fs_1 = __importDefault(require("fs"));
20
21
  const os_1 = __importDefault(require("os"));
21
22
  const path_1 = __importDefault(require("path"));
@@ -24,16 +25,30 @@ const socket_io_client_1 = require("socket.io-client");
24
25
  const SocketServer_1 = require("../../src/services/SocketServer");
25
26
  const EventLogService_1 = require("../../src/services/EventLogService");
26
27
  const JwtKeyService_1 = require("../../src/services/token/JwtKeyService");
28
+ const verifyCredential_1 = require("../../src/middleware/verifyCredential");
27
29
  const ApiKeyService_1 = require("../../src/services/ApiKeyService");
30
+ const UserSessionService_1 = require("../../src/services/UserSessionService");
28
31
  const DeviceTeamResolver_1 = require("../../src/services/device-access/DeviceTeamResolver");
29
32
  const device_store_1 = require("../../src/data-service/device-store");
30
33
  const device_service_1 = require("../../src/data-service/device-service");
31
34
  const event_manager_1 = require("../../src/dashboard/event-manager");
32
35
  const SessionManager_1 = require("../../src/sessions/SessionManager");
33
36
  const NotificationService_1 = require("../../src/services/NotificationService");
37
+ const InterceptorService_1 = require("../../src/services/InterceptorService");
38
+ const SelectorStateService_1 = require("../../src/services/SelectorStateService");
39
+ const SelectorVisibilityResolver_1 = require("../../src/services/selector-health/SelectorVisibilityResolver");
34
40
  const prisma_1 = require("../../src/prisma");
35
41
  const config_1 = require("../../src/config");
42
+ const users_1 = require("../../src/app/routers/users");
43
+ const teams_1 = require("../../src/app/routers/teams");
44
+ const apikeys_1 = require("../../src/app/routers/apikeys");
45
+ const auth_1 = require("../../src/app/routers/auth");
36
46
  const container_registration_1 = require("../helpers/container-registration");
47
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
48
+ const scratch_database_1 = require("../helpers/scratch-database");
49
+ const UserService_1 = require("../../src/services/UserService");
50
+ const identityChanges_1 = require("../../src/services/identity/identityChanges");
51
+ const selector_health_fixture_1 = require("../helpers/selector-health-fixture");
37
52
  /**
38
53
  * A real Socket.io server (SocketServer.initialize on a real http server) and
39
54
  * two real socket.io clients: a team-A member and an admin, both connecting
@@ -173,4 +188,738 @@ describe('Live dashboard events are team-scoped (real Socket.io, two clients)',
173
188
  (0, chai_1.expect)(member.inbox).to.have.length(2);
174
189
  (0, chai_1.expect)(onPhone(member.inbox, 'phone-a')).to.deep.equal(['device_added', 'session_command']);
175
190
  }));
191
+ it("a request captured on the member's own team phone reaches the admin only", () => __awaiter(void 0, void 0, void 0, function* () {
192
+ const member = yield connect('u-member-a');
193
+ const admin = yield connect('u-admin');
194
+ yield until(() => dashboardRoomSize() === 2, 'both clients in the dashboard room');
195
+ // The headers and bodies of a captured request can carry the app's
196
+ // sign-in tokens; REST's /interceptor routes are admin-only.
197
+ const captured = {
198
+ id: 'q1',
199
+ sessionId: 'session-on-phone-a',
200
+ reqHeaders: { authorization: 'Bearer app-secret' },
201
+ resHeaders: { 'set-cookie': 'sid=app-secret' },
202
+ };
203
+ const interceptor = new InterceptorService_1.InterceptorService();
204
+ interceptor.emit({ type: 'session_started', sessionId: 'session-on-phone-a', port: 1, host: 'h' }, 'phone-a');
205
+ interceptor.emit({ type: 'request', sessionId: captured.sessionId, payload: captured }, 'phone-a');
206
+ interceptor.emit({ type: 'session_stopped', sessionId: captured.sessionId }, 'phone-a');
207
+ // A member's event on the same phone, sent after them, proves the
208
+ // member's socket is live: one chain delivers the phone's events in order.
209
+ yield sessionCommand('phone-a');
210
+ yield until(() => admin.inbox.length >= 4, 'the admin to get all four events');
211
+ yield until(() => member.inbox.length >= 1, 'the member to get the session command');
212
+ yield new Promise((r) => setTimeout(r, 50));
213
+ (0, chai_1.expect)(admin.inbox.map(([event]) => event)).to.deep.equal([
214
+ 'interceptor_session_started',
215
+ 'interceptor_request',
216
+ 'interceptor_session_stopped',
217
+ 'session_command',
218
+ ]);
219
+ (0, chai_1.expect)(admin.inbox[1][1]).to.deep.equal(captured);
220
+ (0, chai_1.expect)(member.inbox.map(([event]) => event)).to.deep.equal(['session_command']);
221
+ }));
222
+ });
223
+ /**
224
+ * Selector Health's live events, on a real Socket.io server and a real
225
+ * (scratch) database seeded with Selector Health's own fixture: Priya is on
226
+ * team A, Alex on team B. A selector reaches a member only if it healed in a
227
+ * session they may see, as `GET /healing/selectors` decides.
228
+ */
229
+ describe('Selector events reach only those who may see the selector (real Socket.io, real database)', () => {
230
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
231
+ const ADMIN_ID = 'sh-u-admin';
232
+ let httpServer;
233
+ let server;
234
+ let url;
235
+ let dir;
236
+ let restore;
237
+ let authDisabled;
238
+ const clients = [];
239
+ before(() => __awaiter(void 0, void 0, void 0, function* () {
240
+ yield (0, selector_health_fixture_1.seedSelectorHealth)(scratch.db);
241
+ yield scratch.db.user.create({
242
+ data: {
243
+ id: ADMIN_ID,
244
+ name: 'Admin',
245
+ email: 'admin@xenon.local',
246
+ passwordHash: 'x',
247
+ accessKey: 'ak-admin',
248
+ role: 'ADMIN',
249
+ },
250
+ });
251
+ yield scratch.db.teamMember.create({ data: { teamId: selector_health_fixture_1.TEAM.a, userId: selector_health_fixture_1.USER.priya } });
252
+ yield scratch.db.teamMember.create({ data: { teamId: selector_health_fixture_1.TEAM.b, userId: selector_health_fixture_1.USER.alex } });
253
+ }));
254
+ function connect(userId) {
255
+ return __awaiter(this, void 0, void 0, function* () {
256
+ const bearer = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ sub: userId }, { audience: 'xenon-rest', ttlSeconds: 60 });
257
+ const socket = (0, socket_io_client_1.io)(url, {
258
+ auth: { bearer },
259
+ transports: ['websocket'],
260
+ reconnection: false,
261
+ forceNew: true,
262
+ });
263
+ clients.push(socket);
264
+ const inbox = [];
265
+ socket.onAny((event, data) => inbox.push([event, data]));
266
+ yield new Promise((resolve, reject) => {
267
+ socket.once('connect', () => resolve());
268
+ socket.once('connect_error', reject);
269
+ });
270
+ socket.emit('register_dashboard');
271
+ return inbox;
272
+ });
273
+ }
274
+ function until(check_1, what_1) {
275
+ return __awaiter(this, arguments, void 0, function* (check, what, ms = 3000) {
276
+ const deadline = Date.now() + ms;
277
+ while (!check()) {
278
+ if (Date.now() > deadline)
279
+ throw new Error(`timed out waiting for ${what}`);
280
+ yield new Promise((r) => setTimeout(r, 10));
281
+ }
282
+ });
283
+ }
284
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
285
+ authDisabled = config_1.config.authDisabled;
286
+ config_1.config.authDisabled = false;
287
+ restore = (0, container_registration_1.saveRegistrations)(SocketServer_1.SocketServer, EventLogService_1.EventLogService, JwtKeyService_1.JwtKeyService, ApiKeyService_1.ApiKeyService, SelectorVisibilityResolver_1.SelectorVisibilityResolver);
288
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-selector-scope-'));
289
+ const keys = new JwtKeyService_1.JwtKeyService();
290
+ yield keys.init(dir);
291
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, keys);
292
+ typedi_1.Container.set(ApiKeyService_1.ApiKeyService, { verifyPair: () => __awaiter(void 0, void 0, void 0, function* () { return null; }), verify: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) });
293
+ typedi_1.Container.set(EventLogService_1.EventLogService, { appendSafe: () => undefined });
294
+ typedi_1.Container.set(SelectorVisibilityResolver_1.SelectorVisibilityResolver, new SelectorVisibilityResolver_1.SelectorVisibilityResolver());
295
+ server = new SocketServer_1.SocketServer();
296
+ typedi_1.Container.set(SocketServer_1.SocketServer, server);
297
+ httpServer = http_1.default.createServer();
298
+ server.initialize(httpServer);
299
+ yield new Promise((r) => httpServer.listen(0, '127.0.0.1', () => r()));
300
+ url = `http://127.0.0.1:${httpServer.address().port}`;
301
+ }));
302
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
303
+ for (const c of clients.splice(0))
304
+ c.close();
305
+ yield new Promise((r) => server.io ? server.io.close(() => r()) : r());
306
+ yield new Promise((r) => httpServer.close(() => r()));
307
+ restore();
308
+ config_1.config.authDisabled = authDisabled;
309
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
310
+ }));
311
+ it("muting team B's selector reaches the admin and Alex, not Priya; team A's reaches the admin and Priya", () => __awaiter(void 0, void 0, void 0, function* () {
312
+ const admin = yield connect(ADMIN_ID);
313
+ const priya = yield connect(selector_health_fixture_1.USER.priya);
314
+ const alex = yield connect(selector_health_fixture_1.USER.alex);
315
+ yield until(() => { var _a, _b; return ((_b = (_a = server.io) === null || _a === void 0 ? void 0 : _a.sockets.adapter.rooms.get('dashboard')) === null || _b === void 0 ? void 0 : _b.size) === 3; }, 'all three clients in the dashboard room');
316
+ // The scratch client itself: its transaction must not reach another database.
317
+ const states = new SelectorStateService_1.SelectorStateService(scratch.db);
318
+ yield states.mute({ strategy: 'xpath', selector: selector_health_fixture_1.SEL.bOnly, apiKeyId: '', reason: 'redesign' });
319
+ yield states.mute({ strategy: 'id', selector: selector_health_fixture_1.SEL.warm, apiKeyId: '' });
320
+ yield until(() => admin.length >= 2, 'the admin to get both events');
321
+ yield until(() => priya.length >= 1 && alex.length >= 1, 'each member to get their own');
322
+ // Give any stray delivery a moment to land before asserting its absence.
323
+ yield new Promise((r) => setTimeout(r, 50));
324
+ const muted = (inbox) => inbox
325
+ .filter(([event]) => event === 'selector_muted')
326
+ .map(([, data]) => data.original_selector)
327
+ .sort();
328
+ (0, chai_1.expect)(muted(admin)).to.deep.equal([selector_health_fixture_1.SEL.warm, selector_health_fixture_1.SEL.bOnly].sort());
329
+ (0, chai_1.expect)(muted(priya)).to.deep.equal([selector_health_fixture_1.SEL.warm]);
330
+ (0, chai_1.expect)(muted(alex)).to.deep.equal([selector_health_fixture_1.SEL.bOnly]);
331
+ (0, chai_1.expect)(priya, 'nothing else').to.have.length(1);
332
+ (0, chai_1.expect)(alex, 'nothing else').to.have.length(1);
333
+ }));
334
+ });
335
+ /**
336
+ * A socket keeps who it is (role, teams, and that its sign-in is still good)
337
+ * from its handshake. REST looks the caller up again on every request, so a
338
+ * change to the user reaches it at once; these hold the live events to the
339
+ * same, on a real Socket.io server and real clients, against a real (scratch)
340
+ * database, through the routes and services that make the change.
341
+ *
342
+ * A socket whose user may still connect is updated where it is, never
343
+ * disconnected. One whose credential no longer checks out has its connection
344
+ * closed: the client reconnects as after any drop, and the handshake decides
345
+ * on the credential it has then. These clients don't reconnect, so a closed
346
+ * one stays closed ('transport close'), except where a test says otherwise.
347
+ */
348
+ describe("A socket's identity follows its user (real Socket.io, real database)", () => {
349
+ const scratch = (0, scratch_database_1.useScratchDatabase)();
350
+ const ID = {
351
+ super: 'rv-u-super',
352
+ admin: 'rv-u-admin',
353
+ admin2: 'rv-u-admin2',
354
+ member: 'rv-u-member',
355
+ };
356
+ const TEAMS = { a: 'rv-team-a', b: 'rv-team-b' };
357
+ const PHONES = {
358
+ 'rv-phone-a': TEAMS.a,
359
+ 'rv-phone-b': TEAMS.b,
360
+ 'rv-phone-shared': null,
361
+ };
362
+ const SESSION = { member: 'rv-sess-member', admin: 'rv-sess-admin' };
363
+ const MEMBER_KEY = 'rv-raw-member-key';
364
+ const MEMBER_KEY_ID = 'rv-key-member';
365
+ const SUPER = {
366
+ kind: 'user-session',
367
+ userId: ID.super,
368
+ role: 'SUPER_ADMIN',
369
+ scopes: 'admin,devices,sessions,read',
370
+ teamIds: undefined,
371
+ };
372
+ let httpServer;
373
+ let server;
374
+ let url;
375
+ let dir;
376
+ let restore;
377
+ let authDisabled;
378
+ let app;
379
+ const clients = [];
380
+ const ttl = process.env.XENON_USER_SESSION_TTL_MS;
381
+ function seed() {
382
+ return __awaiter(this, void 0, void 0, function* () {
383
+ const db = scratch.db;
384
+ yield db.teamMember.deleteMany();
385
+ yield db.userSession.deleteMany();
386
+ yield db.apiKey.deleteMany();
387
+ yield db.team.deleteMany();
388
+ yield db.user.deleteMany();
389
+ const user = (id, role) => db.user.create({
390
+ data: {
391
+ id,
392
+ role,
393
+ name: id,
394
+ email: `${id}@xenon.local`,
395
+ passwordHash: 'x',
396
+ accessKey: `ak-${id}`,
397
+ },
398
+ });
399
+ yield user(ID.super, 'SUPER_ADMIN');
400
+ yield user(ID.admin, 'ADMIN');
401
+ yield user(ID.admin2, 'ADMIN');
402
+ yield user(ID.member, 'MEMBER');
403
+ yield db.team.create({ data: { id: TEAMS.a, name: 'Team A' } });
404
+ yield db.team.create({ data: { id: TEAMS.b, name: 'Team B' } });
405
+ yield db.teamMember.create({ data: { teamId: TEAMS.a, userId: ID.member } });
406
+ const inAnHour = new Date(Date.now() + 60 * 60 * 1000);
407
+ yield db.userSession.create({
408
+ data: { id: SESSION.member, userId: ID.member, expiresAt: inAnHour },
409
+ });
410
+ yield db.userSession.create({
411
+ data: { id: SESSION.admin, userId: ID.admin, expiresAt: inAnHour },
412
+ });
413
+ yield db.apiKey.create({
414
+ data: {
415
+ id: MEMBER_KEY_ID,
416
+ name: 'member key',
417
+ keyHash: new ApiKeyService_1.ApiKeyService().hash(MEMBER_KEY),
418
+ scopes: 'devices,sessions,read',
419
+ userId: ID.member,
420
+ },
421
+ });
422
+ });
423
+ }
424
+ /** Starts the server; each test calls it first, with any options it needs. */
425
+ function start(options) {
426
+ return __awaiter(this, void 0, void 0, function* () {
427
+ const listening = http_1.default.createServer();
428
+ httpServer = listening;
429
+ server.initialize(listening, options);
430
+ yield new Promise((r) => listening.listen(0, '127.0.0.1', () => r()));
431
+ url = `http://127.0.0.1:${listening.address().port}`;
432
+ });
433
+ }
434
+ /** A dashboard client (bearer or cookie), or a node's (an access key and token). */
435
+ function connect(as) {
436
+ return __awaiter(this, void 0, void 0, function* () {
437
+ var _a;
438
+ const credentials = 'bearerToken' in as
439
+ ? { auth: { bearer: as.bearerToken } }
440
+ : 'bearer' in as
441
+ ? {
442
+ auth: {
443
+ bearer: yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ sub: as.bearer }, { audience: 'xenon-rest', ttlSeconds: (_a = as.ttlSeconds) !== null && _a !== void 0 ? _a : 60 }),
444
+ },
445
+ }
446
+ : 'cookie' in as
447
+ ? { extraHeaders: { cookie: `xenon_dashboard_session=${as.cookie}` } }
448
+ : { auth: { accessKey: as.accessKey, token: as.token } };
449
+ const socket = (0, socket_io_client_1.io)(url, Object.assign(Object.assign({}, credentials), { transports: ['websocket'], reconnection: false, forceNew: true }));
450
+ clients.push(socket);
451
+ const inbox = [];
452
+ let reason;
453
+ socket.onAny((event, data) => inbox.push([event, data]));
454
+ socket.on('disconnect', (why) => {
455
+ reason = why;
456
+ });
457
+ yield new Promise((resolve, reject) => {
458
+ socket.once('connect', () => resolve());
459
+ socket.once('connect_error', reject);
460
+ });
461
+ if (!('accessKey' in as))
462
+ socket.emit('register_dashboard');
463
+ return { socket, inbox, ended: () => reason };
464
+ });
465
+ }
466
+ /** Who the server holds the client's socket to be now. */
467
+ const identityOf = (client) => { var _a, _b; return (_b = (_a = server.io) === null || _a === void 0 ? void 0 : _a.sockets.sockets.get(client.socket.id)) === null || _b === void 0 ? void 0 : _b.data.identity; };
468
+ function untilAsync(check_1, what_1) {
469
+ return __awaiter(this, arguments, void 0, function* (check, what, ms = 3000) {
470
+ const deadline = Date.now() + ms;
471
+ while (!(yield check())) {
472
+ if (Date.now() > deadline)
473
+ throw new Error(`timed out waiting for ${what}`);
474
+ yield new Promise((r) => setTimeout(r, 10));
475
+ }
476
+ });
477
+ }
478
+ function until(check_1, what_1) {
479
+ return __awaiter(this, arguments, void 0, function* (check, what, ms = 3000) {
480
+ const deadline = Date.now() + ms;
481
+ while (!check()) {
482
+ if (Date.now() > deadline)
483
+ throw new Error(`timed out waiting for ${what}`);
484
+ yield new Promise((r) => setTimeout(r, 10));
485
+ }
486
+ });
487
+ }
488
+ const roomSize = () => { var _a, _b, _c; return (_c = (_b = (_a = server.io) === null || _a === void 0 ? void 0 : _a.sockets.adapter.rooms.get('dashboard')) === null || _b === void 0 ? void 0 : _b.size) !== null && _c !== void 0 ? _c : 0; };
489
+ /** One event about a phone, delivered to every socket that may see it before this settles. */
490
+ function emit(udid, event, adminOnly = false) {
491
+ return server.emitToDashboardForDevices(event, { udid }, { udid, teamId: PHONES[udid], adminOnly });
492
+ }
493
+ /** Lets a delivery that was going to reach a socket land before its absence is asserted. */
494
+ const settle = () => new Promise((r) => setTimeout(r, 50));
495
+ const seen = (client) => client.inbox.map(([event, data]) => `${event}:${data.udid}`);
496
+ beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
497
+ authDisabled = config_1.config.authDisabled;
498
+ config_1.config.authDisabled = false;
499
+ restore = (0, container_registration_1.saveRegistrations)(SocketServer_1.SocketServer, EventLogService_1.EventLogService, JwtKeyService_1.JwtKeyService, ApiKeyService_1.ApiKeyService, UserSessionService_1.UserSessionService, DeviceTeamResolver_1.DeviceTeamResolver, SelectorVisibilityResolver_1.SelectorVisibilityResolver);
500
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-socket-identity-'));
501
+ const keys = new JwtKeyService_1.JwtKeyService();
502
+ yield keys.init(dir);
503
+ typedi_1.Container.set(JwtKeyService_1.JwtKeyService, keys);
504
+ typedi_1.Container.set(ApiKeyService_1.ApiKeyService, new ApiKeyService_1.ApiKeyService());
505
+ typedi_1.Container.set(UserSessionService_1.UserSessionService, new UserSessionService_1.UserSessionService());
506
+ typedi_1.Container.set(EventLogService_1.EventLogService, { appendSafe: () => undefined });
507
+ typedi_1.Container.set(DeviceTeamResolver_1.DeviceTeamResolver, new DeviceTeamResolver_1.DeviceTeamResolver({
508
+ findDevice: (udid) => __awaiter(void 0, void 0, void 0, function* () { return udid in PHONES ? { udid, teamId: PHONES[udid] } : null; }),
509
+ }));
510
+ yield seed();
511
+ server = new SocketServer_1.SocketServer();
512
+ typedi_1.Container.set(SocketServer_1.SocketServer, server);
513
+ // The routes the dashboard's Users, Teams and API keys pages call, and
514
+ // sign-out, as a super admin.
515
+ app = (0, express_1.default)();
516
+ app.use(express_1.default.json());
517
+ app.use('/auth', (0, auth_1.authPublicRouter)());
518
+ app.use((req, _res, next) => {
519
+ req.auth = SUPER;
520
+ next();
521
+ });
522
+ app.use('/users', (0, users_1.usersRouter)());
523
+ app.use('/teams', (0, teams_1.teamsRouter)());
524
+ app.use('/apikeys', (0, apikeys_1.apiKeysRouter)());
525
+ }));
526
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
527
+ for (const c of clients.splice(0))
528
+ c.close();
529
+ yield new Promise((r) => server.io ? server.io.close(() => r()) : r());
530
+ if (ttl === undefined)
531
+ delete process.env.XENON_USER_SESSION_TTL_MS;
532
+ else
533
+ process.env.XENON_USER_SESSION_TTL_MS = ttl;
534
+ const open = httpServer;
535
+ if (open === null || open === void 0 ? void 0 : open.listening)
536
+ yield new Promise((r) => open.close(() => r()));
537
+ httpServer = undefined;
538
+ sinon_1.default.restore();
539
+ restore();
540
+ config_1.config.authDisabled = authDisabled;
541
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
542
+ }));
543
+ it('an admin demoted to member gets no captured request once the change is saved, and stays connected as a member', () => __awaiter(void 0, void 0, void 0, function* () {
544
+ yield start();
545
+ const demoted = yield connect({ bearer: ID.admin });
546
+ const control = yield connect({ bearer: ID.admin2 });
547
+ yield until(() => roomSize() === 2, 'both clients in the dashboard room');
548
+ yield (0, loopbackRequest_1.default)(app).patch(`/users/${ID.admin}`).send({ role: 'MEMBER' }).expect(200);
549
+ yield emit('rv-phone-shared', 'interceptor_request', true);
550
+ yield emit('rv-phone-a', 'device_blocked');
551
+ yield emit('rv-phone-shared', 'device_unblocked');
552
+ yield until(() => control.inbox.length >= 3, 'the other admin to get all three');
553
+ yield until(() => demoted.inbox.length >= 1, "the shared phone's event");
554
+ yield settle();
555
+ (0, chai_1.expect)(seen(control)).to.deep.equal([
556
+ 'interceptor_request:rv-phone-shared',
557
+ 'device_blocked:rv-phone-a',
558
+ 'device_unblocked:rv-phone-shared',
559
+ ]);
560
+ // A member of no team: the shared pool only, and no captured traffic.
561
+ (0, chai_1.expect)(seen(demoted)).to.deep.equal(['device_unblocked:rv-phone-shared']);
562
+ (0, chai_1.expect)(demoted.socket.connected, 'still connected').to.equal(true);
563
+ (0, chai_1.expect)(demoted.ended()).to.equal(undefined);
564
+ }));
565
+ it('a disabled user is signed out of every socket once the change is saved; others stay', () => __awaiter(void 0, void 0, void 0, function* () {
566
+ yield start();
567
+ const disabled = yield connect({ bearer: ID.admin });
568
+ const sameUserCookie = yield connect({ cookie: SESSION.admin });
569
+ const control = yield connect({ bearer: ID.admin2 });
570
+ yield until(() => roomSize() === 3, 'all three clients in the dashboard room');
571
+ yield (0, loopbackRequest_1.default)(app).patch(`/users/${ID.admin}`).send({ status: 'INACTIVE' }).expect(200);
572
+ yield until(() => disabled.ended() !== undefined, 'the bearer socket to end');
573
+ yield until(() => sameUserCookie.ended() !== undefined, 'the cookie socket to end');
574
+ (0, chai_1.expect)(disabled.ended()).to.equal('transport close');
575
+ (0, chai_1.expect)(sameUserCookie.ended()).to.equal('transport close');
576
+ yield emit('rv-phone-shared', 'interceptor_request', true);
577
+ yield until(() => control.inbox.length >= 1, 'the other admin to get it');
578
+ (0, chai_1.expect)(control.socket.connected).to.equal(true);
579
+ }));
580
+ it("a deleted user's socket is ended", () => __awaiter(void 0, void 0, void 0, function* () {
581
+ yield start();
582
+ const deleted = yield connect({ bearer: ID.member });
583
+ yield until(() => roomSize() === 1, 'the client in the dashboard room');
584
+ yield (0, loopbackRequest_1.default)(app).delete(`/users/${ID.member}`).expect(204);
585
+ yield until(() => deleted.ended() !== undefined, 'the socket to end');
586
+ (0, chai_1.expect)(deleted.ended()).to.equal('transport close');
587
+ }));
588
+ it("a member taken off a team stops getting its phones' events, and one added to a team starts, without reconnecting", () => __awaiter(void 0, void 0, void 0, function* () {
589
+ yield start();
590
+ const member = yield connect({ cookie: SESSION.member });
591
+ const admin = yield connect({ bearer: ID.admin });
592
+ yield until(() => roomSize() === 2, 'both clients in the dashboard room');
593
+ yield (0, loopbackRequest_1.default)(app).delete(`/teams/${TEAMS.a}/members/${ID.member}`).expect(200);
594
+ yield emit('rv-phone-a', 'device_blocked');
595
+ yield until(() => admin.inbox.length >= 1, "the admin to get team A's event");
596
+ yield settle();
597
+ (0, chai_1.expect)(seen(member), 'off team A').to.deep.equal([]);
598
+ yield (0, loopbackRequest_1.default)(app).post(`/teams/${TEAMS.b}/members`).send({ userId: ID.member }).expect(200);
599
+ yield emit('rv-phone-b', 'device_blocked');
600
+ yield until(() => member.inbox.length >= 1, "team B's event");
601
+ (0, chai_1.expect)(seen(member), 'on team B').to.deep.equal(['device_blocked:rv-phone-b']);
602
+ (0, chai_1.expect)(member.ended()).to.equal(undefined);
603
+ }));
604
+ it("signing out ends that sign-in's socket, not the same user's other ones", () => __awaiter(void 0, void 0, void 0, function* () {
605
+ yield start();
606
+ const signedOut = yield connect({ cookie: SESSION.member });
607
+ const ide = yield connect({ bearer: ID.member });
608
+ yield until(() => roomSize() === 2, 'both clients in the dashboard room');
609
+ yield (0, loopbackRequest_1.default)(app)
610
+ .post('/auth/logout')
611
+ .set('Cookie', `xenon_dashboard_session=${SESSION.member}`)
612
+ .expect(204);
613
+ yield until(() => signedOut.ended() !== undefined, 'the signed-out socket to end');
614
+ (0, chai_1.expect)(signedOut.ended()).to.equal('transport close');
615
+ yield emit('rv-phone-a', 'device_blocked');
616
+ yield until(() => ide.inbox.length >= 1, "the bearer socket to get team A's event");
617
+ (0, chai_1.expect)(ide.ended()).to.equal(undefined);
618
+ }));
619
+ it("a revoked API key's socket is ended", () => __awaiter(void 0, void 0, void 0, function* () {
620
+ yield start();
621
+ const keyed = yield connect({ cookie: MEMBER_KEY });
622
+ yield until(() => roomSize() === 1, 'the client in the dashboard room');
623
+ yield (0, loopbackRequest_1.default)(app).delete(`/apikeys/${MEMBER_KEY_ID}`).expect(200);
624
+ yield until(() => keyed.ended() !== undefined, 'the socket to end');
625
+ (0, chai_1.expect)(keyed.ended()).to.equal('transport close');
626
+ }));
627
+ it('a bearer socket closes when REST stops taking its token, and the token is refused from then on', function () {
628
+ return __awaiter(this, void 0, void 0, function* () {
629
+ this.timeout(10000);
630
+ yield start();
631
+ // Past its exp, but within the 60 s REST still takes it for: ~2 s left.
632
+ const bearer = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ sub: ID.admin }, { audience: 'xenon-rest', ttlSeconds: 2 - JwtKeyService_1.JWT_CLOCK_TOLERANCE_SEC });
633
+ (0, chai_1.expect)(yield (0, verifyCredential_1.verifyBearerCredential)(bearer), 'REST takes it').to.not.equal(null);
634
+ const ide = yield connect({ bearerToken: bearer });
635
+ yield until(() => ide.ended() !== undefined, 'the socket to close', 3500);
636
+ (0, chai_1.expect)(ide.ended()).to.equal('transport close');
637
+ (0, chai_1.expect)(yield (0, verifyCredential_1.verifyBearerCredential)(bearer), 'nor does REST, by then').to.equal(null);
638
+ const late = (0, socket_io_client_1.io)(url, {
639
+ auth: { bearer },
640
+ transports: ['websocket'],
641
+ reconnection: false,
642
+ forceNew: true,
643
+ });
644
+ clients.push(late);
645
+ const refused = yield new Promise((resolve) => {
646
+ late.once('connect', () => resolve('connected'));
647
+ late.once('connect_error', (err) => resolve(err.message));
648
+ });
649
+ (0, chai_1.expect)(refused).to.equal('unauthorized');
650
+ });
651
+ });
652
+ it('a client that fetches a fresh token is let back in after its token runs out', function () {
653
+ return __awaiter(this, void 0, void 0, function* () {
654
+ this.timeout(10000);
655
+ yield start();
656
+ // As Xenon Studio and the documented client do: a function, which
657
+ // socket.io calls for every connection attempt.
658
+ let tokens = 0;
659
+ const socket = (0, socket_io_client_1.io)(url, {
660
+ auth: (cb) => {
661
+ tokens += 1;
662
+ typedi_1.Container.get(JwtKeyService_1.JwtKeyService)
663
+ .sign({ sub: ID.admin }, { audience: 'xenon-rest', ttlSeconds: tokens === 1 ? 2 - JwtKeyService_1.JWT_CLOCK_TOLERANCE_SEC : 60 })
664
+ .then((bearer) => cb({ bearer }));
665
+ },
666
+ transports: ['websocket'],
667
+ reconnectionDelay: 50,
668
+ forceNew: true,
669
+ });
670
+ clients.push(socket);
671
+ const reasons = [];
672
+ let connects = 0;
673
+ socket.on('disconnect', (why) => reasons.push(why));
674
+ socket.on('connect', () => {
675
+ connects += 1;
676
+ socket.emit('register_dashboard');
677
+ });
678
+ yield until(() => connects === 2, 'the client to come back with a fresh token', 4000);
679
+ (0, chai_1.expect)(reasons).to.deep.equal(['transport close']);
680
+ (0, chai_1.expect)(tokens).to.equal(2);
681
+ yield until(() => roomSize() === 1, 'it to register again');
682
+ yield emit('rv-phone-shared', 'interceptor_request', true);
683
+ const received = yield new Promise((resolve) => socket.once('interceptor_request', (data) => resolve(data.udid)));
684
+ (0, chai_1.expect)(received).to.equal('rv-phone-shared');
685
+ });
686
+ });
687
+ it("a disabled user's client, reconnecting by itself, is refused once and stops", () => __awaiter(void 0, void 0, void 0, function* () {
688
+ yield start();
689
+ const bearer = yield typedi_1.Container.get(JwtKeyService_1.JwtKeyService).sign({ sub: ID.admin }, { audience: 'xenon-rest', ttlSeconds: 60 });
690
+ const socket = (0, socket_io_client_1.io)(url, {
691
+ auth: { bearer },
692
+ transports: ['websocket'],
693
+ reconnectionDelay: 50,
694
+ forceNew: true,
695
+ });
696
+ clients.push(socket);
697
+ const refusals = [];
698
+ socket.on('connect_error', (err) => refusals.push(err.message));
699
+ yield new Promise((resolve) => socket.once('connect', () => resolve()));
700
+ yield (0, loopbackRequest_1.default)(app).patch(`/users/${ID.admin}`).send({ status: 'INACTIVE' }).expect(200);
701
+ yield until(() => refusals.length === 1, 'the reconnect to be refused');
702
+ (0, chai_1.expect)(refusals).to.deep.equal(['unauthorized']);
703
+ (0, chai_1.expect)(socket.active, 'socket.io gives up after a refused handshake').to.equal(false);
704
+ yield new Promise((r) => setTimeout(r, 300));
705
+ (0, chai_1.expect)(refusals, 'no further attempts').to.have.length(1);
706
+ }));
707
+ it('a sign-in that lapses ends its socket when it expires, not before, though nothing ended it', function () {
708
+ return __awaiter(this, void 0, void 0, function* () {
709
+ this.timeout(10000);
710
+ // The handshake is a use of the sign-in: it renews it for the TTL. The
711
+ // socket's own checks renew nothing, so with no REST call it lapses then.
712
+ process.env.XENON_USER_SESSION_TTL_MS = '1500';
713
+ yield scratch.db.userSession.update({
714
+ where: { id: SESSION.member },
715
+ data: { expiresAt: new Date(Date.now() + 700) },
716
+ });
717
+ yield start();
718
+ const t0 = Date.now();
719
+ const lapsing = yield connect({ cookie: SESSION.member });
720
+ yield until(() => lapsing.ended() !== undefined, 'the socket to end with its sign-in', 5000);
721
+ (0, chai_1.expect)(lapsing.ended()).to.equal('transport close');
722
+ (0, chai_1.expect)(Date.now() - t0, 'ended at the renewed expiry').to.be.at.least(1400);
723
+ });
724
+ });
725
+ it("a node whose user's access key was rotated is ended", () => __awaiter(void 0, void 0, void 0, function* () {
726
+ var _a;
727
+ yield start();
728
+ const node = yield connect({ accessKey: `ak-${ID.member}`, token: MEMBER_KEY });
729
+ (0, chai_1.expect)((_a = identityOf(node)) === null || _a === void 0 ? void 0 : _a.principal).to.equal('node');
730
+ yield typedi_1.Container.get(UserService_1.UserService).rotateAccessKey(ID.member);
731
+ yield until(() => node.ended() !== undefined, 'the node socket to end');
732
+ (0, chai_1.expect)(node.ended()).to.equal('transport close');
733
+ }));
734
+ it('a change made elsewhere (straight to the database) reaches the socket at the next sweep', () => __awaiter(void 0, void 0, void 0, function* () {
735
+ yield start({ recheckIntervalMs: 100 });
736
+ const admin = yield connect({ bearer: ID.admin });
737
+ yield until(() => roomSize() === 1, 'the client in the dashboard room');
738
+ // Another server sharing the database, or a hand edit: no notice here.
739
+ yield scratch.db.user.update({ where: { id: ID.admin }, data: { role: 'MEMBER' } });
740
+ yield until(() => { var _a; return ((_a = identityOf(admin)) === null || _a === void 0 ? void 0 : _a.role) === 'MEMBER'; }, 'the next sweep');
741
+ yield emit('rv-phone-shared', 'interceptor_request', true);
742
+ yield emit('rv-phone-shared', 'device_blocked');
743
+ yield until(() => admin.inbox.length >= 1, "the shared phone's event");
744
+ yield settle();
745
+ (0, chai_1.expect)(seen(admin)).to.deep.equal(['device_blocked:rv-phone-shared']);
746
+ (0, chai_1.expect)(admin.ended()).to.equal(undefined);
747
+ }));
748
+ it("checking a socket again renews nothing: a sign-in's TTL doesn't slide and a key isn't marked used", () => __awaiter(void 0, void 0, void 0, function* () {
749
+ const check = sinon_1.default.spy(server, 'check');
750
+ yield start({ recheckIntervalMs: 50 });
751
+ yield connect({ cookie: SESSION.member });
752
+ yield connect({ cookie: MEMBER_KEY });
753
+ // The handshakes are uses, recorded in the background.
754
+ const rows = () => __awaiter(void 0, void 0, void 0, function* () {
755
+ return ({
756
+ session: yield scratch.db.userSession.findUniqueOrThrow({ where: { id: SESSION.member } }),
757
+ key: yield scratch.db.apiKey.findUniqueOrThrow({ where: { id: MEMBER_KEY_ID } }),
758
+ });
759
+ });
760
+ const times = ({ session, key }) => {
761
+ var _a;
762
+ return ({
763
+ expiresAt: session.expiresAt.getTime(),
764
+ lastSeenAt: session.lastSeenAt.getTime(),
765
+ lastUsedAt: (_a = key.lastUsedAt) === null || _a === void 0 ? void 0 : _a.getTime(),
766
+ });
767
+ };
768
+ yield untilAsync(() => __awaiter(void 0, void 0, void 0, function* () {
769
+ const t = times(yield rows());
770
+ return t.lastUsedAt !== undefined && t.expiresAt > Date.now() + 2 * 60 * 60 * 1000;
771
+ }), 'the handshakes to be recorded');
772
+ const before = times(yield rows());
773
+ const rechecks = () => check.getCalls().filter((c) => c.args[1] === true).length;
774
+ yield until(() => rechecks() >= 4, 'two sweeps of both sockets');
775
+ yield settle();
776
+ (0, chai_1.expect)(times(yield rows())).to.deep.equal(before);
777
+ }));
778
+ it('a check that cannot run (the database unavailable) keeps the socket as it was', () => __awaiter(void 0, void 0, void 0, function* () {
779
+ var _a;
780
+ yield start();
781
+ const admin = yield connect({ bearer: ID.admin });
782
+ yield until(() => roomSize() === 1, 'the client in the dashboard room');
783
+ // The scratch database's own stub, put back after the test.
784
+ prisma_1.prisma.user.findUnique.rejects(new Error('database is locked'));
785
+ yield (0, identityChanges_1.identityChanged)(ID.admin);
786
+ yield emit('rv-phone-shared', 'interceptor_request', true);
787
+ yield until(() => admin.inbox.length >= 1, 'the captured request');
788
+ (0, chai_1.expect)(admin.socket.connected).to.equal(true);
789
+ (0, chai_1.expect)((_a = identityOf(admin)) === null || _a === void 0 ? void 0 : _a.role).to.equal('ADMIN');
790
+ }));
791
+ const MEMBER_IDENTITY = { principal: 'dashboard', userId: ID.admin, role: 'MEMBER', teamIds: [] };
792
+ /** A socket for check-order tests: an admin's, whose bearer token no longer verifies. */
793
+ const checkedSocket = () => ({
794
+ id: 'fake',
795
+ connected: true,
796
+ handshake: { auth: { bearer: 'no-longer-valid' }, headers: {} },
797
+ data: {
798
+ identity: { principal: 'dashboard', userId: ID.admin, role: 'ADMIN', teamIds: undefined },
799
+ },
800
+ conn: { close: sinon_1.default.spy() },
801
+ });
802
+ it('a check that finishes first is applied though a newer one is still running', () => __awaiter(void 0, void 0, void 0, function* () {
803
+ // A change's check, then a sweep's started while it runs: REST answers
804
+ // once the change's check settles, so its outcome can't wait on the sweep.
805
+ let release;
806
+ const gate = new Promise((r) => (release = r));
807
+ const check = sinon_1.default.stub(server, 'check');
808
+ check.onFirstCall().resolves({ identity: MEMBER_IDENTITY });
809
+ check.onSecondCall().callsFake(() => __awaiter(void 0, void 0, void 0, function* () {
810
+ yield gate;
811
+ return { identity: Object.assign(Object.assign({}, MEMBER_IDENTITY), { teamIds: [TEAMS.a] }) };
812
+ }));
813
+ const socket = checkedSocket();
814
+ const first = server.recheck(socket);
815
+ const second = server.recheck(socket);
816
+ yield first;
817
+ (0, chai_1.expect)(socket.data.identity.role, 'applied when it settles').to.equal('MEMBER');
818
+ release();
819
+ yield second;
820
+ (0, chai_1.expect)(socket.data.identity.teamIds, 'then the newer one').to.deep.equal([TEAMS.a]);
821
+ }));
822
+ it('an older check that finishes after a newer one has applied changes nothing', () => __awaiter(void 0, void 0, void 0, function* () {
823
+ const real = server.check.bind(server);
824
+ let release;
825
+ const gate = new Promise((r) => (release = r));
826
+ const check = sinon_1.default.stub(server, 'check');
827
+ // The older check refuses (a token that no longer verifies), but only
828
+ // after a newer one has found the user a member.
829
+ check.onFirstCall().callsFake((socket, recheck) => __awaiter(void 0, void 0, void 0, function* () {
830
+ yield gate;
831
+ return real(socket, recheck);
832
+ }));
833
+ check.onSecondCall().resolves({ identity: MEMBER_IDENTITY });
834
+ const socket = checkedSocket();
835
+ const older = server.recheck(socket);
836
+ yield server.recheck(socket);
837
+ release();
838
+ yield older;
839
+ (0, chai_1.expect)(socket.data.identity.role).to.equal('MEMBER');
840
+ (0, chai_1.expect)(socket.conn.close.called, 'the older refusal is dropped').to.equal(false);
841
+ }));
842
+ it("a newer check that can't run doesn't cancel an older refusal", () => __awaiter(void 0, void 0, void 0, function* () {
843
+ const real = server.check.bind(server);
844
+ let release;
845
+ const gate = new Promise((r) => (release = r));
846
+ const check = sinon_1.default.stub(server, 'check');
847
+ check.onFirstCall().callsFake((socket, recheck) => __awaiter(void 0, void 0, void 0, function* () {
848
+ yield gate;
849
+ return real(socket, recheck);
850
+ }));
851
+ check.onSecondCall().rejects(new Error('database is locked'));
852
+ const socket = checkedSocket();
853
+ const older = server.recheck(socket);
854
+ yield server.recheck(socket);
855
+ (0, chai_1.expect)(socket.conn.close.called, 'a check that failed changes nothing').to.equal(false);
856
+ release();
857
+ yield older;
858
+ (0, chai_1.expect)(socket.conn.close.calledOnce, 'the refusal stands').to.equal(true);
859
+ }));
860
+ it('a handshake that overlaps a change to its user is checked again once connected', () => __awaiter(void 0, void 0, void 0, function* () {
861
+ var _a, _b;
862
+ yield start();
863
+ const real = server.check.bind(server);
864
+ let release;
865
+ const gate = new Promise((r) => (release = r));
866
+ let releaseRecheck;
867
+ const recheckGate = new Promise((r) => (releaseRecheck = r));
868
+ let handshakes = 0;
869
+ sinon_1.default.stub(server, 'check').callsFake((socket, recheck) => __awaiter(void 0, void 0, void 0, function* () {
870
+ const result = yield real(socket, recheck);
871
+ if (!recheck && handshakes++ === 0)
872
+ yield gate;
873
+ if (recheck)
874
+ yield recheckGate;
875
+ return result;
876
+ }));
877
+ const connecting = connect({ bearer: ID.admin });
878
+ yield until(() => handshakes === 1, 'the handshake to read the user');
879
+ // The change finds no socket of the user's: this one isn't connected yet.
880
+ yield (0, loopbackRequest_1.default)(app).patch(`/users/${ID.admin}`).send({ role: 'MEMBER' }).expect(200);
881
+ release();
882
+ const admin = yield connecting;
883
+ // It asked to join (connect() registers), but gets no dashboard event
884
+ // under the identity it connected with.
885
+ yield new Promise((r) => setTimeout(r, 100));
886
+ (0, chai_1.expect)((_a = identityOf(admin)) === null || _a === void 0 ? void 0 : _a.role).to.equal('ADMIN');
887
+ (0, chai_1.expect)(roomSize(), 'not in the room before its check').to.equal(0);
888
+ releaseRecheck();
889
+ yield until(() => roomSize() === 1, 'it to join the room');
890
+ (0, chai_1.expect)((_b = identityOf(admin)) === null || _b === void 0 ? void 0 : _b.role).to.equal('MEMBER');
891
+ }));
892
+ it("a selector event asked about before the member's team changed doesn't reach them", () => __awaiter(void 0, void 0, void 0, function* () {
893
+ yield start();
894
+ let release;
895
+ const gate = new Promise((r) => (release = r));
896
+ let asked = 0;
897
+ // Yes for every member, but only once released.
898
+ typedi_1.Container.set(SelectorVisibilityResolver_1.SelectorVisibilityResolver, {
899
+ canSee: () => __awaiter(void 0, void 0, void 0, function* () {
900
+ asked += 1;
901
+ yield gate;
902
+ return true;
903
+ }),
904
+ });
905
+ const member = yield connect({ cookie: SESSION.member });
906
+ const admin = yield connect({ bearer: ID.admin });
907
+ yield until(() => roomSize() === 2, 'both clients in the dashboard room');
908
+ const delivered = server.emitToDashboardForSelector('selector_muted', { original_selector: '//x' }, { strategy: 'xpath', selector: '//x' });
909
+ yield until(() => asked === 1, "the member's visibility to be asked");
910
+ yield (0, loopbackRequest_1.default)(app).delete(`/teams/${TEAMS.a}/members/${ID.member}`).expect(200);
911
+ release();
912
+ yield delivered;
913
+ yield until(() => admin.inbox.length >= 1, 'the admin to get it');
914
+ yield settle();
915
+ (0, chai_1.expect)(member.inbox, 'asked about with the team it no longer has').to.deep.equal([]);
916
+ (0, chai_1.expect)(member.ended()).to.equal(undefined);
917
+ }));
918
+ it('a closed server stops listening for changes', () => __awaiter(void 0, void 0, void 0, function* () {
919
+ yield start();
920
+ const recheckUser = sinon_1.default.spy(server, 'recheckUser');
921
+ yield new Promise((r) => server.io.close(() => r()));
922
+ yield (0, identityChanges_1.identityChanged)(ID.admin);
923
+ (0, chai_1.expect)(recheckUser.called).to.equal(false);
924
+ }));
176
925
  });