Rate-limit the Polar and Oura disconnect routes

Both disconnect routes ran unbounded, unlike nightscout/disconnect and the
connect routes. Add the same per-user 20/60s checkRateLimit guard after
requireAuth so the surface returns 429 on bucket exhaustion.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:58:37 +02:00
1 parent d58f0dc699
commit c2d6772b03
4 files changed
+128

No files matched your search

@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
const post = POST as unknown as () => Promise<RouteResult>;
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();
});
});
+8
View File
@@ -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 },
@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
const post = POST as unknown as () => Promise<RouteResult>;
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();
});
});
+8
View File
@@ -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 },