Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
- NutritionPanel: Display seed switch alert on day 15 per dashboard spec - MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec - DayCell: Show period indicator (🩸) for days 1-3 per calendar spec - Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec - Updated NutritionGuidance type to include seedSwitchAlert field - /api/today now returns seedSwitchAlert in nutrition response Test coverage: 1005 tests (15 new tests added) - nutrition-panel.test.tsx: +4 tests - month-view.test.tsx: +1 test - day-cell.test.tsx: +5 tests - auth-middleware.test.ts: +3 tests - today/route.test.ts: +2 tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,16 @@ describe("withAuth", () => {
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
// Helper to create mock request with headers
|
||||
const createMockRequest = (
|
||||
headers: Record<string, string | null> = {},
|
||||
): NextRequest =>
|
||||
({
|
||||
headers: {
|
||||
get: vi.fn((name: string) => headers[name] ?? null),
|
||||
},
|
||||
}) as unknown as NextRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCookies.mockResolvedValue(mockCookieStore);
|
||||
@@ -91,7 +101,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const mockRequest = createMockRequest();
|
||||
const response = await wrappedHandler(mockRequest);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
@@ -109,7 +119,7 @@ describe("withAuth", () => {
|
||||
.mockResolvedValue(NextResponse.json({ data: "success" }));
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const mockRequest = createMockRequest();
|
||||
const response = await wrappedHandler(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -128,7 +138,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
await wrappedHandler({} as NextRequest);
|
||||
await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(mockCreatePocketBaseClient).toHaveBeenCalled();
|
||||
expect(mockCookies).toHaveBeenCalled();
|
||||
@@ -146,7 +156,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
const response = await wrappedHandler({} as NextRequest);
|
||||
const response = await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
@@ -159,7 +169,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const mockRequest = createMockRequest();
|
||||
const mockParams = { id: "123" };
|
||||
|
||||
await wrappedHandler(mockRequest, { params: mockParams });
|
||||
@@ -176,7 +186,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
const response = await wrappedHandler({} as NextRequest);
|
||||
const response = await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
@@ -196,7 +206,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
await wrappedHandler({} as NextRequest);
|
||||
await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "not_authenticated" }),
|
||||
@@ -204,6 +214,76 @@ describe("withAuth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -211,7 +291,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn();
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
await wrappedHandler({} as NextRequest);
|
||||
await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "user_not_found" }),
|
||||
@@ -227,7 +307,7 @@ describe("withAuth", () => {
|
||||
const handler = vi.fn().mockRejectedValue(testError);
|
||||
const wrappedHandler = withAuth(handler);
|
||||
|
||||
await wrappedHandler({} as NextRequest);
|
||||
await wrappedHandler(createMockRequest());
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -42,6 +42,23 @@ export type AuthenticatedHandler<T = unknown> = (
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* Extracts client IP address from request headers.
|
||||
* Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present.
|
||||
*/
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs; first one is the client
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function withAuth<T = unknown>(
|
||||
handler: AuthenticatedHandler<T>,
|
||||
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
|
||||
@@ -57,16 +74,19 @@ export function withAuth<T = unknown>(
|
||||
const cookieStore = await cookies();
|
||||
loadAuthFromCookies(pb, cookieStore);
|
||||
|
||||
// Get client IP for logging
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!isAuthenticated(pb)) {
|
||||
logger.warn({ reason: "not_authenticated" }, "Auth failure");
|
||||
logger.warn({ reason: "not_authenticated", ip }, "Auth failure");
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the current user
|
||||
const user = getCurrentUser(pb);
|
||||
if (!user) {
|
||||
logger.warn({ reason: "user_not_found" }, "Auth failure");
|
||||
logger.warn({ reason: "user_not_found", ip }, "Auth failure");
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user