mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
656 lines
23 KiB
TypeScript
656 lines
23 KiB
TypeScript
/**
|
|
* The front door, driven as routes, against real Postgres.
|
|
*
|
|
* `/` is the first page a delegate lands on after switching, and until this
|
|
* release ten of the reads it issues refused there with `undeclared_mode`.
|
|
* Admitting them is the easy half; the half that can go quietly wrong is the
|
|
* one this file exists for. A route that stops refusing and answers with the
|
|
* CALLER's own rows is worse than the refusal it replaced, because a 403 is
|
|
* visible and a plausible wrong number is not — and this exact page has
|
|
* already shipped that bug once, when the RSC prefetch seeded the delegate's
|
|
* dashboard under the owner's banner.
|
|
*
|
|
* So no case here asserts a status code and stops. Each one seeds the owner
|
|
* AND the delegate with different values, reads the route as each of them
|
|
* WITHOUT a switch to learn what each answer looks like, asserts the two
|
|
* differ — that assertion is the evidence, without it a route returning
|
|
* nothing at all would satisfy every line below — and only then switches in
|
|
* and demands the owner's answer back, byte for byte, and not the delegate's.
|
|
*
|
|
* The actor surfaces are asserted the other way round, because for them the
|
|
* caller's own answer is the correct one: the locale setter has to write the
|
|
* DELEGATE's row and leave the owner's alone, which is a claim about two rows
|
|
* and is checked against both.
|
|
*
|
|
* Everything runs through the shipped exports — the real `apiHandler`, the
|
|
* real resolvers, the real grant table. The substitution happens above the
|
|
* handler body, so a test that rebuilt a handler would be exercising its own
|
|
* copy of it and would keep passing after the route stopped performing it.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
|
import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
let counter = 0;
|
|
|
|
async function makeUser(label: string) {
|
|
const suffix = `${label}-${counter++}`;
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username: `front-${suffix}`,
|
|
email: `front-${suffix}@example.test`,
|
|
displayName: `Front ${suffix}`,
|
|
role: "USER",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function signIn(userId: string) {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* A live grant at the named level with the delegate's session already inside
|
|
* the owner's record — minted through the shipped transitions, so a release
|
|
* that could no longer create the grant fails here rather than passing against
|
|
* a row this file wrote itself.
|
|
*/
|
|
async function switchInto(
|
|
ownerId: string,
|
|
delegateId: string,
|
|
access: "READ" | "WRITE" = "READ",
|
|
) {
|
|
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
|
const invited = await inviteGrant({
|
|
grantorId: ownerId,
|
|
granteeId: delegateId,
|
|
access,
|
|
scope: null,
|
|
});
|
|
const grant = await acceptGrant({
|
|
grantId: invited.id,
|
|
granteeId: delegateId,
|
|
});
|
|
const session = await signIn(delegateId);
|
|
await switchSessionTo(session.id, ownerId);
|
|
return { grant, session };
|
|
}
|
|
|
|
async function revoke(grantId: string, ownerId: string) {
|
|
const { revokeGrant } = await import("@/lib/sharing/grants");
|
|
await revokeGrant({ grantId, grantorId: ownerId });
|
|
}
|
|
|
|
type Handler = (
|
|
request: NextRequest,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
context: { params: Promise<any> },
|
|
) => Promise<Response>;
|
|
|
|
function request(url: string, method: string, body?: unknown): NextRequest {
|
|
return new NextRequest(`http://localhost${url}`, {
|
|
method,
|
|
...(body === undefined
|
|
? {}
|
|
: {
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function drive(
|
|
handler: Handler,
|
|
url: string,
|
|
method = "GET",
|
|
body?: unknown,
|
|
): Promise<Response> {
|
|
return handler(request(url, method, body), {
|
|
params: Promise.resolve({}),
|
|
});
|
|
}
|
|
|
|
async function envelope(response: Response): Promise<{
|
|
data: unknown;
|
|
error: unknown;
|
|
meta?: { errorCode?: string };
|
|
}> {
|
|
return response.json();
|
|
}
|
|
|
|
/** No grant, or a grant that is gone. */
|
|
async function expectAccessDenied(response: Response) {
|
|
expect(response.status).toBe(403);
|
|
expect((await envelope(response)).meta?.errorCode).toBe(
|
|
"sharing.access.denied",
|
|
);
|
|
}
|
|
|
|
/** The route never declared it can be used under a switch. */
|
|
async function expectNotPermitted(response: Response) {
|
|
expect(response.status).toBe(403);
|
|
expect((await envelope(response)).meta?.errorCode).toBe(
|
|
"sharing.not_permitted",
|
|
);
|
|
}
|
|
|
|
async function ok(response: Response): Promise<unknown> {
|
|
expect(response.status).toBe(200);
|
|
return (await envelope(response)).data;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The shape of a front-door read */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
interface FrontDoorRead {
|
|
/** Seed one account so its answer is recognisable. */
|
|
seed: (userId: string, marker: string) => Promise<void>;
|
|
/** Call the shipped GET export against whatever session is current. */
|
|
call: () => Promise<Response>;
|
|
/** The part of the payload that identifies whose record answered. */
|
|
read: (data: never) => unknown;
|
|
}
|
|
|
|
function frontDoorRead(name: string, route: FrontDoorRead) {
|
|
describe(name, () => {
|
|
it("answers with the owner's record, not the caller's own", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await route.seed(owner.id, "owner");
|
|
await route.seed(delegate.id, "delegate");
|
|
|
|
// What each account's own answer looks like, read the ordinary way.
|
|
// This is also the "unchanged for a caller who never switched" case:
|
|
// both calls run with no carrier at all.
|
|
await signIn(owner.id);
|
|
const ownerAnswer = route.read((await ok(await route.call())) as never);
|
|
await signIn(delegate.id);
|
|
const delegateAnswer = route.read(
|
|
(await ok(await route.call())) as never,
|
|
);
|
|
|
|
// The evidence. Without it every assertion below is satisfied by a
|
|
// route that answers the same thing to everybody — including nothing.
|
|
expect(ownerAnswer).not.toEqual(delegateAnswer);
|
|
|
|
await switchInto(owner.id, delegate.id);
|
|
const switched = route.read((await ok(await route.call())) as never);
|
|
|
|
expect(switched).toEqual(ownerAnswer);
|
|
expect(switched).not.toEqual(delegateAnswer);
|
|
});
|
|
|
|
it("refuses the next request after the grant is revoked", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await route.seed(owner.id, "owner");
|
|
|
|
const { grant } = await switchInto(owner.id, delegate.id);
|
|
expect((await route.call()).status).toBe(200);
|
|
|
|
// Same browser, same session, next request.
|
|
await revoke(grant.id, owner.id);
|
|
await expectAccessDenied(await route.call());
|
|
});
|
|
|
|
it("refuses a caller who names a record they were never granted", async () => {
|
|
const owner = await makeUser("owner");
|
|
const stranger = await makeUser("stranger");
|
|
await route.seed(owner.id, "owner");
|
|
|
|
const session = await signIn(stranger.id);
|
|
await switchSessionTo(session.id, owner.id);
|
|
|
|
await expectAccessDenied(await route.call());
|
|
});
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The aggregates */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
frontDoorRead("GET /api/dashboard/snapshot", {
|
|
// The snapshot names the account it was built for, which makes the marker
|
|
// the account itself rather than a value that happens to differ.
|
|
seed: async () => {},
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/dashboard/snapshot/route");
|
|
return drive(GET as Handler, "/api/dashboard/snapshot");
|
|
},
|
|
read: (data: { user: { username: string } }) => data.user.username,
|
|
});
|
|
|
|
/** The person's own provider (presence only) and an AI consent receipt. */
|
|
function ownProvider() {
|
|
return {
|
|
aiProvider: "ANTHROPIC",
|
|
aiAnthropicKeyEncrypted: "v1:presence-only",
|
|
consentReceipts: {
|
|
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A parseable cached briefing — the only thing the digest lifts prose from. */
|
|
function cachedBriefing(marker: string): string {
|
|
return JSON.stringify({
|
|
dailyBriefing: {
|
|
paragraph: `Briefing for the ${marker}. Second sentence.`,
|
|
keyFindings: [],
|
|
},
|
|
// Generated now: only text written today is served as today's read.
|
|
briefingGeneratedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
frontDoorRead("GET /api/daily/digest", {
|
|
// The briefing lead is stored model text: shown whenever the record's own
|
|
// `briefing` capability allows it, whoever reads. Each account therefore
|
|
// has its own provider (presence only) and consent.
|
|
seed: async (userId, marker) => {
|
|
await getPrismaClient().user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
insightsCachedTextEncrypted: sealInsightsCachedText(
|
|
cachedBriefing(marker),
|
|
),
|
|
insightsCachedAt: new Date(),
|
|
...ownProvider(),
|
|
},
|
|
});
|
|
},
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/daily/digest/route");
|
|
return drive(GET as Handler, "/api/daily/digest");
|
|
},
|
|
read: (data: { briefingLead: string | null }) => data.briefingLead,
|
|
});
|
|
|
|
frontDoorRead("GET /api/gamification/achievements", {
|
|
// Badges are earned from the record's own history, so the marker is the
|
|
// history: the owner has logged readings and the delegate has not. Dates are
|
|
// relative to now — a fixed date slides out of the trailing windows the
|
|
// badge engine counts over and takes the difference with it.
|
|
seed: async (userId, marker) => {
|
|
if (marker !== "owner") return;
|
|
const prisma = getPrismaClient();
|
|
for (let back = 0; back < 6; back++) {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "WEIGHT",
|
|
value: 80 + back,
|
|
unit: "kg",
|
|
measuredAt: new Date(Date.now() - back * 24 * 60 * 60 * 1000),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
}
|
|
},
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/gamification/achievements/route");
|
|
return drive(GET as Handler, "/api/gamification/achievements");
|
|
},
|
|
// Per-badge progress rather than the headline tally: six readings move
|
|
// several counters without necessarily unlocking anything, and a summary
|
|
// that reads 0/0 for both accounts would make the comparison vacuous.
|
|
read: (data: { achievements: { id: string; current: number }[] }) =>
|
|
data.achievements.map((a) => `${a.id}:${a.current}`).join("|"),
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The Coach reads */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
async function seedAssistantMessage(userId: string, at: string) {
|
|
const prisma = getPrismaClient();
|
|
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
|
const conversation = await prisma.coachConversation.create({
|
|
data: { userId, title: "Nudge" },
|
|
});
|
|
await prisma.coachMessage.create({
|
|
data: {
|
|
conversationId: conversation.id,
|
|
role: "assistant",
|
|
encryptedContent: encryptToBytes("A proactive line."),
|
|
createdAt: new Date(at),
|
|
},
|
|
});
|
|
}
|
|
|
|
frontDoorRead("GET /api/insights/coach/nudge-status", {
|
|
seed: async (userId, marker) => {
|
|
await getPrismaClient().user.update({
|
|
where: { id: userId },
|
|
data: ownProvider(),
|
|
});
|
|
await seedAssistantMessage(
|
|
userId,
|
|
marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z",
|
|
);
|
|
},
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/insights/coach/nudge-status/route");
|
|
return drive(GET as Handler, "/api/insights/coach/nudge-status");
|
|
},
|
|
read: (data: { nudgedAt: string | null }) => data.nudgedAt,
|
|
});
|
|
|
|
frontDoorRead("GET /api/coach/reminders", {
|
|
// The note is encrypted at rest, so the marker only appears in the response
|
|
// if the route read the right rows AND decrypted them.
|
|
seed: async (userId, marker) => {
|
|
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
|
await getPrismaClient().coachReminder.create({
|
|
data: {
|
|
userId,
|
|
noteEncrypted: encryptToBytes(`Remind the ${marker} about this.`),
|
|
triggerKind: "date",
|
|
status: "active",
|
|
source: "manual",
|
|
},
|
|
});
|
|
},
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/coach/reminders/route");
|
|
return drive(GET as Handler, "/api/coach/reminders");
|
|
},
|
|
read: (data: { reminders: { note: string }[] }) =>
|
|
data.reminders.map((r) => r.note),
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The presentation trio — read admitted, write refused */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
frontDoorRead("GET /api/settings/reminder-thresholds", {
|
|
seed: async (userId, marker) => {
|
|
await getPrismaClient().user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
notificationPrefs: {
|
|
medication: { lowStockRunwayDays: marker === "owner" ? 21 : 3 },
|
|
},
|
|
},
|
|
});
|
|
},
|
|
call: async () => {
|
|
const { GET } =
|
|
await import("@/app/api/settings/reminder-thresholds/route");
|
|
return drive(GET as Handler, "/api/settings/reminder-thresholds");
|
|
},
|
|
read: (data: { lowStockRunwayDays: number | null }) =>
|
|
data.lowStockRunwayDays,
|
|
});
|
|
|
|
async function seedDashboardLayout(userId: string, marker: string) {
|
|
const { serializeDashboardLayout, DEFAULT_DASHBOARD_LAYOUT } =
|
|
await import("@/lib/dashboard-layout");
|
|
const layout = serializeDashboardLayout({
|
|
...DEFAULT_DASHBOARD_LAYOUT,
|
|
comparisonBaseline: marker === "owner" ? "lastYear" : "lastMonth",
|
|
});
|
|
await getPrismaClient().user.update({
|
|
where: { id: userId },
|
|
// The blob is a plain JSON column; the serializer above produced the shape
|
|
// the resolver reads back.
|
|
data: { dashboardWidgetsJson: layout as never },
|
|
});
|
|
}
|
|
|
|
frontDoorRead("GET /api/dashboard/widgets", {
|
|
seed: seedDashboardLayout,
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/dashboard/widgets/route");
|
|
return drive(GET as Handler, "/api/dashboard/widgets");
|
|
},
|
|
read: (data: { comparisonBaseline: string }) => data.comparisonBaseline,
|
|
});
|
|
|
|
async function seedMedicationLayout(userId: string, marker: string) {
|
|
const { serializeMedicationListLayout } =
|
|
await import("@/lib/medication-list-layout");
|
|
const layout = serializeMedicationListLayout({
|
|
view: marker === "owner" ? "table" : "cards",
|
|
order: [`${marker}-med-id`],
|
|
});
|
|
await getPrismaClient().user.update({
|
|
where: { id: userId },
|
|
data: { medicationListLayoutJson: layout as never },
|
|
});
|
|
}
|
|
|
|
frontDoorRead("GET /api/medications/layout", {
|
|
seed: seedMedicationLayout,
|
|
call: async () => {
|
|
const { GET } = await import("@/app/api/medications/layout/route");
|
|
return drive(GET as Handler, "/api/medications/layout");
|
|
},
|
|
read: (data: { view: string; order: string[] }) =>
|
|
`${data.view}:${data.order.join(",")}`,
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The write arms the split leaves refusing */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("a delegate cannot rewrite the record's presentation", () => {
|
|
it("refuses PUT /api/dashboard/widgets under a WRITE grant and changes nothing", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await seedDashboardLayout(owner.id, "owner");
|
|
|
|
// A WRITE grant, the strongest thing a delegate can hold. The read arm
|
|
// above admits this caller; the write arm still must not.
|
|
await switchInto(owner.id, delegate.id, "WRITE");
|
|
|
|
const { PUT } = await import("@/app/api/dashboard/widgets/route");
|
|
await expectNotPermitted(
|
|
await drive(PUT as Handler, "/api/dashboard/widgets", "PUT", {
|
|
version: 1,
|
|
comparisonBaseline: "lastMonth",
|
|
}),
|
|
);
|
|
|
|
const row = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: owner.id },
|
|
select: { dashboardWidgetsJson: true },
|
|
});
|
|
expect(
|
|
(row.dashboardWidgetsJson as { comparisonBaseline?: string })
|
|
?.comparisonBaseline,
|
|
).toBe("lastYear");
|
|
});
|
|
|
|
it("refuses PUT /api/medications/layout under a WRITE grant and changes nothing", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await seedMedicationLayout(owner.id, "owner");
|
|
|
|
await switchInto(owner.id, delegate.id, "WRITE");
|
|
|
|
const { PUT } = await import("@/app/api/medications/layout/route");
|
|
await expectNotPermitted(
|
|
await drive(PUT as Handler, "/api/medications/layout", "PUT", {
|
|
version: 1,
|
|
view: "cards",
|
|
}),
|
|
);
|
|
|
|
const row = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: owner.id },
|
|
select: { medicationListLayoutJson: true },
|
|
});
|
|
expect((row.medicationListLayoutJson as { view?: string })?.view).toBe(
|
|
"table",
|
|
);
|
|
});
|
|
|
|
it("refuses POST /api/coach/reminders under a WRITE grant and writes no row", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
await switchInto(owner.id, delegate.id, "WRITE");
|
|
|
|
const { POST } = await import("@/app/api/coach/reminders/route");
|
|
await expectNotPermitted(
|
|
await drive(POST as Handler, "/api/coach/reminders", "POST", {
|
|
note: "Put this in somebody else's Coach memory.",
|
|
}),
|
|
);
|
|
|
|
expect(await getPrismaClient().coachReminder.count()).toBe(0);
|
|
});
|
|
|
|
it("still lets the owner write their own presentation", async () => {
|
|
// The regression guard for the split: the arms above refuse a DELEGATE,
|
|
// and a caller who never switched must not be able to tell any of this
|
|
// happened.
|
|
const plain = await makeUser("plain");
|
|
await seedDashboardLayout(plain.id, "owner");
|
|
await signIn(plain.id);
|
|
|
|
const { PUT } = await import("@/app/api/dashboard/widgets/route");
|
|
const response = await drive(
|
|
PUT as Handler,
|
|
"/api/dashboard/widgets",
|
|
"PUT",
|
|
{ version: 1, comparisonBaseline: "lastMonth" },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const row = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: plain.id },
|
|
select: { dashboardWidgetsJson: true },
|
|
});
|
|
expect(
|
|
(row.dashboardWidgetsJson as { comparisonBaseline?: string })
|
|
?.comparisonBaseline,
|
|
).toBe("lastMonth");
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* The actor surfaces */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("PUT /api/auth/me/locale — the language belongs to the person", () => {
|
|
it("writes the delegate's row and leaves the owner's untouched", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: owner.id },
|
|
data: { locale: "en" },
|
|
});
|
|
|
|
await switchInto(owner.id, delegate.id, "WRITE");
|
|
|
|
const { PUT } = await import("@/app/api/auth/me/locale/route");
|
|
const response = await drive(PUT as Handler, "/api/auth/me/locale", "PUT", {
|
|
locale: "fr",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
// Both rows, because "the delegate's row changed" and "the owner's row
|
|
// did not" are two claims and only the pair rules out a substitution.
|
|
expect(
|
|
(
|
|
await prisma.user.findUniqueOrThrow({
|
|
where: { id: delegate.id },
|
|
select: { locale: true },
|
|
})
|
|
).locale,
|
|
).toBe("fr");
|
|
expect(
|
|
(
|
|
await prisma.user.findUniqueOrThrow({
|
|
where: { id: owner.id },
|
|
select: { locale: true },
|
|
})
|
|
).locale,
|
|
).toBe("en");
|
|
});
|
|
|
|
it("refuses a selector header, which an actor surface never has a use for", async () => {
|
|
const caller = await makeUser("caller");
|
|
await signIn(caller.id);
|
|
const { ACCOUNT_SELECTOR_HEADER } =
|
|
await import("@/lib/auth/acting-carrier");
|
|
headerJar.set(ACCOUNT_SELECTOR_HEADER, "some-account-id");
|
|
|
|
const { PUT } = await import("@/app/api/auth/me/locale/route");
|
|
await expectNotPermitted(
|
|
await drive(PUT as Handler, "/api/auth/me/locale", "PUT", {
|
|
locale: "fr",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/feature-flags — the deployment, not the record", () => {
|
|
it("keeps answering while a switch is on", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await switchInto(owner.id, delegate.id);
|
|
|
|
const { GET } = await import("@/app/api/feature-flags/route");
|
|
const data = (await ok(
|
|
await drive(GET as Handler, "/api/feature-flags"),
|
|
)) as { assistant: { coach: boolean } };
|
|
// Not a bare 200: the shell gates the Coach launcher on this field, so an
|
|
// empty envelope would satisfy a status-only assertion and still break the
|
|
// surface this route exists to keep alive.
|
|
expect(data.assistant.coach).toBe(true);
|
|
});
|
|
|
|
it("refuses a selector header", async () => {
|
|
const caller = await makeUser("caller");
|
|
await signIn(caller.id);
|
|
const { ACCOUNT_SELECTOR_HEADER } =
|
|
await import("@/lib/auth/acting-carrier");
|
|
headerJar.set(ACCOUNT_SELECTOR_HEADER, "some-account-id");
|
|
|
|
const { GET } = await import("@/app/api/feature-flags/route");
|
|
await expectNotPermitted(await drive(GET as Handler, "/api/feature-flags"));
|
|
});
|
|
});
|