Files
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

215 lines
7.3 KiB
TypeScript

/**
* v1.41 — the trail of a Coach reply against a real Postgres: the model text
* is sealed in `coach_messages.trail_encrypted` (never in the plaintext
* provenance), the conversation read leaves it out, the owner reads it back
* through `GET …/messages/{messageId}/trail`, another account gets 404, and
* nothing is served while the Coach's text may not be shown.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const serve = vi.hoisted(() => ({ available: true }));
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
// Whether the record's Coach text may be shown is its own suite's question
// (`ai-optional-*`); here it is a switch.
vi.mock("@/lib/ai/capabilities/gate", async (importOriginal) => ({
...(await importOriginal<object>()),
aiCapabilityToServe: vi.fn(async () =>
serve.available
? { available: true, reason: null, onDeviceAllowed: true }
: {
available: false,
reason: "disabled_by_user",
onDeviceAllowed: false,
},
),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
serve.available = true;
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedUserWithSession(username: string): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({ data: { username, role: "USER" } });
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
const TITLE = "Weighing the last two weeks";
const TEXT = "Sleep first, then the resting pulse beside it.";
async function seedReply(userId: string) {
const { appendMessage, createConversation } =
await import("@/lib/ai/coach/persistence");
const convo = await createConversation({ userId, title: "why" });
await appendMessage({
conversationId: convo.id,
role: "user",
content: "Why am I sleeping worse?",
});
const reply = await appendMessage({
conversationId: convo.id,
role: "assistant",
content: "Shorter nights since the 20th.",
metricSource: {
windows: [],
metrics: [],
activity: [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 2100,
},
],
stop: { reason: "time", rounds: 5 },
},
providerType: "codex",
trail: { entries: [{ id: "a1", title: TITLE, text: TEXT }] },
});
return { conversationId: convo.id, messageId: reply.id };
}
type TrailGet = (
req: Request,
ctx: { params: Promise<{ id: string; messageId: string }> },
) => Promise<Response>;
async function getTrail(conversationId: string, messageId: string) {
const { GET } =
await import("@/app/api/insights/chat/[id]/messages/[messageId]/trail/route");
return (GET as unknown as TrailGet)(
new Request(
`http://localhost/api/insights/chat/${conversationId}/messages/${messageId}/trail`,
),
{ params: Promise.resolve({ id: conversationId, messageId }) },
);
}
describe("the trail of a Coach reply", () => {
it("is sealed in its own column and never in the plaintext provenance", async () => {
const userId = await seedUserWithSession("trail-owner");
const { messageId } = await seedReply(userId);
const row = await getPrismaClient().coachMessage.findUniqueOrThrow({
where: { id: messageId },
select: { trailEncrypted: true, metricSourceJson: true },
});
expect(row.trailEncrypted).not.toBeNull();
expect(Buffer.from(row.trailEncrypted!).toString("utf8")).not.toContain(
"Weighing",
);
expect(row.metricSourceJson).toContain('"phase":"thinking"');
expect(row.metricSourceJson).not.toContain("Weighing");
expect(row.metricSourceJson).not.toContain("resting pulse");
});
it("is read back by the owner, structure on the message, text on the trail route", async () => {
const userId = await seedUserWithSession("trail-owner");
const { conversationId, messageId } = await seedReply(userId);
const { GET } = await import("@/app/api/insights/chat/[id]/route");
const detail = await (
GET as unknown as (
req: Request,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(new Request(`http://localhost/api/insights/chat/${conversationId}`), {
params: Promise.resolve({ id: conversationId }),
});
const body = (await detail.json()) as {
data: {
messages: Array<{ metricSource: Record<string, unknown> | null }>;
};
};
const stored = body.data.messages[1].metricSource!;
expect(stored.activity).toEqual([
expect.objectContaining({ id: "a1", phase: "thinking" }),
]);
expect(stored.stop).toEqual({ reason: "time", rounds: 5 });
expect(JSON.stringify(body)).not.toContain("Weighing");
const res = await getTrail(conversationId, messageId);
expect(res.status).toBe(200);
const trail = (await res.json()) as {
data: { trail: { entries: unknown[] } | null };
};
expect(trail.data.trail).toEqual({
entries: [{ id: "a1", title: TITLE, text: TEXT }],
});
});
it("is 404 for another account, never 403", async () => {
const ownerId = await seedUserWithSession("trail-owner");
const { conversationId, messageId } = await seedReply(ownerId);
await seedUserWithSession("trail-other");
const res = await getTrail(conversationId, messageId);
expect(res.status).toBe(404);
});
it("serves no text while the Coach's text may not be shown", async () => {
const userId = await seedUserWithSession("trail-owner");
const { conversationId, messageId } = await seedReply(userId);
serve.available = false;
const res = await getTrail(conversationId, messageId);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { trail: unknown; ai: { available: boolean } };
};
expect(body.data.trail).toBeNull();
expect(body.data.ai.available).toBe(false);
});
it("goes with the conversation when it is deleted", async () => {
const userId = await seedUserWithSession("trail-owner");
const { conversationId, messageId } = await seedReply(userId);
const { deleteConversation } = await import("@/lib/ai/coach/persistence");
await expect(deleteConversation(userId, conversationId)).resolves.toBe(
true,
);
const left = await getPrismaClient().coachMessage.findUnique({
where: { id: messageId },
});
expect(left).toBeNull();
});
});