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
|
||||
*.pem
|
||||
|
||||
# pocketbase
|
||||
pb_data/
|
||||
|
||||
# debug
|
||||
npm-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) |
|
||||
| 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)
|
||||
|
||||
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.
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user