Implement Garmin token management endpoints (P2.2, P2.3)
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 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# pocketbase
|
||||||
|
pb_data/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
@@ -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) |
|
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) |
|
||||||
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
|
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
|
||||||
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
|
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
|
||||||
| POST /api/garmin/tokens | 501 | Returns Not Implemented |
|
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
|
||||||
| DELETE /api/garmin/tokens | 501 | Returns Not Implemented |
|
| DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 tests) |
|
||||||
| GET /api/garmin/status | 501 | Returns Not Implemented |
|
| 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 |
|
| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO |
|
||||||
| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented |
|
| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented |
|
||||||
| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO |
|
| 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/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/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/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** |
|
| E2E tests | **NONE** |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### Critical Business Rules (from Spec)
|
||||||
@@ -260,21 +262,31 @@ Full feature set for production use.
|
|||||||
- `fetchIntensityMinutes()` - Fetches weekly moderate + vigorous intensity minutes
|
- `fetchIntensityMinutes()` - Fetches weekly moderate + vigorous intensity minutes
|
||||||
- **Why:** Real biometric data is required for accurate decisions
|
- **Why:** Real biometric data is required for accurate decisions
|
||||||
|
|
||||||
### P2.2: POST/DELETE /api/garmin/tokens Implementation
|
### P2.2: POST/DELETE /api/garmin/tokens Implementation ✅ COMPLETE
|
||||||
- [ ] Store encrypted Garmin OAuth tokens
|
- [x] Store encrypted Garmin OAuth tokens
|
||||||
- **Files:**
|
- **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:**
|
- **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
|
- **Why:** Users need to connect their Garmin accounts
|
||||||
- **Depends On:** P0.1, P0.2
|
- **Depends On:** P0.1, P0.2
|
||||||
|
|
||||||
### P2.3: GET /api/garmin/status Implementation
|
### P2.3: GET /api/garmin/status Implementation ✅ COMPLETE
|
||||||
- [ ] Return Garmin connection status and days until expiry
|
- [x] Return Garmin connection status and days until expiry
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/api/garmin/status/route.ts` - Implement status check
|
- `src/app/api/garmin/status/route.ts` - GET handler with expiry calculation
|
||||||
- **Tests:**
|
- **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
|
- **Why:** Users need visibility into their Garmin connection
|
||||||
- **Depends On:** P0.1, P0.2, P2.1
|
- **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] **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] **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] **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
|
### Pages
|
||||||
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
||||||
|
|||||||
333
src/app/api/garmin/status/route.test.ts
Normal file
333
src/app/api/garmin/status/route.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,46 @@
|
|||||||
// ABOUTME: Returns connection state and token expiry information.
|
// ABOUTME: Returns connection state and token expiry information.
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
// TODO: Implement status check
|
import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
|
||||||
}
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
336
src/app/api/garmin/tokens/route.test.ts
Normal file
336
src/app/api/garmin/tokens/route.test.ts
Normal file
@@ -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<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,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,95 @@
|
|||||||
// ABOUTME: Accepts tokens from the bootstrap script and encrypts them for storage.
|
// ABOUTME: Accepts tokens from the bootstrap script and encrypts them for storage.
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function POST() {
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
// TODO: Implement token storage
|
import { encrypt } from "@/lib/encryption";
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
import { daysUntilExpiry } from "@/lib/garmin";
|
||||||
}
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
|
|
||||||
export async function DELETE() {
|
export const POST = withAuth(async (request, user) => {
|
||||||
// TODO: Implement token deletion
|
const body = await request.json();
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user