1import { afterEach, describe, expect, it } from "vite-plus/test";
2import {
3 AuthOrchestrationReadScope,
4 AuthOrchestrationOperateScope,
5 AuthSessionId,
6 LOCAL_DEVICE_HOST_ID,
7 type AuthEnvironmentScope,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http";
12import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
13import type * as DeviceHost from "./DeviceHost.ts";
14import * as DeviceService from "./DeviceService.ts";
15import * as DeviceHubProxy from "./DeviceHubProxy.ts";
16
17const disposers: Array<() => Promise<void>> = [];
18afterEach(async () => {
19 for (const dispose of disposers.splice(0)) await dispose();
20});
21
22const fixture = (
23 scopes: ReadonlyArray<AuthEnvironmentScope>,
24 fail = false,
25 authError?: EnvironmentAuth.ServerAuthCredentialError | EnvironmentAuth.ServerAuthInternalError,
26 upstream?: DeviceHost.DeviceHostReady["upstream"],
27) => {
28 let finalized = 0;
29 const requests: string[] = [];
30 const screenshots: Array<Parameters<DeviceService.DeviceService["Service"]["screenshot"]>[0]> =
31 [];
32 const headers: Array<Readonly<Record<string, string>>> = [];
33 const unexpectedOperation = Effect.die("Unexpected device operation");
34 const client = HttpClient.make((request, _url, signal) =>
35 Effect.gen(function* () {
36 requests.push(request.url);
37 headers.push(request.headers);
38 signal.addEventListener("abort", () => {
39 finalized++;
40 });
41 if (fail) return yield* Effect.die(new Error("upstream failed"));
42 return HttpClientResponse.fromWeb(
43 request,
44 new Response("frame", { headers: { "set-cookie": "upstream=1" } }),
45 );
46 }),
47 );
48 const { handler, dispose } = HttpRouter.toWebHandler(
49 DeviceHubProxy.layer.pipe(
50 Layer.provideMerge(
51 Layer.mock(EnvironmentAuth.EnvironmentAuth)({
52 authenticateWebSocketUpgrade: () =>
53 authError
54 ? Effect.fail(authError)
55 : Effect.succeed({
56 sessionId: AuthSessionId.make("test"),
57 subject: "test",
58 method: "bearer-access-token",
59 scopes,
60 }),
61 }),
62 ),
63 Layer.provideMerge(
64 Layer.mock(DeviceService.DeviceService)({
65 currentReadiness: (hostId) =>
66 Effect.succeed({
67 hostId: hostId ?? LOCAL_DEVICE_HOST_ID,
68 nodePath: "node",
69 hub: { origin: "http://hub.test" },
70 devices: {
71 list: unexpectedOperation,
72 boot: () => unexpectedOperation,
73 attach: () => unexpectedOperation,
74 shutdown: () => unexpectedOperation,
75 screenshot: () => unexpectedOperation,
76 },
77 run: () => unexpectedOperation,
78 helpers: { serveSimAxSettings: null, serveSimCli: null },
79 ...(upstream ? { upstream } : {}),
80 }),
81 screenshot: (input) =>
82 Effect.sync(() => {
83 screenshots.push(input);
84 return {
85 device: {
86 hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID,
87 id: input.deviceId,
88 platform: "ios",
89 name: "Phone",
90 version: "27.0",
91 booted: true,
92 physical: false,
93 },
94 png: new Uint8Array([137, 80, 78, 71]),
95 };
96 }),
97 }),
98 ),
99 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)),
100 ),
101 { disableLogger: true },
102 );
103 disposers.push(dispose);
104 return { handler, requests, screenshots, headers, finalized: () => finalized };
105};
106
107describe("device hub proxy", () => {
108 it("releases the upstream response after forwarding its body and strips tickets", async () => {
109 const { handler, requests, finalized } = fixture([AuthOrchestrationReadScope]);
110 const response = await handler(
111 new Request("http://t3.test/api/device-hub/api/devices?wsTicket=secret"),
112 );
113 expect(response.status).toBe(200);
114 expect(await response.text()).toBe("frame");
115 expect(requests).toEqual(["http://hub.test/api/devices"]);
116 expect(finalized()).toBe(1);
117 });
118
119 it.each(["/vendor/serve-sim/helper/ws", "/vendor/serve-emu/ws"])(
120 "rejects input socket %s for a read-only session",
121 async (path) => {
122 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
123 const response = await handler(
124 new Request(`http://t3.test/api/device-hub${path}`, { headers: { upgrade: "websocket" } }),
125 );
126 expect(response.status).toBe(403);
127 expect(requests).toEqual([]);
128 },
129 );
130
131 it("requires operate scope for stream tuning", async () => {
132 const readOnly = fixture([AuthOrchestrationReadScope]);
133 const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/stream-settings";
134 expect((await readOnly.handler(new Request(path, { method: "POST" }))).status).toBe(403);
135 const operator = fixture([AuthOrchestrationOperateScope]);
136 const response = await operator.handler(new Request(path, { method: "POST" }));
137 expect(response.status).toBe(200);
138 await response.text();
139 });
140
141 it("reads Android fold state but requires operate scope to change it", async () => {
142 const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554";
143 const reader = fixture([AuthOrchestrationReadScope]);
144 const read = await reader.handler(new Request(path));
145 expect(read.status).toBe(200);
146 await read.text();
147 expect(reader.requests).toEqual([
148 "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554",
149 ]);
150 const denied = await reader.handler(
151 new Request(path, { method: "POST", body: '{"posture":"closed"}' }),
152 );
153 expect(denied.status).toBe(403);
154 expect(reader.requests).toHaveLength(1);
155
156 const operator = fixture([AuthOrchestrationOperateScope]);
157 const changed = await operator.handler(
158 new Request(path, { method: "POST", body: '{"posture":"closed"}' }),
159 );
160 expect(changed.status).toBe(200);
161 await changed.text();
162 expect(operator.requests).toEqual([
163 "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554",
164 ]);
165 });
166
167 it("presents fold changes from an HTTPS client as same-origin hub requests", async () => {
168 const { handler, headers } = fixture([AuthOrchestrationOperateScope]);
169 const response = await handler(
170 new Request(
171 "https://relay.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554",
172 {
173 method: "POST",
174 headers: { origin: "https://relay.test", "x-forwarded-proto": "https" },
175 body: '{"posture":"opened"}',
176 },
177 ),
178 );
179 expect(response.status).toBe(200);
180 await response.text();
181 expect(headers).toHaveLength(1);
182 expect(headers[0]?.origin).toBe("http://hub.test");
183 expect(headers[0]).not.toHaveProperty("x-forwarded-proto");
184 });
185
186 it("lets a viewer negotiate a WebRTC stream with read scope", async () => {
187 const { handler, requests, headers } = fixture([AuthOrchestrationReadScope]);
188 const offer = await handler(
189 new Request("http://t3.test/api/device-hub/vendor/serve-sim/helper/udid-1/webrtc/offer", {
190 method: "POST",
191 headers: { "content-type": "application/json" },
192 body: '{"type":"offer"}',
193 }),
194 );
195 expect(offer.status).toBe(200);
196 await offer.text();
197 expect(requests).toEqual(["http://hub.test/vendor/serve-sim/helper/udid-1/webrtc/offer"]);
198 expect(headers[0]?.["content-type"]).toBe("application/json");
199 });
200
201 it("lets a viewer read Android stream capabilities and signal WebRTC with read scope", async () => {
202 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
203 const hub = "http://t3.test/api/device-hub/vendor/serve-emu";
204 const api = await handler(new Request(`${hub}/api?device=session-1`));
205 expect(api.status).toBe(200);
206 await api.text();
207 for (const action of ["offer", "close"]) {
208 const signal = await handler(
209 new Request(`${hub}/webrtc/${action}?device=session-1`, {
210 method: "POST",
211 headers: { "content-type": "application/json" },
212 body: '{"sessionId":"viewer"}',
213 }),
214 );
215 expect(signal.status).toBe(200);
216 await signal.text();
217 }
218 expect(requests).toEqual([
219 "http://hub.test/vendor/serve-emu/api?device=session-1",
220 "http://hub.test/vendor/serve-emu/webrtc/offer?device=session-1",
221 "http://hub.test/vendor/serve-emu/webrtc/close?device=session-1",
222 ]);
223 expect((await handler(new Request(`${hub}/api`, { method: "POST" }))).status).toBe(405);
224 expect((await handler(new Request(`${hub}/webrtc/offer`))).status).toBe(405);
225 });
226
227 it("sends requests a host routes itself to its upstream with its credentials", async () => {
228 const { handler, requests, headers } = fixture(
229 [AuthOrchestrationReadScope],
230 false,
231 undefined,
232 (hubPath, search) =>
233 Effect.succeed(
234 hubPath === "/vendor/serve-sim/api"
235 ? {
236 url: `https://session.test${hubPath.slice("/vendor/serve-sim".length)}?${search}`,
237 headers: { authorization: "Bearer session-token" },
238 protocols: [],
239 }
240 : null,
241 ),
242 );
243 const routed = await handler(
244 new Request("http://t3.test/api/device-hub/vendor/serve-sim/api?device=udid-1&hostId=eas", {
245 headers: { authorization: "Bearer environment-token" },
246 }),
247 );
248 expect(routed.status).toBe(200);
249 expect(routed.headers.get("set-cookie")).toBeNull();
250 await routed.text();
251 expect(requests).toEqual(["https://session.test/api?device=udid-1"]);
252 expect(headers[0]?.authorization).toBe("Bearer session-token");
253 const unrouted = await handler(
254 new Request("http://t3.test/api/device-hub/vendor/serve-sim/helper/udid-2/stream.mjpeg"),
255 );
256 expect(unrouted.status).toBe(404);
257 expect(requests).toHaveLength(1);
258 });
259
260 it("never forwards the vendor shell endpoint", async () => {
261 const { handler, requests } = fixture([AuthOrchestrationOperateScope]);
262 expect(
263 (
264 await handler(
265 new Request("http://t3.test/api/device-hub/vendor/serve-sim/exec", { method: "POST" }),
266 )
267 ).status,
268 ).toBe(404);
269 expect(requests).toEqual([]);
270 });
271
272 it.each(["serve-sim", "serve-emu"])(
273 "captures %s screenshots through hosts with their own stream routing",
274 async (vendor) => {
275 const { handler, requests, screenshots } = fixture(
276 [AuthOrchestrationReadScope],
277 false,
278 undefined,
279 () => Effect.succeed(null),
280 );
281 const response = await handler(
282 new Request(
283 `http://t3.test/api/device-hub/vendor/${vendor}/api/screenshot?device=udid-1&hostId=pool`,
284 {
285 method: "POST",
286 },
287 ),
288 );
289 expect(response.status).toBe(200);
290 expect(response.headers.get("content-type")).toBe("image/png");
291 expect(new Uint8Array(await response.arrayBuffer())).toEqual(
292 new Uint8Array([137, 80, 78, 71]),
293 );
294 expect(requests).toEqual([]);
295 expect(screenshots).toEqual([{ hostId: "pool", deviceId: "udid-1" }]);
296 },
297 );
298});
299
300it.each([
301 [new EnvironmentAuth.ServerAuthMissingCredentialError({}), 401],
302 [
303 new EnvironmentAuth.ServerAuthSessionCredentialValidationError({
304 cause: new Error("private credential diagnostic"),
305 }),
306 500,
307 ],
308] as const)("translates authentication failure to HTTP %s", async (error, status) => {
309 const { handler, requests } = fixture([], false, error);
310 const response = await handler(new Request("http://t3.test/api/device-hub/api/devices"));
311 expect(response.status).toBe(status);
312 expect(await response.text()).not.toContain("private credential diagnostic");
313 expect(requests).toEqual([]);
314});
315
316it("rejects unsupported Duo routes", async () => {
317 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
318 const response = await handler(
319 new Request("http://t3.test/api/device-hub/vendor/serve-sim/helper/duo/panel/1/webrtc/offer"),
320 );
321 expect(response.status).toBe(404);
322 expect(requests).toEqual([]);
323});