| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- import { NodeFileSystem } from "@effect/platform-node"
- import { describe, expect, test } from "bun:test"
- import { Cause, Effect, Exit, Scope, Stream } from "effect"
- import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
- import * as fs from "node:fs"
- import * as os from "node:os"
- import * as path from "node:path"
- import { HttpRecorder } from "../src"
- import { redactedErrorRequest } from "../src/effect"
- import type { Interaction } from "../src/schema"
- const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
- Effect.runPromise(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
- }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
- )
- const post = (url: string, body: object) =>
- Effect.gen(function* () {
- const http = yield* HttpClient.HttpClient
- const request = HttpClientRequest.post(url, {
- headers: { "content-type": "application/json" },
- body: HttpBody.text(JSON.stringify(body), "application/json"),
- })
- const response = yield* http.execute(request)
- return yield* response.text
- })
- const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
- Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
- const runWith = <A, E>(
- name: string,
- options: HttpRecorder.RecordReplayOptions,
- effect: Effect.Effect<A, E, HttpClient.HttpClient>,
- ) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
- const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
- Effect.runPromise(
- Effect.scoped(
- effect.pipe(
- Effect.provide(
- HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
- ),
- Effect.provide(NodeFileSystem.layer),
- ),
- ),
- )
- const failureText = (exit: Exit.Exit<unknown, unknown>) => {
- if (Exit.isSuccess(exit)) return ""
- return Cause.prettyErrors(exit.cause).join("\n")
- }
- describe("http-recorder", () => {
- test("redacts sensitive URL query parameters", () => {
- expect(
- HttpRecorder.redactUrl(
- "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
- ),
- ).toBe(
- "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
- )
- })
- test("redacts URL credentials", () => {
- expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
- "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
- )
- })
- test("applies custom URL redaction after built-in redaction", () => {
- expect(
- HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
- url.replace("/accounts/real-account/", "/accounts/{account}/"),
- ),
- ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
- })
- test("redacts sensitive headers when allow-listed", () => {
- expect(
- HttpRecorder.redactHeaders(
- {
- authorization: "Bearer secret-token",
- "content-type": "application/json",
- "x-custom-token": "custom-secret",
- "x-api-key": "secret-key",
- "x-goog-api-key": "secret-google-key",
- },
- ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
- ["x-custom-token"],
- ),
- ).toEqual({
- authorization: "[REDACTED]",
- "content-type": "application/json",
- "x-api-key": "[REDACTED]",
- "x-custom-token": "[REDACTED]",
- "x-goog-api-key": "[REDACTED]",
- })
- })
- test("redacts error requests without retaining headers, params, or body", () => {
- const request = HttpClientRequest.post("https://example.test/path", {
- headers: { authorization: "Bearer super-secret" },
- body: HttpBody.text("super-secret-body", "text/plain"),
- }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
- expect(redactedErrorRequest(request).toJSON()).toMatchObject({
- url: "https://example.test/path",
- urlParams: { params: [] },
- headers: {},
- body: { _tag: "Empty" },
- })
- })
- test("detects secret-looking values without returning the secret", () => {
- expect(
- HttpRecorder.secretFindings({
- version: 1,
- interactions: [
- {
- transport: "http",
- request: {
- method: "POST",
- url: "https://example.test/path?key=sk-123456789012345678901234",
- headers: {},
- body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
- },
- response: {
- status: 200,
- headers: {},
- body: "Bearer abcdefghijklmnopqrstuvwxyz",
- },
- },
- ],
- }),
- ).toEqual([
- { path: "interactions[0].request.url", reason: "API key" },
- { path: "interactions[0].request.body", reason: "Google API key" },
- { path: "interactions[0].response.body", reason: "bearer token" },
- ])
- })
- test("detects secret-looking values inside metadata", () => {
- expect(
- HttpRecorder.secretFindings({
- version: 1,
- metadata: { token: "sk-123456789012345678901234" },
- interactions: [],
- }),
- ).toEqual([{ path: "metadata.token", reason: "API key" }])
- })
- test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
- await Effect.runPromise(
- Effect.scoped(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- const executor = yield* HttpRecorder.makeWebSocketExecutor({
- name: "websocket/replay",
- cassette,
- compareClientMessagesAsJson: true,
- live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
- })
- const connection = yield* executor.open({
- url: "wss://example.test/realtime",
- headers: Headers.fromInput({ "content-type": "application/json" }),
- })
- yield* connection.sendText(JSON.stringify({ type: "response.create" }))
- const messages: Array<string | Uint8Array> = []
- yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
- yield* connection.close
- expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
- }).pipe(
- Effect.provide(
- HttpRecorder.Cassette.memory({
- "websocket/replay": [
- {
- transport: "websocket",
- open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
- client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
- server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
- },
- ],
- }),
- ),
- ),
- ),
- )
- })
- test("records websocket interactions into the shared cassette service", async () => {
- await runRecorder(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- const executor = yield* HttpRecorder.makeWebSocketExecutor({
- name: "websocket/record",
- mode: "record",
- metadata: { provider: "test" },
- cassette,
- live: {
- open: () =>
- Effect.succeed({
- sendText: () => Effect.void,
- messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
- close: Effect.void,
- }),
- },
- })
- const connection = yield* executor.open({
- url: "wss://example.test/realtime",
- headers: Headers.fromInput({ "content-type": "application/json" }),
- })
- yield* connection.sendText(JSON.stringify({ type: "response.create" }))
- yield* connection.messages.pipe(Stream.runDrain)
- yield* connection.close
- expect(yield* cassette.read("websocket/record")).toMatchObject([
- {
- transport: "websocket",
- open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
- client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
- server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
- },
- ])
- }),
- )
- })
- test("replay returns recorded responses in order for identical requests", async () => {
- await runWith(
- "record-replay/retry",
- {},
- Effect.gen(function* () {
- expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
- expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
- }),
- )
- })
- test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
- await run(
- Effect.gen(function* () {
- yield* post("https://example.test/echo", { step: 1 })
- yield* post("https://example.test/echo", { step: 2 })
- const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
- expect(Exit.isFailure(exit)).toBe(true)
- }),
- )
- })
- test("replay validates each recorded request in order", async () => {
- await run(
- Effect.gen(function* () {
- yield* post("https://example.test/echo", { step: 1 })
- const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
- expect(Exit.isFailure(exit)).toBe(true)
- expect(failureText(exit)).toContain("$.step expected 2, received 3")
- expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
- }),
- )
- })
- test("auto mode replays when the cassette exists", async () => {
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
- await seedCassetteDirectory(directory, "auto-replay", [
- {
- transport: "http",
- request: {
- method: "POST",
- url: "https://example.test/echo",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ step: 1 }),
- },
- response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
- },
- ])
- const result = await runWith(
- "auto-replay",
- { directory, mode: "auto" },
- post("https://example.test/echo", { step: 1 }),
- )
- expect(result).toBe('{"reply":"hi"}')
- })
- test("auto mode forces replay when CI=true even if cassette is missing", async () => {
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
- const previous = process.env.CI
- process.env.CI = "true"
- try {
- const exit = await Effect.runPromise(
- Effect.exit(
- post("https://example.test/echo", { step: 1 }).pipe(
- Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
- ),
- ),
- )
- expect(Exit.isFailure(exit)).toBe(true)
- expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
- } finally {
- if (previous === undefined) delete process.env.CI
- else process.env.CI = previous
- }
- })
- test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
- await run(
- Effect.gen(function* () {
- const exit = yield* Effect.exit(
- post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
- )
- const message = failureText(exit)
- expect(message).toContain("url:")
- expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
- expect(message).toContain("body:")
- expect(message).toContain("$.step expected 1, received 3")
- expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
- expect(message).not.toContain("sk-123456789012345678901234")
- }),
- )
- })
- test("auto mode records to disk when the cassette is missing", async () => {
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
- using server = Bun.serve({
- port: 0,
- fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
- })
- const url = `http://127.0.0.1:${server.port}/echo`
- // CI=true forces replay; clear it so we exercise the local-dev auto-record path.
- const previous = process.env.CI
- delete process.env.CI
- try {
- const result = await runWith("auto-record", { directory, mode: "auto" }, post(url, { step: 1 }))
- expect(result).toBe('{"reply":"recorded"}')
- expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
- } finally {
- if (previous !== undefined) process.env.CI = previous
- }
- })
- test("passthrough mode bypasses the recorder entirely", async () => {
- using server = Bun.serve({ port: 0, fetch: () => new Response("from-upstream") })
- const url = `http://127.0.0.1:${server.port}/path`
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-passthrough-"))
- const result = await runWith("passthrough-noop", { directory, mode: "passthrough" }, post(url, {}))
- expect(result).toBe("from-upstream")
- expect(fs.existsSync(path.join(directory, "passthrough-noop.json"))).toBe(false)
- })
- test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
- using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
- const url = `http://127.0.0.1:${server.port}/leaky`
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
- const exit = await Effect.runPromise(
- Effect.exit(
- post(url, { ok: true }).pipe(
- Effect.provide(HttpRecorder.cassetteLayer("unsafe-record", { directory, mode: "record" })),
- ),
- ),
- )
- expect(Exit.isFailure(exit)).toBe(true)
- expect(failureText(exit)).toContain("contains possible secrets")
- expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
- })
- test("Cassette.list enumerates recorded cassette names", async () => {
- const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
- await seedCassetteDirectory(directory, "alpha/one", [
- {
- transport: "http",
- request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
- response: { status: 200, headers: {}, body: "a" },
- },
- ])
- await seedCassetteDirectory(directory, "beta", [
- {
- transport: "http",
- request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
- response: { status: 200, headers: {}, body: "b" },
- },
- ])
- const names = await Effect.runPromise(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- return yield* cassette.list()
- }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
- )
- expect(names).toEqual(["alpha/one", "beta"])
- })
- test("WebSocket replay decodes binary frames recorded as base64", async () => {
- const binaryServer = new Uint8Array([1, 2, 3, 4])
- await Effect.runPromise(
- Effect.scoped(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- const executor = yield* HttpRecorder.makeWebSocketExecutor({
- name: "ws/binary",
- cassette,
- live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
- })
- const connection = yield* executor.open({
- url: "wss://example.test/binary",
- headers: Headers.fromInput({}),
- })
- const messages: Array<string | Uint8Array> = []
- yield* connection.messages.pipe(Stream.runForEach((m) => Effect.sync(() => messages.push(m))))
- yield* connection.close
- expect(messages).toHaveLength(1)
- expect(messages[0]).toBeInstanceOf(Uint8Array)
- expect(Array.from(messages[0] as Uint8Array)).toEqual([1, 2, 3, 4])
- }).pipe(
- Effect.provide(
- HttpRecorder.Cassette.memory({
- "ws/binary": [
- {
- transport: "websocket",
- open: { url: "wss://example.test/binary", headers: {} },
- client: [],
- server: [
- { kind: "binary", body: Buffer.from(binaryServer).toString("base64"), bodyEncoding: "base64" },
- ],
- },
- ],
- }),
- ),
- ),
- ),
- )
- })
- })
|