mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction, the admin shell renders nothing until the role is confirmed, and the json-body helper merges Headers instances correctly. The compose image reference accepts a pinned digest through an env var, and a promote script resolves the published digest, verifies its signature and hands the operator the exact pinning call — the documented path away from a moving latest tag.
This commit is contained in:
1 parent
599b7e8c99
commit
f13aa684f9
12 files changed
+876
-79
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* POST /api/coach/about-me/adopt — locks the read-modify-write.
|
||||
*
|
||||
* The original handler read the self-context through the global client,
|
||||
* computed the appended value, then upserted: two concurrent adoptions
|
||||
* could read the same base value and one append silently lost. The
|
||||
* suite pins the transactional shape — empty upsert → `FOR UPDATE` row
|
||||
* lock → read through the SAME `tx` client → single-column update —
|
||||
* so a refactor can't quietly fall back to the racy read.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
const tx = {
|
||||
userHealthProfile: {
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => Buffer.from(s),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
|
||||
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
|
||||
function adoptRequest(body: { question: string; answer: string }): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/adopt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
type Envelope = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const emptyCtx = {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
|
||||
describe("POST /api/coach/about-me/adopt", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" });
|
||||
tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() });
|
||||
tx.$queryRaw.mockResolvedValue([{ id: "p1" }]);
|
||||
});
|
||||
|
||||
it("runs the read-modify-write inside one transaction with a row lock", async () => {
|
||||
getCtx.mockResolvedValue(emptyCtx);
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ adopted: true, field: "coachFocus" });
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
// Row pinned, then locked FOR UPDATE before the read.
|
||||
expect(tx.userHealthProfile.upsert).toHaveBeenCalledWith({
|
||||
where: { userId: "u1" },
|
||||
create: { userId: "u1" },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
// The read goes through the SAME tx client, not the global one.
|
||||
expect(getCtx).toHaveBeenCalledWith("u1", tx);
|
||||
// Exactly one encrypted column written.
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]);
|
||||
});
|
||||
|
||||
it("dedupes inside the locked section without writing", async () => {
|
||||
getCtx.mockResolvedValue({ ...emptyCtx, coachFocus: "Evening walks" });
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({
|
||||
adopted: false,
|
||||
field: "coachFocus",
|
||||
reason: "duplicate",
|
||||
});
|
||||
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to an existing value read under the lock", async () => {
|
||||
getCtx.mockResolvedValue({ ...emptyCtx, allergies: "pollen" });
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "Any allergies?", answer: "penicillin" }),
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({ adopted: true, field: "allergies" });
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Buffer.from(data.allergiesEncrypted).toString()).toBe(
|
||||
"pollen\npenicillin",
|
||||
);
|
||||
});
|
||||
|
||||
it("422s when the overflow target aboutMe is full, still without a write", async () => {
|
||||
getCtx.mockResolvedValue({
|
||||
...emptyCtx,
|
||||
coachFocus: "x".repeat(499),
|
||||
aboutMe: "y".repeat(3999),
|
||||
});
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "z".repeat(10) }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -113,56 +113,82 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
const { question, answer } = parsed.data;
|
||||
const ctx = await getSelfContextForUser(user.id);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
const normAnswer = normalise(answer);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
const targetExisting = ctx[field];
|
||||
if (
|
||||
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
|
||||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
|
||||
) {
|
||||
// Read-modify-write under a row lock: two concurrent adoptions used
|
||||
// to read the same base value and one append silently lost (or the
|
||||
// dedupe missed the other's in-flight write). The empty upsert pins
|
||||
// the row, `FOR UPDATE` serialises every adoption for this user, and
|
||||
// the read happens through the same `tx` so it sees the locked state.
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await tx.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.$queryRaw`
|
||||
SELECT id FROM user_health_profiles
|
||||
WHERE user_id = ${user.id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const ctx = await getSelfContextForUser(user.id, tx);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
const targetExisting = ctx[field];
|
||||
if (
|
||||
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
|
||||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
|
||||
) {
|
||||
return { kind: "duplicate" as const, field };
|
||||
}
|
||||
|
||||
// Append on its own line; a structured field at its cap overflows
|
||||
// into the free-text aboutMe (4000 chars) rather than failing the
|
||||
// adoption outright.
|
||||
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
|
||||
field = "aboutMe";
|
||||
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
|
||||
return { kind: "full" as const, field };
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field assembly (no mass assignment): exactly one
|
||||
// encrypted column is written per adoption.
|
||||
const column = (
|
||||
{
|
||||
conditions: "conditionsEncrypted",
|
||||
allergies: "allergiesEncrypted",
|
||||
coachFocus: "coachFocusEncrypted",
|
||||
aboutMe: "aboutMeEncrypted",
|
||||
} as const
|
||||
)[field];
|
||||
|
||||
await tx.userHealthProfile.update({
|
||||
where: { userId: user.id },
|
||||
data: { [column]: encryptToBytes(nextValue) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return { kind: "adopted" as const, field };
|
||||
});
|
||||
|
||||
const { field } = outcome;
|
||||
if (outcome.kind === "duplicate") {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopt_deduped" },
|
||||
meta: { field },
|
||||
});
|
||||
return apiSuccess({ adopted: false, field, reason: "duplicate" });
|
||||
}
|
||||
|
||||
// Append on its own line; a structured field at its cap overflows
|
||||
// into the free-text aboutMe (4000 chars) rather than failing the
|
||||
// adoption outright.
|
||||
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
|
||||
field = "aboutMe";
|
||||
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
|
||||
return apiError("Self-context is full", 422);
|
||||
}
|
||||
if (outcome.kind === "full") {
|
||||
return apiError("Self-context is full", 422);
|
||||
}
|
||||
|
||||
// Field-by-field assembly (no mass assignment): exactly one encrypted
|
||||
// column is written per adoption.
|
||||
const column = (
|
||||
{
|
||||
conditions: "conditionsEncrypted",
|
||||
allergies: "allergiesEncrypted",
|
||||
coachFocus: "coachFocusEncrypted",
|
||||
aboutMe: "aboutMeEncrypted",
|
||||
} as const
|
||||
)[field];
|
||||
const payload = { [column]: encryptToBytes(nextValue) };
|
||||
|
||||
await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...payload },
|
||||
update: payload,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
|
||||
await auditLog("coach.about_me.adopted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
Reference in new issue
Block a user