cassette.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Exit } from "effect"
  3. import { existsSync, readdirSync, writeFileSync } from "node:fs"
  4. import type { Interaction } from "../src/cassette/model"
  5. import { HttpRecorder } from "../src"
  6. import { Service, hasCassetteSync, memory } from "../src/cassette/store"
  7. import { cassetteLayer } from "../src/http/recorder"
  8. import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support"
  9. describe("cassette", () => {
  10. test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => {
  11. using server = Bun.serve({
  12. port: 0,
  13. fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"),
  14. })
  15. const url = `http://127.0.0.1:${server.port}/leaky`
  16. using directory = tempDirectory("http-recorder-unsafe-")
  17. const exit = await Effect.runPromise(
  18. Effect.exit(
  19. post(url, { ok: true }).pipe(
  20. Effect.provide(
  21. cassetteLayer("unsafe-record", {
  22. directory: directory.path,
  23. mode: "record",
  24. }),
  25. ),
  26. ),
  27. ),
  28. )
  29. expect(Exit.isFailure(exit)).toBe(true)
  30. expect(failureText(exit)).toContain("contains possible secrets")
  31. expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false)
  32. })
  33. test("failed memory appends leave cassette state unchanged", async () => {
  34. await Effect.runPromise(
  35. Effect.gen(function* () {
  36. const cassette = yield* Service
  37. const interaction: Interaction = {
  38. transport: "http",
  39. request: {
  40. method: "GET",
  41. url: "https://example.test",
  42. headers: {},
  43. body: "",
  44. },
  45. response: { status: 200, headers: {}, body: "safe" },
  46. }
  47. yield* cassette.append("transactional", interaction)
  48. yield* cassette
  49. .append("transactional", {
  50. ...interaction,
  51. response: {
  52. ...interaction.response,
  53. body: "Bearer abcdefghijklmnopqrstuvwxyz1234",
  54. },
  55. })
  56. .pipe(Effect.flip)
  57. expect(yield* cassette.read("transactional")).toEqual([interaction])
  58. }).pipe(Effect.provide(memory())),
  59. )
  60. })
  61. test("concurrent file appends preserve every interaction", async () => {
  62. using directory = tempDirectory("http-recorder-concurrent-")
  63. await runFileCassette(
  64. directory.path,
  65. Effect.gen(function* () {
  66. const cassette = yield* Service
  67. yield* Effect.forEach(
  68. Array.from({ length: 20 }, (_, index) => index),
  69. (index) =>
  70. cassette.append("concurrent", {
  71. transport: "http",
  72. request: {
  73. method: "GET",
  74. url: `https://example.test/${index}`,
  75. headers: {},
  76. body: "",
  77. },
  78. response: { status: 200, headers: {}, body: String(index) },
  79. }),
  80. { concurrency: "unbounded" },
  81. )
  82. }),
  83. )
  84. const cassette = readCassette(`${directory.path}/concurrent.json`)
  85. expect(cassette.interactions).toHaveLength(20)
  86. expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([])
  87. })
  88. test("generated metadata cannot be overridden", async () => {
  89. using directory = tempDirectory("http-recorder-metadata-")
  90. await runFileCassette(
  91. directory.path,
  92. Effect.gen(function* () {
  93. const cassette = yield* Service
  94. yield* cassette.append(
  95. "metadata",
  96. {
  97. transport: "http",
  98. request: { method: "GET", url: "https://example.test", headers: {}, body: "" },
  99. response: { status: 200, headers: {}, body: "safe" },
  100. },
  101. { name: "wrong", recordedAt: "wrong" },
  102. )
  103. }),
  104. )
  105. const cassette = readCassette(`${directory.path}/metadata.json`)
  106. expect(cassette.metadata?.name).toBe("metadata")
  107. expect(cassette.metadata?.recordedAt).not.toBe("wrong")
  108. })
  109. test("reports malformed cassettes as invalid", async () => {
  110. using directory = tempDirectory("http-recorder-invalid-")
  111. writeFileSync(`${directory.path}/invalid.json`, "{not-json")
  112. const error = await runFileCassette(
  113. directory.path,
  114. Effect.gen(function* () {
  115. const cassette = yield* Service
  116. return yield* cassette.read("invalid").pipe(Effect.flip)
  117. }),
  118. )
  119. expect(error._tag).toBe("InvalidCassetteError")
  120. })
  121. test("rejects cassette paths outside the recordings directory", () => {
  122. using directory = tempDirectory("http-recorder-path-")
  123. expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name")
  124. expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name")
  125. })
  126. test("public cassette lifecycle helpers check and remove a recording", async () => {
  127. using directory = tempDirectory("http-recorder-lifecycle-")
  128. const options = { directory: directory.path }
  129. expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false)
  130. await seedCassetteDirectory(directory.path, "nested/example", [
  131. {
  132. transport: "http",
  133. request: { method: "GET", url: "https://example.test", headers: {}, body: "" },
  134. response: { status: 200, headers: {}, body: "safe" },
  135. },
  136. ])
  137. expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true)
  138. HttpRecorder.removeCassetteSync("nested/example", options)
  139. expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false)
  140. expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow()
  141. expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name")
  142. })
  143. test("Cassette.list enumerates recorded cassette names", async () => {
  144. using directory = tempDirectory("http-recorder-list-")
  145. await seedCassetteDirectory(directory.path, "alpha/one", [
  146. {
  147. transport: "http",
  148. request: {
  149. method: "GET",
  150. url: "https://x.test/a",
  151. headers: {},
  152. body: "",
  153. },
  154. response: { status: 200, headers: {}, body: "a" },
  155. },
  156. ])
  157. await seedCassetteDirectory(directory.path, "beta", [
  158. {
  159. transport: "http",
  160. request: {
  161. method: "GET",
  162. url: "https://x.test/b",
  163. headers: {},
  164. body: "",
  165. },
  166. response: { status: 200, headers: {}, body: "b" },
  167. },
  168. ])
  169. const names = await runFileCassette(
  170. directory.path,
  171. Effect.gen(function* () {
  172. const cassette = yield* Service
  173. return yield* cassette.list()
  174. }),
  175. )
  176. expect(names).toEqual(["alpha/one", "beta"])
  177. })
  178. })