From 0fc25a49f13297bd161505065a3afb9e3450d70b Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 19:45:16 +0000 Subject: [PATCH] Implement Garmin token management endpoints (P2.2, P2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added three Garmin API endpoints for token management: - POST /api/garmin/tokens: Accepts oauth1, oauth2, expires_at; encrypts tokens using AES-256-GCM; stores in user record; returns daysUntilExpiry - DELETE /api/garmin/tokens: Clears encrypted tokens from user record and sets garminConnected to false - GET /api/garmin/status: Returns connection status, days until expiry, expired flag, and warning level (critical ≤7 days, warning 8-14 days) All endpoints use withAuth() middleware for authentication. Added 26 tests covering encryption, validation, auth, and warning level thresholds. Also added pb_data/ to .gitignore for PocketBase data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + IMPLEMENTATION_PLAN.md | 37 ++- src/app/api/garmin/status/route.test.ts | 333 +++++++++++++++++++++++ src/app/api/garmin/status/route.ts | 47 +++- src/app/api/garmin/tokens/route.test.ts | 336 ++++++++++++++++++++++++ src/app/api/garmin/tokens/route.ts | 99 ++++++- 6 files changed, 832 insertions(+), 23 deletions(-) create mode 100644 src/app/api/garmin/status/route.test.ts create mode 100644 src/app/api/garmin/tokens/route.test.ts diff --git a/.gitignore b/.gitignore index 24ccfaa..89a1af8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ .DS_Store *.pem +# pocketbase +pb_data/ + # debug npm-debug.log* yarn-debug.log* diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 4c40d20..ff26bf2 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -32,9 +32,9 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) | | POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) | | DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) | -| POST /api/garmin/tokens | 501 | Returns Not Implemented | -| DELETE /api/garmin/tokens | 501 | Returns Not Implemented | -| GET /api/garmin/status | 501 | Returns Not Implemented | +| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) | +| DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 tests) | +| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) | | GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO | | POST /api/calendar/regenerate-token | 501 | Returns Not Implemented | | POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO | @@ -82,6 +82,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) | | `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) | | `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) | +| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) | +| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -260,21 +262,31 @@ Full feature set for production use. - `fetchIntensityMinutes()` - Fetches weekly moderate + vigorous intensity minutes - **Why:** Real biometric data is required for accurate decisions -### P2.2: POST/DELETE /api/garmin/tokens Implementation -- [ ] Store encrypted Garmin OAuth tokens +### P2.2: POST/DELETE /api/garmin/tokens Implementation ✅ COMPLETE +- [x] Store encrypted Garmin OAuth tokens - **Files:** - - `src/app/api/garmin/tokens/route.ts` - Implement with encryption.ts + - `src/app/api/garmin/tokens/route.ts` - POST/DELETE handlers with encryption, validation - **Tests:** - - `src/app/api/garmin/tokens/route.test.ts` - Test encryption, validation, storage + - `src/app/api/garmin/tokens/route.test.ts` - 15 tests covering encryption, validation, storage, auth, deletion +- **Features Implemented:** + - POST: Accepts oauth1, oauth2, expires_at; encrypts tokens; stores in user record + - DELETE: Clears tokens and sets garminConnected to false + - Validation for required fields and types + - Returns daysUntilExpiry in POST response - **Why:** Users need to connect their Garmin accounts - **Depends On:** P0.1, P0.2 -### P2.3: GET /api/garmin/status Implementation -- [ ] Return Garmin connection status and days until expiry +### P2.3: GET /api/garmin/status Implementation ✅ COMPLETE +- [x] Return Garmin connection status and days until expiry - **Files:** - - `src/app/api/garmin/status/route.ts` - Implement status check + - `src/app/api/garmin/status/route.ts` - GET handler with expiry calculation - **Tests:** - - `src/app/api/garmin/status/route.test.ts` - Test connected/disconnected states, expiry calc + - `src/app/api/garmin/status/route.test.ts` - 11 tests covering connected/disconnected states, expiry calc, warning levels +- **Response Shape:** + - `connected` - Boolean indicating if tokens exist + - `daysUntilExpiry` - Days until token expires (null if not connected) + - `expired` - Boolean indicating if tokens have expired + - `warningLevel` - "critical" (≤7 days), "warning" (8-14 days), or null - **Why:** Users need visibility into their Garmin connection - **Depends On:** P0.1, P0.2, P2.1 @@ -553,6 +565,9 @@ P2.14 Mini calendar - [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4) - [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5) - [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5) +- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2) +- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2) +- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3) ### Pages - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) diff --git a/src/app/api/garmin/status/route.test.ts b/src/app/api/garmin/status/route.test.ts new file mode 100644 index 0000000..59131ee --- /dev/null +++ b/src/app/api/garmin/status/route.test.ts @@ -0,0 +1,333 @@ +// ABOUTME: Unit tests for Garmin status API route. +// ABOUTME: Tests GET /api/garmin/status for connection status and token expiry. +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; + +// Mock PocketBase +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn(), + })), +})); + +// 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); + }; + }), +})); + +import { GET } from "./route"; + +describe("GET /api/garmin/status", () => { + beforeEach(() => { + vi.clearAllMocks(); + currentMockUser = null; + }); + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns connected false when user has no tokens", async () => { + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date("2025-01-01"), + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.connected).toBe(false); + }); + + it("returns connected true when user has valid tokens", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.connected).toBe(true); + }); + + it("returns daysUntilExpiry when connected", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.daysUntilExpiry).toBeGreaterThanOrEqual(29); + expect(body.daysUntilExpiry).toBeLessThanOrEqual(30); + }); + + it("returns null daysUntilExpiry when not connected", async () => { + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date("2025-01-01"), + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.daysUntilExpiry).toBeNull(); + }); + + it("returns expired true when tokens have expired", async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 5); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: pastDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.connected).toBe(true); + expect(body.expired).toBe(true); + expect(body.daysUntilExpiry).toBeLessThan(0); + }); + + it("returns expired false when tokens are valid", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.expired).toBe(false); + }); + + it("returns warningLevel 'critical' when expiring in 7 days or less", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 5); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.warningLevel).toBe("critical"); + }); + + it("returns warningLevel 'warning' when expiring in 8-14 days", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.warningLevel).toBe("warning"); + }); + + it("returns warningLevel null when more than 14 days until expiry", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted-token", + garminOauth2Token: "encrypted-token", + garminTokenExpiresAt: futureDate, + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.warningLevel).toBeNull(); + }); + + it("returns warningLevel null when not connected", async () => { + currentMockUser = { + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date("2025-01-01"), + 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"), + }; + + const mockRequest = {} as NextRequest; + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.warningLevel).toBeNull(); + }); +}); diff --git a/src/app/api/garmin/status/route.ts b/src/app/api/garmin/status/route.ts index 6558960..1806e37 100644 --- a/src/app/api/garmin/status/route.ts +++ b/src/app/api/garmin/status/route.ts @@ -2,7 +2,46 @@ // ABOUTME: Returns connection state and token expiry information. import { NextResponse } from "next/server"; -export async function GET() { - // TODO: Implement status check - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); -} +import { withAuth } from "@/lib/auth-middleware"; +import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin"; + +export const GET = withAuth(async (_request, user) => { + const connected = user.garminConnected; + + if (!connected) { + return NextResponse.json({ + connected: false, + daysUntilExpiry: null, + expired: false, + warningLevel: null, + }); + } + + const expiresAt = + user.garminTokenExpiresAt instanceof Date + ? user.garminTokenExpiresAt.toISOString() + : String(user.garminTokenExpiresAt); + + const tokens = { + oauth1: "", + oauth2: "", + expires_at: expiresAt, + }; + + const days = daysUntilExpiry(tokens); + const expired = isTokenExpired(tokens); + + let warningLevel: "warning" | "critical" | null = null; + if (days <= 7) { + warningLevel = "critical"; + } else if (days <= 14) { + warningLevel = "warning"; + } + + return NextResponse.json({ + connected: true, + daysUntilExpiry: days, + expired, + warningLevel, + }); +}); diff --git a/src/app/api/garmin/tokens/route.test.ts b/src/app/api/garmin/tokens/route.test.ts new file mode 100644 index 0000000..fbf6e74 --- /dev/null +++ b/src/app/api/garmin/tokens/route.test.ts @@ -0,0 +1,336 @@ +// 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({}); + +// Mock PocketBase +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn(() => ({ + update: mockPbUpdate, + })), + })), +})); + +// 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); + }; + }), +})); + +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"), + 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): 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, + 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"), + 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, + 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); + }); +}); diff --git a/src/app/api/garmin/tokens/route.ts b/src/app/api/garmin/tokens/route.ts index 2227405..b5fb6dd 100644 --- a/src/app/api/garmin/tokens/route.ts +++ b/src/app/api/garmin/tokens/route.ts @@ -2,12 +2,95 @@ // ABOUTME: Accepts tokens from the bootstrap script and encrypts them for storage. import { NextResponse } from "next/server"; -export async function POST() { - // TODO: Implement token storage - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); -} +import { withAuth } from "@/lib/auth-middleware"; +import { encrypt } from "@/lib/encryption"; +import { daysUntilExpiry } from "@/lib/garmin"; +import { createPocketBaseClient } from "@/lib/pocketbase"; -export async function DELETE() { - // TODO: Implement token deletion - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); -} +export const POST = withAuth(async (request, user) => { + const body = await request.json(); + const { oauth1, oauth2, expires_at } = body; + + // Validate required fields + if (!oauth1) { + return NextResponse.json({ error: "oauth1 is required" }, { status: 400 }); + } + + if (!oauth2) { + return NextResponse.json({ error: "oauth2 is required" }, { status: 400 }); + } + + if (!expires_at) { + return NextResponse.json( + { error: "expires_at is required" }, + { status: 400 }, + ); + } + + // Validate oauth1 is an object + if (typeof oauth1 !== "object" || oauth1 === null) { + return NextResponse.json( + { error: "oauth1 must be an object" }, + { status: 400 }, + ); + } + + // Validate oauth2 is an object + if (typeof oauth2 !== "object" || oauth2 === null) { + return NextResponse.json( + { error: "oauth2 must be an object" }, + { status: 400 }, + ); + } + + // Validate expires_at is a valid date + const expiryDate = new Date(expires_at); + if (Number.isNaN(expiryDate.getTime())) { + return NextResponse.json( + { error: "expires_at must be a valid date" }, + { status: 400 }, + ); + } + + // Encrypt tokens before storing + const encryptedOauth1 = encrypt(JSON.stringify(oauth1)); + const encryptedOauth2 = encrypt(JSON.stringify(oauth2)); + + // Update user record + const pb = createPocketBaseClient(); + await pb.collection("users").update(user.id, { + garminOauth1Token: encryptedOauth1, + garminOauth2Token: encryptedOauth2, + garminTokenExpiresAt: expires_at, + garminConnected: true, + }); + + // Calculate days until expiry + const expiryDays = daysUntilExpiry({ + oauth1: "", + oauth2: "", + expires_at, + }); + + return NextResponse.json({ + success: true, + garminConnected: true, + daysUntilExpiry: expiryDays, + }); +}); + +export const DELETE = withAuth(async (_request, user) => { + const pb = createPocketBaseClient(); + + await pb.collection("users").update(user.id, { + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: null, + garminConnected: false, + }); + + return NextResponse.json({ + success: true, + garminConnected: false, + }); +});