| 1 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; |
| 2 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { expect, it } from "@effect/vitest"; |
| 5 | import { |
| 6 | AuthOrchestrationOperateScope, |
| 7 | AuthOrchestrationReadScope, |
| 8 | AuthPreviewOperateScope, |
| 9 | AuthSessionId, |
| 10 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, |
| 11 | PreviewStreamHostSetup, |
| 12 | type AuthEnvironmentScope, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import * as Context from "effect/Context"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Option from "effect/Option"; |
| 19 | import * as Queue from "effect/Queue"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import { HttpRouter, HttpServer } from "effect/http"; |
| 22 | |
| 23 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 24 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; |
| 25 | import * as ServerBrowser from "./ServerBrowser.ts"; |
| 26 | import { routeLayer } from "./ServerBrowserStream.ts"; |
| 27 | |
| 28 | const platformLayer = NodeHttpPlatform.layer.pipe(Layer.provideMerge(NodeServices.layer)); |
| 29 | |
| 30 | const 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 | |
| 51 | const 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 | |
| 67 | it.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 | |
| 158 | it.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 | |
| 201 | it.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 | |
| 252 | it.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 | |
| 306 | const decodeHostSetup = Schema.decodeUnknownEffect(Schema.fromJsonString(PreviewStreamHostSetup)); |
| 307 | |
| 308 | it.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 | ); |