mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Rate-limit the AI-consent endpoints per user
POST /api/consent/ai, POST /api/consent/ai/web, and GET/DELETE /api/consent/ai/latest had no rate limit, so a scripted loop or a stolen session could pin the receipts table. Add a shared per-user bucket (20/min, keyed by user id) mirroring the coach-chat ceiling, checked right after requireAuth, returning 429 with the X-RateLimit headers attached. The web heal endpoint runs on every AI-settings mount, so the bucket is generous enough to never throttle normal use.
This commit is contained in:
1 parent
6a173a89dd
commit
cc34cbd03d
7 files changed
+343
-67
No files matched your search
@@ -1,15 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` runs its supersede + insert inside a
|
||||
// transaction; run the callback against the same mock proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// v1.16.16 — rate limit is exercised by its own suite; here we default to
|
||||
// "allowed" and flip a single test to assert the 429 wiring.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +54,13 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -59,6 +82,15 @@ beforeEach(() => {
|
||||
// Re-arm it here so the fire-and-forget `.catch()` in the route
|
||||
// doesn't crash on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm the rate-limit + transaction pass-throughs cleared by
|
||||
// `resetAllMocks`.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai", () => {
|
||||
@@ -74,6 +106,25 @@ describe("POST /api/consent/ai", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before any write touches the receipts table.
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +52,13 @@ import { POST as POST_GRANT } from "../../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -50,6 +71,11 @@ beforeEach(() => {
|
||||
// on `auditLog`. Re-arm so the route's fire-and-forget `.catch()`
|
||||
// chain doesn't NPE on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -69,6 +95,20 @@ describe("GET /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null receipt when nothing has been granted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
@@ -153,21 +193,33 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the latest receipt as revoked and returns it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
// v1.16.16 — `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count) then re-reads the revoked row for the audit id.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -188,15 +240,17 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString());
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent — returns null receipt without writing when nothing to revoke", async () => {
|
||||
it("is idempotent — returns null receipt without re-reading when nothing to revoke", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
@@ -208,7 +262,7 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
data: { kind: string; receipt: unknown | null };
|
||||
};
|
||||
expect(body.data.receipt).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("master toggle revokes the latest active row per kind", async () => {
|
||||
@@ -217,8 +271,14 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// First call (ai_full) finds a row, second (ai_insights_only)
|
||||
// finds none, third (ai_coach) finds a row.
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -226,33 +286,19 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-coach",
|
||||
userId: "user-1",
|
||||
kind: "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.consentReceipt.update).mockImplementation(
|
||||
((args: { where: { id: string }; data: { revokedAt: Date } }) =>
|
||||
Promise.resolve({
|
||||
id: args.where.id,
|
||||
userId: "user-1",
|
||||
kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest", {
|
||||
method: "DELETE",
|
||||
@@ -287,7 +333,11 @@ describe("append-only invariant", () => {
|
||||
const signedAt2 = new Date("2026-05-18T12:00:00.000Z");
|
||||
const createdAt2 = new Date("2026-05-18T12:00:01.000Z");
|
||||
|
||||
// Step 1: initial grant.
|
||||
// Step 1: initial grant. `createReceipt` supersedes any active row
|
||||
// (none yet → count 0) then inserts.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
@@ -311,17 +361,13 @@ describe("append-only invariant", () => {
|
||||
);
|
||||
expect(grant1.status).toBe(200);
|
||||
|
||||
// Step 2: revoke. The DELETE handler reads + updates.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt1,
|
||||
// Step 2: revoke. `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count 1) then re-reads the revoked row. The row is
|
||||
// NEVER deleted — only marked revoked, so the audit chain survives.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -337,12 +383,11 @@ describe("append-only invariant", () => {
|
||||
}),
|
||||
);
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1);
|
||||
// Critical invariant — `delete` was NEVER called on the receipt.
|
||||
// Only an update setting `revokedAt`. The old row stays in place.
|
||||
const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0];
|
||||
expect(updateCall).toMatchObject({
|
||||
where: { id: "rcpt-1" },
|
||||
// Critical invariant — `delete` was NEVER called on the receipt. The
|
||||
// revoke is an in-place `updateMany` setting `revokedAt`.
|
||||
expect(prisma.consentReceipt.delete).toBeUndefined();
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
consentKindEnum,
|
||||
consentLatestQuery,
|
||||
@@ -37,6 +39,17 @@ import { serialiseReceipt } from "../route";
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
@@ -86,6 +99,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
|
||||
@@ -9,15 +9,35 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { consentPostBody } from "@/lib/validations/consent";
|
||||
import { createReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user ceiling in front of the receipts write. 20/min is well outside
|
||||
// any legitimate grant cadence; it caps a scripted loop from filling the
|
||||
// audit table.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 128 * 1024,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
|
||||
// route test stubs it to focus on auth + rate-limit + serialisation.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai/web", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the heal mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a receipt and reports minted:true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { minted: boolean; kind: string };
|
||||
};
|
||||
expect(body.data.minted).toBe(true);
|
||||
expect(body.data.kind).toBe("ai_full");
|
||||
});
|
||||
|
||||
it("is a no-op when an active receipt already exists (minted:false)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false });
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { minted: boolean } };
|
||||
expect(body.data.minted).toBe(false);
|
||||
// No audit row on the no-op path.
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,29 @@
|
||||
* revoke) lives in `../route.ts` + `../latest/route.ts`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The web AI-settings surface calls this on every mount; the bucket is
|
||||
// generous enough that the heal path is never throttled in normal use,
|
||||
// tight enough to cap a scripted loop.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id);
|
||||
|
||||
if (result.minted) {
|
||||
|
||||
@@ -87,6 +87,25 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.16 — per-user ceiling for the AI-consent surfaces
|
||||
* (`POST /api/consent/ai`, `POST /api/consent/ai/web`,
|
||||
* `GET/DELETE /api/consent/ai/latest`). The web client calls the heal
|
||||
* endpoint on every AI-settings mount and the iOS toggle hits grant/revoke
|
||||
* per flip, so the bucket is generous — 20/min only caps a scripted loop or
|
||||
* a stolen session pinning the receipts table, mirroring the coach-chat
|
||||
* ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap
|
||||
* meaningful when a loop rotates across the four endpoints.
|
||||
*/
|
||||
const CONSENT_LIMIT = 20;
|
||||
const CONSENT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export async function checkConsentRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every
|
||||
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
|
||||
|
||||
Reference in new issue
Block a user