@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.
- package/lib/package.json +3 -3
- package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-CIU02EIx.js} +1 -1
- package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-_akEEjXm.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
- package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-DRKRm19_.js} +1 -1
- package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-zQQHPh7x.js} +1 -1
- package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-C9_StnDi.js} +1 -1
- package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-0J7io6pF.js} +1 -1
- package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-C677ljNX.js} +1 -1
- package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-BDJFck-L.js} +1 -1
- package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-DQkimbKK.js} +1 -1
- package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-DvGMZBIE.js} +1 -1
- package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-ogyouYBq.js} +1 -1
- package/lib/public/assets/{Table-ucR4bnmc.js → Table-BLFCOLlO.js} +1 -1
- package/lib/public/assets/{activity-K-hdylwq.js → activity-BU55TGRG.js} +1 -1
- package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-GpDAiHav.js} +1 -1
- package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-dOzP0k8_.js} +1 -1
- package/lib/public/assets/{apps-Dez1petz.js → apps-mAwAQmkn.js} +1 -1
- package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-Dg9mxe9T.js} +1 -1
- package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-BVcmUyq3.js} +1 -1
- package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-ByR5pZrH.js} +1 -1
- package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Jpgk8CgY.js} +2 -2
- package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-VnRTrcdl.js} +1 -1
- package/lib/public/assets/{button-Dd-IhMC1.js → button-D-X4wjO8.js} +1 -1
- package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-ur_p5ETA.js} +1 -1
- package/lib/public/assets/{check-C4Jrjg8f.js → check-K33iMeyq.js} +1 -1
- package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-DMVWShd-.js} +1 -1
- package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-DphLJ2_X.js} +1 -1
- package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-CNFhu8oE.js} +1 -1
- package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-BH_cUbqZ.js} +1 -1
- package/lib/public/assets/{clock-B8vmwkHj.js → clock-CVMVdVvp.js} +1 -1
- package/lib/public/assets/{copy-BAqnFI-F.js → copy-CZ2XHafL.js} +1 -1
- package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-B94BhaqF.js} +52 -52
- package/lib/public/assets/{download-B6UEAp_1.js → download-UUiyw7kO.js} +1 -1
- package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-CrS14F79.js} +1 -1
- package/lib/public/assets/{funnel-Da90BBbT.js → funnel-BMOE5qJY.js} +1 -1
- package/lib/public/assets/{index-BuIIcmGp.js → index-2XPPbPwy.js} +43 -43
- package/lib/public/assets/{index-MX7IzqyW.js → index-D0R9Yic3.js} +1 -1
- package/lib/public/assets/{input-Bq9aY-We.js → input-C-1b8J7i.js} +1 -1
- package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-Cly7dQc9.js} +4 -4
- package/lib/public/assets/{layers-bXjpswQI.js → layers-DFZfqGdX.js} +1 -1
- package/lib/public/assets/{lock-pJ72y5Fy.js → lock-ylHMJpfP.js} +1 -1
- package/lib/public/assets/{login-UOcHALV2.js → login-DWsWKqDx.js} +1 -1
- package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-9B6_MTqW.js} +1 -1
- package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-CzX0hBH1.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
- package/lib/public/assets/{network-CKVqcyp6.js → network-Bikc4MyT.js} +1 -1
- package/lib/public/assets/{overview-BORGkN3N.js → overview-DzaA0aMe.js} +1 -1
- package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-DqGlDufy.js} +1 -1
- package/lib/public/assets/{plus-BpFKVdC8.js → plus-Ct6aQTGM.js} +1 -1
- package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-CCnUQ1Qk.js} +1 -1
- package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-AmkGGXo5.js} +1 -1
- package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-DQQn9VyE.js} +1 -1
- package/lib/public/assets/{select-DIZWIoVR.js → select-k5ogtEWW.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-BCcwC5ku.js} +1 -1
- package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-BVyX6kIh.js} +1 -1
- package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CElBB6XT.js} +1 -1
- package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-6bqo_-ND.js} +1 -1
- package/lib/public/assets/{settings-BcRBKUXd.js → settings-DKBj9KyN.js} +1 -1
- package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-CZNpvqej.js} +1 -1
- package/lib/public/assets/{tablet-_sXqZztt.js → tablet-B6HI5OxV.js} +1 -1
- package/lib/public/assets/{teams-DGHT8MKm.js → teams-owoDHbOU.js} +1 -1
- package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-D61PoUrZ.js} +1 -1
- package/lib/public/assets/{upload-CIDH9wkj.js → upload-1ZYEVvgD.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
- package/lib/public/assets/{users-7yviqflc.js → users-C5CS5Qez.js} +1 -1
- package/lib/public/assets/{users-BUE7MoTq.js → users-PbGMd5sD.js} +1 -1
- package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-_XusI0HK.js} +1 -1
- package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Bi_HHQlQ.js} +1 -1
- package/lib/public/assets/{zap-CWE94qcK.js → zap-uzHWFAKI.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +119 -203
- package/lib/src/app/routers/nodePhoneControl.js +520 -0
- package/lib/src/app/ws/h264StreamWs.js +30 -4
- package/lib/src/app/ws/logcatWs.js +36 -7
- package/lib/src/app/ws/nodeSocketRelay.js +167 -0
- package/lib/src/data-service/device-store.js +17 -2
- package/lib/src/data-service/deviceClaims.js +32 -0
- package/lib/src/data-service/deviceFieldOwners.js +3 -1
- package/lib/src/data-service/prisma-store.js +9 -1
- package/lib/src/device-managers/AndroidDeviceManager.js +38 -28
- package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
- package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
- package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
- package/lib/src/gateway/forwardToNode.js +10 -3
- package/lib/src/generated/client/edge.js +5 -4
- package/lib/src/generated/client/index-browser.js +2 -1
- package/lib/src/generated/client/index.d.ts +36 -1
- package/lib/src/generated/client/index.js +5 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +4 -0
- package/lib/src/generated/client/wasm.js +2 -1
- package/lib/src/middleware/deviceAccessGuard.js +4 -1
- package/lib/src/services/ServerManager.js +13 -0
- package/lib/src/services/device-access/ticketActorAccess.js +3 -1
- package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
- package/lib/test/helpers/own-node-id.js +29 -0
- package/lib/test/setup/scratch-default-database.js +32 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +60 -0
- package/lib/test/unit/android-h264-service.spec.js +18 -0
- package/lib/test/unit/clipboard-route.spec.js +4 -0
- package/lib/test/unit/control-forward-decision.spec.js +17 -17
- package/lib/test/unit/device-access-guard.spec.js +38 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +374 -1
- package/lib/test/unit/hub-node-race.spec.js +51 -0
- package/lib/test/unit/install-repository-app-team.spec.js +4 -0
- package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
- package/lib/test/unit/logcat-ws.spec.js +8 -0
- package/lib/test/unit/mark-node-busy-loki.spec.js +66 -0
- package/lib/test/unit/node-phone-control.spec.js +225 -0
- package/lib/test/unit/node-socket-relay.spec.js +357 -0
- package/lib/test/unit/screenshot-route.spec.js +104 -0
- package/lib/test/unit/stream-leave-route.spec.js +4 -0
- package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
- package/lib/test/unit/stream-stop-recording.spec.js +4 -0
- package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
- package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
- package/lib/test/unit/upload-install-route.spec.js +205 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
- package/prisma/schema.prisma +4 -0
- 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
|
-
? {
|
|
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
|
+
});
|