session-manager-redaction.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * Test: SessionManager redaction based on STORE_SESSION_MESSAGES env
  3. *
  4. * Acceptance criteria (Task 3):
  5. * - When STORE_SESSION_MESSAGES=false (default): store but redact message content
  6. * - When STORE_SESSION_MESSAGES=true: store raw content without redaction
  7. */
  8. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  9. // Mock server-only (must be before imports)
  10. vi.mock("server-only", () => ({}));
  11. // Mock logger
  12. const loggerMock = {
  13. trace: vi.fn(),
  14. debug: vi.fn(),
  15. info: vi.fn(),
  16. warn: vi.fn(),
  17. error: vi.fn(),
  18. };
  19. vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
  20. // Mock sanitizeHeaders/sanitizeUrl
  21. vi.mock("@/app/v1/_lib/proxy/errors", () => ({
  22. sanitizeHeaders: vi.fn(() => "(empty)"),
  23. sanitizeUrl: vi.fn((url: unknown) => String(url)),
  24. }));
  25. // Mock Redis
  26. const redisMock = {
  27. status: "ready",
  28. setex: vi.fn().mockResolvedValue("OK"),
  29. get: vi.fn(),
  30. set: vi.fn().mockResolvedValue("OK"),
  31. expire: vi.fn().mockResolvedValue(1),
  32. incr: vi.fn().mockResolvedValue(1),
  33. pipeline: vi.fn(() => ({
  34. setex: vi.fn().mockReturnThis(),
  35. hset: vi.fn().mockReturnThis(),
  36. expire: vi.fn().mockReturnThis(),
  37. del: vi.fn().mockReturnThis(),
  38. exec: vi.fn().mockResolvedValue([]),
  39. })),
  40. };
  41. vi.mock("@/lib/redis", () => ({
  42. getRedisClient: () => redisMock,
  43. }));
  44. // Mock config - we'll control STORE_SESSION_MESSAGES dynamically
  45. let mockStoreMessages = false;
  46. vi.mock("@/lib/config/env.schema", () => ({
  47. getEnvConfig: () => ({
  48. STORE_SESSION_MESSAGES: mockStoreMessages,
  49. SESSION_TTL: 300,
  50. }),
  51. }));
  52. // Import after mocks
  53. const { SessionManager } = await import("@/lib/session-manager");
  54. describe("SessionManager - Redaction based on STORE_SESSION_MESSAGES", () => {
  55. beforeEach(() => {
  56. vi.clearAllMocks();
  57. mockStoreMessages = false; // default: redact
  58. });
  59. afterEach(() => {
  60. mockStoreMessages = false;
  61. });
  62. describe("storeSessionMessages", () => {
  63. const testMessages = [
  64. { role: "user", content: "Hello secret message" },
  65. { role: "assistant", content: "Secret response" },
  66. ];
  67. it("should store redacted messages when STORE_SESSION_MESSAGES=false", async () => {
  68. mockStoreMessages = false;
  69. await SessionManager.storeSessionMessages("sess_123", testMessages, 1);
  70. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  71. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  72. expect(key).toBe("session:sess_123:req:1:messages");
  73. expect(ttl).toBe(300);
  74. const stored = JSON.parse(value);
  75. expect(stored[0].content).toBe("[REDACTED]");
  76. expect(stored[1].content).toBe("[REDACTED]");
  77. });
  78. it("should store raw messages when STORE_SESSION_MESSAGES=true", async () => {
  79. mockStoreMessages = true;
  80. await SessionManager.storeSessionMessages("sess_456", testMessages, 1);
  81. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  82. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  83. expect(key).toBe("session:sess_456:req:1:messages");
  84. const stored = JSON.parse(value);
  85. expect(stored[0].content).toBe("Hello secret message");
  86. expect(stored[1].content).toBe("Secret response");
  87. });
  88. });
  89. describe("storeSessionRequestBody", () => {
  90. const testRequestBody = {
  91. model: "claude-3-opus",
  92. messages: [
  93. { role: "user", content: "Secret user input" },
  94. { role: "assistant", content: "Secret assistant reply" },
  95. ],
  96. system: "Secret system prompt",
  97. };
  98. it("should store redacted request body when STORE_SESSION_MESSAGES=false", async () => {
  99. mockStoreMessages = false;
  100. await SessionManager.storeSessionRequestBody("sess_789", testRequestBody, 1);
  101. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  102. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  103. expect(key).toBe("session:sess_789:req:1:requestBody");
  104. const stored = JSON.parse(value);
  105. expect(stored.model).toBe("claude-3-opus"); // preserved
  106. expect(stored.messages[0].content).toBe("[REDACTED]");
  107. expect(stored.messages[1].content).toBe("[REDACTED]");
  108. expect(stored.system).toBe("[REDACTED]");
  109. });
  110. it("should store raw request body when STORE_SESSION_MESSAGES=true", async () => {
  111. mockStoreMessages = true;
  112. await SessionManager.storeSessionRequestBody("sess_abc", testRequestBody, 1);
  113. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  114. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  115. const stored = JSON.parse(value);
  116. expect(stored.model).toBe("claude-3-opus");
  117. expect(stored.messages[0].content).toBe("Secret user input");
  118. expect(stored.messages[1].content).toBe("Secret assistant reply");
  119. expect(stored.system).toBe("Secret system prompt");
  120. });
  121. it("should handle Gemini contents format when STORE_SESSION_MESSAGES=false", async () => {
  122. mockStoreMessages = false;
  123. const geminiBody = {
  124. contents: [{ role: "user", parts: [{ text: "Secret Gemini message" }] }],
  125. };
  126. await SessionManager.storeSessionRequestBody("sess_gemini", geminiBody, 1);
  127. const [, , value] = redisMock.setex.mock.calls[0];
  128. const stored = JSON.parse(value);
  129. expect(stored.contents[0].parts[0].text).toBe("[REDACTED]");
  130. });
  131. });
  132. describe("storeSessionResponse", () => {
  133. it("should store redacted JSON response when STORE_SESSION_MESSAGES=false", async () => {
  134. mockStoreMessages = false;
  135. const responseBody = {
  136. id: "msg_123",
  137. content: [{ type: "text", text: "Secret response text" }],
  138. };
  139. await SessionManager.storeSessionResponse("sess_res", JSON.stringify(responseBody), 1);
  140. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  141. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  142. expect(key).toBe("session:sess_res:req:1:response");
  143. const stored = JSON.parse(value);
  144. expect(stored.id).toBe("msg_123"); // preserved
  145. expect(stored.content[0].text).toBe("[REDACTED]");
  146. });
  147. it("should store raw JSON response when STORE_SESSION_MESSAGES=true", async () => {
  148. mockStoreMessages = true;
  149. const responseBody = {
  150. id: "msg_456",
  151. content: [{ type: "text", text: "Visible response text" }],
  152. };
  153. await SessionManager.storeSessionResponse("sess_res2", JSON.stringify(responseBody), 1);
  154. const [, , value] = redisMock.setex.mock.calls[0];
  155. const stored = JSON.parse(value);
  156. expect(stored.content[0].text).toBe("Visible response text");
  157. });
  158. it("should store non-JSON response as-is when STORE_SESSION_MESSAGES=false", async () => {
  159. mockStoreMessages = false;
  160. const nonJsonResponse = "data: event stream chunk";
  161. await SessionManager.storeSessionResponse("sess_stream", nonJsonResponse, 1);
  162. const [, , value] = redisMock.setex.mock.calls[0];
  163. // Non-JSON should be stored as-is (cannot redact)
  164. expect(value).toBe(nonJsonResponse);
  165. });
  166. it("should handle OpenAI choices format when STORE_SESSION_MESSAGES=false", async () => {
  167. mockStoreMessages = false;
  168. const openaiResponse = {
  169. id: "chatcmpl-123",
  170. choices: [
  171. {
  172. message: {
  173. role: "assistant",
  174. content: "Secret OpenAI response",
  175. },
  176. },
  177. ],
  178. };
  179. await SessionManager.storeSessionResponse("sess_openai", JSON.stringify(openaiResponse), 1);
  180. const [, , value] = redisMock.setex.mock.calls[0];
  181. const stored = JSON.parse(value);
  182. expect(stored.id).toBe("chatcmpl-123");
  183. expect(stored.choices[0].message.content).toBe("[REDACTED]");
  184. });
  185. it("should handle Gemini candidates format when STORE_SESSION_MESSAGES=false", async () => {
  186. mockStoreMessages = false;
  187. const geminiResponse = {
  188. candidates: [
  189. {
  190. content: {
  191. role: "model",
  192. parts: [{ text: "Secret Gemini response" }],
  193. },
  194. },
  195. ],
  196. };
  197. await SessionManager.storeSessionResponse(
  198. "sess_gemini_res",
  199. JSON.stringify(geminiResponse),
  200. 1
  201. );
  202. const [, , value] = redisMock.setex.mock.calls[0];
  203. const stored = JSON.parse(value);
  204. expect(stored.candidates[0].content.parts[0].text).toBe("[REDACTED]");
  205. });
  206. it("should handle object response (auto-stringify)", async () => {
  207. mockStoreMessages = false;
  208. const responseObj = {
  209. content: [{ type: "text", text: "Object response" }],
  210. };
  211. await SessionManager.storeSessionResponse("sess_obj", responseObj, 1);
  212. const [, , value] = redisMock.setex.mock.calls[0];
  213. const stored = JSON.parse(value);
  214. expect(stored.content[0].text).toBe("[REDACTED]");
  215. });
  216. });
  217. });