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:
@@ -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}`));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user