| 1 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 3 | AuthOrchestrationReadScope, |
| 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSessionId, |
| 6 | LOCAL_DEVICE_HOST_ID, |
| 7 | type AuthEnvironmentScope, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; |
| 12 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 13 | import * as DeviceService from "./DeviceService.ts"; |
| 14 | import * as DeviceHubProxy from "./DeviceHubProxy.ts"; |
| 15 | |
| 16 | const disposers: Array<() => Promise<void>> = []; |
| 17 | afterEach(async () => { |
| 18 | for (const dispose of disposers.splice(0)) await dispose(); |
| 19 | }); |
| 20 | |
| 21 | const 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 | |
| 67 | describe("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 | |
| 147 | it.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 | |
| 163 | it.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 | |
| 177 | it.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 | ); |