| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- 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/diff"
- 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.layer({ 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.cassetteSecretFindings({
- 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.cassetteSecretFindings({
- version: 1,
- metadata: { token: "sk-123456789012345678901234" },
- interactions: [],
- }),
- ).toEqual([{ path: "metadata.token", reason: "API key" }])
- })
- test("formats websocket cassettes with shared metadata", () => {
- const cassette = HttpRecorder.cassetteFor(
- "websocket/basic",
- [
- {
- 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" }) }],
- },
- ],
- { provider: "openai" },
- )
- expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
- expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
- })
- test("replays websocket interactions from the shared cassette service", async () => {
- await runRecorder(
- Effect.gen(function* () {
- const cassette = yield* HttpRecorder.Cassette.Service
- yield* cassette.write(
- "websocket/replay",
- HttpRecorder.cassetteFor(
- "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" }) }],
- },
- ],
- undefined,
- ),
- )
- 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" })])
- }),
- )
- })
- 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({
- metadata: { name: "websocket/record", provider: "test" },
- interactions: [
- {
- 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("default matcher dispatches multi-interaction cassettes by request shape", async () => {
- await run(
- Effect.gen(function* () {
- expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
- expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
- }),
- )
- })
- test("sequential dispatch returns recorded responses in order for identical requests", async () => {
- await runWith(
- "record-replay/retry",
- { dispatch: "sequential" },
- 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("default matcher returns the first match 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":"pending"}')
- }),
- )
- })
- test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
- await runWith(
- "record-replay/multi-step",
- { dispatch: "sequential" },
- 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("sequential dispatch still validates each recorded request", async () => {
- await runWith(
- "record-replay/multi-step",
- { dispatch: "sequential" },
- 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("mismatch diagnostics show closest redacted request differences", 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("closest interaction: #1")
- 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")
- }),
- )
- })
- })
|