|
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// The exact URL introspection dials. |
| 3 | +// |
| 4 | +// Keeping a query-carried credential out of the log means splitting the query |
| 5 | +// off `request.url` and into `request.urlParams` (see |
| 6 | +// `introspect-credential-logging.test.ts`). The client recombines the two by |
| 7 | +// appending each pair through `URLSearchParams`, so the query is re-serialized |
| 8 | +// in form-urlencoded form rather than passed through byte-for-byte. |
| 9 | +// |
| 10 | +// That is a real, if narrow, wire change, and it is not recoverable: raw query |
| 11 | +// bytes only survive inside `request.url`, which is the single field every |
| 12 | +// `HttpClientError` message renders. So the normalization is pinned here by |
| 13 | +// exact string equality rather than described in prose — a `toContain` check |
| 14 | +// would let any of these encodings drift silently, and an upstream that signs |
| 15 | +// its query string would notice. |
| 16 | +// --------------------------------------------------------------------------- |
| 17 | + |
| 18 | +import { describe, expect, it } from "@effect/vitest"; |
| 19 | +import { Cause, Effect, Layer, Logger } from "effect"; |
| 20 | +import { FetchHttpClient } from "effect/unstable/http"; |
| 21 | + |
| 22 | +import { GraphqlIntrospectionError } from "./errors"; |
| 23 | +import { introspect } from "./introspect"; |
| 24 | + |
| 25 | +const ENDPOINT = "https://graph.example.test/graphql"; |
| 26 | + |
| 27 | +/** Records the URL handed to the platform, then fails the transport so the |
| 28 | + * effect finishes without a live host. */ |
| 29 | +const recordingFetch = (seen: Array<string>): typeof globalThis.fetch => |
| 30 | + (async (input: RequestInfo | URL) => { |
| 31 | + seen.push(input instanceof Request ? input.url : String(input)); |
| 32 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host |
| 33 | + throw new Error("getaddrinfo ENOTFOUND graph.example.test"); |
| 34 | + }) as typeof globalThis.fetch; |
| 35 | + |
| 36 | +const clientLayer = (seen: Array<string>) => |
| 37 | + FetchHttpClient.layer.pipe( |
| 38 | + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(recordingFetch(seen))), |
| 39 | + ); |
| 40 | + |
| 41 | +const silentLogger = Logger.layer([Logger.make<unknown, void>(() => {})]); |
| 42 | + |
| 43 | +/** Runs introspection against a fetch that always fails, and returns the one |
| 44 | + * URL it was asked to dial. */ |
| 45 | +const dialedUrl = (endpoint: string, queryParams?: Record<string, string>) => |
| 46 | + Effect.gen(function* () { |
| 47 | + const seen: Array<string> = []; |
| 48 | + yield* introspect(endpoint, undefined, queryParams).pipe( |
| 49 | + Effect.flip, |
| 50 | + Effect.provide(clientLayer(seen)), |
| 51 | + Effect.provide(silentLogger), |
| 52 | + ); |
| 53 | + expect(seen).toHaveLength(1); |
| 54 | + return seen[0]; |
| 55 | + }); |
| 56 | + |
| 57 | +describe("GraphQL introspection request URL", () => { |
| 58 | + it.effect("merges query params into an endpoint that already has some", () => |
| 59 | + Effect.gen(function* () { |
| 60 | + // Endpoint params keep their position and order; merged params are |
| 61 | + // appended after them. |
| 62 | + const url = yield* dialedUrl(`${ENDPOINT}?apiVersion=2®ion=eu`, { |
| 63 | + token: "s3cr3t", |
| 64 | + trace: "on", |
| 65 | + }); |
| 66 | + expect(url).toBe(`${ENDPOINT}?apiVersion=2®ion=eu&token=s3cr3t&trace=on`); |
| 67 | + }), |
| 68 | + ); |
| 69 | + |
| 70 | + it.effect("adds a query to an endpoint that has none", () => |
| 71 | + Effect.gen(function* () { |
| 72 | + const url = yield* dialedUrl(ENDPOINT, { token: "s3cr3t" }); |
| 73 | + expect(url).toBe(`${ENDPOINT}?token=s3cr3t`); |
| 74 | + }), |
| 75 | + ); |
| 76 | + |
| 77 | + it.effect("overwrites a duplicate param in place rather than appending it", () => |
| 78 | + Effect.gen(function* () { |
| 79 | + // The supplied param wins, and it keeps the endpoint param's position — |
| 80 | + // so the stale value is gone, not merely shadowed. |
| 81 | + const url = yield* dialedUrl(`${ENDPOINT}?token=stale&keep=1`, { token: "fresh" }); |
| 82 | + expect(url).toBe(`${ENDPOINT}?token=fresh&keep=1`); |
| 83 | + }), |
| 84 | + ); |
| 85 | + |
| 86 | + it.effect("collapses repeats of an overwritten key to the supplied value", () => |
| 87 | + Effect.gen(function* () { |
| 88 | + const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2`, { a: "3" }); |
| 89 | + expect(url).toBe(`${ENDPOINT}?a=3`); |
| 90 | + }), |
| 91 | + ); |
| 92 | + |
| 93 | + it.effect("keeps repeated keys the caller did not overwrite", () => |
| 94 | + Effect.gen(function* () { |
| 95 | + const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2&b=3`); |
| 96 | + expect(url).toBe(`${ENDPOINT}?a=1&a=2&b=3`); |
| 97 | + }), |
| 98 | + ); |
| 99 | + |
| 100 | + it.effect("re-encodes a %20 space as +", () => |
| 101 | + Effect.gen(function* () { |
| 102 | + // Documented normalization, not an accident: `URLSearchParams` serializes |
| 103 | + // a space in form-urlencoded form. The decoded value is unchanged. |
| 104 | + const url = yield* dialedUrl(`${ENDPOINT}?token=a%20b`); |
| 105 | + expect(url).toBe(`${ENDPOINT}?token=a+b`); |
| 106 | + }), |
| 107 | + ); |
| 108 | + |
| 109 | + it.effect("leaves a + space as +", () => |
| 110 | + Effect.gen(function* () { |
| 111 | + const url = yield* dialedUrl(`${ENDPOINT}?token=a+b`); |
| 112 | + expect(url).toBe(`${ENDPOINT}?token=a+b`); |
| 113 | + }), |
| 114 | + ); |
| 115 | + |
| 116 | + it.effect("percent-encodes a literal tilde", () => |
| 117 | + Effect.gen(function* () { |
| 118 | + // `~` is legal in a raw query and is left alone by `URL`, but the |
| 119 | + // form-urlencoded serializer escapes it. |
| 120 | + const url = yield* dialedUrl(`${ENDPOINT}?sig=~tilde~`); |
| 121 | + expect(url).toBe(`${ENDPOINT}?sig=%7Etilde%7E`); |
| 122 | + }), |
| 123 | + ); |
| 124 | + |
| 125 | + it.effect("gives a valueless flag a trailing =", () => |
| 126 | + Effect.gen(function* () { |
| 127 | + const url = yield* dialedUrl(`${ENDPOINT}?debug`); |
| 128 | + expect(url).toBe(`${ENDPOINT}?debug=`); |
| 129 | + }), |
| 130 | + ); |
| 131 | + |
| 132 | + it.effect("leaves an already-encoded reserved character encoded", () => |
| 133 | + Effect.gen(function* () { |
| 134 | + const url = yield* dialedUrl(`${ENDPOINT}?a=%2B`); |
| 135 | + expect(url).toBe(`${ENDPOINT}?a=%2B`); |
| 136 | + }), |
| 137 | + ); |
| 138 | + |
| 139 | + it.effect("rejects an endpoint carrying userinfo, without echoing it", () => |
| 140 | + Effect.gen(function* () { |
| 141 | + // `user:pass@host` is a credential placement that `URL` keeps in the |
| 142 | + // origin, so it would ride along in `request.url` and render into every |
| 143 | + // `HttpClientError` message — the exact leak the query split closes. |
| 144 | + const seen: Array<string> = []; |
| 145 | + const logged: Array<string> = []; |
| 146 | + |
| 147 | + const error = yield* introspect("https://svc:hunter2@graph.example.test/graphql").pipe( |
| 148 | + Effect.flip, |
| 149 | + Effect.provide(clientLayer(seen)), |
| 150 | + Effect.provide( |
| 151 | + Logger.layer([ |
| 152 | + Logger.make<unknown, void>((options) => { |
| 153 | + logged.push(String(options.message)); |
| 154 | + logged.push(Cause.pretty(options.cause)); |
| 155 | + }), |
| 156 | + ]), |
| 157 | + ), |
| 158 | + ); |
| 159 | + |
| 160 | + expect(error).toBeInstanceOf(GraphqlIntrospectionError); |
| 161 | + expect(error.reason).toBe("invalid-endpoint"); |
| 162 | + |
| 163 | + // Nothing was dialed, and neither the error nor the log names the secret. |
| 164 | + expect(seen).toHaveLength(0); |
| 165 | + const rendered = `${Cause.pretty(Cause.fail(error))}\n${logged.join("\n")}`; |
| 166 | + expect(rendered).not.toContain("hunter2"); |
| 167 | + expect(rendered).not.toContain("svc:"); |
| 168 | + }), |
| 169 | + ); |
| 170 | + |
| 171 | + it.effect("rejects userinfo even when only a username is present", () => |
| 172 | + Effect.gen(function* () { |
| 173 | + const seen: Array<string> = []; |
| 174 | + const error = yield* introspect("https://svc@graph.example.test/graphql", undefined, { |
| 175 | + token: "s3cr3t", |
| 176 | + }).pipe(Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide(silentLogger)); |
| 177 | + |
| 178 | + expect(error.reason).toBe("invalid-endpoint"); |
| 179 | + expect(seen).toHaveLength(0); |
| 180 | + }), |
| 181 | + ); |
| 182 | +}); |
0 commit comments