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 * as DeviceService from "./DeviceService.ts";
14import * as DeviceHubProxy from "./DeviceHubProxy.ts";
15
16const disposers: Array<() => Promise<void>> = [];
17afterEach(async () => {
18 for (const dispose of disposers.splice(0)) await dispose();
19});
20
21const fixture = (
22 scopes: ReadonlyArray<AuthEnvironmentScope>,
23 fail = false,
24 authError?: EnvironmentAuth.ServerAuthCredentialError | EnvironmentAuth.ServerAuthInternalError,
25) => {
26 let finalized = 0;
27 const requests: string[] = [];
28 const client = HttpClient.make((request, _url, signal) =>
29 Effect.gen(function* () {
30 requests.push(request.url);
31 signal.addEventListener("abort", () => {
32 finalized++;
33 });
34 if (fail) return yield* Effect.die(new Error("upstream failed"));
35 return HttpClientResponse.fromWeb(request, new Response("frame"));
36 }),
37 );
38 const { handler, dispose } = HttpRouter.toWebHandler(
39 DeviceHubProxy.layer.pipe(
40 Layer.provideMerge(
41 Layer.succeed(EnvironmentAuth.EnvironmentAuth, {
42 authenticateWebSocketUpgrade: () =>
43 authError
44 ? Effect.fail(authError)
45 : Effect.succeed({
46 sessionId: AuthSessionId.make("test"),
47 subject: "test",
48 method: "bearer-access-token",
49 scopes,
50 }),
51 } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]),
52 ),
53 Layer.provideMerge(
54 Layer.succeed(DeviceService.DeviceService, {
55 currentReadiness: () =>
56 Effect.succeed({ hostId: LOCAL_DEVICE_HOST_ID, hub: { origin: "http://hub.test" } }),
57 } as DeviceService.DeviceService["Service"]),
58 ),
59 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)),
60 ),
61 { disableLogger: true },
62 );
63 disposers.push(dispose);
64 return { handler, requests, finalized: () => finalized };
65};
66
67describe("device hub proxy", () => {
68 it("releases the upstream response after forwarding its body and strips tickets", async () => {
69 const { handler, requests, finalized } = fixture([AuthOrchestrationReadScope]);
70 const response = await handler(
71 new Request("http://t3.test/api/device-hub/api/devices?wsTicket=secret"),
72 );
73 expect(response.status).toBe(200);
74 expect(await response.text()).toBe("frame");
75 expect(requests).toEqual(["http://hub.test/api/devices"]);
76 expect(finalized()).toBe(1);
77 });
78
79 it("releases resources when upstream acquisition fails", async () => {
80 const { handler, finalized } = fixture([AuthOrchestrationReadScope], true);
81 const response = await handler(new Request("http://t3.test/api/device-hub/api/devices"));
82 expect(response.status).toBe(500);
83 expect(finalized()).toBe(1);
84 });
85
86 it.each(["/vendor/serve-sim/helper/ws", "/vendor/serve-emu/ws"])(
87 "rejects input socket %s for a read-only session",
88 async (path) => {
89 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
90 const response = await handler(
91 new Request(`http://t3.test/api/device-hub${path}`, { headers: { upgrade: "websocket" } }),
92 );
93 expect(response.status).toBe(403);
94 expect(requests).toEqual([]);
95 },
96 );
97
98 it("requires operate scope for stream tuning", async () => {
99 const readOnly = fixture([AuthOrchestrationReadScope]);
100 const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/stream-settings";
101 expect((await readOnly.handler(new Request(path, { method: "POST" }))).status).toBe(403);
102 const operator = fixture([AuthOrchestrationOperateScope]);
103 const response = await operator.handler(new Request(path, { method: "POST" }));
104 expect(response.status).toBe(200);
105 await response.text();
106 });
107
108 it("reads Android fold state but requires operate scope to change it", async () => {
109 const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554";
110 const reader = fixture([AuthOrchestrationReadScope]);
111 const read = await reader.handler(new Request(path));
112 expect(read.status).toBe(200);
113 await read.text();
114 expect(reader.requests).toEqual([
115 "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554",
116 ]);
117 const denied = await reader.handler(
118 new Request(path, { method: "POST", body: '{"posture":"closed"}' }),
119 );
120 expect(denied.status).toBe(403);
121 expect(reader.requests).toHaveLength(1);
122
123 const operator = fixture([AuthOrchestrationOperateScope]);
124 const changed = await operator.handler(
125 new Request(path, { method: "POST", body: '{"posture":"closed"}' }),
126 );
127 expect(changed.status).toBe(200);
128 await changed.text();
129 expect(operator.requests).toEqual([
130 "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554",
131 ]);
132 });
133
134 it("never forwards the vendor shell endpoint", async () => {
135 const { handler, requests } = fixture([AuthOrchestrationOperateScope]);
136 expect(
137 (
138 await handler(
139 new Request("http://t3.test/api/device-hub/vendor/serve-sim/exec", { method: "POST" }),
140 )
141 ).status,
142 ).toBe(404);
143 expect(requests).toEqual([]);
144 });
145});
146
147it.each([
148 [new EnvironmentAuth.ServerAuthMissingCredentialError({}), 401],
149 [
150 new EnvironmentAuth.ServerAuthSessionCredentialValidationError({
151 cause: new Error("private credential diagnostic"),
152 }),
153 500,
154 ],
155] as const)("translates authentication failure to HTTP %s", async (error, status) => {
156 const { handler, requests } = fixture([], false, error);
157 const response = await handler(new Request("http://t3.test/api/device-hub/api/devices"));
158 expect(response.status).toBe(status);
159 expect(await response.text()).not.toContain("private credential diagnostic");
160 expect(requests).toEqual([]);
161});
162
163it.each([1, 3])(
164 "forwards fixed Duo display %s through the authenticated read proxy",
165 async (panel) => {
166 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
167 const route = `/vendor/serve-sim/helper/duo/panel/${panel}/stream.avcc`;
168 const response = await handler(
169 new Request(`http://t3.test/api/device-hub${route}?wsTicket=secret`),
170 );
171 expect(response.status).toBe(200);
172 await response.text();
173 expect(requests).toEqual([`http://hub.test${route}`]);
174 },
175);
176
177it.each(["/panel/2/stream.avcc", "/panel/1/webrtc/offer", "/panel/3/exec"])(
178 "rejects unsupported Duo route %s",
179 async (route) => {
180 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
181 const response = await handler(
182 new Request(`http://t3.test/api/device-hub/vendor/serve-sim/helper/duo${route}`),
183 );
184 expect(response.status).toBe(404);
185 expect(requests).toEqual([]);
186 },
187);