1
0

record-replay.test.ts 12 KB

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