| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | |
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; |
| 9 | import { |
| 10 | appendClientConnectionParams, |
| 11 | bootstrapRemoteBearerSession, |
| 12 | exchangeRemoteDpopAccessToken, |
| 13 | fetchRemoteSessionState, |
| 14 | issueRemoteDpopWebSocketTicket, |
| 15 | issueRemoteWebSocketTicket, |
| 16 | RemoteEnvironmentAuthInvalidJsonError, |
| 17 | RemoteEnvironmentAuthTimeoutError, |
| 18 | resolveRemoteWebSocketConnectionUrl, |
| 19 | resolveRemoteDpopWebSocketConnectionUrl, |
| 20 | } from "./remote.ts"; |
| 21 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 22 | import * as RpcHttp from "../rpc/http.ts"; |
| 23 | |
| 24 | const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); |
| 25 | |
| 26 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; |
| 27 | |
| 28 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { |
| 29 | const calls: Array<FetchCall> = []; |
| 30 | let responseIndex = 0; |
| 31 | const fetchFn = ((input, init) => { |
| 32 | calls.push([input, init ?? {}]); |
| 33 | const response = responses[responseIndex++]; |
| 34 | if (!response) { |
| 35 | return Promise.reject(new Error("Unexpected fetch call")); |
| 36 | } |
| 37 | return Promise.resolve(response); |
| 38 | }) satisfies typeof fetch; |
| 39 | |
| 40 | return { fetchFn, calls }; |
| 41 | }; |
| 42 | |
| 43 | const hangingFetch = () => { |
| 44 | const calls: Array<FetchCall> = []; |
| 45 | const fetchFn = ((input, init) => { |
| 46 | calls.push([input, init ?? {}]); |
| 47 | return new Promise<Response>(() => undefined); |
| 48 | }) satisfies typeof fetch; |
| 49 | |
| 50 | return { fetchFn, calls }; |
| 51 | }; |
| 52 | |
| 53 | const provideRemoteHttp = (fetchFn: typeof fetch) => |
| 54 | Effect.provide(RpcHttp.layerRemoteHttpClient(fetchFn)); |
| 55 | |
| 56 | const expectFetchCall = ( |
| 57 | calls: ReadonlyArray<FetchCall>, |
| 58 | index: number, |
| 59 | expected: { |
| 60 | readonly url: string; |
| 61 | readonly method: string; |
| 62 | readonly headers?: Record<string, string>; |
| 63 | readonly body?: string; |
| 64 | }, |
| 65 | ): void => { |
| 66 | const call = calls[index - 1]; |
| 67 | expect(call).toBeDefined(); |
| 68 | if (!call) { |
| 69 | return; |
| 70 | } |
| 71 | |
| 72 | const [url, init] = call; |
| 73 | expect(String(url)).toBe(expected.url); |
| 74 | expect(init).toEqual( |
| 75 | expect.objectContaining({ |
| 76 | method: expected.method, |
| 77 | }), |
| 78 | ); |
| 79 | expect(init.headers).toEqual(expect.objectContaining(expected.headers ?? {})); |
| 80 | |
| 81 | if ("body" in expected) { |
| 82 | const body = init.body; |
| 83 | if (typeof body === "string") { |
| 84 | expect(body).toBe(expected.body); |
| 85 | } else if (body instanceof Uint8Array) { |
| 86 | expect(new TextDecoder().decode(body)).toBe(expected.body); |
| 87 | } else { |
| 88 | throw new Error("Expected fetch request body"); |
| 89 | } |
| 90 | } |
| 91 | }; |
| 92 | |
| 93 | describe("remote environment authorization", () => { |
| 94 | it.effect("keeps prefixed descriptor, auth, proof, and socket URLs on one environment", () => |
| 95 | Effect.gen(function* () { |
| 96 | const base = "https://hub.example/prefix/environments/one/"; |
| 97 | const ticket = () => |
| 98 | Response.json({ ticket: "ws-ticket", expiresAt: "2026-05-01T12:05:00.000Z" }); |
| 99 | const fetch = recordedFetch( |
| 100 | Response.json({ |
| 101 | environmentId: "one", |
| 102 | label: "One", |
| 103 | platform: { os: "linux", arch: "x64" }, |
| 104 | serverVersion: "test", |
| 105 | capabilities: { repositoryIdentity: true }, |
| 106 | }), |
| 107 | Response.json({ |
| 108 | access_token: "access", |
| 109 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 110 | token_type: "DPoP", |
| 111 | expires_in: 3600, |
| 112 | scope: "orchestration:read", |
| 113 | }), |
| 114 | ticket(), |
| 115 | ticket(), |
| 116 | ); |
| 117 | yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: base }).pipe( |
| 118 | provideRemoteHttp(fetch.fetchFn), |
| 119 | ); |
| 120 | yield* exchangeRemoteDpopAccessToken({ |
| 121 | httpBaseUrl: base, |
| 122 | credential: "bootstrap", |
| 123 | dpopProof: "proof", |
| 124 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 125 | const bearerSocket = yield* resolveRemoteWebSocketConnectionUrl({ |
| 126 | httpBaseUrl: base, |
| 127 | wsBaseUrl: base.replace("https:", "wss:"), |
| 128 | bearerToken: "bearer", |
| 129 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 130 | const dpopSocket = yield* resolveRemoteDpopWebSocketConnectionUrl({ |
| 131 | httpBaseUrl: base, |
| 132 | wsBaseUrl: base.replace("https:", "wss:"), |
| 133 | accessToken: "access", |
| 134 | dpopProof: "proof", |
| 135 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 136 | expect(fetch.calls.map(([url]) => String(url))).toEqual([ |
| 137 | `${base}.well-known/t3/environment`, |
| 138 | `${base}oauth/token`, |
| 139 | `${base}api/auth/websocket-ticket`, |
| 140 | `${base}api/auth/websocket-ticket`, |
| 141 | ]); |
| 142 | const urls = RpcHttp.makeEnvironmentHttpApiUrlBuilder(base); |
| 143 | expect(urls.auth.token()).toBe(`${base}oauth/token`); |
| 144 | expect(urls.auth.webSocketTicket()).toBe(`${base}api/auth/websocket-ticket`); |
| 145 | expect(bearerSocket).toBe("wss://hub.example/prefix/environments/one/ws?wsTicket=ws-ticket"); |
| 146 | expect(dpopSocket).toBe(bearerSocket); |
| 147 | }), |
| 148 | ); |
| 149 | |
| 150 | it.effect("bootstraps bearer auth against a remote backend", () => |
| 151 | Effect.gen(function* () { |
| 152 | const fetch = recordedFetch( |
| 153 | Response.json( |
| 154 | { |
| 155 | access_token: "bearer-token", |
| 156 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 157 | token_type: "Bearer", |
| 158 | expires_in: 3600, |
| 159 | scope: |
| 160 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 161 | }, |
| 162 | { status: 200 }, |
| 163 | ), |
| 164 | ); |
| 165 | |
| 166 | const result = yield* bootstrapRemoteBearerSession({ |
| 167 | httpBaseUrl: "https://remote.example.com/", |
| 168 | credential: "pairing-token", |
| 169 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 170 | |
| 171 | expect(result).toMatchObject({ |
| 172 | token_type: "Bearer", |
| 173 | access_token: "bearer-token", |
| 174 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 175 | }); |
| 176 | expectFetchCall(fetch.calls, 1, { |
| 177 | url: "https://remote.example.com/oauth/token", |
| 178 | method: "POST", |
| 179 | headers: { |
| 180 | "content-type": "application/x-www-form-urlencoded", |
| 181 | }, |
| 182 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", |
| 183 | }); |
| 184 | }), |
| 185 | ); |
| 186 | |
| 187 | it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => |
| 188 | Effect.gen(function* () { |
| 189 | const fetch = recordedFetch( |
| 190 | Response.json({ |
| 191 | access_token: "dpop-access-token", |
| 192 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 193 | token_type: "DPoP", |
| 194 | expires_in: 3600, |
| 195 | scope: "orchestration:read orchestration:operate terminal:operate review:write", |
| 196 | }), |
| 197 | Response.json({ |
| 198 | ticket: "ws-ticket", |
| 199 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 200 | }), |
| 201 | ); |
| 202 | |
| 203 | const token = yield* exchangeRemoteDpopAccessToken({ |
| 204 | httpBaseUrl: "https://remote.example.com/", |
| 205 | credential: "one-time-credential", |
| 206 | dpopProof: "token-proof", |
| 207 | clientMetadata: { |
| 208 | label: "T3 Code Mobile", |
| 209 | deviceType: "mobile", |
| 210 | os: "iOS", |
| 211 | }, |
| 212 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 213 | yield* issueRemoteDpopWebSocketTicket({ |
| 214 | httpBaseUrl: "https://remote.example.com/", |
| 215 | accessToken: token.access_token, |
| 216 | dpopProof: "resource-proof", |
| 217 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 218 | |
| 219 | expectFetchCall(fetch.calls, 1, { |
| 220 | url: "https://remote.example.com/oauth/token", |
| 221 | method: "POST", |
| 222 | headers: { dpop: "token-proof", "content-type": "application/x-www-form-urlencoded" }, |
| 223 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=one-time-credential&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", |
| 224 | }); |
| 225 | expectFetchCall(fetch.calls, 2, { |
| 226 | url: "https://remote.example.com/api/auth/websocket-ticket", |
| 227 | method: "POST", |
| 228 | headers: { |
| 229 | authorization: "DPoP dpop-access-token", |
| 230 | dpop: "resource-proof", |
| 231 | }, |
| 232 | }); |
| 233 | }), |
| 234 | ); |
| 235 | |
| 236 | it.effect("submits optional client display metadata during bearer token exchange", () => |
| 237 | Effect.gen(function* () { |
| 238 | const fetch = recordedFetch( |
| 239 | Response.json( |
| 240 | { |
| 241 | access_token: "bearer-token", |
| 242 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 243 | token_type: "Bearer", |
| 244 | expires_in: 3600, |
| 245 | scope: |
| 246 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 247 | }, |
| 248 | { status: 200 }, |
| 249 | ), |
| 250 | ); |
| 251 | |
| 252 | yield* bootstrapRemoteBearerSession({ |
| 253 | httpBaseUrl: "https://remote.example.com/", |
| 254 | credential: "pairing-token", |
| 255 | clientMetadata: { |
| 256 | label: "T3 Code Mobile", |
| 257 | deviceType: "mobile", |
| 258 | os: "iOS", |
| 259 | }, |
| 260 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 261 | |
| 262 | expectFetchCall(fetch.calls, 1, { |
| 263 | url: "https://remote.example.com/oauth/token", |
| 264 | method: "POST", |
| 265 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", |
| 266 | }); |
| 267 | }), |
| 268 | ); |
| 269 | |
| 270 | it.effect("keeps OS sentinels in telemetry but out of display metadata", () => |
| 271 | Effect.gen(function* () { |
| 272 | const tokenResponse = () => |
| 273 | Response.json( |
| 274 | { |
| 275 | access_token: "bearer-token", |
| 276 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 277 | token_type: "Bearer", |
| 278 | expires_in: 3600, |
| 279 | scope: "orchestration:read", |
| 280 | }, |
| 281 | { status: 200 }, |
| 282 | ); |
| 283 | const fetch = recordedFetch(tokenResponse(), tokenResponse()); |
| 284 | |
| 285 | for (const os of ["unknown", "other"] as const) { |
| 286 | yield* bootstrapRemoteBearerSession({ |
| 287 | httpBaseUrl: "https://remote.example.com/", |
| 288 | credential: "pairing-token", |
| 289 | clientMetadata: { |
| 290 | label: "T3 Code Web", |
| 291 | deviceType: "desktop", |
| 292 | os, |
| 293 | }, |
| 294 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 295 | } |
| 296 | |
| 297 | for (const [, init] of fetch.calls) { |
| 298 | expect(String(init.body)).not.toContain("client_os="); |
| 299 | } |
| 300 | |
| 301 | const websocketUrl = new URL("wss://remote.example.com/ws"); |
| 302 | appendClientConnectionParams(websocketUrl, { |
| 303 | surface: "web", |
| 304 | deviceType: "desktop", |
| 305 | os: "unknown", |
| 306 | }); |
| 307 | expect(websocketUrl.searchParams.get("clientOs")).toBeNull(); |
| 308 | }), |
| 309 | ); |
| 310 | |
| 311 | it.effect("allows a client to explicitly narrow a pairing grant", () => |
| 312 | Effect.gen(function* () { |
| 313 | const fetch = recordedFetch( |
| 314 | Response.json( |
| 315 | { |
| 316 | access_token: "read-only-token", |
| 317 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 318 | token_type: "Bearer", |
| 319 | expires_in: 3600, |
| 320 | scope: "orchestration:read", |
| 321 | }, |
| 322 | { status: 200 }, |
| 323 | ), |
| 324 | ); |
| 325 | |
| 326 | yield* bootstrapRemoteBearerSession({ |
| 327 | httpBaseUrl: "https://remote.example.com/", |
| 328 | credential: "pairing-token", |
| 329 | scopes: ["orchestration:read"], |
| 330 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 331 | |
| 332 | expectFetchCall(fetch.calls, 1, { |
| 333 | url: "https://remote.example.com/oauth/token", |
| 334 | method: "POST", |
| 335 | body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread", |
| 336 | }); |
| 337 | }), |
| 338 | ); |
| 339 | |
| 340 | it.effect("loads remote session state and websocket tickets over bearer auth", () => |
| 341 | Effect.gen(function* () { |
| 342 | const fetch = recordedFetch( |
| 343 | Response.json( |
| 344 | { |
| 345 | environmentId: "environment-remote", |
| 346 | label: "Remote environment", |
| 347 | platform: { |
| 348 | os: "linux", |
| 349 | arch: "x64", |
| 350 | }, |
| 351 | serverVersion: "0.0.0-test", |
| 352 | capabilities: { |
| 353 | repositoryIdentity: true, |
| 354 | }, |
| 355 | }, |
| 356 | { status: 200 }, |
| 357 | ), |
| 358 | Response.json( |
| 359 | { |
| 360 | authenticated: true, |
| 361 | auth: { |
| 362 | policy: "remote-reachable", |
| 363 | bootstrapMethods: ["one-time-token"], |
| 364 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], |
| 365 | sessionCookieName: "t3_session", |
| 366 | }, |
| 367 | scopes: [ |
| 368 | "orchestration:read", |
| 369 | "orchestration:operate", |
| 370 | "terminal:operate", |
| 371 | "review:write", |
| 372 | "relay:read", |
| 373 | ], |
| 374 | sessionMethod: "bearer-access-token", |
| 375 | expiresAt: "2026-05-01T12:00:00.000Z", |
| 376 | }, |
| 377 | { status: 200 }, |
| 378 | ), |
| 379 | Response.json( |
| 380 | { |
| 381 | ticket: "ws-ticket", |
| 382 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 383 | }, |
| 384 | { status: 200 }, |
| 385 | ), |
| 386 | ); |
| 387 | |
| 388 | const environment = yield* fetchRemoteEnvironmentDescriptor({ |
| 389 | httpBaseUrl: "https://remote.example.com/", |
| 390 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 391 | expect(environment).toMatchObject({ |
| 392 | environmentId: "environment-remote", |
| 393 | label: "Remote environment", |
| 394 | }); |
| 395 | |
| 396 | const session = yield* fetchRemoteSessionState({ |
| 397 | httpBaseUrl: "https://remote.example.com/", |
| 398 | bearerToken: "bearer-token", |
| 399 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 400 | expect(session).toMatchObject({ |
| 401 | authenticated: true, |
| 402 | scopes: [ |
| 403 | "orchestration:read", |
| 404 | "orchestration:operate", |
| 405 | "terminal:operate", |
| 406 | "review:write", |
| 407 | "relay:read", |
| 408 | ], |
| 409 | }); |
| 410 | |
| 411 | const ticket = yield* issueRemoteWebSocketTicket({ |
| 412 | httpBaseUrl: "https://remote.example.com/", |
| 413 | bearerToken: "bearer-token", |
| 414 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 415 | expect(ticket).toMatchObject({ |
| 416 | ticket: "ws-ticket", |
| 417 | }); |
| 418 | |
| 419 | expectFetchCall(fetch.calls, 1, { |
| 420 | url: "https://remote.example.com/.well-known/t3/environment", |
| 421 | method: "GET", |
| 422 | }); |
| 423 | expectFetchCall(fetch.calls, 2, { |
| 424 | url: "https://remote.example.com/api/auth/session", |
| 425 | method: "GET", |
| 426 | headers: { |
| 427 | authorization: "Bearer bearer-token", |
| 428 | }, |
| 429 | }); |
| 430 | expectFetchCall(fetch.calls, 3, { |
| 431 | url: "https://remote.example.com/api/auth/websocket-ticket", |
| 432 | method: "POST", |
| 433 | headers: { |
| 434 | authorization: "Bearer bearer-token", |
| 435 | }, |
| 436 | }); |
| 437 | }), |
| 438 | ); |
| 439 | |
| 440 | it.effect("fails hung fetch requests on the configured timeout", () => |
| 441 | Effect.gen(function* () { |
| 442 | const fetch = hangingFetch(); |
| 443 | |
| 444 | const errorFiber = yield* fetchRemoteEnvironmentDescriptor({ |
| 445 | httpBaseUrl: "http://remote.example.com/", |
| 446 | timeoutMs: 25, |
| 447 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip, Effect.forkScoped); |
| 448 | yield* Effect.yieldNow; |
| 449 | yield* TestClock.adjust(Duration.millis(25)); |
| 450 | const error = yield* Fiber.join(errorFiber); |
| 451 | |
| 452 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); |
| 453 | expect(error.message).toBe( |
| 454 | "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", |
| 455 | ); |
| 456 | }).pipe(Effect.provide(TestClock.layer())), |
| 457 | ); |
| 458 | |
| 459 | it.effect("revives declared typed errors from remote auth failures", () => |
| 460 | Effect.gen(function* () { |
| 461 | const fetch = recordedFetch( |
| 462 | Response.json( |
| 463 | { |
| 464 | _tag: "EnvironmentAuthInvalidError", |
| 465 | code: "auth_invalid", |
| 466 | reason: "missing_credential", |
| 467 | traceId: "trace-auth-test", |
| 468 | }, |
| 469 | { status: 401 }, |
| 470 | ), |
| 471 | ); |
| 472 | |
| 473 | const error = yield* issueRemoteWebSocketTicket({ |
| 474 | httpBaseUrl: "https://remote.example.com/", |
| 475 | bearerToken: "expired-token", |
| 476 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); |
| 477 | |
| 478 | expect(isEnvironmentAuthInvalidError(error)).toBe(true); |
| 479 | if (isEnvironmentAuthInvalidError(error)) { |
| 480 | expect(error.reason).toBe("missing_credential"); |
| 481 | expect(error.traceId).toBe("trace-auth-test"); |
| 482 | } |
| 483 | }), |
| 484 | ); |
| 485 | |
| 486 | it.effect("classifies malformed successful remote auth responses as invalid responses", () => |
| 487 | Effect.gen(function* () { |
| 488 | const fetch = recordedFetch( |
| 489 | Response.json( |
| 490 | { |
| 491 | access_token: "", |
| 492 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", |
| 493 | token_type: "Bearer", |
| 494 | expires_in: 3600, |
| 495 | scope: |
| 496 | "orchestration:read orchestration:operate terminal:operate review:write relay:read", |
| 497 | }, |
| 498 | { status: 200 }, |
| 499 | ), |
| 500 | ); |
| 501 | |
| 502 | const error = yield* bootstrapRemoteBearerSession({ |
| 503 | httpBaseUrl: "https://remote.example.com/", |
| 504 | credential: "pairing-token", |
| 505 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip); |
| 506 | |
| 507 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); |
| 508 | expect(error.message).toBe( |
| 509 | "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.", |
| 510 | ); |
| 511 | }), |
| 512 | ); |
| 513 | |
| 514 | it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () => |
| 515 | Effect.gen(function* () { |
| 516 | const fetch = recordedFetch( |
| 517 | Response.json( |
| 518 | { |
| 519 | ticket: "ws-ticket", |
| 520 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 521 | }, |
| 522 | { status: 200 }, |
| 523 | ), |
| 524 | ); |
| 525 | |
| 526 | const url = yield* resolveRemoteWebSocketConnectionUrl({ |
| 527 | wsBaseUrl: "wss://remote.example.com/", |
| 528 | httpBaseUrl: "https://remote.example.com/", |
| 529 | bearerToken: "bearer-token", |
| 530 | clientMetadata: { |
| 531 | surface: "mobile", |
| 532 | appVersion: "1.2.3", |
| 533 | deviceType: "mobile", |
| 534 | os: "Android", |
| 535 | }, |
| 536 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 537 | |
| 538 | expect(url).toBe( |
| 539 | "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3", |
| 540 | ); |
| 541 | }), |
| 542 | ); |
| 543 | }); |