All checks were successful
Deploy / deploy (push) Successful in 1m36s
- Add skip navigation link to root layout - Add semantic HTML landmarks (main element) to login and settings pages - Add aria-labels to calendar day buttons with date, cycle day, and phase info - Add id="main-content" to dashboard main element for skip link target - Fix pre-existing type error in auth-middleware.test.ts Tests: 781 passing (11 new accessibility tests) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
236 lines
6.7 KiB
TypeScript
236 lines
6.7 KiB
TypeScript
// 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<typeof vi.fn>;
|
|
error: ReturnType<typeof vi.fn>;
|
|
info: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
const mockCookies = cookies as ReturnType<typeof vi.fn>;
|
|
const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType<
|
|
typeof vi.fn
|
|
>;
|
|
const mockLoadAuthFromCookies = loadAuthFromCookies as ReturnType<typeof vi.fn>;
|
|
const mockIsAuthenticated = isAuthenticated as ReturnType<typeof vi.fn>;
|
|
const mockGetCurrentUser = getCurrentUser as ReturnType<typeof vi.fn>;
|
|
|
|
describe("withAuth", () => {
|
|
const mockUser = {
|
|
id: "user123",
|
|
email: "test@example.com",
|
|
garminConnected: false,
|
|
garminOauth1Token: "",
|
|
garminOauth2Token: "",
|
|
garminTokenExpiresAt: new Date(),
|
|
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(),
|
|
};
|
|
|
|
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 = {} as NextRequest;
|
|
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 = {} as NextRequest;
|
|
const response = await wrappedHandler(mockRequest);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, 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({} as NextRequest);
|
|
|
|
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({} as NextRequest);
|
|
|
|
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 = {} as NextRequest;
|
|
const mockParams = { id: "123" };
|
|
|
|
await wrappedHandler(mockRequest, { params: mockParams });
|
|
|
|
expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, {
|
|
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({} as NextRequest);
|
|
|
|
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({} as NextRequest);
|
|
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ reason: "not_authenticated" }),
|
|
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({} as NextRequest);
|
|
|
|
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({} as NextRequest);
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: testError,
|
|
}),
|
|
expect.stringContaining("Auth middleware error"),
|
|
);
|
|
});
|
|
});
|
|
});
|