Merge branch 'wave/v125-sec' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:40:39 +02:00
commit 52e8dab3d7
27 files changed
+1111 -60

No files matched your search

@@ -4,6 +4,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.25 — password change requires a fresh MFA step-up for enrolled accounts.
// Spread the real module so `apiHandler` keeps working; override the step-up
// guard so each test drives the enrolled / fresh-factor outcome directly.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireFreshMfaIfEnrolled: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
@@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
// Fail-open breach check: a null result (corpus unreachable) never blocks.
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
StepUpRequiredError,
} from "@/lib/api-handler";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +103,44 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// Default: account clears the step-up (no MFA enrolled, or a fresh factor).
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
it("blocks an MFA-enrolled account without a fresh second factor", async () => {
vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue(
new StepUpRequiredError(),
);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(401);
// The credential is never rotated when the step-up fails.
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => {
// vi.resetAllMocks() wiped the factory defaults; restore the success path.
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
confirmPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
});
});
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
+11 -2
View File
@@ -15,7 +15,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.25 — for an account with a second factor active a password change
// requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition
// to the current-password re-proof below, so a hijacked live session cannot
// rotate the credential. Accounts without MFA keep the current-password-only
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`auth:password:${user.id}`,
+3 -5
View File
@@ -29,7 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id the layout accepts. The
@@ -211,13 +211,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// body is routed through `redactForExcerpt` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: {
+2 -1
View File
@@ -12,6 +12,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readNote } from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
const DEFAULT_LIMIT = 60;
@@ -131,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
doseDelta: delta,
note: dc.note,
note: readNote(dc.noteEncrypted, dc.note),
});
}
for (const intake of med.intakeEvents) {
+2 -4
View File
@@ -35,7 +35,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
@@ -129,9 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// round-trip; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "insights.layout.validation-failed" },
meta: {
+3 -3
View File
@@ -20,7 +20,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { VALUE_RANGES } from "@/lib/validations/measurement";
@@ -158,11 +158,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
// do not currently accept any but the truncation keeps that
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// query map is routed through `redactForExcerpt` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(rawQuery),
redactForExcerpt(rawQuery),
);
annotate({
action: { name: "measurements.series.validation-failed" },
+10 -3
View File
@@ -39,6 +39,11 @@ import {
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
encryptNote,
readNote,
shapeDoseChangeNote,
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -193,7 +198,7 @@ export const GET = apiHandler(
effectiveFrom: dc.effectiveFrom.toISOString(),
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
note: dc.note,
note: readNote(dc.noteEncrypted, dc.note),
})),
recentIntakes: medication.intakeEvents.map((iv) => ({
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
@@ -261,7 +266,9 @@ export const POST = apiHandler(
effectiveFrom,
doseValue,
doseUnit,
note: note ?? null,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
@@ -291,7 +298,7 @@ export const POST = apiHandler(
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ doseChange: created }, 201);
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
@@ -37,6 +37,7 @@ import {
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -142,6 +143,14 @@ export const PATCH = apiHandler(
Date.now(),
);
// Only touch the note columns when the caller supplied a value; an absent
// `notes` leaves the existing ciphertext untouched. When present, encrypt
// at rest and null the plaintext column.
const notesData =
notes === undefined
? {}
: { notesEncrypted: encryptNote(notes), notes: null };
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -150,7 +159,7 @@ export const PATCH = apiHandler(
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
notes: notes === undefined ? existing.notes : notes,
...notesData,
},
});
@@ -180,7 +189,7 @@ export const PATCH = apiHandler(
// read instead of after the `cachedSwr` stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(updated));
return apiSuccess(serializeInventoryItem(shapeInventoryItemNotes(updated)));
},
);
@@ -35,6 +35,7 @@ import {
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string }> };
@@ -60,7 +61,11 @@ export const GET = apiHandler(
}),
]);
const serialized = items.map(serializeInventoryItem);
// Decrypt each note and strip the raw `notesEncrypted` ciphertext before
// the decimal-unit serialisation runs.
const serialized = items.map((item) =>
serializeInventoryItem(shapeInventoryItemNotes(item)),
);
// v1.19.0 (iOS#25) — the canonical supply summary is computed HERE,
// server-side, through the one source of truth (`summariseSupply`).
// The detail-page client renders these ready figures instead of
@@ -162,6 +167,9 @@ export const POST = apiHandler(
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(created), 201);
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
},
);
@@ -57,6 +57,13 @@ import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
// The POST path encrypts the free-text note at rest; drive the legacy single
// key so encryptNote has material to work with.
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -233,17 +240,33 @@ describe("POST /api/medications/[id]/side-effects", () => {
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
notes: "after breakfast",
}),
const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as {
data: {
userId: string;
medicationId: string;
category: string;
entry: string;
severity: number;
occurredAt: Date;
notes: string | null;
notesEncrypted: Uint8Array | null;
};
};
expect(createArgs.data).toMatchObject({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
// v1.25 — the plaintext column is nulled; the note lands encrypted.
notes: null,
});
expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array);
expect(readNote(createArgs.data.notesEncrypted, null)).toBe(
"after breakfast",
);
});
it("defaults occurredAt to now when omitted", async () => {
@@ -36,6 +36,7 @@ import {
} from "@/lib/medications/side-effects/validators";
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string }> };
@@ -85,7 +86,12 @@ export const GET = apiHandler(
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
// Decrypt each note and strip the raw `notesEncrypted` ciphertext so it
// never leaves the server.
return apiSuccess({
items: items.map(shapeSideEffectNotes),
meta: { total: items.length },
});
},
);
@@ -139,7 +145,9 @@ export const POST = apiHandler(
entry,
severity,
occurredAt: occurredAt ?? new Date(),
notes: notes ?? null,
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
@@ -163,6 +171,6 @@ export const POST = apiHandler(
meta: { medication_id: id, entry, severity },
});
return apiSuccess(created, 201);
return apiSuccess(shapeSideEffectNotes(created), 201);
},
);
+2 -4
View File
@@ -34,7 +34,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { redactForExcerpt } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
@@ -92,9 +92,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// layout routes; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body));
annotate({
action: { name: "medication.layout.validation-failed" },
meta: {