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:
2026-01-10 19:45:16 +00:00
parent 24b7c0fd3e
commit 0fc25a49f1
6 changed files with 832 additions and 23 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@
.DS_Store
*.pem
# pocketbase
pb_data/
# debug
npm-debug.log*
yarn-debug.log*

View File

@@ -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)

View 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();
});
});

View File

@@ -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,
});
});

View 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);
});
});

View File

@@ -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,
});
});