mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness audit (the two server-query findings — comprehensive-generate + boot-discovery distinct — are deferred to a careful separate perf release): - format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call (main-thread stall across every history list). Routed through the existing process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle in the key); output byte-identical. - measurement-list rendered every row twice (desktop table + mobile cards, one CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe useIsMobile(md) hook; row markup + every data-slot unchanged. - moodDependentKeys omitted dashboardSnapshot(), so a mood write left the dashboard tile/score stale ~120s (server cache was already correct). Added it. - register first-user admin was a count-then-create race (two concurrent first sign-ups could both become ADMIN). Now an advisory lock + in-transaction re-count makes first-admin atomic; P2002 maps to 409 instead of 500. Regression test per fix. No migration (0243 still free).
This commit is contained in:
1 parent
9569d1c528
commit
d4be568625
13 files changed
+708
-426
No files matched your search
@@ -14,9 +14,23 @@ vi.mock("@/lib/db", () => ({
|
||||
appSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }),
|
||||
},
|
||||
// v1.28.42 (M1) — the first-admin count+insert now runs inside a
|
||||
// transaction-scoped advisory lock. Default `$transaction` to run the
|
||||
// callback with the same prisma mock as its `tx` handle.
|
||||
$transaction: vi.fn(),
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ locked: 1 }]),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.28.42 — fail-open breach check + silent device record; mock so the
|
||||
// happy-path register tests don't reach the network / a real DB write.
|
||||
vi.mock("@/lib/auth/hibp", () => ({
|
||||
checkPasswordBreach: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -62,6 +76,10 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/register", {
|
||||
@@ -119,6 +137,90 @@ async function postPathThrough(body: unknown): Promise<Response> {
|
||||
return POST(postReq(body));
|
||||
}
|
||||
|
||||
// v1.28.42 (M1 + L4) — first-admin race + duplicate-under-race status code.
|
||||
describe("POST /api/auth/register — first-admin atomicity (M1)", () => {
|
||||
beforeEach(() => {
|
||||
// Happy-path setup: reach the create call deterministically.
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("hashed");
|
||||
vi.mocked(createSession).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.create).mockImplementation((async (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => ({
|
||||
id: "u1",
|
||||
username: args.data.username,
|
||||
email: args.data.email,
|
||||
role: args.data.role,
|
||||
})) as never);
|
||||
// Run the transaction callback against the same prisma mock as `tx`.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
});
|
||||
|
||||
const validBody = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
};
|
||||
|
||||
it("mints ADMIN when the in-transaction count is 0 (genuine first user)", async () => {
|
||||
// Early gate count and in-tx count both 0.
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("ADMIN");
|
||||
});
|
||||
|
||||
it("mints USER when a concurrent registration committed first (stale early read, fresh in-tx re-count)", async () => {
|
||||
// The early gate read observed the empty-DB window (0), but by the time
|
||||
// the locked transaction re-counts, the racing first registration has
|
||||
// committed → 1. Without the in-tx re-count this second user would also
|
||||
// be minted ADMIN (the double-admin bug); the fresh count closes it.
|
||||
vi.mocked(prisma.user.count)
|
||||
.mockResolvedValueOnce(0 as never) // early registrationEnabled gate
|
||||
.mockResolvedValueOnce(1 as never); // inside the advisory-locked tx
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("USER");
|
||||
});
|
||||
|
||||
it("acquires the transaction-scoped advisory lock before counting", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
|
||||
await postPathThrough(validBody);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a P2002 duplicate under the race to 409, not 500 (L4)", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.user.create).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await postPathThrough(validBody);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Username or email already taken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
consumeInviteToken,
|
||||
recordInviteConsumer,
|
||||
@@ -179,19 +180,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// First user becomes admin
|
||||
const role = userCount === 0 ? "ADMIN" : "USER";
|
||||
// v1.28.42 (M1) — the first registered user becomes ADMIN. Deriving that
|
||||
// role from the early `userCount` (read at the top, before all the
|
||||
// validation above) and then creating without coordination is a
|
||||
// check-then-act race: on a freshly-exposed instance two registrations
|
||||
// racing the empty-DB window both observe `0` and are BOTH minted ADMIN.
|
||||
// Serialise the count+insert behind a transaction-scoped advisory lock
|
||||
// (released on commit/rollback) and re-count *inside* the lock, so the
|
||||
// second registration always observes the first's committed row and is
|
||||
// minted USER. The lock scope is only the count + insert — none of the
|
||||
// argon2 hash / HIBP / invite work above runs under it. Mirrors the
|
||||
// advisory-lock single-flight in `drain-per-sample-cumulative.ts`.
|
||||
//
|
||||
// v1.28.42 (L4) — a concurrent same-email/username signup (both passed the
|
||||
// findUnique probe above before either committed) surfaces the unique
|
||||
// violation as `P2002`; map it to the unified 409 rather than an unhandled
|
||||
// 500. The DB unique index is the real guard against duplicate accounts;
|
||||
// this only fixes the status code under the race.
|
||||
const user = await prisma
|
||||
.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column — selecting FROM it yields a plain int row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
|
||||
`;
|
||||
const priorUsers = await tx.user.count();
|
||||
const role = priorUsers === 0 ? "ADMIN" : "USER";
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return apiError("Username or email already taken", 409);
|
||||
}
|
||||
|
||||
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
|
||||
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
|
||||
|
||||
Reference in new issue
Block a user