build-request-details-redaction.test.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * Test: buildRequestDetails redaction based on STORE_SESSION_MESSAGES env
  3. *
  4. * Acceptance criteria:
  5. * - When STORE_SESSION_MESSAGES=false (default): redact message content in request body
  6. * - When STORE_SESSION_MESSAGES=true: store raw request body 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 config - we'll control STORE_SESSION_MESSAGES dynamically
  21. let mockStoreMessages = false;
  22. vi.mock("@/lib/config/env.schema", () => ({
  23. getEnvConfig: () => ({
  24. STORE_SESSION_MESSAGES: mockStoreMessages,
  25. }),
  26. }));
  27. // Mock error rule detector
  28. vi.mock("@/lib/error-rule-detector", () => ({
  29. errorRuleDetector: {
  30. detect: vi.fn().mockResolvedValue(null),
  31. },
  32. }));
  33. // Import after mocks
  34. const { buildRequestDetails, sanitizeUrl, sanitizeHeaders, truncateRequestBody } = await import(
  35. "@/app/v1/_lib/proxy/errors"
  36. );
  37. // Create a minimal mock session for testing
  38. function createMockSession(requestLog: string) {
  39. return {
  40. requestUrl: new URL("https://api.example.com/v1/messages"),
  41. method: "POST",
  42. headerLog: "content-type: application/json\nauthorization: Bearer sk-1234",
  43. request: {
  44. log: requestLog,
  45. },
  46. } as Parameters<typeof buildRequestDetails>[0];
  47. }
  48. describe("buildRequestDetails - Redaction based on STORE_SESSION_MESSAGES", () => {
  49. beforeEach(() => {
  50. vi.clearAllMocks();
  51. mockStoreMessages = false; // default: redact
  52. });
  53. afterEach(() => {
  54. mockStoreMessages = false;
  55. });
  56. describe("when STORE_SESSION_MESSAGES=false", () => {
  57. it("should redact messages in request body", () => {
  58. mockStoreMessages = false;
  59. const requestBody = JSON.stringify({
  60. model: "claude-3-opus",
  61. messages: [
  62. { role: "user", content: "Secret user message" },
  63. { role: "assistant", content: "Secret response" },
  64. ],
  65. system: "Secret system prompt",
  66. });
  67. const session = createMockSession(requestBody);
  68. const result = buildRequestDetails(session);
  69. // Parse the body to verify redaction
  70. const parsedBody = JSON.parse(result.body);
  71. expect(parsedBody.model).toBe("claude-3-opus"); // preserved
  72. expect(parsedBody.messages[0].content).toBe("[REDACTED]");
  73. expect(parsedBody.messages[1].content).toBe("[REDACTED]");
  74. expect(parsedBody.system).toBe("[REDACTED]");
  75. });
  76. it("should redact Gemini contents format", () => {
  77. mockStoreMessages = false;
  78. const requestBody = JSON.stringify({
  79. contents: [{ role: "user", parts: [{ text: "Secret Gemini message" }] }],
  80. });
  81. const session = createMockSession(requestBody);
  82. const result = buildRequestDetails(session);
  83. const parsedBody = JSON.parse(result.body);
  84. expect(parsedBody.contents[0].parts[0].text).toBe("[REDACTED]");
  85. });
  86. it("should preserve non-JSON body as-is", () => {
  87. mockStoreMessages = false;
  88. const nonJsonBody = "plain text request body";
  89. const session = createMockSession(nonJsonBody);
  90. const result = buildRequestDetails(session);
  91. // Non-JSON cannot be redacted, kept as-is
  92. expect(result.body).toBe(nonJsonBody);
  93. });
  94. });
  95. describe("when STORE_SESSION_MESSAGES=true", () => {
  96. it("should store raw messages without redaction", () => {
  97. mockStoreMessages = true;
  98. const requestBody = JSON.stringify({
  99. model: "claude-3-opus",
  100. messages: [
  101. { role: "user", content: "Visible user message" },
  102. { role: "assistant", content: "Visible response" },
  103. ],
  104. system: "Visible system prompt",
  105. });
  106. const session = createMockSession(requestBody);
  107. const result = buildRequestDetails(session);
  108. const parsedBody = JSON.parse(result.body);
  109. expect(parsedBody.model).toBe("claude-3-opus");
  110. expect(parsedBody.messages[0].content).toBe("Visible user message");
  111. expect(parsedBody.messages[1].content).toBe("Visible response");
  112. expect(parsedBody.system).toBe("Visible system prompt");
  113. });
  114. });
  115. describe("other fields", () => {
  116. it("should always sanitize URL and headers", () => {
  117. mockStoreMessages = false;
  118. const session = createMockSession("{}");
  119. const result = buildRequestDetails(session);
  120. expect(result.url).toBe("https://api.example.com/v1/messages");
  121. expect(result.method).toBe("POST");
  122. // Headers should be sanitized (authorization masked)
  123. expect(result.headers).toContain("authorization:");
  124. expect(result.headers).not.toContain("sk-1234");
  125. });
  126. it("should set bodyTruncated flag when body exceeds limit", () => {
  127. mockStoreMessages = true;
  128. // Create a body that exceeds the 2000 char limit
  129. const longBody = "x".repeat(3000);
  130. const session = createMockSession(longBody);
  131. const result = buildRequestDetails(session);
  132. expect(result.bodyTruncated).toBe(true);
  133. expect(result.body.length).toBe(2000);
  134. });
  135. });
  136. });