// --------------------------------------------------------------------------- // The exact URL introspection dials. // // Keeping a query-carried credential out of the log means splitting the query // off `request.url` and into `request.urlParams` (see // `introspect-credential-logging.test.ts`). The client recombines the two by // appending each pair through `URLSearchParams`, so the query is re-serialized // in form-urlencoded form rather than passed through byte-for-byte. // // That is a real, if narrow, wire change, and it is not recoverable: raw query // bytes only survive inside `request.url`, which is the single field every // `HttpClientError` message renders. So the normalization is pinned here by // exact string equality rather than described in prose — a `toContain` check // would let any of these encodings drift silently, and an upstream that signs // its query string would notice. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Layer, Logger } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { GraphqlIntrospectionError } from "./errors"; import { introspect } from "./introspect"; const ENDPOINT = "https://graph.example.test/graphql"; /** Records the URL handed to the platform, then fails the transport so the * effect finishes without a live host. */ const recordingFetch = (seen: Array): typeof globalThis.fetch => (async (input: RequestInfo | URL) => { seen.push(input instanceof Request ? input.url : String(input)); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host throw new Error("getaddrinfo ENOTFOUND graph.example.test"); }) as typeof globalThis.fetch; const clientLayer = (seen: Array) => FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(recordingFetch(seen))), ); const silentLogger = Logger.layer([Logger.make(() => {})]); /** Runs introspection against a fetch that always fails, and returns the one * URL it was asked to dial. */ const dialedUrl = (endpoint: string, queryParams?: Record) => Effect.gen(function* () { const seen: Array = []; yield* introspect(endpoint, undefined, queryParams).pipe( Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide(silentLogger), ); expect(seen).toHaveLength(1); return seen[0]; }); describe("GraphQL introspection request URL", () => { it.effect("merges query params into an endpoint that already has some", () => Effect.gen(function* () { // Endpoint params keep their position and order; merged params are // appended after them. const url = yield* dialedUrl(`${ENDPOINT}?apiVersion=2®ion=eu`, { token: "s3cr3t", trace: "on", }); expect(url).toBe(`${ENDPOINT}?apiVersion=2®ion=eu&token=s3cr3t&trace=on`); }), ); it.effect("adds a query to an endpoint that has none", () => Effect.gen(function* () { const url = yield* dialedUrl(ENDPOINT, { token: "s3cr3t" }); expect(url).toBe(`${ENDPOINT}?token=s3cr3t`); }), ); it.effect("overwrites a duplicate param in place rather than appending it", () => Effect.gen(function* () { // The supplied param wins, and it keeps the endpoint param's position — // so the stale value is gone, not merely shadowed. const url = yield* dialedUrl(`${ENDPOINT}?token=stale&keep=1`, { token: "fresh" }); expect(url).toBe(`${ENDPOINT}?token=fresh&keep=1`); }), ); it.effect("collapses repeats of an overwritten key to the supplied value", () => Effect.gen(function* () { const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2`, { a: "3" }); expect(url).toBe(`${ENDPOINT}?a=3`); }), ); it.effect("keeps repeated keys the caller did not overwrite", () => Effect.gen(function* () { const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2&b=3`); expect(url).toBe(`${ENDPOINT}?a=1&a=2&b=3`); }), ); it.effect("re-encodes a %20 space as +", () => Effect.gen(function* () { // Documented normalization, not an accident: `URLSearchParams` serializes // a space in form-urlencoded form. The decoded value is unchanged. const url = yield* dialedUrl(`${ENDPOINT}?token=a%20b`); expect(url).toBe(`${ENDPOINT}?token=a+b`); }), ); it.effect("leaves a + space as +", () => Effect.gen(function* () { const url = yield* dialedUrl(`${ENDPOINT}?token=a+b`); expect(url).toBe(`${ENDPOINT}?token=a+b`); }), ); it.effect("percent-encodes a literal tilde", () => Effect.gen(function* () { // `~` is legal in a raw query and is left alone by `URL`, but the // form-urlencoded serializer escapes it. const url = yield* dialedUrl(`${ENDPOINT}?sig=~tilde~`); expect(url).toBe(`${ENDPOINT}?sig=%7Etilde%7E`); }), ); it.effect("gives a valueless flag a trailing =", () => Effect.gen(function* () { const url = yield* dialedUrl(`${ENDPOINT}?debug`); expect(url).toBe(`${ENDPOINT}?debug=`); }), ); it.effect("leaves an already-encoded reserved character encoded", () => Effect.gen(function* () { const url = yield* dialedUrl(`${ENDPOINT}?a=%2B`); expect(url).toBe(`${ENDPOINT}?a=%2B`); }), ); it.effect("rejects an endpoint carrying userinfo, without echoing it", () => Effect.gen(function* () { // `user:pass@host` is a credential placement that `URL` keeps in the // origin, so it would ride along in `request.url` and render into every // `HttpClientError` message — the exact leak the query split closes. const seen: Array = []; const logged: Array = []; const error = yield* introspect("https://svc:hunter2@graph.example.test/graphql").pipe( Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide( Logger.layer([ Logger.make((options) => { logged.push(String(options.message)); logged.push(Cause.pretty(options.cause)); }), ]), ), ); expect(error).toBeInstanceOf(GraphqlIntrospectionError); expect(error.reason).toBe("invalid-endpoint"); // Nothing was dialed, and neither the error nor the log names the secret. expect(seen).toHaveLength(0); const rendered = `${Cause.pretty(Cause.fail(error))}\n${logged.join("\n")}`; expect(rendered).not.toContain("hunter2"); expect(rendered).not.toContain("svc:"); }), ); it.effect("rejects userinfo even when only a username is present", () => Effect.gen(function* () { const seen: Array = []; const error = yield* introspect("https://svc@graph.example.test/graphql", undefined, { token: "s3cr3t", }).pipe(Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide(silentLogger)); expect(error.reason).toBe("invalid-endpoint"); expect(seen).toHaveLength(0); }), ); });