// ABOUTME: Unit tests for API route authentication middleware. // ABOUTME: Tests withAuth wrapper for protected route handlers. import { type NextRequest, NextResponse } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withAuth } from "./auth-middleware"; // Mock the pocketbase module vi.mock("./pocketbase", () => ({ createPocketBaseClient: vi.fn(), loadAuthFromCookies: vi.fn(), isAuthenticated: vi.fn(), getCurrentUser: vi.fn(), })); // Mock next/headers vi.mock("next/headers", () => ({ cookies: vi.fn(), })); // Mock the logger module vi.mock("./logger", () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), }, })); import { cookies } from "next/headers"; import { logger } from "./logger"; import { createPocketBaseClient, getCurrentUser, isAuthenticated, loadAuthFromCookies, } from "./pocketbase"; const mockLogger = logger as unknown as { warn: ReturnType; error: ReturnType; info: ReturnType; }; const mockCookies = cookies as ReturnType; const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType< typeof vi.fn >; const mockLoadAuthFromCookies = loadAuthFromCookies as ReturnType; const mockIsAuthenticated = isAuthenticated as ReturnType; const mockGetCurrentUser = getCurrentUser as ReturnType; describe("withAuth", () => { const mockUser = { id: "user123", email: "test@example.com", garminConnected: false, garminOauth1Token: "", garminOauth2Token: "", garminTokenExpiresAt: new Date(), garminRefreshTokenExpiresAt: null, calendarToken: "cal-token", lastPeriodDate: new Date("2025-01-01"), cycleLength: 31, notificationTime: "07:00", timezone: "UTC", activeOverrides: [], created: new Date(), updated: new Date(), }; const mockPbClient = { authStore: { isValid: true, model: mockUser, }, }; const mockCookieStore = { get: vi.fn(), }; // Helper to create mock request with headers const createMockRequest = ( headers: Record = {}, ): NextRequest => ({ headers: { get: vi.fn((name: string) => headers[name] ?? null), }, }) as unknown as NextRequest; beforeEach(() => { vi.clearAllMocks(); mockCookies.mockResolvedValue(mockCookieStore); mockCreatePocketBaseClient.mockReturnValue(mockPbClient); }); it("returns 401 when not authenticated", async () => { mockIsAuthenticated.mockReturnValue(false); const handler = vi.fn(); const wrappedHandler = withAuth(handler); const mockRequest = createMockRequest(); const response = await wrappedHandler(mockRequest); expect(response.status).toBe(401); const body = await response.json(); expect(body.error).toBe("Unauthorized"); expect(handler).not.toHaveBeenCalled(); }); it("calls handler with user when authenticated", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(mockUser); const handler = vi .fn() .mockResolvedValue(NextResponse.json({ data: "success" })); const wrappedHandler = withAuth(handler); const mockRequest = createMockRequest(); const response = await wrappedHandler(mockRequest); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledWith( mockRequest, mockUser, mockPbClient, undefined, ); }); it("loads auth from cookies before checking authentication", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(mockUser); const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const wrappedHandler = withAuth(handler); await wrappedHandler(createMockRequest()); expect(mockCreatePocketBaseClient).toHaveBeenCalled(); expect(mockCookies).toHaveBeenCalled(); expect(mockLoadAuthFromCookies).toHaveBeenCalledWith( mockPbClient, mockCookieStore, ); expect(mockIsAuthenticated).toHaveBeenCalledWith(mockPbClient); }); it("returns 401 when getCurrentUser returns null", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(null); const handler = vi.fn(); const wrappedHandler = withAuth(handler); const response = await wrappedHandler(createMockRequest()); expect(response.status).toBe(401); expect(handler).not.toHaveBeenCalled(); }); it("passes route params to handler when provided", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(mockUser); const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const wrappedHandler = withAuth(handler); const mockRequest = createMockRequest(); const mockParams = { id: "123" }; await wrappedHandler(mockRequest, { params: mockParams }); expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, mockPbClient, { params: mockParams, }); }); it("handles handler errors gracefully", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(mockUser); const handler = vi.fn().mockRejectedValue(new Error("Handler error")); const wrappedHandler = withAuth(handler); const response = await wrappedHandler(createMockRequest()); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("Internal server error"); }); describe("structured logging", () => { beforeEach(() => { mockLogger.warn.mockClear(); mockLogger.error.mockClear(); mockLogger.info.mockClear(); }); it("logs auth failure with warn level", async () => { mockIsAuthenticated.mockReturnValue(false); const handler = vi.fn(); const wrappedHandler = withAuth(handler); await wrappedHandler(createMockRequest()); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "not_authenticated" }), expect.stringContaining("Auth failure"), ); }); it("logs auth failure with IP address from x-forwarded-for header", async () => { mockIsAuthenticated.mockReturnValue(false); const handler = vi.fn(); const wrappedHandler = withAuth(handler); const mockRequest = { headers: { get: vi.fn((name: string) => name === "x-forwarded-for" ? "192.168.1.100" : null, ), }, } as unknown as NextRequest; await wrappedHandler(mockRequest); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "not_authenticated", ip: "192.168.1.100", }), expect.stringContaining("Auth failure"), ); }); it("logs auth failure with IP address from x-real-ip header when x-forwarded-for not present", async () => { mockIsAuthenticated.mockReturnValue(false); const handler = vi.fn(); const wrappedHandler = withAuth(handler); const mockRequest = { headers: { get: vi.fn((name: string) => name === "x-real-ip" ? "10.0.0.1" : null, ), }, } as unknown as NextRequest; await wrappedHandler(mockRequest); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "not_authenticated", ip: "10.0.0.1", }), expect.stringContaining("Auth failure"), ); }); it("logs auth failure with unknown IP when no IP headers present", async () => { mockIsAuthenticated.mockReturnValue(false); const handler = vi.fn(); const wrappedHandler = withAuth(handler); const mockRequest = { headers: { get: vi.fn(() => null), }, } as unknown as NextRequest; await wrappedHandler(mockRequest); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "not_authenticated", ip: "unknown" }), expect.stringContaining("Auth failure"), ); }); it("logs auth failure when getCurrentUser returns null", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(null); const handler = vi.fn(); const wrappedHandler = withAuth(handler); await wrappedHandler(createMockRequest()); expect(mockLogger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "user_not_found" }), expect.stringContaining("Auth failure"), ); }); it("logs internal errors with error level and stack trace", async () => { mockIsAuthenticated.mockReturnValue(true); mockGetCurrentUser.mockReturnValue(mockUser); const testError = new Error("Handler error"); const handler = vi.fn().mockRejectedValue(testError); const wrappedHandler = withAuth(handler); await wrappedHandler(createMockRequest()); expect(mockLogger.error).toHaveBeenCalledWith( expect.objectContaining({ err: testError, }), expect.stringContaining("Auth middleware error"), ); }); }); });