record-replay.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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/diff"
  10. const post = (url: string, body: object) =>
  11. Effect.gen(function* () {
  12. const http = yield* HttpClient.HttpClient
  13. const request = HttpClientRequest.post(url, {
  14. headers: { "content-type": "application/json" },
  15. body: HttpBody.text(JSON.stringify(body), "application/json"),
  16. })
  17. const response = yield* http.execute(request)
  18. return yield* response.text
  19. })
  20. const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
  21. Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
  22. const runWith = <A, E>(
  23. name: string,
  24. options: HttpRecorder.RecordReplayOptions,
  25. effect: Effect.Effect<A, E, HttpClient.HttpClient>,
  26. ) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
  27. const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
  28. Effect.runPromise(
  29. Effect.scoped(
  30. effect.pipe(
  31. Effect.provide(
  32. HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
  33. ),
  34. Effect.provide(NodeFileSystem.layer),
  35. ),
  36. ),
  37. )
  38. const failureText = (exit: Exit.Exit<unknown, unknown>) => {
  39. if (Exit.isSuccess(exit)) return ""
  40. return Cause.prettyErrors(exit.cause).join("\n")
  41. }
  42. describe("http-recorder", () => {
  43. test("redacts sensitive URL query parameters", () => {
  44. expect(
  45. HttpRecorder.redactUrl(
  46. "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
  47. ),
  48. ).toBe(
  49. "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
  50. )
  51. })
  52. test("redacts URL credentials", () => {
  53. expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
  54. "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
  55. )
  56. })
  57. test("applies custom URL redaction after built-in redaction", () => {
  58. expect(
  59. HttpRecorder.redactUrl(
  60. "https://example.test/accounts/real-account/path?key=secret-key",
  61. undefined,
  62. (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"),
  63. ),
  64. ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
  65. })
  66. test("redacts sensitive headers when allow-listed", () => {
  67. expect(
  68. HttpRecorder.redactHeaders(
  69. {
  70. authorization: "Bearer secret-token",
  71. "content-type": "application/json",
  72. "x-custom-token": "custom-secret",
  73. "x-api-key": "secret-key",
  74. "x-goog-api-key": "secret-google-key",
  75. },
  76. ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
  77. ["x-custom-token"],
  78. ),
  79. ).toEqual({
  80. authorization: "[REDACTED]",
  81. "content-type": "application/json",
  82. "x-api-key": "[REDACTED]",
  83. "x-custom-token": "[REDACTED]",
  84. "x-goog-api-key": "[REDACTED]",
  85. })
  86. })
  87. test("redacts error requests without retaining headers, params, or body", () => {
  88. const request = HttpClientRequest.post("https://example.test/path", {
  89. headers: { authorization: "Bearer super-secret" },
  90. body: HttpBody.text("super-secret-body", "text/plain"),
  91. }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
  92. expect(redactedErrorRequest(request).toJSON()).toMatchObject({
  93. url: "https://example.test/path",
  94. urlParams: { params: [] },
  95. headers: {},
  96. body: { _tag: "Empty" },
  97. })
  98. })
  99. test("detects secret-looking values without returning the secret", () => {
  100. expect(
  101. HttpRecorder.cassetteSecretFindings({
  102. version: 1,
  103. interactions: [
  104. {
  105. transport: "http",
  106. request: {
  107. method: "POST",
  108. url: "https://example.test/path?key=sk-123456789012345678901234",
  109. headers: {},
  110. body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
  111. },
  112. response: {
  113. status: 200,
  114. headers: {},
  115. body: "Bearer abcdefghijklmnopqrstuvwxyz",
  116. },
  117. },
  118. ],
  119. }),
  120. ).toEqual([
  121. { path: "interactions[0].request.url", reason: "API key" },
  122. { path: "interactions[0].request.body", reason: "Google API key" },
  123. { path: "interactions[0].response.body", reason: "bearer token" },
  124. ])
  125. })
  126. test("detects secret-looking values inside metadata", () => {
  127. expect(
  128. HttpRecorder.cassetteSecretFindings({
  129. version: 1,
  130. metadata: { token: "sk-123456789012345678901234" },
  131. interactions: [],
  132. }),
  133. ).toEqual([{ path: "metadata.token", reason: "API key" }])
  134. })
  135. test("formats websocket cassettes with shared metadata", () => {
  136. const cassette = HttpRecorder.cassetteFor(
  137. "websocket/basic",
  138. [
  139. {
  140. transport: "websocket",
  141. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  142. client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
  143. server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
  144. },
  145. ],
  146. { provider: "openai" },
  147. )
  148. expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
  149. expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
  150. })
  151. test("replays websocket interactions from the shared cassette service", async () => {
  152. await runRecorder(
  153. Effect.gen(function* () {
  154. const cassette = yield* HttpRecorder.Cassette.Service
  155. yield* cassette.write(
  156. "websocket/replay",
  157. HttpRecorder.cassetteFor(
  158. "websocket/replay",
  159. [
  160. {
  161. transport: "websocket",
  162. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  163. client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
  164. server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
  165. },
  166. ],
  167. undefined,
  168. ),
  169. )
  170. const executor = yield* HttpRecorder.makeWebSocketExecutor({
  171. name: "websocket/replay",
  172. cassette,
  173. compareClientMessagesAsJson: true,
  174. live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
  175. })
  176. const connection = yield* executor.open({
  177. url: "wss://example.test/realtime",
  178. headers: Headers.fromInput({ "content-type": "application/json" }),
  179. })
  180. yield* connection.sendText(JSON.stringify({ type: "response.create" }))
  181. const messages: Array<string | Uint8Array> = []
  182. yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
  183. yield* connection.close
  184. expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
  185. }),
  186. )
  187. })
  188. test("records websocket interactions into the shared cassette service", async () => {
  189. await runRecorder(
  190. Effect.gen(function* () {
  191. const cassette = yield* HttpRecorder.Cassette.Service
  192. const executor = yield* HttpRecorder.makeWebSocketExecutor({
  193. name: "websocket/record",
  194. mode: "record",
  195. metadata: { provider: "test" },
  196. cassette,
  197. live: {
  198. open: () =>
  199. Effect.succeed({
  200. sendText: () => Effect.void,
  201. messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
  202. close: Effect.void,
  203. }),
  204. },
  205. })
  206. const connection = yield* executor.open({
  207. url: "wss://example.test/realtime",
  208. headers: Headers.fromInput({ "content-type": "application/json" }),
  209. })
  210. yield* connection.sendText(JSON.stringify({ type: "response.create" }))
  211. yield* connection.messages.pipe(Stream.runDrain)
  212. yield* connection.close
  213. expect(yield* cassette.read("websocket/record")).toMatchObject({
  214. metadata: { name: "websocket/record", provider: "test" },
  215. interactions: [
  216. {
  217. transport: "websocket",
  218. open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
  219. client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
  220. server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
  221. },
  222. ],
  223. })
  224. }),
  225. )
  226. })
  227. test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
  228. await run(
  229. Effect.gen(function* () {
  230. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  231. expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
  232. }),
  233. )
  234. })
  235. test("sequential dispatch returns recorded responses in order for identical requests", async () => {
  236. await runWith(
  237. "record-replay/retry",
  238. { dispatch: "sequential" },
  239. Effect.gen(function* () {
  240. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  241. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
  242. }),
  243. )
  244. })
  245. test("default matcher returns the first match for identical requests", async () => {
  246. await runWith(
  247. "record-replay/retry",
  248. {},
  249. Effect.gen(function* () {
  250. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  251. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  252. }),
  253. )
  254. })
  255. test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
  256. await runWith(
  257. "record-replay/multi-step",
  258. { dispatch: "sequential" },
  259. Effect.gen(function* () {
  260. yield* post("https://example.test/echo", { step: 1 })
  261. yield* post("https://example.test/echo", { step: 2 })
  262. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  263. expect(Exit.isFailure(exit)).toBe(true)
  264. }),
  265. )
  266. })
  267. test("sequential dispatch still validates each recorded request", async () => {
  268. await runWith(
  269. "record-replay/multi-step",
  270. { dispatch: "sequential" },
  271. Effect.gen(function* () {
  272. yield* post("https://example.test/echo", { step: 1 })
  273. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  274. expect(Exit.isFailure(exit)).toBe(true)
  275. expect(failureText(exit)).toContain("$.step expected 2, received 3")
  276. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  277. }),
  278. )
  279. })
  280. test("mismatch diagnostics show closest redacted request differences", async () => {
  281. await run(
  282. Effect.gen(function* () {
  283. const exit = yield* Effect.exit(
  284. post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
  285. )
  286. const message = failureText(exit)
  287. expect(message).toContain("closest interaction: #1")
  288. expect(message).toContain("url:")
  289. expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
  290. expect(message).toContain("body:")
  291. expect(message).toContain("$.step expected 1, received 3")
  292. expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
  293. expect(message).not.toContain("sk-123456789012345678901234")
  294. }),
  295. )
  296. })
  297. })