@xenon-device-management/xenon 2.1.0 → 2.3.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 (124) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-CIU02EIx.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-_akEEjXm.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-DRKRm19_.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-zQQHPh7x.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-C9_StnDi.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-0J7io6pF.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-C677ljNX.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-BDJFck-L.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-DQkimbKK.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-DvGMZBIE.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-ogyouYBq.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-BLFCOLlO.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-BU55TGRG.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-GpDAiHav.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-dOzP0k8_.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-mAwAQmkn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-Dg9mxe9T.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-BVcmUyq3.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-ByR5pZrH.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Jpgk8CgY.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-VnRTrcdl.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-D-X4wjO8.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-ur_p5ETA.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-K33iMeyq.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-DMVWShd-.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-DphLJ2_X.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-CNFhu8oE.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-BH_cUbqZ.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-CVMVdVvp.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-CZ2XHafL.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-B94BhaqF.js} +52 -52
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-UUiyw7kO.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-CrS14F79.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-BMOE5qJY.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-2XPPbPwy.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-D0R9Yic3.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-C-1b8J7i.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-Cly7dQc9.js} +4 -4
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-DFZfqGdX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-ylHMJpfP.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DWsWKqDx.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-9B6_MTqW.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-CzX0hBH1.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-Bikc4MyT.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-DzaA0aMe.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-DqGlDufy.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-Ct6aQTGM.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-CCnUQ1Qk.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-AmkGGXo5.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-DQQn9VyE.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-k5ogtEWW.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-BCcwC5ku.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-BVyX6kIh.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CElBB6XT.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-6bqo_-ND.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-DKBj9KyN.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-CZNpvqej.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-B6HI5OxV.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-owoDHbOU.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-D61PoUrZ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-1ZYEVvgD.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
  67. package/lib/public/assets/{users-7yviqflc.js → users-C5CS5Qez.js} +1 -1
  68. package/lib/public/assets/{users-BUE7MoTq.js → users-PbGMd5sD.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-_XusI0HK.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Bi_HHQlQ.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-uzHWFAKI.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +119 -203
  74. package/lib/src/app/routers/nodePhoneControl.js +520 -0
  75. package/lib/src/app/ws/h264StreamWs.js +30 -4
  76. package/lib/src/app/ws/logcatWs.js +36 -7
  77. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  78. package/lib/src/data-service/device-store.js +17 -2
  79. package/lib/src/data-service/deviceClaims.js +32 -0
  80. package/lib/src/data-service/deviceFieldOwners.js +3 -1
  81. package/lib/src/data-service/prisma-store.js +9 -1
  82. package/lib/src/device-managers/AndroidDeviceManager.js +38 -28
  83. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  84. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  85. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  86. package/lib/src/gateway/forwardToNode.js +10 -3
  87. package/lib/src/generated/client/edge.js +5 -4
  88. package/lib/src/generated/client/index-browser.js +2 -1
  89. package/lib/src/generated/client/index.d.ts +36 -1
  90. package/lib/src/generated/client/index.js +5 -4
  91. package/lib/src/generated/client/package.json +1 -1
  92. package/lib/src/generated/client/schema.prisma +4 -0
  93. package/lib/src/generated/client/wasm.js +2 -1
  94. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  95. package/lib/src/services/ServerManager.js +13 -0
  96. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  97. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  98. package/lib/test/helpers/own-node-id.js +29 -0
  99. package/lib/test/setup/scratch-default-database.js +32 -0
  100. package/lib/test/unit/AndroidDeviceManager.spec.js +60 -0
  101. package/lib/test/unit/android-h264-service.spec.js +18 -0
  102. package/lib/test/unit/clipboard-route.spec.js +4 -0
  103. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  104. package/lib/test/unit/device-access-guard.spec.js +38 -0
  105. package/lib/test/unit/hub-node-control-forward.spec.js +374 -1
  106. package/lib/test/unit/hub-node-race.spec.js +51 -0
  107. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  108. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  109. package/lib/test/unit/logcat-ws.spec.js +8 -0
  110. package/lib/test/unit/mark-node-busy-loki.spec.js +66 -0
  111. package/lib/test/unit/node-phone-control.spec.js +225 -0
  112. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  113. package/lib/test/unit/screenshot-route.spec.js +104 -0
  114. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  115. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  116. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  117. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  118. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  119. package/lib/test/unit/upload-install-route.spec.js +205 -0
  120. package/lib/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +3 -3
  122. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  123. package/prisma/schema.prisma +4 -0
  124. package/lib/public/assets/DeviceMosaicView-D-rGOBbJ.js +0 -2
@@ -0,0 +1,104 @@
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 express_1 = __importDefault(require("express"));
19
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
20
+ const typedi_1 = require("typedi");
21
+ const control_1 = __importDefault(require("../../src/app/routers/control"));
22
+ const device_store_1 = require("../../src/data-service/device-store");
23
+ const ApiKeyService_1 = require("../../src/services/ApiKeyService");
24
+ const device_managers_1 = require("../../src/device-managers");
25
+ const authMiddleware_1 = require("../../src/middleware/authMiddleware");
26
+ const container_registration_1 = require("../helpers/container-registration");
27
+ const loopbackServer_1 = require("../helpers/loopbackServer");
28
+ const own_node_id_1 = require("../helpers/own-node-id");
29
+ /**
30
+ * GET /control/:udid/screenshot. A capture that threw left the request
31
+ * unanswered: the handler had no catch, and Express 4 doesn't catch a
32
+ * rejected async handler. An iPhone with no live preview (no WebDriverAgent,
33
+ * no go-ios tunnel) did exactly that, and its Screenshot tab spun forever.
34
+ */
35
+ const UDID = 'DEV-SHOT';
36
+ // The route finds the manager by constructor name.
37
+ class IOSDeviceManager {
38
+ constructor() {
39
+ this.getScreenshot = sinon_1.default.stub();
40
+ }
41
+ }
42
+ function buildApp() {
43
+ const app = (0, express_1.default)();
44
+ app.use(express_1.default.json());
45
+ const apiRouter = express_1.default.Router();
46
+ apiRouter.use((req, _res, next) => {
47
+ req.auth = {
48
+ kind: 'user-session',
49
+ userId: 'usr_me',
50
+ role: 'MEMBER',
51
+ scopes: (0, authMiddleware_1.scopesForRole)('MEMBER'),
52
+ rateLimit: 100,
53
+ };
54
+ next();
55
+ });
56
+ control_1.default.register(apiRouter);
57
+ app.use('/xenon/api', apiRouter);
58
+ return app;
59
+ }
60
+ describe('GET /control/:udid/screenshot', () => {
61
+ // The fake row is this server's own phone.
62
+ (0, own_node_id_1.useOwnNodeId)();
63
+ let manager;
64
+ let restoreContainer;
65
+ const loopback = (0, loopbackServer_1.loopbackServers)();
66
+ const app = () => __awaiter(void 0, void 0, void 0, function* () { return loopback.serve(buildApp()); });
67
+ beforeEach(() => {
68
+ restoreContainer = (0, container_registration_1.saveRegistrations)(ApiKeyService_1.ApiKeyService, device_managers_1.XenonManager);
69
+ manager = new IOSDeviceManager();
70
+ sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
71
+ findDevice: () => __awaiter(void 0, void 0, void 0, function* () {
72
+ return ({
73
+ udid: UDID,
74
+ host: '127.0.0.1',
75
+ nodeId: own_node_id_1.OWN_NODE_ID,
76
+ platform: 'ios',
77
+ busy: false,
78
+ session_id: null,
79
+ });
80
+ }),
81
+ });
82
+ typedi_1.Container.set(ApiKeyService_1.ApiKeyService, new ApiKeyService_1.ApiKeyService());
83
+ typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
84
+ });
85
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
86
+ sinon_1.default.restore();
87
+ restoreContainer();
88
+ yield loopback.closeAll();
89
+ }));
90
+ it('returns the screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
91
+ manager.getScreenshot.resolves('A'.repeat(200));
92
+ const res = yield (0, loopbackRequest_1.default)(yield app()).get(`/xenon/api/control/${UDID}/screenshot`);
93
+ (0, chai_1.expect)(res.status).to.equal(200);
94
+ (0, chai_1.expect)(res.body.screenshot).to.have.length(200);
95
+ }));
96
+ it('answers a capture that failed with 502 and the reason, rather than never', () => __awaiter(void 0, void 0, void 0, function* () {
97
+ manager.getScreenshot.rejects(new Error('connect ECONNREFUSED 127.0.0.1:8100'));
98
+ const res = yield (0, loopbackRequest_1.default)(yield app())
99
+ .get(`/xenon/api/control/${UDID}/screenshot`)
100
+ .timeout(5000);
101
+ (0, chai_1.expect)(res.status).to.equal(502);
102
+ (0, chai_1.expect)(res.body.error).to.include('ECONNREFUSED');
103
+ }));
104
+ });
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
62
62
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
63
63
  const container_registration_1 = require("../helpers/container-registration");
64
64
  const loopbackServer_1 = require("../helpers/loopbackServer");
65
+ const own_node_id_1 = require("../helpers/own-node-id");
65
66
  const UDID = 'DEV-1';
66
67
  const ALICE = 'usr_alice';
67
68
  const BOB = 'usr_bob';
@@ -84,6 +85,8 @@ function buildApp(userId) {
84
85
  return app;
85
86
  }
86
87
  describe('POST /control/:udid/stream/leave', () => {
88
+ // The fake rows are this server's own phones.
89
+ (0, own_node_id_1.useOwnNodeId)();
87
90
  let deviceRow;
88
91
  let unblock;
89
92
  let androidStop;
@@ -101,6 +104,7 @@ describe('POST /control/:udid/stream/leave', () => {
101
104
  deviceRow = {
102
105
  udid: UDID,
103
106
  host: '127.0.0.1',
107
+ nodeId: own_node_id_1.OWN_NODE_ID,
104
108
  platform: 'android',
105
109
  busy: true,
106
110
  session_id: `manual_${ALICE}_${UDID}`,
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
62
62
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
63
63
  const container_registration_1 = require("../helpers/container-registration");
64
64
  const loopbackServer_1 = require("../helpers/loopbackServer");
65
+ const own_node_id_1 = require("../helpers/own-node-id");
65
66
  /**
66
67
  * Round-trip guard for POST /control/:udid/stream/stop.
67
68
  *
@@ -119,6 +120,8 @@ function buildApp(caller) {
119
120
  return app;
120
121
  }
121
122
  describe('POST /control/:udid/stream/stop — lock ownership round-trip', () => {
123
+ // The fake rows are this server's own phones.
124
+ (0, own_node_id_1.useOwnNodeId)();
122
125
  let unblock;
123
126
  let androidStop;
124
127
  let h264Stop;
@@ -131,6 +134,7 @@ describe('POST /control/:udid/stream/stop — lock ownership round-trip', () =>
131
134
  deviceRow = {
132
135
  udid: UDID,
133
136
  host: '127.0.0.1',
137
+ nodeId: own_node_id_1.OWN_NODE_ID,
134
138
  platform: 'android',
135
139
  busy: sessionId !== null,
136
140
  session_id: sessionId,
@@ -62,6 +62,7 @@ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-man
62
62
  const authMiddleware_1 = require("../../src/middleware/authMiddleware");
63
63
  const container_registration_1 = require("../helpers/container-registration");
64
64
  const loopbackServer_1 = require("../helpers/loopbackServer");
65
+ const own_node_id_1 = require("../helpers/own-node-id");
65
66
  /**
66
67
  * POST /control/:udid/stream/stop must not stop the stream under a live
67
68
  * recording. The recording reads from that stream: measured 2026-09-26 on a
@@ -94,6 +95,8 @@ function buildApp(caller) {
94
95
  return app;
95
96
  }
96
97
  describe('POST /control/:udid/stream/stop — a device being recorded', () => {
98
+ // The fake rows are this server's own phones.
99
+ (0, own_node_id_1.useOwnNodeId)();
97
100
  let unblock;
98
101
  let androidStop;
99
102
  let h264Stop;
@@ -110,6 +113,7 @@ describe('POST /control/:udid/stream/stop — a device being recorded', () => {
110
113
  return ({
111
114
  udid: UDID,
112
115
  host: '127.0.0.1',
116
+ nodeId: own_node_id_1.OWN_NODE_ID,
113
117
  platform: 'android',
114
118
  busy: true,
115
119
  session_id: `manual_${ALICE}_${UDID}`,
@@ -31,6 +31,7 @@ const UserSessionService_1 = require("../../src/services/UserSessionService");
31
31
  const device_store_1 = require("../../src/data-service/device-store");
32
32
  const container_registration_1 = require("../helpers/container-registration");
33
33
  const loopbackServer_1 = require("../helpers/loopbackServer");
34
+ const own_node_id_1 = require("../helpers/own-node-id");
34
35
  /**
35
36
  * `req.auth.userId` must always hold a User id.
36
37
  *
@@ -51,6 +52,8 @@ const UDID = 'UDID-1';
51
52
  const ALICE_USER = 'usr_alice';
52
53
  const ALICE_KEY = 'key_abc';
53
54
  describe('stream/ticket carries the user identity, not the credential', () => {
55
+ // The fake rows are this server's own phones.
56
+ (0, own_node_id_1.useOwnNodeId)();
54
57
  let dir;
55
58
  let ticketSvc;
56
59
  function buildApp(caller = {}) {
@@ -103,7 +106,14 @@ describe('stream/ticket carries the user identity, not the credential', () => {
103
106
  sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
104
107
  findDevice: (_a) => __awaiter(void 0, [_a], void 0, function* ({ udid }) {
105
108
  return udid === UDID
106
- ? { udid, host: 'h', platform: 'android', screenWidth: '1080', screenHeight: '1920' }
109
+ ? {
110
+ udid,
111
+ host: 'h',
112
+ nodeId: own_node_id_1.OWN_NODE_ID,
113
+ platform: 'android',
114
+ screenWidth: '1080',
115
+ screenHeight: '1920',
116
+ }
107
117
  : null;
108
118
  }),
109
119
  });
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ require("reflect-metadata");
7
+ const chai_1 = require("chai");
8
+ const sinon_1 = __importDefault(require("sinon"));
9
+ const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
10
+ const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
11
+ const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
12
+ /**
13
+ * The stream services start their watchdogs in their constructors. On a
14
+ * server the HTTP listener keeps the process alive, so those intervals only
15
+ * need to fire while it runs; referenced, they also kept a test run (or any
16
+ * script that merely constructs a service) alive after its work was done.
17
+ */
18
+ describe('stream service watchdogs', () => {
19
+ let created;
20
+ beforeEach(() => {
21
+ created = [];
22
+ const realSetInterval = global.setInterval;
23
+ sinon_1.default.stub(global, 'setInterval').callsFake(((fn, ms) => {
24
+ const timer = realSetInterval(fn, ms);
25
+ created.push(timer);
26
+ return timer;
27
+ }));
28
+ });
29
+ afterEach(() => {
30
+ sinon_1.default.restore();
31
+ for (const timer of created)
32
+ clearInterval(timer);
33
+ });
34
+ for (const [name, Service] of [
35
+ ['AndroidStreamService', AndroidStreamService_1.default],
36
+ ['AndroidH264StreamService', AndroidH264StreamService_1.default],
37
+ ['IOSStreamService', IOSStreamService_1.default],
38
+ ]) {
39
+ it(`${name}'s intervals don't keep the process alive`, () => {
40
+ new Service();
41
+ (0, chai_1.expect)(created.length, 'the constructor starts its watchdog').to.be.greaterThan(0);
42
+ for (const timer of created)
43
+ (0, chai_1.expect)(timer.hasRef(), 'unref()ed').to.equal(false);
44
+ });
45
+ }
46
+ });
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ require("reflect-metadata");
49
+ const chai_1 = require("chai");
50
+ const fs_1 = __importDefault(require("fs"));
51
+ const os_1 = __importDefault(require("os"));
52
+ const path_1 = __importDefault(require("path"));
53
+ const sinon_1 = __importDefault(require("sinon"));
54
+ const express_1 = __importDefault(require("express"));
55
+ const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
56
+ const typedi_1 = require("typedi");
57
+ const control_1 = __importStar(require("../../src/app/routers/control"));
58
+ const device_store_1 = require("../../src/data-service/device-store");
59
+ const ApiKeyService_1 = require("../../src/services/ApiKeyService");
60
+ const device_managers_1 = require("../../src/device-managers");
61
+ const authMiddleware_1 = require("../../src/middleware/authMiddleware");
62
+ const container_registration_1 = require("../helpers/container-registration");
63
+ const loopbackServer_1 = require("../helpers/loopbackServer");
64
+ const own_node_id_1 = require("../helpers/own-node-id");
65
+ /**
66
+ * POST /control/:udid/upload-install, device control's "Upload file". No
67
+ * multipart parser was mounted for /control, only for /apps, so every upload
68
+ * was answered "No files were uploaded." and nothing was installed.
69
+ */
70
+ const UDID = 'DEV-UPLOAD';
71
+ const UPLOADS = path_1.default.join(os_1.default.tmpdir(), 'xenon-uploads');
72
+ // The route finds the manager by constructor name.
73
+ class AndroidDeviceManager {
74
+ constructor() {
75
+ this.installed = [];
76
+ this.fail = false;
77
+ this.installApp = (_udid, appPath) => __awaiter(this, void 0, void 0, function* () {
78
+ this.installed.push({ path: appPath, bytes: fs_1.default.readFileSync(appPath, 'utf8') });
79
+ if (this.fail)
80
+ throw new Error('INSTALL_FAILED_INVALID_APK');
81
+ });
82
+ }
83
+ }
84
+ function buildApp() {
85
+ const app = (0, express_1.default)();
86
+ app.use(express_1.default.json());
87
+ const apiRouter = express_1.default.Router();
88
+ apiRouter.use((req, _res, next) => {
89
+ req.auth = {
90
+ kind: 'user-session',
91
+ userId: 'usr_me',
92
+ role: 'MEMBER',
93
+ scopes: (0, authMiddleware_1.scopesForRole)('MEMBER'),
94
+ rateLimit: 100,
95
+ };
96
+ next();
97
+ });
98
+ control_1.default.register(apiRouter);
99
+ app.use('/xenon/api', apiRouter);
100
+ return app;
101
+ }
102
+ describe('POST /control/:udid/upload-install', () => {
103
+ // The fake row is this server's own phone.
104
+ (0, own_node_id_1.useOwnNodeId)();
105
+ let manager;
106
+ let restoreContainer;
107
+ const loopback = (0, loopbackServer_1.loopbackServers)();
108
+ const app = () => __awaiter(void 0, void 0, void 0, function* () { return loopback.serve(buildApp()); });
109
+ const upload = (bytes, name) => __awaiter(void 0, void 0, void 0, function* () {
110
+ return (0, loopbackRequest_1.default)(yield app())
111
+ .post(`/xenon/api/control/${UDID}/upload-install`)
112
+ .attach('app', Buffer.from(bytes), name);
113
+ });
114
+ beforeEach(() => {
115
+ restoreContainer = (0, container_registration_1.saveRegistrations)(ApiKeyService_1.ApiKeyService, device_managers_1.XenonManager);
116
+ manager = new AndroidDeviceManager();
117
+ sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
118
+ findDevice: () => __awaiter(void 0, void 0, void 0, function* () {
119
+ return ({
120
+ udid: UDID,
121
+ host: '127.0.0.1',
122
+ nodeId: own_node_id_1.OWN_NODE_ID,
123
+ platform: 'android',
124
+ busy: false,
125
+ session_id: null,
126
+ });
127
+ }),
128
+ });
129
+ typedi_1.Container.set(ApiKeyService_1.ApiKeyService, new ApiKeyService_1.ApiKeyService());
130
+ typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
131
+ });
132
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
133
+ sinon_1.default.restore();
134
+ restoreContainer();
135
+ yield loopback.closeAll();
136
+ }));
137
+ it('installs the uploaded file, under its own extension, in the upload folder', () => __awaiter(void 0, void 0, void 0, function* () {
138
+ const res = yield upload('fake-apk-bytes', 'build-42.apk');
139
+ (0, chai_1.expect)(res.status, res.text).to.equal(200);
140
+ (0, chai_1.expect)(res.body.success).to.equal(true);
141
+ (0, chai_1.expect)(manager.installed).to.have.length(1);
142
+ const [{ path: saved, bytes }] = manager.installed;
143
+ (0, chai_1.expect)(bytes).to.equal('fake-apk-bytes');
144
+ (0, chai_1.expect)(path_1.default.dirname(saved)).to.equal(UPLOADS);
145
+ (0, chai_1.expect)(saved.endsWith('.apk')).to.equal(true);
146
+ }));
147
+ it('keeps a crafted file name inside the upload folder', () => __awaiter(void 0, void 0, void 0, function* () {
148
+ // Built by hand: the test client's multipart library cuts a file name to
149
+ // its last segment itself, but a client under nobody's control needn't.
150
+ // The parser (busboy) cuts it too; uploadName is tested alone below.
151
+ const boundary = 'xenonboundary';
152
+ const body = [
153
+ `--${boundary}`,
154
+ 'Content-Disposition: form-data; name="app"; filename="a/../../../../../tmp/xenon-escape.apk"',
155
+ 'Content-Type: application/octet-stream',
156
+ '',
157
+ 'x',
158
+ `--${boundary}--`,
159
+ '',
160
+ ].join('\r\n');
161
+ const res = yield (0, loopbackRequest_1.default)(yield app())
162
+ .post(`/xenon/api/control/${UDID}/upload-install`)
163
+ .set('content-type', `multipart/form-data; boundary=${boundary}`)
164
+ .send(body);
165
+ (0, chai_1.expect)(res.status, res.text).to.equal(200);
166
+ const [{ path: saved }] = manager.installed;
167
+ (0, chai_1.expect)(path_1.default.dirname(saved)).to.equal(UPLOADS);
168
+ (0, chai_1.expect)(fs_1.default.existsSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-escape.apk'))).to.equal(false);
169
+ }));
170
+ it('removes the saved file once the install is over, whether it worked or not', () => __awaiter(void 0, void 0, void 0, function* () {
171
+ yield upload('ok', 'one.apk');
172
+ manager.fail = true;
173
+ const failed = yield upload('bad', 'two.apk');
174
+ (0, chai_1.expect)(failed.status).to.equal(500);
175
+ (0, chai_1.expect)(failed.body.error).to.include('INSTALL_FAILED_INVALID_APK');
176
+ for (const { path: saved } of manager.installed) {
177
+ (0, chai_1.expect)(fs_1.default.existsSync(saved), saved).to.equal(false);
178
+ }
179
+ }));
180
+ it('still says so when no file came', () => __awaiter(void 0, void 0, void 0, function* () {
181
+ const res = yield (0, loopbackRequest_1.default)(yield app())
182
+ .post(`/xenon/api/control/${UDID}/upload-install`)
183
+ .send({});
184
+ (0, chai_1.expect)(res.status).to.equal(400);
185
+ (0, chai_1.expect)(manager.installed).to.deep.equal([]);
186
+ }));
187
+ });
188
+ // The saved name, alone: the route's parser already cuts a name to its last
189
+ // segment, so this is what holds if one ever doesn't.
190
+ describe('uploadName', () => {
191
+ it('keeps only the last path segment, in safe characters, with its extension', () => {
192
+ for (const [name, rest] of [
193
+ ['a/../../../../../tmp/xenon-escape.apk', 'xenon-escape.apk'],
194
+ ['..\\..\\evil.ipa', '_.._evil.ipa'],
195
+ ['My App (1).apk', 'My_App_1_.apk'],
196
+ ['..', 'app'],
197
+ ['', 'app'],
198
+ ]) {
199
+ const saved = (0, control_1.uploadName)(name);
200
+ (0, chai_1.expect)(saved, name).to.match(/^\d+-/);
201
+ (0, chai_1.expect)(saved.replace(/^\d+-/, ''), name).to.equal(rest);
202
+ (0, chai_1.expect)(path_1.default.basename(saved), name).to.equal(saved);
203
+ }
204
+ });
205
+ });