mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user