mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE)
Three handlers at /api/auth/me/research-mode gate the opt-in GLP-1
PK research-view chart behind an MDR-disclaimer acknowledgment.
GET — returns the persisted flag/version + the live
currentDisclaimerVersion so the Settings UI can detect
copy drift and re-prompt.
POST — { acknowledged: true, version } turns the flag on.
Rejects 400 when the submitted version does not match
the server-side RESEARCH_MODE_DISCLAIMER_VERSION; rejects
422 on malformed body. Per-user rate-limit 5/min — this
is a one-tap dialog, not a hot path.
DELETE — idempotent: clears all three columns and writes an
audit row even when the flag is already off.
Prompt-injection guard — the body shape accepts the version string
only (not free text); the value is matched against a server-side
constant before any DB write and never reaches the LLM. Hand-rolled
validation keeps the surface visibly tiny in code review.
11 route tests cover unauth/enabled/disabled GET; happy-path POST
with audit; stale-version POST; malformed POST; rate-limited POST;
unauth/successful/idempotent DELETE with audit.
This commit is contained in:
1 parent
36d147e4f4
commit
cf27df49c6
2 files changed
+522
No files matched your search
@@ -0,0 +1,304 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
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("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mkDelete(): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the disabled default for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
expect(env.data.currentDisclaimerVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the enabled state with the persisted version", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const ackAt = new Date("2026-05-14T10:00:00Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: ackAt,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string;
|
||||
acknowledgedVersion: string;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Research Mode and writes the audit log on a valid ack", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale disclaimer version with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a body without acknowledged=true with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Research Mode and clears the acknowledgment", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when Research Mode is already off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
// The route writes the canonical off-state even when the
|
||||
// column is already off — guarantees audit trail symmetry.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
|
||||
*
|
||||
* GET /api/auth/me/research-mode — returns the current flag,
|
||||
* the timestamp + version of
|
||||
* the last acknowledgment, and
|
||||
* the live disclaimer version
|
||||
* so the Settings UI can
|
||||
* compare and re-prompt.
|
||||
* POST /api/auth/me/research-mode — body { acknowledged: true,
|
||||
* version }. Flips the flag
|
||||
* on. Rejects 400 if `version`
|
||||
* is not the live version.
|
||||
* DELETE /api/auth/me/research-mode — disables Research Mode and
|
||||
* clears the acknowledgment.
|
||||
* Idempotent.
|
||||
*
|
||||
* Per-user POST rate-limit is intentionally low (5/min) — this is a
|
||||
* one-tap acknowledgment dialog, not a hot path. Anything above that
|
||||
* traffic looks like a malformed client retry-loop or an exploit.
|
||||
*
|
||||
* Prompt-injection surface — the API accepts the disclaimer
|
||||
* `version` string and nothing else from the client. The version
|
||||
* value never reaches the LLM; it is matched against the
|
||||
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
|
||||
* persisted as the stamp on the user row. No free-text body.
|
||||
*
|
||||
* Audit-log entries fire on every state-changing call (POST, DELETE)
|
||||
* with the previous + next state, mirroring the doctor-report-prefs
|
||||
* route's reconcile pattern (W10 security M-3).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const POST_RATE_LIMIT = 5;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type ResearchModeResponse = {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
|
||||
function shape(row: {
|
||||
researchModeEnabled: boolean;
|
||||
researchModeAcknowledgedAt: Date | null;
|
||||
researchModeAcknowledgedVersion: string | null;
|
||||
}): ResearchModeResponse {
|
||||
return {
|
||||
enabled: row.researchModeEnabled,
|
||||
acknowledgedAt: row.researchModeAcknowledgedAt
|
||||
? row.researchModeAcknowledgedAt.toISOString()
|
||||
: null,
|
||||
acknowledgedVersion: row.researchModeAcknowledgedVersion,
|
||||
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.research-mode.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
// `requireAuth` already proved the user exists; defensive default
|
||||
// here only matters for the (impossible-in-practice) race where
|
||||
// the user is deleted mid-request.
|
||||
return apiSuccess(
|
||||
shape(
|
||||
row ?? {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user rate-limit. 5/min is generous for a once-per-update
|
||||
// acknowledgment dialog; anything more looks like a retry loop.
|
||||
const rl = await checkRateLimit(
|
||||
`research-mode:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "research-mode.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — the body is tiny (two fields), and
|
||||
// staying off Zod keeps the prompt-injection surface visibly
|
||||
// simple in a code review.
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
(body as { acknowledged?: unknown }).acknowledged !== true ||
|
||||
typeof (body as { version?: unknown }).version !== "string"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
|
||||
throw new HttpError(422, "research-mode.body.invalid_shape");
|
||||
}
|
||||
|
||||
const submittedVersion = (body as { version: string }).version;
|
||||
|
||||
// Stale-version guard — the dialog must show the *live* copy at
|
||||
// the moment of acknowledgment. A stale version means the user
|
||||
// is acknowledging older wording; we refuse and force the
|
||||
// Settings UI to re-render with the new disclaimer.
|
||||
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post.stale_version" },
|
||||
meta: {
|
||||
submitted: submittedVersion,
|
||||
current: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
throw new HttpError(400, "research-mode.version.stale");
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.enable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post" },
|
||||
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
|
||||
});
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent — if Research Mode is already off, we still write
|
||||
// the canonical "off" state so the audit row exists.
|
||||
const next = {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.disable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { previous: previous ?? null, next },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.me.research-mode.delete" } });
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
Reference in new issue
Block a user