auth-login-failure-taxonomy.test.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest } from "next/server";
  3. const mockValidateKey = vi.hoisted(() => vi.fn());
  4. const mockSetAuthCookie = vi.hoisted(() => vi.fn());
  5. const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
  6. const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
  7. const mockGetTranslations = vi.hoisted(() => vi.fn());
  8. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  9. const mockLogger = vi.hoisted(() => ({
  10. warn: vi.fn(),
  11. error: vi.fn(),
  12. info: vi.fn(),
  13. debug: vi.fn(),
  14. }));
  15. vi.mock("@/lib/auth", () => ({
  16. validateKey: mockValidateKey,
  17. setAuthCookie: mockSetAuthCookie,
  18. getSessionTokenMode: mockGetSessionTokenMode,
  19. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  20. withNoStoreHeaders: <T>(res: T): T => {
  21. (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  22. (res as any).headers.set("Pragma", "no-cache");
  23. return res;
  24. },
  25. }));
  26. vi.mock("next-intl/server", () => ({
  27. getTranslations: mockGetTranslations,
  28. }));
  29. vi.mock("@/lib/logger", () => ({
  30. logger: mockLogger,
  31. }));
  32. vi.mock("@/lib/config/env.schema", () => ({
  33. getEnvConfig: mockGetEnvConfig,
  34. }));
  35. vi.mock("@/lib/security/auth-response-headers", () => ({
  36. withAuthResponseHeaders: <T>(res: T): T => {
  37. (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  38. (res as any).headers.set("Pragma", "no-cache");
  39. return res;
  40. },
  41. }));
  42. function makeRequest(
  43. body: unknown,
  44. opts?: { locale?: string; acceptLanguage?: string; xForwardedProto?: string }
  45. ): NextRequest {
  46. const headers: Record<string, string> = { "Content-Type": "application/json" };
  47. if (opts?.acceptLanguage) {
  48. headers["accept-language"] = opts.acceptLanguage;
  49. }
  50. headers["x-forwarded-proto"] = opts?.xForwardedProto ?? "https";
  51. const req = new NextRequest("http://localhost/api/auth/login", {
  52. method: "POST",
  53. headers,
  54. body: JSON.stringify(body),
  55. });
  56. if (opts?.locale) {
  57. req.cookies.set("NEXT_LOCALE", opts.locale);
  58. }
  59. return req;
  60. }
  61. describe("POST /api/auth/login failure taxonomy", () => {
  62. let POST: (request: NextRequest) => Promise<Response>;
  63. beforeEach(async () => {
  64. const mockT = vi.fn((key: string) => `translated:${key}`);
  65. mockGetTranslations.mockResolvedValue(mockT);
  66. mockSetAuthCookie.mockResolvedValue(undefined);
  67. mockGetSessionTokenMode.mockReturnValue("legacy");
  68. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  69. const mod = await import("../../../src/app/api/auth/login/route");
  70. POST = mod.POST;
  71. });
  72. it("returns KEY_REQUIRED taxonomy for missing key", async () => {
  73. const res = await POST(makeRequest({}));
  74. expect(res.status).toBe(400);
  75. const json = await res.json();
  76. expect(json).toEqual({
  77. error: "translated:apiKeyRequired",
  78. errorCode: "KEY_REQUIRED",
  79. });
  80. expect(mockValidateKey).not.toHaveBeenCalled();
  81. });
  82. it("returns KEY_INVALID taxonomy for invalid key", async () => {
  83. mockValidateKey.mockResolvedValue(null);
  84. const res = await POST(makeRequest({ key: "bad-key" }));
  85. expect(res.status).toBe(401);
  86. const json = await res.json();
  87. expect(json).toEqual({
  88. error: "translated:apiKeyInvalidOrExpired",
  89. errorCode: "KEY_INVALID",
  90. });
  91. });
  92. it("returns SERVER_ERROR taxonomy when validation throws", async () => {
  93. mockValidateKey.mockRejectedValue(new Error("DB connection failed"));
  94. const res = await POST(makeRequest({ key: "some-key" }));
  95. expect(res.status).toBe(500);
  96. const json = await res.json();
  97. expect(json).toEqual({
  98. error: "translated:serverError",
  99. errorCode: "SERVER_ERROR",
  100. });
  101. expect(mockLogger.error).toHaveBeenCalled();
  102. });
  103. it("adds httpMismatchGuidance on invalid key when secure cookies require HTTPS", async () => {
  104. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
  105. mockValidateKey.mockResolvedValue(null);
  106. const res = await POST(makeRequest({ key: "bad-key" }, { xForwardedProto: "http" }));
  107. expect(res.status).toBe(401);
  108. const json = await res.json();
  109. expect(json.error).toBe("translated:apiKeyInvalidOrExpired");
  110. expect(json.errorCode).toBe("KEY_INVALID");
  111. expect(typeof json.httpMismatchGuidance).toBe("string");
  112. expect(json.httpMismatchGuidance.length).toBeGreaterThan(0);
  113. });
  114. it("does not add httpMismatchGuidance when no HTTPS mismatch", async () => {
  115. mockValidateKey.mockResolvedValue(null);
  116. const noSecureCookieRes = await POST(
  117. makeRequest({ key: "bad-key" }, { xForwardedProto: "http" })
  118. );
  119. expect(noSecureCookieRes.status).toBe(401);
  120. expect(await noSecureCookieRes.json()).toEqual({
  121. error: "translated:apiKeyInvalidOrExpired",
  122. errorCode: "KEY_INVALID",
  123. });
  124. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
  125. const httpsRes = await POST(makeRequest({ key: "bad-key" }, { xForwardedProto: "https" }));
  126. expect(httpsRes.status).toBe(401);
  127. expect(await httpsRes.json()).toEqual({
  128. error: "translated:apiKeyInvalidOrExpired",
  129. errorCode: "KEY_INVALID",
  130. });
  131. });
  132. });