Files
phaseflow/src/app/api/garmin/tokens/route.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

343 lines
10 KiB
TypeScript

// ABOUTME: Unit tests for Garmin tokens API route.
// ABOUTME: Tests POST and DELETE /api/garmin/tokens for token storage and deletion.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Track PocketBase getOne calls - returns user with garminConnected: true after update
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: mockPbUpdate,
getOne: mockPbGetOne,
})),
};
// Track encryption calls
const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`);
// Mock encryption module
vi.mock("@/lib/encryption", () => ({
encrypt: (plaintext: string) => mockEncrypt(plaintext),
}));
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb);
};
}),
}));
import { DELETE, POST } from "./route";
describe("POST /api/garmin/tokens", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: false,
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
});
// Helper to create mock request with JSON body
function createMockRequest(body: Record<string, unknown>): NextRequest {
return {
json: vi.fn().mockResolvedValue(body),
} as unknown as NextRequest;
}
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: { accessToken: "token123" },
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("stores encrypted tokens successfully", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: { accessToken: "token123" },
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.garminConnected).toBe(true);
});
it("encrypts oauth1 and oauth2 tokens before storing", async () => {
currentMockUser = mockUser;
const oauth1 = { token: "abc", secret: "xyz" };
const oauth2 = { accessToken: "token123" };
const mockRequest = createMockRequest({
oauth1,
oauth2,
expires_at: "2025-04-01T00:00:00Z",
});
await POST(mockRequest);
// Verify encrypt was called with JSON stringified tokens
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(oauth1));
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(oauth2));
});
it("updates user record with encrypted tokens and expiry", async () => {
currentMockUser = mockUser;
const oauth1 = { token: "abc", secret: "xyz" };
const oauth2 = { accessToken: "token123" };
const expiresAt = "2025-04-01T00:00:00Z";
const mockRequest = createMockRequest({
oauth1,
oauth2,
expires_at: expiresAt,
});
await POST(mockRequest);
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
garminTokenExpiresAt: expiresAt,
garminRefreshTokenExpiresAt: expect.any(String),
garminConnected: true,
});
});
it("returns 400 when oauth1 is missing", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth2: { accessToken: "token123" },
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("oauth1");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when oauth2 is missing", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("oauth2");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when expires_at is missing", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: { accessToken: "token123" },
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("expires_at");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when expires_at is not a valid date", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: { accessToken: "token123" },
expires_at: "not-a-date",
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("expires_at");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when oauth1 is not an object", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: "not-an-object",
oauth2: { accessToken: "token123" },
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("oauth1");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns 400 when oauth2 is not an object", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: "not-an-object",
expires_at: "2025-04-01T00:00:00Z",
});
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("oauth2");
expect(mockPbUpdate).not.toHaveBeenCalled();
});
it("returns daysUntilExpiry in response", async () => {
currentMockUser = mockUser;
// Set expires_at to 30 days from now
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 30);
const mockRequest = createMockRequest({
oauth1: { token: "abc", secret: "xyz" },
oauth2: { accessToken: "token123" },
expires_at: futureDate.toISOString(),
});
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilExpiry).toBeGreaterThanOrEqual(29);
expect(body.daysUntilExpiry).toBeLessThanOrEqual(30);
});
});
describe("DELETE /api/garmin/tokens", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = {} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("clears tokens and sets garminConnected to false", async () => {
currentMockUser = mockUser;
const mockRequest = {} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: null,
garminRefreshTokenExpiresAt: null,
garminConnected: false,
});
});
it("returns success response after deletion", async () => {
currentMockUser = mockUser;
const mockRequest = {} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.garminConnected).toBe(false);
});
it("works even when user has no tokens", async () => {
currentMockUser = {
...mockUser,
garminConnected: false,
garminOauth1Token: "",
garminOauth2Token: "",
};
const mockRequest = {} as NextRequest;
const response = await DELETE(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.success).toBe(true);
});
});