mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'fix/v1439' into release/v1.43.9
This commit is contained in:
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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user