Skip to content
Source
Sign inSign up

ServerBrowserStream.test.ts

434 lines16.6 KB

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.url);
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 profileReporter: Effect.succeed(() => 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 socket ack follows every mutation, providing a barrier without sleeps.
137 yield* Effect.promise(() => acked.promise);
138 expect(frames).toEqual([frame]);
139 expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]);
140 expect(inputs).toEqual(canOperate ? [...mutations, null] : []);
141 expect(attachments).toEqual([
142 {
143 threadId: "thread",
144 tabId: "tab",
145 maxWidth: 1280,
146 maxHeight: 800,
147 quality: 70,
148 canOperate,
149 },
150 ]);
151 expect(auth.requests).toEqual([resource]);
152 }),
153);
154​
155it.effect("sends replies ahead of stale frames and only the newest frame after an ack", () =>
156 Effect.gen(function* () {
157 const output = yield* Queue.make<ServerBrowser.ServerBrowserViewerOutput>();
158 const released: number[] = [];
159 const frame = (id: number): ServerBrowser.ServerBrowserViewerOutput => ({
160 _tag: "frame",
161 data: new Uint8Array([id]),
162 ack: Effect.sync(() => void released.push(id)),
163 });
164 const browser = ServerBrowser.ServerBrowser.of({
165 clearProfile: () => Effect.void,
166 profileReporter: Effect.succeed(() => Effect.void),
167 openDownload: () => Effect.succeedNone,
168 answerFileChooser: () => Effect.succeed(false),
169 attachViewer: () => Effect.succeed({ output, input: () => Effect.void }),
170 });
171 const services = yield* Layer.build(
172 HttpRouter.serve(
173 routeLayer.pipe(
174 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
175 Layer.provide(platformLayer),
176 ),
177 { disableListenLog: true },
178 ).pipe(
179 Layer.provideMerge(NodeHttpServer.layerTest),
180 Layer.provide(makeAuth([AuthOrchestrationReadScope]).layer),
181 ),
182 );
183 const server = Context.get(services, HttpServer.HttpServer);
184 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
185 const received: Array<number | string> = [];
186 let next = Promise.withResolvers<void>();
187 const socket = yield* Effect.acquireRelease(
188 Effect.sync(
189 () =>
190 new WebSocket(
191 `${origin}/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket`,
192 ),
193 ),
194 (socket) => Effect.sync(() => socket.close()),
195 );
196 socket.binaryType = "arraybuffer";
197 socket.addEventListener("message", (event) => {
198 received.push(
199 typeof event.data === "string"
200 ? JSON.parse(event.data).type
201 : new Uint8Array(event.data as ArrayBuffer)[0]!,
202 );
203 next.resolve();
204 });
205​
206 yield* Queue.offer(output, frame(1));
207 yield* Effect.promise(() => next.promise);
208 next = Promise.withResolvers();
209 yield* Queue.offerAll(output, [
210 frame(2),
211 frame(3),
212 frame(4),
213 { _tag: "probe", x: 0, y: 0, editable: false },
214 ]);
215 yield* Effect.promise(() => next.promise);
216 expect(received).toEqual([1, "probe"]);
217 expect(released).toEqual([2, 3]);
218​
219 next = Promise.withResolvers();
220 socket.send(JSON.stringify({ type: "ack" }));
221 yield* Effect.promise(() => next.promise);
222 expect(received).toEqual([1, "probe", 4]);
223 expect(released).toEqual([2, 3, 1]);
224 }),
225);
226​
227it.effect.each([
228 { scopes: [], error: undefined, status: 403 },
229 { scopes: [AuthOrchestrationOperateScope], error: undefined, status: 403 },
230 { scopes: [], error: new EnvironmentAuth.ServerAuthMissingCredentialError({}), status: 401 },
231])("rejects unauthorized stream connections before attaching a viewer (%s)", (testCase) =>
232 Effect.gen(function* () {
233 const auth = makeAuth(testCase.scopes, testCase.error);
234 let attachments = 0;
235 const browser = ServerBrowser.ServerBrowser.of({
236 clearProfile: () => Effect.void,
237 profileReporter: Effect.succeed(() => Effect.void),
238 openDownload: () => Effect.succeedNone,
239 answerFileChooser: () => Effect.succeed(false),
240 attachViewer: () => {
241 attachments++;
242 return Effect.die("unauthorized viewer must not attach");
243 },
244 });
245 const handler = yield* Effect.acquireRelease(
246 Effect.sync(() =>
247 HttpRouter.toWebHandler(
248 routeLayer.pipe(
249 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
250 Layer.provide(platformLayer),
251 Layer.provideMerge(auth.layer),
252 ),
253 { disableLogger: true },
254 ),
255 ),
256 ({ dispose }) => Effect.promise(dispose),
257 );
258 const response = yield* Effect.promise(() =>
259 handler.handler(
260 new Request("http://t3.test/api/preview-stream/ws?threadId=thread&tabId=tab", {
261 headers: { upgrade: "websocket" },
262 }),
263 ),
264 );
265 expect(response.status).toBe(testCase.status);
266 expect(attachments).toBe(0);
267 }),
268);
269​
270it.effect("serves a tab's download only to an authorized session", () =>
271 Effect.gen(function* () {
272 const fs = yield* FileSystem.FileSystem;
273 const path = `${yield* fs.makeTempDirectoryScoped({ prefix: "t3-download-" })}/file`;
274 yield* fs.writeFileString(path, "report contents");
275 const requests: Array<unknown> = [];
276 const browser = ServerBrowser.ServerBrowser.of({
277 clearProfile: () => Effect.void,
278 profileReporter: Effect.succeed(() => Effect.void),
279 openDownload: (input) =>
280 Effect.sync(() => {
281 requests.push(input);
282 return input.downloadId === "download-1"
283 ? Option.some({ path, fileName: "Q3 report.csv" })
284 : Option.none();
285 }),
286 answerFileChooser: () => Effect.succeed(false),
287 attachViewer: () => Effect.die("unused"),
288 });
289 const serve = (scopes: ReadonlyArray<AuthEnvironmentScope>, url: string) =>
290 Effect.gen(function* () {
291 const handler = yield* Effect.acquireRelease(
292 Effect.sync(() =>
293 HttpRouter.toWebHandler(
294 routeLayer.pipe(
295 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
296 Layer.provide(platformLayer),
297 Layer.provideMerge(makeAuth(scopes, undefined).layer),
298 ),
299 { disableLogger: true },
300 ),
301 ),
302 ({ dispose }) => Effect.promise(dispose),
303 );
304 return yield* Effect.promise(() => handler.handler(new Request(url)));
305 });
306 const base = "http://t3.test/api/preview-stream/download?threadId=thread&tabId=tab";
307 const denied = yield* serve([], `${base}&id=download-1`);
308 expect(denied.status).toBe(403);
309 expect(requests).toEqual([]);
310 const response = yield* serve([AuthOrchestrationReadScope], `${base}&id=download-1`);
311 expect(response.status).toBe(200);
312 expect(response.headers.get("content-disposition")).toBe(
313 'attachment; filename="Q3 report.csv"',
314 );
315 expect(yield* Effect.promise(() => response.text())).toBe("report contents");
316 const missing = yield* serve([AuthOrchestrationReadScope], `${base}&id=other`);
317 expect(missing.status).toBe(404);
318 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
319);
320​
321it.effect("passes uploaded files to the page's open picker and needs operate scope", () =>
322 Effect.gen(function* () {
323 const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = [];
324 const browser = ServerBrowser.ServerBrowser.of({
325 clearProfile: () => Effect.void,
326 profileReporter: Effect.succeed(() => Effect.void),
327 openDownload: () => Effect.succeedNone,
328 answerFileChooser: (input) =>
329 Effect.sync(() => {
330 answers.push({
331 chooserId: input.chooserId,
332 files: input.files.map((file) => ({ name: file.name, text: file.buffer.toString() })),
333 });
334 return input.chooserId === "chooser-1";
335 }),
336 attachViewer: () => Effect.die("unused"),
337 });
338 const upload = (scopes: ReadonlyArray<AuthEnvironmentScope>, chooser: string) =>
339 Effect.gen(function* () {
340 const handler = yield* Effect.acquireRelease(
341 Effect.sync(() =>
342 HttpRouter.toWebHandler(
343 routeLayer.pipe(
344 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
345 Layer.provide(platformLayer),
346 Layer.provideMerge(makeAuth(scopes, undefined).layer),
347 ),
348 { disableLogger: true },
349 ),
350 ),
351 ({ dispose }) => Effect.promise(dispose),
352 );
353 const body = new FormData();
354 body.append("file", new File(["hello"], "notes.txt", { type: "text/plain" }));
355 return yield* Effect.promise(() =>
356 handler.handler(
357 new Request(
358 `http://t3.test/api/preview-stream/upload?threadId=thread&tabId=tab&chooser=${chooser}`,
359 { method: "POST", body },
360 ),
361 ),
362 );
363 });
364 expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);
365 expect(answers).toEqual([]);
366 expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403);
367 expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204);
368 expect(answers).toEqual([
369 { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },
370 ]);
371 expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409);
372 }),
373);
374​
375const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup));
376​
377it.effect.each([
378 {
379 error: new PreviewBrowserHost.PreviewBrowserSandboxError({
380 setupCommand: "sudo t3 browser setup",
381 }),
382 need: "sandbox",
383 },
384 {
385 error: new PreviewBrowserHost.PreviewBrowserLibrariesError({
386 setupCommand: "sudo t3 browser setup",
387 libraries: ["libnss3.so"],
388 }),
389 need: "libraries",
390 },
391])("tells viewers the command that sets up the host ($need)", ({ error, need }) =>
392 Effect.gen(function* () {
393 const browser = ServerBrowser.ServerBrowser.of({
394 clearProfile: () => Effect.void,
395 profileReporter: Effect.succeed(() => Effect.void),
396 openDownload: () => Effect.succeedNone,
397 answerFileChooser: () => Effect.succeed(false),
398 attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })),
399 });
400 const services = yield* Layer.build(
401 HttpRouter.serve(
402 routeLayer.pipe(
403 Layer.provide(Layer.succeed(ServerBrowser.ServerBrowser, browser)),
404 Layer.provide(platformLayer),
405 ),
406 { disableListenLog: true },
407 ).pipe(
408 Layer.provideMerge(NodeHttpServer.layerTest),
409 Layer.provide(makeAuth([AuthOrchestrationReadScope]).layer),
410 ),
411 );
412 const server = Context.get(services, HttpServer.HttpServer);
413 const origin = HttpServer.formatAddress(server.address).replace(/^http/, "ws");
414 const closed = Promise.withResolvers<{ code: number; reason: string }>();
415 yield* Effect.acquireRelease(
416 Effect.sync(() => {
417 const socket = new WebSocket(
418 `${origin}/api/preview-stream/ws?threadId=thread&tabId=tab&wsTicket=one-use-ticket`,
419 );
420 socket.addEventListener("close", (event) =>
421 closed.resolve({ code: event.code, reason: event.reason }),
422 );
423 return socket;
424 }),
425 (socket) => Effect.sync(() => socket.close()),
426 );
427 const { code, reason } = yield* Effect.promise(() => closed.promise);
428 expect(code).toBe(PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE);
429 expect(yield* decodeHostSetup(reason)).toEqual({
430 need,
431 command: "sudo t3 browser setup",
432 });
433 }),
434);