Files
phaseflow/src/lib/auth-middleware.test.ts
Petru Paler b221acee40 Implement automatic Garmin token refresh and fix expiry tracking
- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint
- Track refresh token expiry (~30 days) instead of access token expiry (~21 hours)
- Auto-refresh access tokens in cron sync before they expire
- Update Python script to output refresh_token_expires_at
- Add garminRefreshTokenExpiresAt field to User type and database schema
- Fix token input UX: show when warning active, not just when disconnected
- Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data
- Add oauth-1.0a package for OAuth1 signature generation

The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token,
so users only need to re-run the Python auth script every ~30 days (when refresh
token expires) instead of every ~21 hours (when access token expires).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:33:10 +00:00

322 lines
9.1 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(),
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<string, string | null> = {},
): 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"),
);
});
});
});