session-manager-redaction.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  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. let mockStoreSessionResponseBody = true;
  47. vi.mock("@/lib/config/env.schema", () => ({
  48. getEnvConfig: () => ({
  49. STORE_SESSION_MESSAGES: mockStoreMessages,
  50. STORE_SESSION_RESPONSE_BODY: mockStoreSessionResponseBody,
  51. SESSION_TTL: 300,
  52. }),
  53. }));
  54. // Import after mocks
  55. const { SessionManager } = await import("@/lib/session-manager");
  56. describe("SessionManager - Redaction based on STORE_SESSION_MESSAGES", () => {
  57. beforeEach(() => {
  58. vi.clearAllMocks();
  59. mockStoreMessages = false; // default: redact
  60. mockStoreSessionResponseBody = true; // default: store response body
  61. });
  62. afterEach(() => {
  63. mockStoreMessages = false;
  64. mockStoreSessionResponseBody = true;
  65. });
  66. describe("storeSessionMessages", () => {
  67. const testMessages = [
  68. { role: "user", content: "Hello secret message" },
  69. { role: "assistant", content: "Secret response" },
  70. ];
  71. it("should store redacted messages when STORE_SESSION_MESSAGES=false", async () => {
  72. mockStoreMessages = false;
  73. await SessionManager.storeSessionMessages("sess_123", testMessages, 1);
  74. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  75. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  76. expect(key).toBe("session:sess_123:req:1:messages");
  77. expect(ttl).toBe(300);
  78. const stored = JSON.parse(value);
  79. expect(stored[0].content).toBe("[REDACTED]");
  80. expect(stored[1].content).toBe("[REDACTED]");
  81. });
  82. it("should store raw messages when STORE_SESSION_MESSAGES=true", async () => {
  83. mockStoreMessages = true;
  84. await SessionManager.storeSessionMessages("sess_456", testMessages, 1);
  85. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  86. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  87. expect(key).toBe("session:sess_456:req:1:messages");
  88. const stored = JSON.parse(value);
  89. expect(stored[0].content).toBe("Hello secret message");
  90. expect(stored[1].content).toBe("Secret response");
  91. });
  92. });
  93. describe("storeSessionRequestBody", () => {
  94. const testRequestBody = {
  95. model: "claude-3-opus",
  96. messages: [
  97. { role: "user", content: "Secret user input" },
  98. { role: "assistant", content: "Secret assistant reply" },
  99. ],
  100. system: "Secret system prompt",
  101. };
  102. it("should store redacted request body when STORE_SESSION_MESSAGES=false", async () => {
  103. mockStoreMessages = false;
  104. await SessionManager.storeSessionRequestBody("sess_789", testRequestBody, 1);
  105. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  106. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  107. expect(key).toBe("session:sess_789:req:1:requestBody");
  108. const stored = JSON.parse(value);
  109. expect(stored.model).toBe("claude-3-opus"); // preserved
  110. expect(stored.messages[0].content).toBe("[REDACTED]");
  111. expect(stored.messages[1].content).toBe("[REDACTED]");
  112. expect(stored.system).toBe("[REDACTED]");
  113. });
  114. it("should store raw request body when STORE_SESSION_MESSAGES=true", async () => {
  115. mockStoreMessages = true;
  116. await SessionManager.storeSessionRequestBody("sess_abc", testRequestBody, 1);
  117. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  118. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  119. const stored = JSON.parse(value);
  120. expect(stored.model).toBe("claude-3-opus");
  121. expect(stored.messages[0].content).toBe("Secret user input");
  122. expect(stored.messages[1].content).toBe("Secret assistant reply");
  123. expect(stored.system).toBe("Secret system prompt");
  124. });
  125. it("should handle Gemini contents format when STORE_SESSION_MESSAGES=false", async () => {
  126. mockStoreMessages = false;
  127. const geminiBody = {
  128. contents: [{ role: "user", parts: [{ text: "Secret Gemini message" }] }],
  129. };
  130. await SessionManager.storeSessionRequestBody("sess_gemini", geminiBody, 1);
  131. const [, , value] = redisMock.setex.mock.calls[0];
  132. const stored = JSON.parse(value);
  133. expect(stored.contents[0].parts[0].text).toBe("[REDACTED]");
  134. });
  135. });
  136. describe("storeSessionResponse", () => {
  137. it("should skip storing response body when STORE_SESSION_RESPONSE_BODY=false", async () => {
  138. mockStoreSessionResponseBody = false;
  139. await SessionManager.storeSessionResponse("sess_disabled", '{"message":"hello"}', 1);
  140. expect(redisMock.setex).not.toHaveBeenCalled();
  141. });
  142. it("should store redacted JSON response when STORE_SESSION_MESSAGES=false", async () => {
  143. mockStoreMessages = false;
  144. const responseBody = {
  145. id: "msg_123",
  146. content: [{ type: "text", text: "Secret response text" }],
  147. };
  148. await SessionManager.storeSessionResponse("sess_res", JSON.stringify(responseBody), 1);
  149. expect(redisMock.setex).toHaveBeenCalledTimes(1);
  150. const [key, ttl, value] = redisMock.setex.mock.calls[0];
  151. expect(key).toBe("session:sess_res:req:1:response");
  152. const stored = JSON.parse(value);
  153. expect(stored.id).toBe("msg_123"); // preserved
  154. expect(stored.content[0].text).toBe("[REDACTED]");
  155. });
  156. it("should store raw JSON response when STORE_SESSION_MESSAGES=true", async () => {
  157. mockStoreMessages = true;
  158. const responseBody = {
  159. id: "msg_456",
  160. content: [{ type: "text", text: "Visible response text" }],
  161. };
  162. await SessionManager.storeSessionResponse("sess_res2", JSON.stringify(responseBody), 1);
  163. const [, , value] = redisMock.setex.mock.calls[0];
  164. const stored = JSON.parse(value);
  165. expect(stored.content[0].text).toBe("Visible response text");
  166. });
  167. it("should store non-JSON response as-is when STORE_SESSION_MESSAGES=false", async () => {
  168. mockStoreMessages = false;
  169. const nonJsonResponse = "data: event stream chunk";
  170. await SessionManager.storeSessionResponse("sess_stream", nonJsonResponse, 1);
  171. const [, , value] = redisMock.setex.mock.calls[0];
  172. // Non-JSON should be stored as-is (cannot redact)
  173. expect(value).toBe(nonJsonResponse);
  174. });
  175. it("should handle OpenAI choices format when STORE_SESSION_MESSAGES=false", async () => {
  176. mockStoreMessages = false;
  177. const openaiResponse = {
  178. id: "chatcmpl-123",
  179. choices: [
  180. {
  181. message: {
  182. role: "assistant",
  183. content: "Secret OpenAI response",
  184. },
  185. },
  186. ],
  187. };
  188. await SessionManager.storeSessionResponse("sess_openai", JSON.stringify(openaiResponse), 1);
  189. const [, , value] = redisMock.setex.mock.calls[0];
  190. const stored = JSON.parse(value);
  191. expect(stored.id).toBe("chatcmpl-123");
  192. expect(stored.choices[0].message.content).toBe("[REDACTED]");
  193. });
  194. it("should handle Gemini candidates format when STORE_SESSION_MESSAGES=false", async () => {
  195. mockStoreMessages = false;
  196. const geminiResponse = {
  197. candidates: [
  198. {
  199. content: {
  200. role: "model",
  201. parts: [{ text: "Secret Gemini response" }],
  202. },
  203. },
  204. ],
  205. };
  206. await SessionManager.storeSessionResponse(
  207. "sess_gemini_res",
  208. JSON.stringify(geminiResponse),
  209. 1
  210. );
  211. const [, , value] = redisMock.setex.mock.calls[0];
  212. const stored = JSON.parse(value);
  213. expect(stored.candidates[0].content.parts[0].text).toBe("[REDACTED]");
  214. });
  215. it("should handle object response (auto-stringify)", async () => {
  216. mockStoreMessages = false;
  217. const responseObj = {
  218. content: [{ type: "text", text: "Object response" }],
  219. };
  220. await SessionManager.storeSessionResponse("sess_obj", responseObj, 1);
  221. const [, , value] = redisMock.setex.mock.calls[0];
  222. const stored = JSON.parse(value);
  223. expect(stored.content[0].text).toBe("[REDACTED]");
  224. });
  225. });
  226. });