Merge branch 'fix/v1439' into release/v1.43.9

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 22:49:33 +02:00
commit b4618f2ec3
55 files changed
+1547 -270

No files matched your search

@@ -14,7 +14,11 @@ vi.mock("@/lib/db", () => ({
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
user: { findFirst: vi.fn() },
user: {
findFirst: vi.fn(),
// The stored row: the password hash is not on the session user.
findUnique: vi.fn(async () => ({ passwordHash: "argon" })),
},
accountGrant: { create: vi.fn(), findFirst: vi.fn() },
},
}));
@@ -50,7 +54,6 @@ const OWNER = {
role: "USER",
username: "owner",
totpConfirmedAt: null,
passwordHash: "argon",
};
function invite(): NextRequest {
@@ -60,7 +60,10 @@ export const POST = apiHandler(
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
const target = await prisma.user.findUnique({
where: { id },
select: { locale: true, username: true },
});
if (!target) return apiError("User not found", 404);
const locale = await resolveServerLocale({
+4 -1
View File
@@ -60,7 +60,10 @@ export const PUT = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
const target = await prisma.user.findUnique({
where: { id },
select: { role: true },
});
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
@@ -34,6 +34,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/auth/mfa/verify-factor", () => ({
verifyMfaFactor: vi.fn(),
loadFactorUser: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
@@ -42,7 +43,7 @@ import { POST } from "../route";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
function req() {
return new Request("http://localhost/api/auth/me/mfa/disable", {
@@ -54,6 +55,12 @@ function req() {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadFactorUser).mockResolvedValue({
id: "u1",
totpSecretEncrypted: "enc",
totpLastStep: null,
totpConfirmedAt: new Date("2026-01-01"),
});
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { id: "u1" },
+5 -6
View File
@@ -30,7 +30,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { loadFactorUser, verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
@@ -72,11 +72,10 @@ export const POST = apiHandler(async (req: Request) => {
);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
const factorUser = await loadFactorUser(user.id);
const factor = factorUser
? await verifyMfaFactor(factorUser, parsed.data.method, parsed.data.code)
: { ok: false, replay: false };
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
@@ -37,6 +37,7 @@ import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
import { loadFactorUser } from "@/lib/auth/mfa/verify-factor";
import {
generateRecoveryCodes,
persistRecoveryCodes,
@@ -69,7 +70,9 @@ export const POST = apiHandler(async (req: Request) => {
if (user.totpConfirmedAt) {
throw new HttpError(409, "A second factor is already active");
}
if (!user.totpSecretEncrypted) {
// The pending secret and its replay floor are not on the per-request user.
const pending = await loadFactorUser(user.id);
if (!pending?.totpSecretEncrypted) {
throw new HttpError(409, "Start enrollment before confirming");
}
@@ -90,11 +93,11 @@ export const POST = apiHandler(async (req: Request) => {
// Decrypt is fail-closed — a missing/rotated key throws rather than
// confirming against junk.
const secret = decrypt(user.totpSecretEncrypted);
const secret = decrypt(pending.totpSecretEncrypted);
const result = verifyTotp(
secret,
parsed.data.code,
user.totpLastStep === null ? null : Number(user.totpLastStep),
pending.totpLastStep === null ? null : Number(pending.totpLastStep),
);
if (!result.valid) {
+10 -2
View File
@@ -162,6 +162,7 @@ export const GET = apiHandler(async () => {
onboardingRow,
ai,
coachReasoning,
identity,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
@@ -235,6 +236,13 @@ export const GET = apiHandler(async () => {
recordId,
authority: aiAuthority,
}),
// The encrypted insurance number is not on the per-request user
// (`SESSION_USER_SELECT`); it is read here, for the one response that
// shows it.
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true },
}),
]);
// The masking step, and the one place the record scoping is narrowed rather
@@ -286,9 +294,9 @@ export const GET = apiHandler(async () => {
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (user.insuranceNumberEncrypted) {
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(user.insuranceNumberEncrypted);
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
@@ -21,6 +21,7 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
passkey: {
findUnique: vi.fn(),
update: vi.fn(),
@@ -40,7 +41,9 @@ import { DELETE, PATCH } from "../route";
import { requireAuth, requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
const USER = { id: "user-1", username: "u" };
/** The stored credential row; the session user carries no hash. */
const STORED = { passwordHash: "argon2id$…" };
const ROW = {
id: "pk-1",
@@ -75,6 +78,7 @@ const commitElevation = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(STORED as never);
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
commitElevation.mockClear().mockResolvedValue(undefined);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
@@ -256,9 +260,12 @@ describe("DELETE /api/auth/passkeys/{id} — the removal's gate", () => {
name: "Only one",
} as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: null,
} as never);
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "cookie",
user: { ...USER, passwordHash: null },
user: USER,
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
commitElevation,
} as never);
+8 -4
View File
@@ -128,10 +128,14 @@ export const DELETE = apiHandler(
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
const [passkeyCount, credential] = await Promise.all([
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
}),
]);
const hasPassword = !!credential?.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
@@ -20,7 +20,7 @@ vi.mock("@/lib/api-handler", async () => {
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
user: { update: vi.fn(), findUnique: vi.fn() },
},
}));
@@ -92,7 +92,6 @@ const SESSION_OK = {
username: "tester",
email: "t@example.com",
role: "USER" as const,
passwordHash: "h",
onboardingCompletedAt: new Date(),
locale: "en",
},
@@ -109,6 +108,10 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// The hash is read off the stored row, not the session user.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
passwordHash: "h",
} 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);
+9 -2
View File
@@ -58,11 +58,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
const credential = await prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
});
if (!credential?.passwordHash) {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
const currentValid = await verifyPassword(
credential.passwordHash,
currentPassword,
);
if (!currentValid) {
await recordReproofFailure(
user.id,
@@ -17,7 +17,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {},
// The cached briefing text is read off its own row, not the session user.
prisma: { user: { findUnique: async () => null } },
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
@@ -71,7 +72,6 @@ const SESSION_OK = {
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
disableCoach: false,
insightsCachedText: null,
insightsCachedAt: null,
dashboardWidgetsJson: null,
},
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
@@ -406,6 +406,9 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
enteredHereAt.toISOString(),
);
// The reading entered here is in Apple Health too, so it is the type's
// newest reading: the row and the verdict read the same instant.
expect(entries.WEIGHT.lastSeenAt).toBe(enteredHereAt.toISOString());
// A read-only type keeps its own verdict and carries no such field.
expect(entries.PULSE.stale).toBe(true);
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
@@ -35,6 +35,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/app-settings", () => ({
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -38,38 +38,50 @@ export const GET = apiHandler(async () => {
const context = await requireGuardianAuth();
const access = resolveGuardianRecordSettingsAccess(context);
if (!access) throw new SharingAccessDeniedError();
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
const [statuses, withings, whoop, fitbit, googleHealth, credentials] =
await Promise.all([
Promise.all(
MANAGED_INTEGRATIONS.map((integration) =>
getIntegrationStatus(access.recordId, integration),
),
),
),
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
]);
prisma.withingsConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.whoopConnection.findUnique({
where: { userId: access.recordId },
select: { whoopUserId: true },
}),
prisma.fitbitConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
prisma.googleHealthConnection.findUnique({
where: { userId: access.recordId },
select: { id: true },
}),
// Presence only; the credentials themselves are not on the per-request
// user and are not read past this null check.
prisma.user.findUnique({
where: { id: context.user.id },
select: {
nightscoutUrlEncrypted: true,
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
stravaAccessTokenEncrypted: true,
},
}),
]);
const connected: Record<IntegrationKey, boolean> = {
withings: withings !== null,
whoop: whoop?.whoopUserId != null,
fitbit: fitbit !== null,
"google-health": googleHealth !== null,
nightscout: context.user.nightscoutUrlEncrypted !== null,
polar: context.user.polarAccessTokenEncrypted !== null,
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
nightscout: (credentials?.nightscoutUrlEncrypted ?? null) !== null,
polar: (credentials?.polarAccessTokenEncrypted ?? null) !== null,
oura: (credentials?.ouraAccessTokenEncrypted ?? null) !== null,
strava: (credentials?.stravaAccessTokenEncrypted ?? null) !== null,
// Not offered on a managed profile (no managed integrations before
// v1.44); listed so the map stays total.
dawarich: false,
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));