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>
This commit is contained in:
2026-01-14 20:33:10 +00:00
parent 6df145d916
commit b221acee40
31 changed files with 607 additions and 92 deletions

View File

@@ -1,6 +1,6 @@
// ABOUTME: Unit tests for Garmin sync cron endpoint.
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/types";
@@ -8,6 +8,8 @@ import type { User } from "@/types";
let mockUsers: User[] = [];
// Track DailyLog creations
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
// Track user updates
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
@@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({
return [];
}),
create: mockPbCreate,
update: mockPbUpdate,
})),
})),
}));
@@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({
const mockDecrypt = vi.fn((ciphertext: string) => {
// Return mock OAuth2 token JSON
if (ciphertext.includes("oauth2")) {
return JSON.stringify({ accessToken: "mock-token-123" });
return JSON.stringify({ access_token: "mock-token-123" });
}
// Return mock OAuth1 token JSON (needed for refresh flow)
if (ciphertext.includes("oauth1")) {
return JSON.stringify({
oauth_token: "mock-oauth1-token",
oauth_token_secret: "mock-oauth1-secret",
});
}
return ciphertext.replace("encrypted:", "");
});
@@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({
// Mock email sending
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
sendPeriodConfirmationEmail: (...args: unknown[]) =>
mockSendPeriodConfirmationEmail(...args),
}));
// Mock logger (required for route to run without side effects)
@@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => {
garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 28,
@@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
mockUsers = [];
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
process.env.CRON_SECRET = validSecret;
});
@@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
});
it("skips users with expired tokens", async () => {
mockIsTokenExpired.mockReturnValue(true);
mockUsers = [createMockUser()];
it("skips users with expired refresh tokens", async () => {
// Set refresh token to expired (in the past)
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => {
});
describe("Token expiration warnings", () => {
it("sends warning email when token expires in exactly 14 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
// Use fake timers to ensure consistent date calculations
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
// Helper to create a date N days from now
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
it("sends warning email when refresh token expires in exactly 14 days", async () => {
mockUsers = [
createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.warningsSent).toBe(1);
});
it("sends warning email when token expires in exactly 7 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(7);
it("sends warning email when refresh token expires in exactly 7 days", async () => {
mockUsers = [
createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(7),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.warningsSent).toBe(1);
});
it("does not send warning when token expires in 30 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(30);
it("does not send warning when refresh token expires in 30 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 15 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(15);
it("does not send warning when refresh token expires in 15 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 8 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(8);
it("does not send warning when refresh token expires in 8 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 6 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(6);
it("does not send warning when refresh token expires in 6 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -483,11 +531,17 @@ describe("POST /api/cron/garmin-sync", () => {
it("sends warnings for multiple users on different thresholds", async () => {
mockUsers = [
createMockUser({ id: "user1", email: "user1@example.com" }),
createMockUser({ id: "user2", email: "user2@example.com" }),
createMockUser({
id: "user1",
email: "user1@example.com",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
createMockUser({
id: "user2",
email: "user2@example.com",
garminRefreshTokenExpiresAt: daysFromNow(7),
}),
];
// First user at 14 days, second user at 7 days
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -507,8 +561,12 @@ describe("POST /api/cron/garmin-sync", () => {
});
it("continues processing sync even if warning email fails", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
mockUsers = [
createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
];
mockSendTokenExpirationWarning.mockRejectedValueOnce(
new Error("Email failed"),
);
@@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.usersProcessed).toBe(1);
});
it("does not send warning for expired tokens", async () => {
mockUsers = [createMockUser()];
mockIsTokenExpired.mockReturnValue(true);
mockDaysUntilExpiry.mockReturnValue(-1);
it("does not send warning for expired refresh tokens", async () => {
// Expired refresh tokens are skipped entirely (not synced), so no warning
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));