diff --git a/src/app/api/oura/disconnect/__tests__/route.test.ts b/src/app/api/oura/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..fb7802709 --- /dev/null +++ b/src/app/api/oura/disconnect/__tests__/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() })); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: rateLimitMock, + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 }); + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" }); +}); + +describe("POST /api/oura/disconnect", () => { + it("disconnects a connected user", async () => { + const res = await post(); + expect(res.status).toBe(200); + expect(res.data).toEqual({ disconnected: true }); + }); + + it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => { + rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); + const res = await post(); + expect(res.status).toBe(429); + // The user lookup never runs once the bucket is exhausted. + expect(userFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts index 6eb47e33a..a5daf5f97 100644 --- a/src/app/api/oura/disconnect/route.ts +++ b/src/app/api/oura/disconnect/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markDisconnected } from "@/lib/integrations/status"; /** @@ -16,6 +17,13 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "oura.disconnect" } }); + const rl = await checkRateLimit(`oura-disconnect:${user.id}`, 20, 60_000); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { ouraAccessTokenEncrypted: true }, diff --git a/src/app/api/polar/disconnect/__tests__/route.test.ts b/src/app/api/polar/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..db7fb375e --- /dev/null +++ b/src/app/api/polar/disconnect/__tests__/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() })); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: rateLimitMock, + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 }); + userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc:tok" }); +}); + +describe("POST /api/polar/disconnect", () => { + it("disconnects a connected user", async () => { + const res = await post(); + expect(res.status).toBe(200); + expect(res.data).toEqual({ disconnected: true }); + }); + + it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => { + rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); + const res = await post(); + expect(res.status).toBe(429); + // The user lookup never runs once the bucket is exhausted. + expect(userFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts index f18e46635..f39a73692 100644 --- a/src/app/api/polar/disconnect/route.ts +++ b/src/app/api/polar/disconnect/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markDisconnected } from "@/lib/integrations/status"; /** @@ -16,6 +17,13 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "polar.disconnect" } }); + const rl = await checkRateLimit(`polar-disconnect:${user.id}`, 20, 60_000); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { polarAccessTokenEncrypted: true },