1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import {
6 AuthOrchestrationOperateScope,
7 AuthOrchestrationReadScope,
8 AuthPreviewOperateScope,
9 AuthSessionId,
10 PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
11 PreviewStreamHostSetup,
12 type AuthEnvironmentScope,
13} from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Queue from "effect/Queue";
20import * as Schema from "effect/Schema";
21import { HttpRouter, HttpServer } from "effect/http";
22
23import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
24import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
25import * as ServerBrowser from "./ServerBrowser.ts";
26import { routeLayer } from "./ServerBrowserStream.ts";
27
28const platformLayer = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeServices.layer));
29
30const makeAuth = (
31 scopes: ReadonlyArray<AuthEnvironmentScope>,
32 error?: EnvironmentAuth.ServerAuthCredentialError,
33) => {
34 const requests: string[] = [];
35 const layer = Layer.mock(EnvironmentAuth.EnvironmentAuth, {
36 authenticateWebSocketUpgrade: (request) => {
37 requests.push(request.originalUrl);
38 return error
39 ? Effect.fail(error)
40 : Effect.succeed({
41 sessionId: AuthSessionId.make("stream-test"),
42 subject: "stream-test",
43 method: "bearer-access-token",
44 scopes,
45 });
46 },
47 });
48 return { layer, requests };
49};
50
51const mutations = [
52 { type: "resize", width: 390, height: 844 },
53 { type: "mouse", action: "down", x: 10, y: 10, button: "left", buttons: 1 },
54 { type: "key", action: "down", key: "Enter" },
55 { type: "text", text: "reader must not type" },
56 { type: "wheel", deltaY: 100 },
57 { type: "navigate", url: "https://example.com" },
58 { type: "history", delta: -1 },
59 { type: "reload" },
60 { type: "probe", x: 10, y: 10 },
61 { type: "takeControl" },
62 { type: "releaseControl" },
63 { type: "dialog", accept: true },
64 { type: "viewport", setting: { _tag: "fill" } },
65];
66
67it.effect.each([
68 { hasOperateScope: false, interactive: true },
69 { hasOperateScope: true, interactive: true },
70 { hasOperateScope: true, interactive: false },
71])("streams frames and acks while gating page mutations (%s)", ({ hasOperateScope, interactive }) =>
72 Effect.gen(function* () {
73 const canOperate = hasOperateScope && interactive;
74 const scopes = hasOperateScope
75 ? [AuthOrchestrationReadScope, AuthPreviewOperateScope]
76 : [AuthOrchestrationReadScope];
77 const auth = makeAuth(scopes);
78 const inputs: unknown[] = [];
79 const attachments: Parameters<ServerBrowser.ServerBrowser["Service"]["attachViewer"]>[0][] = [];
80 const acked = Promise.withResolvers<void>();
81 const frame = new Uint8Array([255, 216, 255, 217]);
82 const output = yield* Queue.make<ServerBrowser.ServerBrowserViewerOutput>();
83 yield* Queue.offer(output, { _tag: "viewport", width: 1280, height: 800 });
84 yield* Queue.offer(output, {
85 _tag: "frame",
86 data: frame,
87 ack: Effect.sync(() => acked.resolve()),
88 });
89 const browser = ServerBrowser.ServerBrowser.of({
90 clearProfile: () => Effect.void,
91 reportProfiles: () => Effect.void,
92 openDownload: () => Effect.succeedNone,
93 answerFileChooser: () => Effect.succeed(false),
94 attachViewer: (input) =>
95 Effect.sync(() => {
96 attachments.push(input);
97 return {
98 output,
99 input: (message) => Effect.sync(() => void inputs.push(message)),
100 };
101 }),
102 });
103 const services = yield* Layer.build(
104 HttpRouter.serve(
105 routeLayer.pipe(
106 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
107 Layer.provide(platformLayer),
108 ),
109 { disableListenLog: true },
110 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest), Layer.provide(auth.layer)),
111 );
112 const server = Context.get(services, HttpServer.HttpServer);
113 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
114 const resource = `/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket${interactive ? "" : "&interactive=false"}`;
115 const received = Promise.withResolvers<void>();
116 const socket = yield* Effect.acquireRelease(
117 Effect.sync(() => new WebSocket(`${origin}${resource}`)),
118 (socket) => Effect.sync(() => socket.close()),
119 );
120 socket.binaryType = "arraybuffer";
121 const viewports: unknown[] = [];
122 const frames: Uint8Array[] = [];
123 socket.addEventListener("error", () => received.reject(new Error("stream failed")));
124 socket.addEventListener("message", (event) => {
125 if (typeof event.data === "string") {
126 viewports.push(JSON.parse(event.data));
127 return;
128 }
129 frames.push(new Uint8Array(event.data as ArrayBuffer));
130 for (const message of mutations) socket.send(JSON.stringify(message));
131 socket.send("malformed input");
132 socket.send(JSON.stringify({ type: "ack" }));
133 received.resolve();
134 });
135 yield* Effect.promise(() => received.promise);
136 // The ack follows every mutation on the socket, so this is also a barrier
137 // proving all preceding inputs were processed, without a timing sleep.
138 yield* Effect.promise(() => acked.promise);
139 expect(frames).toEqual([frame]);
140 expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]);
141 expect(inputs).toEqual(canOperate ? [...mutations, null] : []);
142 expect(attachments).toEqual([
143 {
144 threadId: "thread",
145 tabId: "tab",
146 maxWidth: 1280,
147 maxHeight: 800,
148 quality: 70,
149 canOperate,
150 },
151 ]);
152 // In particular, a one-use ticket must never be authenticated a second
153 // time to discover whether this read session also has operate scope.
154 expect(auth.requests).toEqual([resource]);
155 }).pipe(Effect.scoped),
156);
157
158it.effect.each([
159 { scopes: [], error: undefined, status: 403 },
160 { scopes: [AuthOrchestrationOperateScope], error: undefined, status: 403 },
161 { scopes: [], error: new EnvironmentAuth.ServerAuthMissingCredentialError({}), status: 401 },
162])("rejects unauthorized stream connections before attaching a viewer (%s)", (testCase) =>
163 Effect.gen(function* () {
164 const auth = makeAuth(testCase.scopes, testCase.error);
165 let attachments = 0;
166 const browser = ServerBrowser.ServerBrowser.of({
167 clearProfile: () => Effect.void,
168 reportProfiles: () => Effect.void,
169 openDownload: () => Effect.succeedNone,
170 answerFileChooser: () => Effect.succeed(false),
171 attachViewer: () => {
172 attachments++;
173 return Effect.die("unauthorized viewer must not attach");
174 },
175 });
176 const handler = yield* Effect.acquireRelease(
177 Effect.sync(() =>
178 HttpRouter.toWebHandler(
179 routeLayer.pipe(
180 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
181 Layer.provide(platformLayer),
182 Layer.provideMerge(auth.layer),
183 ),
184 { disableLogger: true },
185 ),
186 ),
187 ({ dispose }) => Effect.promise(dispose),
188 );
189 const response = yield* Effect.promise(() =>
190 handler.handler(
191 new Request("http://t3.test/api/preview-stream/ws?threadId=thread&tabId=tab", {
192 headers: { upgrade: "websocket" },
193 }),
194 ),
195 );
196 expect(response.status).toBe(testCase.status);
197 expect(attachments).toBe(0);
198 }).pipe(Effect.scoped),
199);
200
201it.effect("serves a tab's download only to an authorized session", () =>
202 Effect.gen(function* () {
203 const fs = yield* FileSystem.FileSystem;
204 const path = `${yield* fs.makeTempDirectoryScoped({ prefix: "t3-download-" })}/file`;
205 yield* fs.writeFileString(path, "report contents");
206 const requests: Array<unknown> = [];
207 const browser = ServerBrowser.ServerBrowser.of({
208 clearProfile: () => Effect.void,
209 reportProfiles: () => Effect.void,
210 openDownload: (input) =>
211 Effect.sync(() => {
212 requests.push(input);
213 return input.downloadId === "download-1"
214 ? Option.some({ path, fileName: "Q3 report.csv" })
215 : Option.none();
216 }),
217 answerFileChooser: () => Effect.succeed(false),
218 attachViewer: () => Effect.die("unused"),
219 });
220 const serve = (scopes: ReadonlyArray<AuthEnvironmentScope>, url: string) =>
221 Effect.gen(function* () {
222 const handler = yield* Effect.acquireRelease(
223 Effect.sync(() =>
224 HttpRouter.toWebHandler(
225 routeLayer.pipe(
226 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
227 Layer.provide(platformLayer),
228 Layer.provideMerge(makeAuth(scopes, undefined).layer),
229 ),
230 { disableLogger: true },
231 ),
232 ),
233 ({ dispose }) => Effect.promise(dispose),
234 );
235 return yield* Effect.promise(() => handler.handler(new Request(url)));
236 });
237 const base = "http://t3.test/api/preview-stream/download?threadId=thread&tabId=tab";
238 const denied = yield* serve([], `${base}&id=download-1`);
239 expect(denied.status).toBe(403);
240 expect(requests).toEqual([]);
241 const response = yield* serve([AuthOrchestrationReadScope], `${base}&id=download-1`);
242 expect(response.status).toBe(200);
243 expect(response.headers.get("content-disposition")).toBe(
244 'attachment; filename="Q3 report.csv"',
245 );
246 expect(yield* Effect.promise(() => response.text())).toBe("report contents");
247 const missing = yield* serve([AuthOrchestrationReadScope], `${base}&id=other`);
248 expect(missing.status).toBe(404);
249 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
250);
251
252it.effect("passes uploaded files to the page's open picker and needs operate scope", () =>
253 Effect.gen(function* () {
254 const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = [];
255 const browser = ServerBrowser.ServerBrowser.of({
256 clearProfile: () => Effect.void,
257 reportProfiles: () => Effect.void,
258 openDownload: () => Effect.succeedNone,
259 answerFileChooser: (input) =>
260 Effect.sync(() => {
261 answers.push({
262 chooserId: input.chooserId,
263 files: input.files.map((file) => ({ name: file.name, text: file.buffer.toString() })),
264 });
265 return input.chooserId === "chooser-1";
266 }),
267 attachViewer: () => Effect.die("unused"),
268 });
269 const upload = (scopes: ReadonlyArray<AuthEnvironmentScope>, chooser: string) =>
270 Effect.gen(function* () {
271 const handler = yield* Effect.acquireRelease(
272 Effect.sync(() =>
273 HttpRouter.toWebHandler(
274 routeLayer.pipe(
275 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
276 Layer.provide(platformLayer),
277 Layer.provideMerge(makeAuth(scopes, undefined).layer),
278 ),
279 { disableLogger: true },
280 ),
281 ),
282 ({ dispose }) => Effect.promise(dispose),
283 );
284 const body = new FormData();
285 body.append("file", new File(["hello"], "notes.txt", { type: "text/plain" }));
286 return yield* Effect.promise(() =>
287 handler.handler(
288 new Request(
289 `http://t3.test/api/preview-stream/upload?threadId=thread&tabId=tab&chooser=${chooser}`,
290 { method: "POST", body },
291 ),
292 ),
293 );
294 });
295 expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);
296 expect(answers).toEqual([]);
297 expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403);
298 expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204);
299 expect(answers).toEqual([
300 { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },
301 ]);
302 expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409);
303 }).pipe(Effect.scoped),
304);
305
306const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup));
307
308it.effect.each([
309 {
310 error: new PreviewBrowserHost.PreviewBrowserSandboxError({
311 setupCommand: "sudo t3 browser setup",
312 }),
313 need: "sandbox",
314 },
315 {
316 error: new PreviewBrowserHost.PreviewBrowserLibrariesError({
317 setupCommand: "sudo t3 browser setup",
318 libraries: ["libnss3.so"],
319 }),
320 need: "libraries",
321 },
322])("tells viewers the command that sets up the host ($need)", ({ error, need }) =>
323 Effect.gen(function* () {
324 const browser = ServerBrowser.ServerBrowser.of({
325 clearProfile: () => Effect.void,
326 reportProfiles: () => Effect.void,
327 openDownload: () => Effect.succeedNone,
328 answerFileChooser: () => Effect.succeed(false),
329 attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })),
330 });
331 const services = yield* Layer.build(
332 HttpRouter.serve(
333 routeLayer.pipe(
334 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
335 Layer.provide(platformLayer),
336 ),
337 { disableListenLog: true },
338 ).pipe(
339 Layer.provideMerge(NodeHttpServer.layerTest),
340 Layer.provide(makeAuth([AuthOrchestrationReadScope]).layer),
341 ),
342 );
343 const server = Context.get(services, HttpServer.HttpServer);
344 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
345 const closed = Promise.withResolvers<{ code: number; reason: string }>();
346 yield* Effect.acquireRelease(
347 Effect.sync(() => {
348 const socket = new WebSocket(
349 `${origin}/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket`,
350 );
351 socket.addEventListener("close", (event) =>
352 closed.resolve({ code: event.code, reason: event.reason }),
353 );
354 return socket;
355 }),
356 (socket) => Effect.sync(() => socket.close()),
357 );
358 const { code, reason } = yield* Effect.promise(() => closed.promise);
359 expect(code).toBe(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE);
360 expect(yield* decodeHostSetup(reason)).toEqual({
361 need,
362 command: "sudo t3 browser setup",
363 });
364 }).pipe(Effect.scoped),
365);