| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | AuthOrchestrationReadScope, |
| 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSessionId, |
| 6 | EnvironmentId, |
| 7 | ORCHESTRATION_PROTOCOL_VERSION, |
| 8 | type ExecutionEnvironmentDescriptor, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; |
| 15 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 16 | import * as SessionStore from "../auth/SessionStore.ts"; |
| 17 | import * as Hub from "./Hub.ts"; |
| 18 | import * as HubHttp from "./HubHttp.ts"; |
| 19 | import * as HubProxy from "./HubProxy.ts"; |
| 20 | |
| 21 | const descriptor: ExecutionEnvironmentDescriptor = { |
| 22 | environmentId: EnvironmentId.make("leaf"), |
| 23 | label: "Laptop", |
| 24 | platform: { os: "linux", arch: "x64" }, |
| 25 | serverVersion: "test", |
| 26 | orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, |
| 27 | capabilities: { repositoryIdentity: true }, |
| 28 | }; |
| 29 | const authDescriptor = { |
| 30 | policy: "remote-reachable" as const, |
| 31 | bootstrapMethods: ["one-time-token" as const], |
| 32 | sessionMethods: ["bearer-access-token" as const], |
| 33 | sessionCookieName: "hub-session", |
| 34 | }; |
| 35 | const session = { |
| 36 | sessionId: AuthSessionId.make("phone"), |
| 37 | subject: "phone", |
| 38 | method: "bearer-access-token" as const, |
| 39 | scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], |
| 40 | }; |
| 41 | |
| 42 | const fixture = ( |
| 43 | responseFactory: ( |
| 44 | request: import("effect/http/HttpClientRequest").HttpClientRequest, |
| 45 | ) => Response = () => new Response("leaf"), |
| 46 | ) => |
| 47 | Effect.gen(function* () { |
| 48 | const removed = yield* Deferred.make<void>(); |
| 49 | const revoked = yield* Deferred.make<void>(); |
| 50 | const requests: Array<{ |
| 51 | url: string; |
| 52 | headers: Readonly<Record<string, string>>; |
| 53 | body: string; |
| 54 | }> = []; |
| 55 | let finalized = 0; |
| 56 | const client = HttpClient.make((request, _url, signal) => |
| 57 | Effect.gen(function* () { |
| 58 | signal.addEventListener("abort", () => { |
| 59 | finalized++; |
| 60 | }); |
| 61 | const body = |
| 62 | request.body._tag === "Stream" |
| 63 | ? yield* request.body.stream.pipe( |
| 64 | Stream.runCollect, |
| 65 | Effect.orDie, |
| 66 | Effect.map((chunks) => |
| 67 | chunks.map((chunk) => new TextDecoder().decode(chunk)).join(""), |
| 68 | ), |
| 69 | ) |
| 70 | : ""; |
| 71 | requests.push({ url: request.url, headers: request.headers, body }); |
| 72 | return HttpClientResponse.fromWeb(request, responseFactory(request)); |
| 73 | }), |
| 74 | ); |
| 75 | const hub = Hub.Hub.of({ |
| 76 | authorize: () => Effect.succeed([AuthOrchestrationReadScope]), |
| 77 | descriptor: () => Effect.succeed(descriptor), |
| 78 | directory: () => |
| 79 | Effect.succeed({ |
| 80 | hubEnvironmentId: EnvironmentId.make("hub"), |
| 81 | environments: [ |
| 82 | { |
| 83 | ...descriptor, |
| 84 | descriptor, |
| 85 | httpBaseUrl: "https://hub.test/environments/leaf/", |
| 86 | scopes: [AuthOrchestrationReadScope], |
| 87 | }, |
| 88 | ], |
| 89 | }), |
| 90 | manage: () => Effect.die("unused"), |
| 91 | connect: () => |
| 92 | Effect.succeed({ |
| 93 | url: "http://leaf.private/base/", |
| 94 | token: "private-leaf-token", |
| 95 | invalidated: Deferred.await(removed), |
| 96 | }), |
| 97 | }); |
| 98 | const auth = { |
| 99 | authenticateWebSocketUpgrade: ( |
| 100 | request: import("effect/http/HttpServerRequest").HttpServerRequest, |
| 101 | ) => |
| 102 | request.headers.authorization || request.url.includes("wsTicket=hub-ticket") |
| 103 | ? Effect.succeed(session) |
| 104 | : Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError({})), |
| 105 | issueWebSocketTicket: () => |
| 106 | Effect.succeed({ ticket: "hub-ticket", expiresAt: "2030-01-01T00:00:00.000Z" }), |
| 107 | getDescriptor: () => Effect.succeed(authDescriptor), |
| 108 | } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]; |
| 109 | const dependencies = Layer.mergeAll( |
| 110 | Layer.succeed(Hub.Hub, hub), |
| 111 | Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth), |
| 112 | Layer.succeed(SessionStore.SessionStore, { |
| 113 | awaitInvalidation: () => Deferred.await(revoked), |
| 114 | } as unknown as SessionStore.SessionStore["Service"]), |
| 115 | Layer.succeed(HttpClient.HttpClient, client), |
| 116 | ); |
| 117 | const { handler, dispose } = HttpRouter.toWebHandler( |
| 118 | HubHttp.layer.pipe( |
| 119 | Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))), |
| 120 | Layer.provideMerge(dependencies), |
| 121 | ), |
| 122 | { disableLogger: true }, |
| 123 | ); |
| 124 | yield* Effect.addFinalizer(() => Effect.promise(dispose)); |
| 125 | const request = (path: string, init: RequestInit = {}) => |
| 126 | handler( |
| 127 | new Request(`http://hub.test${path}`, { |
| 128 | ...init, |
| 129 | headers: { authorization: "Bearer hub-token", ...init.headers }, |
| 130 | }), |
| 131 | ); |
| 132 | return { |
| 133 | handler, |
| 134 | request, |
| 135 | requests, |
| 136 | revoke: Deferred.succeed(revoked, undefined), |
| 137 | remove: Deferred.succeed(removed, undefined), |
| 138 | finalized: () => finalized, |
| 139 | }; |
| 140 | }); |
| 141 | |
| 142 | describe("self-hosted server proxy", () => { |
| 143 | it.effect("does not advertise a compressed upstream size for a decoded response", () => |
| 144 | Effect.gen(function* () { |
| 145 | const f = yield* fixture( |
| 146 | () => |
| 147 | new Response("expanded", { |
| 148 | headers: { "content-encoding": "gzip", "content-length": "1" }, |
| 149 | }), |
| 150 | ); |
| 151 | const response = yield* Effect.promise(() => f.request("/environments/leaf/api/example")); |
| 152 | expect(response.headers.get("content-encoding")).toBeNull(); |
| 153 | expect(response.headers.get("content-length")).toBeNull(); |
| 154 | expect(yield* Effect.promise(() => response.text())).toBe("expanded"); |
| 155 | }), |
| 156 | ); |
| 157 | |
| 158 | it.effect("requires hub authentication and management permission", () => |
| 159 | Effect.gen(function* () { |
| 160 | const f = yield* fixture(); |
| 161 | yield* Effect.promise(async () => { |
| 162 | expect((await f.handler(new Request("http://hub.test/api/hub/environments"))).status).toBe( |
| 163 | 401, |
| 164 | ); |
| 165 | expect((await f.request("/api/hub/manage", { method: "POST", body: "{}" })).status).toBe( |
| 166 | 403, |
| 167 | ); |
| 168 | expect((await f.request("/api/hub/environments")).status).toBe(200); |
| 169 | }); |
| 170 | }), |
| 171 | ); |
| 172 | |
| 173 | it.effect( |
| 174 | "replaces hub credentials, preserves byte ranges, streams bodies, and releases upstream resources", |
| 175 | () => |
| 176 | Effect.gen(function* () { |
| 177 | const f = yield* fixture( |
| 178 | () => |
| 179 | new Response("bytes", { |
| 180 | status: 206, |
| 181 | headers: { |
| 182 | "content-range": "bytes 1-5/10", |
| 183 | "set-cookie": "leaf=private", |
| 184 | "x-private-secret": "private", |
| 185 | location: "/base/api/next", |
| 186 | }, |
| 187 | }), |
| 188 | ); |
| 189 | yield* Effect.promise(async () => { |
| 190 | const response = await f.request( |
| 191 | "/environments/leaf/api/assets?wsTicket=hub-ticket&asset=one", |
| 192 | { |
| 193 | method: "PUT", |
| 194 | headers: { |
| 195 | dpop: "hub-proof", |
| 196 | cookie: "hub-session=secret", |
| 197 | range: "bytes=1-5", |
| 198 | "x-forwarded-host": "evil.test", |
| 199 | }, |
| 200 | body: "upload", |
| 201 | }, |
| 202 | ); |
| 203 | expect(response.status).toBe(206); |
| 204 | expect(await response.text()).toBe("bytes"); |
| 205 | expect(response.headers.get("content-range")).toBe("bytes 1-5/10"); |
| 206 | expect(response.headers.get("set-cookie")).toBeNull(); |
| 207 | expect(response.headers.get("x-private-secret")).toBeNull(); |
| 208 | expect(response.headers.get("location")).toBe("/environments/leaf/api/next"); |
| 209 | expect(f.requests).toHaveLength(1); |
| 210 | expect(f.requests[0]).toMatchObject({ |
| 211 | url: "http://leaf.private/base/api/assets?asset=one", |
| 212 | body: "upload", |
| 213 | headers: { authorization: "Bearer private-leaf-token", range: "bytes=1-5" }, |
| 214 | }); |
| 215 | expect(f.requests[0]?.headers).not.toHaveProperty("dpop"); |
| 216 | expect(f.requests[0]?.headers).not.toHaveProperty("cookie"); |
| 217 | expect(f.requests[0]?.headers).not.toHaveProperty("x-forwarded-host"); |
| 218 | expect(f.finalized()).toBe(1); |
| 219 | }); |
| 220 | }), |
| 221 | ); |
| 222 | |
| 223 | it.effect.each([ |
| 224 | "/api/auth/pairing-token", |
| 225 | "/api/auth/delegated-session", |
| 226 | "/api/auth/%64elegated-session", |
| 227 | "/api/auth/browser-session", |
| 228 | "/api/auth/clients", |
| 229 | "/oauth/token", |
| 230 | "/.well-known/oauth-authorization-server", |
| 231 | "/%61pi/auth/pairing-token", |
| 232 | "/api%2fauth/pairing-token", |
| 233 | "/%6fauth/token", |
| 234 | ])("does not expose leaf credentials through %s", (path) => |
| 235 | Effect.gen(function* () { |
| 236 | const f = yield* fixture(); |
| 237 | const response = yield* Effect.promise(() => |
| 238 | f.request(`/environments/leaf${path}`, { method: "POST" }), |
| 239 | ); |
| 240 | expect(response.status).toBe(404); |
| 241 | expect(f.requests).toHaveLength(0); |
| 242 | }), |
| 243 | ); |
| 244 | |
| 245 | it.effect.each(["/%61pi/auth/websocket-ticket", "/api%2fauth/websocket-ticket"])( |
| 246 | "keeps encoded %s ticket requests on the hub", |
| 247 | (path) => |
| 248 | Effect.gen(function* () { |
| 249 | const f = yield* fixture(); |
| 250 | const response = yield* Effect.promise(() => |
| 251 | f.request(`/environments/leaf${path}`, { method: "POST" }), |
| 252 | ); |
| 253 | expect(response.status).toBe(200); |
| 254 | expect(yield* Effect.promise(() => response.json())).toMatchObject({ |
| 255 | ticket: "hub-ticket", |
| 256 | }); |
| 257 | expect(f.requests).toHaveLength(0); |
| 258 | }), |
| 259 | ); |
| 260 | |
| 261 | it.effect( |
| 262 | "serves bounded hub session and ticket responses locally and preserves public leaf identity", |
| 263 | () => |
| 264 | Effect.gen(function* () { |
| 265 | const f = yield* fixture(); |
| 266 | yield* Effect.promise(async () => { |
| 267 | const state = await (await f.request("/environments/leaf/api/auth/session")).json(); |
| 268 | expect(state).toMatchObject({ |
| 269 | authenticated: true, |
| 270 | auth: authDescriptor, |
| 271 | scopes: [AuthOrchestrationReadScope], |
| 272 | }); |
| 273 | expect( |
| 274 | await ( |
| 275 | await f.request("/environments/leaf/api/auth/websocket-ticket", { method: "POST" }) |
| 276 | ).json(), |
| 277 | ).toMatchObject({ ticket: "hub-ticket" }); |
| 278 | expect( |
| 279 | await ( |
| 280 | await f.handler( |
| 281 | new Request("http://hub.test/environments/leaf/.well-known/t3/environment"), |
| 282 | ) |
| 283 | ).json(), |
| 284 | ).toMatchObject({ environmentId: "leaf" }); |
| 285 | expect(f.requests).toHaveLength(0); |
| 286 | }); |
| 287 | }), |
| 288 | ); |
| 289 | |
| 290 | it.effect("never turns a wildcard path into an arbitrary upstream origin", () => |
| 291 | Effect.gen(function* () { |
| 292 | const f = yield* fixture(); |
| 293 | yield* Effect.promise(async () => { |
| 294 | await (await f.request("/environments/leaf/https://evil.test/steal")).text(); |
| 295 | }); |
| 296 | expect(new URL(f.requests[0]?.url ?? "").origin).toBe("http://leaf.private"); |
| 297 | }), |
| 298 | ); |
| 299 | |
| 300 | it.effect.each(["revoke", "remove"] as const)( |
| 301 | "interrupts an active HTTP stream on %s", |
| 302 | (action) => |
| 303 | Effect.gen(function* () { |
| 304 | const f = yield* fixture( |
| 305 | () => |
| 306 | new Response( |
| 307 | new ReadableStream({ |
| 308 | start(controller) { |
| 309 | controller.enqueue(new TextEncoder().encode("first")); |
| 310 | }, |
| 311 | }), |
| 312 | ), |
| 313 | ); |
| 314 | const response = yield* Effect.promise(() => f.request("/environments/leaf/api/stream")); |
| 315 | if (!response.body) return yield* Effect.die("Missing response stream"); |
| 316 | const reader = response.body.getReader(); |
| 317 | const first = yield* Effect.promise(() => reader.read()); |
| 318 | expect(new TextDecoder().decode(first.value)).toBe("first"); |
| 319 | yield* f[action]; |
| 320 | expect((yield* Effect.promise(() => reader.read())).done).toBe(true); |
| 321 | expect(f.finalized()).toBe(1); |
| 322 | }), |
| 323 | ); |
| 324 | }); |