merge(reconcile): b3-doctorreport audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:20:45 +02:00
commit 95f4b5a263
15 files changed
+51 -1824

No files matched your search

@@ -76,7 +76,11 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/labs",
"src/app/api/cycle",
"src/app/api/gamification",
"src/app/api/doctor-report",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
// availability probe) was orphaned dead code (no production caller) and
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
// which gates on the `doctorReport` module directly AND through the
// `collectDoctorReportData` builder.
"src/app/api/fhir",
];
@@ -118,10 +122,6 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Section-availability presence probe for the doctor-report dialog —
// returns per-section row-count booleans, no module data. The dialog
// itself is hidden when the doctorReport module is off.
"src/app/api/doctor-report/availability/route.ts",
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
];
@@ -1,298 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
}));
vi.mock("@/lib/analytics/effective-range", () => ({
getEffectiveRange: vi.fn(() => ({ range: null })),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
// v1.18.0 — the doctor-report aggregator resolves the per-user module map
// so a disabled data-domain module never reaches the report. Stub the gate
// to "all modules enabled" (an empty map ⇒ default-on) so these pre-existing
// route tests don't stand up the real gate's DB reads.
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(),
isModuleEnabled: vi.fn(),
requireModuleEnabled: vi.fn(),
};
});
import { POST } from "../pdf/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import {
resolveModuleMap,
isModuleEnabled,
requireModuleEnabled,
} from "@/lib/modules/gate";
function setModuleGateAllEnabled() {
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
}
function makeRequest(
body?: Record<string, unknown>,
headers: Record<string, string> = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
...headers,
};
return new NextRequest("http://localhost/api/doctor-report/pdf", {
method: "POST",
headers: baseHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
});
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { id: "user-1", role: "USER" as const },
};
function setEmptyDataMocks() {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "testuser",
dateOfBirth: null,
gender: null,
heightCm: null,
glucoseUnit: null,
thresholdsJson: null,
} as never);
}
describe("POST /api/doctor-report/pdf", () => {
beforeEach(() => {
vi.resetAllMocks();
setModuleGateAllEnabled();
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(401);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("Not authenticated");
});
it("returns 429 when rate limit exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(429);
const json = (await res.json()) as { error: string };
expect(json.error).toMatch(/Maximum/);
});
it("returns 200 application/pdf with PDF bytes on happy path", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }),
);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
expect(res.headers.get("cache-control")).toBe("no-store");
const disposition = res.headers.get("content-disposition") ?? "";
expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/);
expect(disposition).toMatch(/\.pdf"/);
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.byteLength).toBeGreaterThan(1024);
expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-");
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ days: 30, locale: "de" }),
}),
);
});
it("falls back to default days=90 when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(makeRequest({}));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ days: 90 }),
}),
);
});
it("uses Accept-Language to pick the locale when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ locale: "en" }),
}),
);
});
// ── v1.4.15 phase B6 ── configurable date range + practice name.
it("accepts an explicit startDate / endDate range in the body", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const startDate = "2026-01-01T00:00:00.000Z";
const endDate = "2026-04-01T00:00:00.000Z";
const res = await POST(makeRequest({ startDate, endDate }));
expect(res.status).toBe(200);
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
measuredAt: { gte: new Date(startDate), lte: new Date(endDate) },
}),
}),
);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ startDate, endDate }),
}),
);
});
it("falls back when range is invalid (end < start)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({
startDate: "2026-04-01T00:00:00.000Z",
endDate: "2026-01-01T00:00:00.000Z",
}),
);
expect(res.status).toBe(200);
// Falls back to days=90.
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ days: 90 }),
}),
);
});
it("persists practiceName as a user preference and includes it in audit", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await POST(
makeRequest({ days: 30, practiceName: "Praxis Dr. Müller" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastReportPracticeName: "Praxis Dr. Müller" },
});
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ practiceNameProvided: true }),
}),
);
});
});
@@ -1,140 +0,0 @@
/**
* v1.4.25 W6c — doctor-report section availability probe.
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (the maintainer's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
* Body shape (mirrors `/api/doctor-report`):
* { startDate?: ISO, endDate?: ISO, days?: number }
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { normaliseDateRange } from "@/lib/doctor-report-data";
export const dynamic = "force-dynamic";
export interface DoctorReportAvailability {
/** Section flags — `true` when at least one row exists in the range. */
bp: boolean;
weight: boolean;
pulse: boolean;
/** BMI requires both a weight reading AND a configured height. */
bmi: boolean;
mood: boolean;
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
compliance: boolean;
sleep: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
// `count` queries are cheaper than `findMany` for a presence check —
// we never read the rows themselves. The user has indexes on
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
// `(userId, scheduledFor)` so these stay sub-millisecond even with
// multi-year ranges.
const [
bpCount,
weightCount,
pulseCount,
moodCount,
complianceCount,
sleepCount,
profile,
] = await Promise.all([
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
// v1.4.41 W-DELETED-2 — hide soft-deleted rows from the
// section-availability probe so the dialog does not offer a
// section that would render an empty PDF block.
deletedAt: null,
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "WEIGHT",
deletedAt: null,
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "PULSE",
deletedAt: null,
},
}),
prisma.moodEntry.count({
// v1.7.0 sync — exclude tombstoned rows from the availability count.
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: start, lte: end },
},
}),
prisma.medicationIntakeEvent.count({
// v1.7.0 sync — exclude tombstoned rows from the availability count.
where: {
userId: user.id,
deletedAt: null,
scheduledFor: { gte: start, lte: end },
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "SLEEP_DURATION",
deletedAt: null,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true },
}),
]);
// BMI is derived (latest weight × profile height) so it only shows up
// when BOTH inputs are available — otherwise the user would tick a
// box that produces a silently-empty section.
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
const availability: DoctorReportAvailability = {
bp: bpCount > 0,
weight: weightCount > 0,
pulse: pulseCount > 0,
bmi,
mood: moodCount > 0,
compliance: complianceCount > 0,
sleep: sleepCount > 0,
};
annotate({
meta: {
report_days: range.days,
sections_available: Object.values(availability).filter(Boolean).length,
},
});
return apiSuccess(availability);
});
-177
View File
@@ -1,177 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
collectDoctorReportData,
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
doctorReportPrefsSchema,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* Server-rendered PDF doctor report.
*
* Mirrors `/api/doctor-report` (which returns JSON for client-side rendering)
* but produces the finished PDF bytes server-side. Useful on iOS / Safari where
* the client-side jsPDF download UX is unreliable.
*
* Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.pdf.generate" } });
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
// Body is optional — JSON is allowed but not required.
const body = await readOptionalJsonBody(request);
const range = normaliseDateRange(body ?? undefined);
const locale = resolveLocale(
body?.locale,
request.headers.get("accept-language"),
);
const rawPracticeName = body?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall back
// to documented defaults so a forward-compat client never lands a
// 422 on a benign drift.
const sectionsParsed = doctorReportPrefsSchema.safeParse(
body?.sections ?? {},
);
const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {};
const sections: DoctorReportPrefs = {
...DEFAULT_DOCTOR_REPORT_PREFS,
...sectionsInput,
};
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
where: { id: user.id },
data: { lastReportPracticeName: practiceName },
});
} catch {
// Best-effort; never let preference persistence break the report.
}
}
const [data, userTz] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
]);
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz });
await auditLog("doctor-report.pdf.generate", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
locale,
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record exactly which sections this report
// rendered, so a future privacy review can confirm mood was
// never rendered when the user opted out.
sections,
},
});
annotate({
meta: {
report_days: range.days,
report_start: range.start.toISOString(),
report_end: range.end.toISOString(),
locale,
bytes: pdfBytes.byteLength,
practice_name_provided: practiceName !== null,
},
});
const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`;
// NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid
// SharedArrayBuffer typing surprises with Uint8Array.
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(pdfBytes.byteLength),
"Cache-Control": "no-store",
},
});
});
interface PdfRequestBody {
days?: unknown;
startDate?: unknown;
endDate?: unknown;
locale?: unknown;
practiceName?: unknown;
/** v1.4.25 W6c — per-section visibility toggles. */
sections?: unknown;
}
/**
* Best-effort JSON body parse. Returns `null` if the request had no body or
* a non-JSON content type — both are acceptable for this endpoint.
*/
async function readOptionalJsonBody(
request: NextRequest,
): Promise<PdfRequestBody | null> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
try {
const raw = await request.text();
// Small options object — treat an oversized body as no body.
if (raw.length > 64 * 1024) return null;
return JSON.parse(raw) as PdfRequestBody;
} catch {
return null;
}
}
/**
* Resolve the active locale: explicit body override wins, then a parsed
* `Accept-Language`, then a hard fallback to German (the primary HealthLog
* locale for the medical context).
*/
function resolveLocale(
bodyLocale: unknown,
acceptLanguage: string | null,
): Locale {
if (
typeof bodyLocale === "string" &&
(locales as readonly string[]).includes(bodyLocale)
) {
return bodyLocale as Locale;
}
if (!acceptLanguage) return "de";
// parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise
// "en" — matching the broader app's i18n behaviour for present headers.
return parseLocaleFromAcceptLanguage(acceptLanguage);
}
-110
View File
@@ -1,110 +0,0 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import {
collectDoctorReportData,
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs";
/**
* Collect data for doctor report PDF generation (client-side).
* Returns aggregated health data for the specified time range.
*
* Body shape (all fields optional — sensible defaults apply):
* {
* startDate?: string, // ISO timestamp, inclusive
* endDate?: string, // ISO timestamp, inclusive
* days?: number, // legacy "last N days" fallback (1..365)
* practiceName?: string // free-text, persisted as user preference
* }
*
* Resolution: explicit range wins; otherwise `days` (default 90) is applied.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.generate" } });
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const range = normaliseDateRange(body);
const rawPracticeName = (body as Record<string, unknown> | null)
?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall
// through to the documented defaults so a forward-compat client
// never lands a 422 on a benign drift.
const rawSections = (body as Record<string, unknown> | null)?.sections;
const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {});
const sections = sectionsParsed.success ? sectionsParsed.data : {};
// Persist the most-recent practice name as a user preference so the
// dialog can pre-fill it next time. We only write when the caller
// actually supplied a non-empty string — passing `null`/empty does NOT
// clear the stored preference (use the dedicated profile endpoint for
// that). Best-effort: a write failure here MUST NOT break the report.
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
where: { id: user.id },
data: { lastReportPracticeName: practiceName },
});
} catch {
// Non-fatal — preference persistence is a UX nicety, not a contract.
}
}
const data = await collectDoctorReportData(user.id, range, {
practiceName,
sections,
});
await auditLog("doctor-report.generate", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record which sections were included. The audit
// trail proves mood data was never aggregated when the toggle
// was off (privacy contract).
sections,
},
});
annotate({
meta: {
report_days: range.days,
report_start: range.start.toISOString(),
report_end: range.end.toISOString(),
practice_name_provided: practiceName !== null,
},
});
return apiSuccess(data);
});
@@ -4,7 +4,6 @@
* - /api/export (legacy CSV/JSON endpoint),
* - /api/export/full-backup (single-file JSON bundle),
* - /api/export/measurements (per-type CSV),
* - /api/doctor-report/availability (section probe),
* - /api/gamification/achievements (achievement progress aggregator).
*
* Every assertion is shape-level: the route's measurement read MUST scope
@@ -156,30 +155,4 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
});
}
});
it("/api/doctor-report/availability scopes every measurement count to deletedAt: null", async () => {
const { POST } = await import(
"../../doctor-report/availability/route"
);
const req = new NextRequest(
"http://localhost/api/doctor-report/availability",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
},
);
await POST(req);
const calls = vi.mocked(prisma.measurement.count).mock.calls;
// The probe runs four parallel measurement.count queries (BP /
// weight / pulse / sleep). All must filter tombstoned rows so a
// soft-deleted history does not light up a section the user has
// since wiped.
expect(calls.length).toBeGreaterThanOrEqual(4);
for (const [arg] of calls) {
expect(arg).toMatchObject({
where: expect.objectContaining({ deletedAt: null }),
});
}
});
});
@@ -132,6 +132,21 @@ describe("POST /api/export/health-record — validation", () => {
expect(res.status).toBe(422);
});
it("returns 403 when the doctorReport module is disabled (B3 gate)", async () => {
const { apiError } = await import("@/lib/api-response");
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "doctorReport" is not enabled', 403, {
errorCode: "module.disabled",
module: "doctorReport",
}),
} as never);
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "fhir" }));
expect(res.status).toBe(403);
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "doctorReport");
});
it("returns 429 when the export rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
@@ -31,6 +31,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import {
@@ -56,6 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "export.health-record.build" } });
// v1.18.0 B3 — the whole doctor-report / health-record surface is the
// `doctorReport` module. Refuse with a 403 `module.disabled` envelope when
// the account turned it off — even with a valid Bearer token. The settings
// entry-point already hides client-side; this is the hard enforcement.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);