mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'wave/v125-sec' into release/v1.25.0
This commit is contained in:
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)", () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user