record-replay.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { describe, expect, test } from "bun:test"
  3. import { Cause, Effect, Exit, Scope, Stream } from "effect"
  4. import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
  5. import * as fs from "node:fs"
  6. import * as os from "node:os"
  7. import * as path from "node:path"
  8. import { HttpRecorder } from "../src"
  9. import { redactedErrorRequest } from "../src/effect"
  10. import type { Interaction } from "../src/schema"
  11. const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
  12. Effect.runPromise(
  13. Effect.gen(function* () {
  14. const cassette = yield* HttpRecorder.Cassette.Service
  15. yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
  16. }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
  17. )
  18. const post = (url: string, body: object) =>
  19. Effect.gen(function* () {
  20. const http = yield* HttpClient.HttpClient
  21. const request = HttpClientRequest.post(url, {
  22. headers: { "content-type": "application/json" },
  23. body: HttpBody.text(JSON.stringify(body), "application/json"),
  24. })
  25. const response = yield* http.execute(request)
  26. return yield* response.text
  27. })
  28. const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
  29. Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
  30. const runWith = <A, E>(
  31. name: string,
  32. options: HttpRecorder.RecordReplayOptions,
  33. effect: Effect.Effect<A, E, HttpClient.HttpClient>,
  34. ) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
  35. const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
  36. Effect.runPromise(
  37. Effect.scoped(
  38. effect.pipe(
  39. Effect.provide(
  40. HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
  41. ),
  42. Effect.provide(NodeFileSystem.layer),
  43. ),
  44. ),
  45. )
  46. const failureText = (exit: Exit.Exit<unknown, unknown>) => {
  47. if (Exit.isSuccess(exit)) return ""
  48. return Cause.prettyErrors(exit.cause).join("\n")
  49. }
  50. describe("http-recorder", () => {
  51. test("redacts sensitive URL query parameters", () => {
  52. expect(
  53. HttpRecorder.redactUrl(
  54. "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
  55. ),
  56. ).toBe(
  57. "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
  58. )
  59. })
  60. test("redacts URL credentials", () => {
  61. expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
  62. "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
  63. )
  64. })
  65. test("applies custom URL redaction after built-in redaction", () => {
  66. expect(
  67. HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
  68. url.replace("/accounts/real-account/", "/accounts/{account}/"),
  69. ),
  70. ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
  71. })
  72. test("redacts sensitive headers when allow-listed", () => {
  73. expect(
  74. HttpRecorder.redactHeaders(
  75. {
  76. authorization: "Bearer secret-token",
  77. "content-type": "application/json",
  78. "x-custom-token": "custom-secret",
  79. "x-api-key": "secret-key",
  80. "x-goog-api-key": "secret-google-key",
  81. },
  82. ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
  83. ["x-custom-token"],
  84. ),
  85. ).toEqual({
  86. authorization: "[REDACTED]",
  87. "content-type": "application/json",
  88. "x-api-key": "[REDACTED]",
  89. "x-custom-token": "[REDACTED]",
  90. "x-goog-api-key": "[REDACTED]",
  91. })
  92. })
  93. test("redacts error requests without retaining headers, params, or body", () => {
  94. const request = HttpClientRequest.post("https://example.test/path", {
  95. headers: { authorization: "Bearer super-secret" },
  96. body: HttpBody.text("super-secret-body", "text/plain"),
  97. }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
  98. expect(redactedErrorRequest(request).toJSON()).toMatchObject({
  99. url: "https://example.test/path",
  100. urlParams: { params: [] },
  101. headers: {},
  102. body: { _tag: "Empty" },
  103. })
  104. })
  105. test("detects secret-looking values without returning the secret", () => {
  106. expect(
  107. HttpRecorder.secretFindings({
  108. version: 1,
  109. interactions: [
  110. {
  111. transport: "http",
  112. request: {
  113. method: "POST",
  114. url: "https://example.test/path?key=sk-123456789012345678901234",
  115. headers: {},
  116. body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
  117. },
  118. response: {
  119. status: 200,
  120. headers: {},
  121. body: "Bearer abcdefghijklmnopqrstuvwxyz",
  122. },
  123. },
  124. ],
  125. }),
  126. ).toEqual([
  127. { path: "interactions[0].request.url", reason: "API key" },
  128. { path: "interactions[0].request.body", reason: "Google API key" },
  129. { path: "interactions[0].response.body", reason: "bearer token" },
  130. ])
  131. })
  132. test("detects secret-looking values inside metadata", () => {
  133. expect(
  134. HttpRecorder.secretFindings({
  135. version: 1,
  136. metadata: { token: "sk-123456789012345678901234" },
  137. interactions: [],
  138. }),
  139. ).toEqual([{ path: "metadata.token", reason: "API key" }])
  140. })
  141. test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
  142. await Effect.runPromise(
  143. Effect.scoped(
  144. Effect.gen(function* () {
  145. const cassette = yield* HttpRecorder.Cassette.Service
  146. const executor = yield* HttpRecorder.makeWebSocketExecutor({
  147. name: "websocket/replay",
  148. cassette,
  149. compareClientMessagesAsJson: true,
  150. live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
  151. })
  152. const connection = yield* executor.open({
  153. url: "wss://example.test/realtime",
  154. headers: Headers.fromInput({ "content-type": "application/json" }),
  155. })
  156. yield* connection.sendText(JSON.stringify({ type: "response.create" }))
  157. const messages: Array<string | Uint8Array> = []
  158. yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
  159. yield* connection.close
  160. expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
  161. }).pipe(
  162. Effect.provide(
  163. HttpRecorder.Cassette.memory({
  164. "websocket/replay": [
  165. {
  166. transport: "websocket",
  167. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  168. client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
  169. server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
  170. },
  171. ],
  172. }),
  173. ),
  174. ),
  175. ),
  176. )
  177. })
  178. test("records websocket interactions into the shared cassette service", async () => {
  179. await runRecorder(
  180. Effect.gen(function* () {
  181. const cassette = yield* HttpRecorder.Cassette.Service
  182. const executor = yield* HttpRecorder.makeWebSocketExecutor({
  183. name: "websocket/record",
  184. mode: "record",
  185. metadata: { provider: "test" },
  186. cassette,
  187. live: {
  188. open: () =>
  189. Effect.succeed({
  190. sendText: () => Effect.void,
  191. messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
  192. close: Effect.void,
  193. }),
  194. },
  195. })
  196. const connection = yield* executor.open({
  197. url: "wss://example.test/realtime",
  198. headers: Headers.fromInput({ "content-type": "application/json" }),
  199. })
  200. yield* connection.sendText(JSON.stringify({ type: "response.create" }))
  201. yield* connection.messages.pipe(Stream.runDrain)
  202. yield* connection.close
  203. expect(yield* cassette.read("websocket/record")).toMatchObject([
  204. {
  205. transport: "websocket",
  206. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  207. client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
  208. server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
  209. },
  210. ])
  211. }),
  212. )
  213. })
  214. test("replay returns recorded responses in order for identical requests", async () => {
  215. await runWith(
  216. "record-replay/retry",
  217. {},
  218. Effect.gen(function* () {
  219. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  220. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
  221. }),
  222. )
  223. })
  224. test("replay reports cursor exhaustion when more requests are made than recorded", async () => {
  225. await run(
  226. Effect.gen(function* () {
  227. yield* post("https://example.test/echo", { step: 1 })
  228. yield* post("https://example.test/echo", { step: 2 })
  229. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  230. expect(Exit.isFailure(exit)).toBe(true)
  231. }),
  232. )
  233. })
  234. test("replay validates each recorded request in order", async () => {
  235. await run(
  236. Effect.gen(function* () {
  237. yield* post("https://example.test/echo", { step: 1 })
  238. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  239. expect(Exit.isFailure(exit)).toBe(true)
  240. expect(failureText(exit)).toContain("$.step expected 2, received 3")
  241. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  242. }),
  243. )
  244. })
  245. test("auto mode replays when the cassette exists", async () => {
  246. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
  247. await seedCassetteDirectory(directory, "auto-replay", [
  248. {
  249. transport: "http",
  250. request: {
  251. method: "POST",
  252. url: "https://example.test/echo",
  253. headers: { "content-type": "application/json" },
  254. body: JSON.stringify({ step: 1 }),
  255. },
  256. response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
  257. },
  258. ])
  259. const result = await runWith(
  260. "auto-replay",
  261. { directory, mode: "auto" },
  262. post("https://example.test/echo", { step: 1 }),
  263. )
  264. expect(result).toBe('{"reply":"hi"}')
  265. })
  266. test("auto mode forces replay when CI=true even if cassette is missing", async () => {
  267. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
  268. const previous = process.env.CI
  269. process.env.CI = "true"
  270. try {
  271. const exit = await Effect.runPromise(
  272. Effect.exit(
  273. post("https://example.test/echo", { step: 1 }).pipe(
  274. Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
  275. ),
  276. ),
  277. )
  278. expect(Exit.isFailure(exit)).toBe(true)
  279. expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
  280. } finally {
  281. if (previous === undefined) delete process.env.CI
  282. else process.env.CI = previous
  283. }
  284. })
  285. test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
  286. await run(
  287. Effect.gen(function* () {
  288. const exit = yield* Effect.exit(
  289. post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
  290. )
  291. const message = failureText(exit)
  292. expect(message).toContain("url:")
  293. expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
  294. expect(message).toContain("body:")
  295. expect(message).toContain("$.step expected 1, received 3")
  296. expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
  297. expect(message).not.toContain("sk-123456789012345678901234")
  298. }),
  299. )
  300. })
  301. test("auto mode records to disk when the cassette is missing", async () => {
  302. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-"))
  303. using server = Bun.serve({
  304. port: 0,
  305. fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }),
  306. })
  307. const url = `http://127.0.0.1:${server.port}/echo`
  308. // CI=true forces replay; clear it so we exercise the local-dev auto-record path.
  309. const previous = process.env.CI
  310. delete process.env.CI
  311. try {
  312. const result = await runWith("auto-record", { directory, mode: "auto" }, post(url, { step: 1 }))
  313. expect(result).toBe('{"reply":"recorded"}')
  314. expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true)
  315. } finally {
  316. if (previous !== undefined) process.env.CI = previous
  317. }
  318. })
  319. test("passthrough mode bypasses the recorder entirely", async () => {
  320. using server = Bun.serve({ port: 0, fetch: () => new Response("from-upstream") })
  321. const url = `http://127.0.0.1:${server.port}/path`
  322. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-passthrough-"))
  323. const result = await runWith("passthrough-noop", { directory, mode: "passthrough" }, post(url, {}))
  324. expect(result).toBe("from-upstream")
  325. expect(fs.existsSync(path.join(directory, "passthrough-noop.json"))).toBe(false)
  326. })
  327. test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
  328. using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") })
  329. const url = `http://127.0.0.1:${server.port}/leaky`
  330. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-"))
  331. const exit = await Effect.runPromise(
  332. Effect.exit(
  333. post(url, { ok: true }).pipe(
  334. Effect.provide(HttpRecorder.cassetteLayer("unsafe-record", { directory, mode: "record" })),
  335. ),
  336. ),
  337. )
  338. expect(Exit.isFailure(exit)).toBe(true)
  339. expect(failureText(exit)).toContain("contains possible secrets")
  340. expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false)
  341. })
  342. test("Cassette.list enumerates recorded cassette names", async () => {
  343. const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-"))
  344. await seedCassetteDirectory(directory, "alpha/one", [
  345. {
  346. transport: "http",
  347. request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" },
  348. response: { status: 200, headers: {}, body: "a" },
  349. },
  350. ])
  351. await seedCassetteDirectory(directory, "beta", [
  352. {
  353. transport: "http",
  354. request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" },
  355. response: { status: 200, headers: {}, body: "b" },
  356. },
  357. ])
  358. const names = await Effect.runPromise(
  359. Effect.gen(function* () {
  360. const cassette = yield* HttpRecorder.Cassette.Service
  361. return yield* cassette.list()
  362. }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
  363. )
  364. expect(names).toEqual(["alpha/one", "beta"])
  365. })
  366. test("WebSocket replay decodes binary frames recorded as base64", async () => {
  367. const binaryServer = new Uint8Array([1, 2, 3, 4])
  368. await Effect.runPromise(
  369. Effect.scoped(
  370. Effect.gen(function* () {
  371. const cassette = yield* HttpRecorder.Cassette.Service
  372. const executor = yield* HttpRecorder.makeWebSocketExecutor({
  373. name: "ws/binary",
  374. cassette,
  375. live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
  376. })
  377. const connection = yield* executor.open({
  378. url: "wss://example.test/binary",
  379. headers: Headers.fromInput({}),
  380. })
  381. const messages: Array<string | Uint8Array> = []
  382. yield* connection.messages.pipe(Stream.runForEach((m) => Effect.sync(() => messages.push(m))))
  383. yield* connection.close
  384. expect(messages).toHaveLength(1)
  385. expect(messages[0]).toBeInstanceOf(Uint8Array)
  386. expect(Array.from(messages[0] as Uint8Array)).toEqual([1, 2, 3, 4])
  387. }).pipe(
  388. Effect.provide(
  389. HttpRecorder.Cassette.memory({
  390. "ws/binary": [
  391. {
  392. transport: "websocket",
  393. open: { url: "wss://example.test/binary", headers: {} },
  394. client: [],
  395. server: [
  396. { kind: "binary", body: Buffer.from(binaryServer).toString("base64"), bodyEncoding: "base64" },
  397. ],
  398. },
  399. ],
  400. }),
  401. ),
  402. ),
  403. ),
  404. )
  405. })
  406. })