Skip to content
Source
Sign inSign up
Open History to see the last commit

environmentHttpAuth.test.ts

668 lines24.3 KB

1import { describe, expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_HEADER,
5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
6 ProjectId,
7 ThreadId,
8 type AuthSessionState,
9 type OrchestrationV2ShellSnapshot,
10 OrchestrationV2ThreadDetailSnapshot,
11 OrchestrationV2ThreadBoundedSnapshot,
12 OrchestrationV2ThreadTranscript,
13 type OrchestrationV2ThreadHistoryPage,
14} from "@t3tools/contracts";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Queue from "effect/Queue";
21import * as Schema from "effect/Schema";
22import * as TestClock from "effect/testing/TestClock";
23import type { HttpClient } from "effect/http";
24​
25import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
26import {
27 ConnectionTransientError,
28 RelayConnectionTarget,
29 type PreparedConnection,
30 type PreparedHttpAuthorization,
31} from "../connection/model.ts";
32import * as ManagedRelay from "../relay/managedRelay.ts";
33import { type RemoteEnvironmentRequestError } from "../rpc/http.ts";
34import * as RpcHttp from "../rpc/http.ts";
35import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts";
36import { fetchEnvironmentSessionState } from "./session.ts";
37import { fetchEnvironmentShellSnapshot } from "./shellSnapshotHttp.ts";
38import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
39import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts";
40import { fetchEnvironmentBoundedThreadSnapshot } from "./boundedThreadSnapshotHttp.ts";
41import * as BoundedThreadSnapshotHttp from "./boundedThreadSnapshotHttp.ts";
42import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts";
43import { v2Projection } from "./orchestrationV2TestFixtures.ts";
44​
45const encodeBoundedSnapshot = Schema.encodeSync(OrchestrationV2ThreadBoundedSnapshot);
46const encodeThreadTranscript = Schema.encodeSync(OrchestrationV2ThreadTranscript);
47​
48const TARGET = new RelayConnectionTarget({
49 environmentId: EnvironmentId.make("environment-1"),
50 label: "Remote environment",
51});
52const PREPARED: PreparedConnection = {
53 environmentId: TARGET.environmentId,
54 label: TARGET.label,
55 httpBaseUrl: "https://previous.example.test",
56 socketUrl: "wss://previous.example.test/ws",
57 httpAuthorization: { _tag: "Dpop", accessToken: "expired-token", expiresAtEpochMs: 0 },
58 target: TARGET,
59};
60const CURRENT_ORIGIN = "https://current.example.test";
61const RENEWED_ORIGIN = "https://renewed.example.test";
62const DIFF = {
63 projectId: ProjectId.make("project-1"),
64 repository: "owner/repository",
65 number: 42,
66};
67const DIFF_RESULT = { patch: "diff --git a/file.ts b/file.ts", truncated: false, nextCursor: null };
68const AUTH = {
69 policy: "remote-reachable",
70 bootstrapMethods: ["one-time-token"],
71 sessionMethods: ["dpop-access-token"],
72 sessionCookieName: "t3_session",
73} satisfies AuthSessionState["auth"];
74const SESSION = {
75 authenticated: true,
76 auth: AUTH,
77 scopes: ["orchestration:read", "orchestration:operate"],
78 sessionMethod: "dpop-access-token",
79} satisfies AuthSessionState;
80const UNAUTHENTICATED_SESSION = { authenticated: false, auth: AUTH } satisfies AuthSessionState;
81const SHELL = {
82 schemaVersion: 1,
83 snapshotSequence: 1,
84 projects: [],
85 threads: [],
86 archivedThreads: [],
87} satisfies OrchestrationV2ShellSnapshot;
88const THREAD = {
89 snapshotSequence: 2,
90 projection: v2Projection,
91} satisfies OrchestrationV2ThreadDetailSnapshot;
92const TRANSCRIPT = {
93 threadId: v2Projection.thread.id,
94 title: v2Projection.thread.title,
95 updatedAt: v2Projection.updatedAt,
96 items: v2Projection.visibleTurnItems,
97} satisfies OrchestrationV2ThreadTranscript;
98const BOUNDED_THREAD = {
99 ...THREAD,
100 historyCursor: "older-page",
101 hasMoreHistory: true,
102 latestLocalTurnOrdinal: 3,
103} satisfies OrchestrationV2ThreadBoundedSnapshot;
104const THREAD_HISTORY = {
105 snapshotSequence: 2,
106 items: [],
107 nextCursor: null,
108 hasMoreHistory: false,
109} satisfies OrchestrationV2ThreadHistoryPage;
110​
111function credentialRejectedResponse(reason = "invalid_credential") {
112 return Response.json(
113 {
114 _tag: "EnvironmentAuthInvalidError",
115 code: "auth_invalid",
116 reason,
117 traceId: "trace-rejected",
118 },
119 { status: 401 },
120 );
122​
123function makeHarness(reply: (requestNumber: number) => Response | Promise<Response>) {
124 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
125 const authorizations: Array<
126 Parameters<
127 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]
128 >[0]
129 > = [];
130 const proofs: Array<ManagedRelay.ManagedRelayDpopProofInput> = [];
131 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
132 authorizeBearer: () => Effect.die("Unexpected bearer connection preparation."),
133 authorizeDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."),
134 authorizeDpopHttp: (input) =>
135 Effect.sync(() => {
136 authorizations.push(input);
137 const rejected = input.rejectedAccessToken !== undefined;
138 return {
139 environmentId: TARGET.environmentId,
140 label: TARGET.label,
141 httpBaseUrl: rejected ? RENEWED_ORIGIN : CURRENT_ORIGIN,
142 httpAuthorization: {
143 _tag: "Dpop" as const,
144 accessToken: rejected ? "renewed-token" : "current-token",
145 expiresAtEpochMs: 3_600_000,
146 },
147 };
148 }),
149 rememberCheckedDescriptor: () => Effect.void,
150 renewDpopBeforeExpiry: () => Effect.die("HTTP requests must not renew a socket's token."),
151 });
152 const signer = ManagedRelay.ManagedRelayDpopSigner.of({
153 thumbprint: Effect.succeed("test-thumbprint"),
154 createProof: (input) =>
155 Effect.sync(() => {
156 proofs.push(input);
157 return `proof-${proofs.length}`;
158 }),
159 });
160 const fetchFn: typeof fetch = async (request, init) => {
161 calls.push({ url: String(request), init: init ?? {} });
162 return reply(calls.length);
163 };
164 return {
165 calls,
166 authorizations,
167 proofs,
168 remoteAuthorization,
169 input: {
170 prepared: PREPARED,
171 signer: Option.some(signer),
172 remoteAuthorization: Option.some(remoteAuthorization),
173 },
174 httpLayer: RpcHttp.layerRemoteHttpClient(fetchFn),
175 };
177​
178// Resolves "settled" instead of hanging when the load finishes without sending another request.
179function nextRequestOrSettled(requests: Queue.Queue<number>, load: Fiber.Fiber<unknown, unknown>) {
180 return Effect.raceFirst(
181 Queue.take(requests).pipe(Effect.as("sent" as const)),
182 Fiber.await(load).pipe(Effect.as("settled" as const)),
183 );
185​
186type HttpInput = ReturnType<typeof makeHarness>["input"];
187const LOADERS: ReadonlyArray<{
188 readonly name: string;
189 readonly method: string;
190 readonly path: string;
191 readonly response: unknown;
192 readonly expected: unknown;
193 readonly load: (
194 input: HttpInput,
195 ) => Effect.Effect<
196 unknown,
197 RemoteEnvironmentRequestError | PullRequestDiffLoader.PullRequestDiffCredentialRejectedError,
198 HttpClient.HttpClient
199 >;
200}> = [
201 {
202 name: "PR diff",
203 method: "POST",
204 path: "/api/pull-requests/diff",
205 response: DIFF_RESULT,
206 expected: DIFF_RESULT,
207 load: (input: HttpInput) =>
208 PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({ ...input, diff: DIFF }),
209 },
210 {
211 name: "session permissions",
212 method: "GET",
213 path: "/api/auth/session",
214 response: SESSION,
215 expected: SESSION,
216 load: fetchEnvironmentSessionState,
217 },
218 {
219 name: "shell snapshot",
220 method: "GET",
221 path: "/api/orchestration/shell",
222 response: SHELL,
223 expected: SHELL,
224 load: (input: HttpInput) =>
225 fetchEnvironmentShellSnapshot(input).pipe(
226 Effect.map((answer) => {
227 if ("resumable" in answer) return answer;
228 const { loadPullRequests: _links, ...snapshot } = answer;
229 return snapshot;
230 }),
231 ),
232 },
233 {
234 name: "thread transcript",
235 method: "GET",
236 path: `/api/orchestration/threads/${TRANSCRIPT.threadId}/transcript`,
237 response: encodeThreadTranscript(TRANSCRIPT),
238 expected: TRANSCRIPT,
239 load: (input: HttpInput) =>
240 fetchEnvironmentThreadTranscript({ ...input, threadId: TRANSCRIPT.threadId }),
241 },
242 {
243 name: "bounded thread snapshot",
244 method: "GET",
245 path: `/api/orchestration/threads/${THREAD.projection.thread.id}/bounded`,
246 response: encodeBoundedSnapshot(BOUNDED_THREAD),
247 expected: BOUNDED_THREAD,
248 load: (input: HttpInput) =>
249 fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: THREAD.projection.thread.id }),
250 },
251 {
252 name: "older thread history",
253 method: "GET",
254 path: `/api/orchestration/threads/${THREAD.projection.thread.id}/history`,
255 response: THREAD_HISTORY,
256 expected: THREAD_HISTORY,
257 load: (input: HttpInput) =>
258 fetchEnvironmentThreadHistoryPage({
259 ...input,
260 threadId: THREAD.projection.thread.id,
261 cursor: "older-page",
262 }),
263 },
264];
265​
266describe("authenticated environment HTTP requests", () => {
267 it.effect.each(LOADERS)("rejects an invalid $name response", (loader) =>
268 Effect.gen(function* () {
269 const harness = makeHarness(() => Response.json({}));
270 const result = yield* loader
271 .load(harness.input)
272 .pipe(Effect.provide(harness.httpLayer), Effect.asVoid, Effect.flip);
273 expect(result._tag).toBe("RemoteEnvironmentAuthInvalidJsonError");
274 expect(harness.calls).toHaveLength(1);
275 }),
276 );
277​
278 it.effect("keeps the status of a shell snapshot error that is not a declared error", () =>
279 Effect.gen(function* () {
280 const harness = makeHarness(() => Response.json({ error: "bad_gateway" }, { status: 502 }));
281 const error = yield* fetchEnvironmentShellSnapshot(harness.input).pipe(
282 Effect.provide(harness.httpLayer),
283 Effect.flip,
284 );
285 expect(error).toMatchObject({
286 _tag: "RemoteEnvironmentAuthUndeclaredStatusError",
287 status: 502,
288 });
289 }),
290 );
291​
292 it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) =>
293 Effect.gen(function* () {
294 const harness = makeHarness(() => Response.json(loader.response));
295 const result = yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
296​
297 expect(result).toEqual(loader.expected);
298 expect(harness.calls).toHaveLength(1);
299 const call = harness.calls[0]!;
300 const url = new URL(call.url);
301 expect(url.origin).toBe(CURRENT_ORIGIN);
302 expect(url.pathname).toBe(loader.path);
303 expect(call.init.method).toBe(loader.method);
304 expect(new Headers(call.init.headers).get("authorization")).toBe("DPoP current-token");
305 expect(new Headers(call.init.headers).get("dpop")).toBe("proof-1");
306 expect(call.init.credentials).toBeUndefined();
307 if (loader.path.startsWith("/api/orchestration/")) {
308 expect(new Headers(call.init.headers).get(ORCHESTRATION_PROTOCOL_HEADER)).toBe(
309 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
310 );
311 }
312 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
313 // The proof signs exactly the URL sent; signers drop the query for `htu`.
314 expect(harness.proofs).toEqual([
315 { method: loader.method, url: call.url, accessToken: "current-token" },
316 ]);
317 if (loader.name === "older thread history") {
318 expect(url.searchParams.get("cursor")).toBe("older-page");
319 }
320 expect(PREPARED.httpAuthorization).toMatchObject({ accessToken: "expired-token" });
321 }),
322 );
323​
324 // MCP-created thread ids contain ":", which the request path percent-encodes.
325 // The DPoP proof must sign the URL that is actually sent, or the environment
326 // rejects it as a URL mismatch.
327 const MCP_THREAD_ID = ThreadId.make("mcp:3534bc83-1c17-4a1e-9118-601c2766d355");
328 const MCP_THREAD_LOADERS: ReadonlyArray<
329 Pick<(typeof LOADERS)[number], "name" | "response" | "load">
330 > = [
331 {
332 name: "thread transcript",
333 response: encodeThreadTranscript(TRANSCRIPT),
334 load: (input: HttpInput) =>
335 fetchEnvironmentThreadTranscript({ ...input, threadId: MCP_THREAD_ID }),
336 },
337 {
338 name: "bounded thread snapshot",
339 response: encodeBoundedSnapshot(BOUNDED_THREAD),
340 load: (input: HttpInput) =>
341 fetchEnvironmentBoundedThreadSnapshot({ ...input, threadId: MCP_THREAD_ID }),
342 },
343 {
344 name: "older thread history",
345 response: THREAD_HISTORY,
346 load: (input: HttpInput) =>
347 fetchEnvironmentThreadHistoryPage({
348 ...input,
349 threadId: MCP_THREAD_ID,
350 cursor: "older-page",
351 }),
352 },
353 ];
354 it.effect.each(MCP_THREAD_LOADERS)(
355 "signs the sent URL for a $name of a thread id that needs encoding",
356 (loader) =>
357 Effect.gen(function* () {
358 const harness = makeHarness(() => Response.json(loader.response));
359 yield* loader.load(harness.input).pipe(Effect.provide(harness.httpLayer));
360​
361 const sent = harness.calls[0]!.url;
362 expect(new URL(sent).pathname).toContain("/mcp%3A3534bc83-");
363 expect(harness.proofs.map((proof) => proof.url)).toEqual([sent]);
364 }),
365 );
366​
367 it.effect("retries a rejected diff once with a new token, endpoint, and proof", () =>
368 Effect.gen(function* () {
369 const harness = makeHarness((requestNumber) =>
370 requestNumber === 1 ? credentialRejectedResponse() : Response.json(DIFF_RESULT),
371 );
372 const result = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
373 ...harness.input,
374 diff: DIFF,
375 }).pipe(Effect.provide(harness.httpLayer));
376​
377 expect(result).toEqual(DIFF_RESULT);
378 expect(harness.authorizations).toEqual([
379 { expectedEnvironmentId: TARGET.environmentId },
380 { expectedEnvironmentId: TARGET.environmentId, rejectedAccessToken: "current-token" },
381 ]);
382 expect(harness.calls.map((call) => call.url)).toEqual([
383 `${CURRENT_ORIGIN}/api/pull-requests/diff`,
384 `${RENEWED_ORIGIN}/api/pull-requests/diff`,
385 ]);
386 expect(
387 harness.calls.map((call) => new Headers(call.init.headers).get("authorization")),
388 ).toEqual(["DPoP current-token", "DPoP renewed-token"]);
389 expect(harness.calls.map((call) => new Headers(call.init.headers).get("dpop"))).toEqual([
390 "proof-1",
391 "proof-2",
392 ]);
393 expect(harness.proofs[1]).toEqual({
394 method: "POST",
395 url: `${RENEWED_ORIGIN}/api/pull-requests/diff`,
396 accessToken: "renewed-token",
397 });
398 expect(harness.calls[1]!.init.body).toEqual(harness.calls[0]!.init.body);
399 }),
400 );
401​
402 it.effect("renews credentials captured by the bounded snapshot loader", () =>
403 Effect.gen(function* () {
404 const harness = makeHarness((requestNumber) =>
405 requestNumber === 1
406 ? credentialRejectedResponse()
407 : Response.json(encodeBoundedSnapshot(BOUNDED_THREAD)),
408 );
409 const layerLoader = BoundedThreadSnapshotHttp.layer.pipe(
410 Layer.provide(
411 Layer.mergeAll(
412 harness.httpLayer,
413 Layer.succeed(
414 ManagedRelay.ManagedRelayDpopSigner,
415 Option.getOrThrow(harness.input.signer),
416 ),
417 Layer.succeed(
418 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
419 harness.remoteAuthorization,
420 ),
421 ),
422 ),
423 );
424 const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader.pipe(
425 Effect.provide(layerLoader),
426 );
427 const result = yield* loader.load(PREPARED, THREAD.projection.thread.id);
428 expect(result).toEqual({
429 _tag: "present",
430 snapshot: { ...THREAD, latestLocalTurnOrdinal: BOUNDED_THREAD.latestLocalTurnOrdinal },
431 history: {
432 historyCursor: BOUNDED_THREAD.historyCursor,
433 hasMoreHistory: true,
434 latestLocalTurnOrdinal: BOUNDED_THREAD.latestLocalTurnOrdinal,
435 },
436 });
437 expect(harness.calls).toHaveLength(2);
438 expect(new Headers(harness.calls[1]!.init.headers).get("authorization")).toBe(
439 "DPoP renewed-token",
440 );
441 }),
442 );
443​
444 it.effect("preserves the credential rejection after the one recovery attempt fails", () =>
445 Effect.gen(function* () {
446 const harness = makeHarness(() => credentialRejectedResponse());
447 const error = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
448 ...harness.input,
449 diff: DIFF,
450 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
451​
452 expect(error).toMatchObject({
453 _tag: "PullRequestDiffCredentialRejectedError",
454 traceId: "trace-rejected",
455 });
456 expect(harness.calls).toHaveLength(2);
457 expect(harness.authorizations).toHaveLength(2);
458 }),
459 );
460​
461 it.effect.each([
462 {
463 name: "insufficient scope",
464 reply: () =>
465 Response.json(
466 {
467 _tag: "EnvironmentScopeRequiredError",
468 code: "insufficient_scope",
469 requiredScope: "orchestration:read",
470 traceId: "trace-scope",
471 },
472 { status: 403 },
473 ),
474 errorTag: "EnvironmentScopeRequiredError",
475 },
476 {
477 name: "missing credential",
478 reply: () => credentialRejectedResponse("missing_credential"),
479 errorTag: "EnvironmentAuthInvalidError",
480 },
481 {
482 name: "Cloudflare 530",
483 reply: () => new Response("error code: 1033", { status: 530 }),
484 errorTag: "RemoteEnvironmentAuthUndeclaredStatusError",
485 },
486 {
487 name: "network failure",
488 reply: () => Promise.reject(new Error("Network unreachable")),
489 errorTag: "RemoteEnvironmentAuthFetchError",
490 },
491 ])("does not renew or retry on $name", ({ reply, errorTag }) =>
492 Effect.gen(function* () {
493 const harness = makeHarness(reply);
494 const error = yield* PullRequestDiffLoader.fetchEnvironmentPullRequestDiff({
495 ...harness.input,
496 diff: DIFF,
497 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
498​
499 expect(error._tag).toBe(errorTag);
500 expect(harness.calls).toHaveLength(1);
501 expect(harness.authorizations).toEqual([{ expectedEnvironmentId: TARGET.environmentId }]);
502 }),
503 );
504​
505 it.effect("recovers a session's unauthenticated 200 response before checking permissions", () =>
506 Effect.gen(function* () {
507 const harness = makeHarness((requestNumber) =>
508 Response.json(requestNumber === 1 ? UNAUTHENTICATED_SESSION : SESSION),
509 );
510 const result = yield* fetchEnvironmentSessionState(harness.input).pipe(
511 Effect.provide(harness.httpLayer),
512 );
513​
514 expect(result).toEqual(SESSION);
515 expect(harness.authorizations[1]).toEqual({
516 expectedEnvironmentId: TARGET.environmentId,
517 rejectedAccessToken: "current-token",
518 });
519 expect(harness.calls).toHaveLength(2);
520 }),
521 );
522​
523 it.effect(
524 "waits longer for session permissions after the environment is too slow to answer",
525 () =>
526 Effect.gen(function* () {
527 const requests = yield* Queue.unbounded<number>();
528 let answer: () => void = () => {};
529 const answered = new Promise<void>((resolve) => {
530 answer = resolve;
531 });
532 const harness = makeHarness((requestNumber) => {
533 Queue.offerUnsafe(requests, requestNumber);
534 return requestNumber === 1
535 ? new Promise<Response>(() => {})
536 : answered.then(() => Response.json(SESSION));
537 });
538 const load = yield* fetchEnvironmentSessionState({
539 prepared: { ...PREPARED, httpAuthorization: { _tag: "Bearer", token: "bearer-token" } },
540 signer: Option.none(),
541 timeoutMs: 1_000,
542 }).pipe(Effect.provide(harness.httpLayer), Effect.forkChild({ startImmediately: true }));
543​
544 yield* Queue.take(requests);
545 yield* TestClock.adjust("1 second");
546 expect(yield* nextRequestOrSettled(requests, load)).toBe("sent");
547 yield* TestClock.adjust("1500 millis");
548 answer();
549​
550 expect(yield* Fiber.await(load)).toStrictEqual(Exit.succeed(SESSION));
551 expect(harness.calls).toHaveLength(2);
552 }),
553 );
554​
555 it.effect("gives up on session permissions once a 60 second wait also times out", () =>
556 Effect.gen(function* () {
557 const requests = yield* Queue.unbounded<number>();
558 const harness = makeHarness((requestNumber) => {
559 Queue.offerUnsafe(requests, requestNumber);
560 return new Promise<Response>(() => {});
561 });
562 const load = yield* fetchEnvironmentSessionState({
563 prepared: { ...PREPARED, httpAuthorization: { _tag: "Bearer", token: "bearer-token" } },
564 signer: Option.none(),
565 timeoutMs: 15_000,
566 }).pipe(Effect.provide(harness.httpLayer), Effect.forkChild({ startImmediately: true }));
567​
568 yield* Queue.take(requests);
569 yield* TestClock.adjust("15 seconds");
570 expect(yield* nextRequestOrSettled(requests, load)).toBe("sent");
571 yield* TestClock.adjust("30 seconds");
572 expect(yield* nextRequestOrSettled(requests, load)).toBe("sent");
573 yield* TestClock.adjust("60 seconds");
574 const exit = yield* Fiber.await(load);
575​
576 expect(exit).toMatchObject({
577 _tag: "Failure",
578 cause: {
579 reasons: [{ error: { _tag: "RemoteEnvironmentAuthTimeoutError", timeoutMs: 60_000 } }],
580 },
581 });
582 expect(harness.calls).toHaveLength(3);
583 }),
584 );
585​
586 it.effect("reports persistent session rejection instead of showing missing permissions", () =>
587 Effect.gen(function* () {
588 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
589 const error = yield* fetchEnvironmentSessionState(harness.input).pipe(
590 Effect.provide(harness.httpLayer),
591 Effect.flip,
592 );
593​
594 expect(error).toMatchObject({
595 _tag: "RemoteEnvironmentAuthFetchError",
596 message: "The environment rejected the renewed session authorization.",
597 });
598 expect(harness.calls).toHaveLength(2);
599 }),
600 );
601​
602 it.effect.each([
603 { name: "cookie", authorization: null },
604 { name: "bearer", authorization: { _tag: "Bearer", token: "bearer-token" } },
605 ] satisfies ReadonlyArray<{ name: string; authorization: PreparedHttpAuthorization | null }>)(
606 "leaves $name sessions unchanged without relay services",
607 ({ authorization }) =>
608 Effect.gen(function* () {
609 const harness = makeHarness(() => Response.json(UNAUTHENTICATED_SESSION));
610 const result = yield* fetchEnvironmentSessionState({
611 prepared: { ...PREPARED, httpAuthorization: authorization },
612 signer: Option.none(),
613 }).pipe(Effect.provide(harness.httpLayer));
614​
615 expect(result).toEqual(UNAUTHENTICATED_SESSION);
616 expect(harness.calls).toHaveLength(1);
617 expect(harness.authorizations).toEqual([]);
618 expect(new Headers(harness.calls[0]!.init.headers).get("authorization")).toBe(
619 authorization === null ? null : "Bearer bearer-token",
620 );
621 expect(harness.calls[0]!.init.credentials).toBe(
622 authorization === null ? "include" : undefined,
623 );
624 }),
625 );
626​
627 it.effect("reports renewal failure without sending the expired prepared token", () =>
628 Effect.gen(function* () {
629 const harness = makeHarness(() => Response.json(SESSION));
630 const failure = new ConnectionTransientError({
631 reason: "transport",
632 detail: "Relay unavailable",
633 });
634 const remoteAuthorization = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
635 ...harness.remoteAuthorization,
636 authorizeDpopHttp: () => Effect.fail(failure),
637 });
638 const error = yield* fetchEnvironmentSessionState({
639 ...harness.input,
640 remoteAuthorization: Option.some(remoteAuthorization),
641 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
642​
643 expect(error).toMatchObject({
644 _tag: "RemoteEnvironmentAuthFetchError",
645 message: "Could not authorize the environment request.",
646 cause: failure,
647 });
648 expect(harness.calls).toEqual([]);
649 expect(harness.proofs).toEqual([]);
650 }),
651 );
652​
653 it.effect("does not fall back to a captured DPoP token when authorization is unavailable", () =>
654 Effect.gen(function* () {
655 const harness = makeHarness(() => Response.json(SESSION));
656 const error = yield* fetchEnvironmentSessionState({
657 prepared: PREPARED,
658 signer: harness.input.signer,
659 }).pipe(Effect.provide(harness.httpLayer), Effect.flip);
660​
661 expect(error).toMatchObject({
662 _tag: "RemoteEnvironmentAuthFetchError",
663 message: "No relay authorization service is available for the environment request.",
664 });
665 expect(harness.calls).toEqual([]);
666 }),
667 );
668});