mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #710 from MBombeck/fix/delegated-fix1
Never seed one person's record into another person's page
This commit is contained in:
18 files changed
+802
-70
No files matched your search
@@ -279,6 +279,20 @@ test.describe("account sharing", () => {
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// The positive control for the shell-door assertion further down: the
|
||||
// delegate, in their OWN record, is offered the Coach launcher like anyone
|
||||
// else. Without this line the later "it is gone" check would also pass on
|
||||
// a build where the launcher had stopped rendering for everybody.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Back to where the rest of this journey happens. The check above leaves
|
||||
// the browser on the dashboard, and Accept lives on the access page.
|
||||
await page.goto("/settings/access");
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await page.locator('[data-slot="grant-accept"]').click();
|
||||
await expect(row).toHaveAttribute("data-grant-state", "ACTIVE");
|
||||
});
|
||||
@@ -353,6 +367,12 @@ test.describe("account sharing", () => {
|
||||
await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/coach"]')).toHaveCount(0);
|
||||
await expect(page.locator('a[href="/insights"]')).toHaveCount(0);
|
||||
|
||||
// And neither is the door that stood beside the removed entries. The Coach
|
||||
// launcher hangs off the shell rather than off a route, so dropping the
|
||||
// nav entry left it floating over every page in the record — one tap from
|
||||
// a drawer whose every action resolves `requireAuth()` and refuses.
|
||||
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a surface sharing does not cover says so", async ({ page }) => {
|
||||
|
||||
@@ -5,9 +5,10 @@ import { describe, expect, it } from "vitest";
|
||||
/**
|
||||
* Structural guard over the server-prefetching pages.
|
||||
*
|
||||
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the
|
||||
* caller's own health record into the HTML document. Two things must hold for
|
||||
* every such page, and neither is enforced by the type system:
|
||||
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises a
|
||||
* health record into the HTML document and seeds it into the client's query
|
||||
* cache. Three things must hold for every such page, and none is enforced by
|
||||
* the type system:
|
||||
*
|
||||
* 1. It must be session-gated at the edge, so `src/proxy.ts` stamps
|
||||
* `Cache-Control: private, no-store` on the document. A prefetching page
|
||||
@@ -17,9 +18,18 @@ import { describe, expect, it } from "vitest";
|
||||
* revalidate = <n>` or `dynamic = "force-static"` would let Next serve one
|
||||
* account's prefetched HTML to the next caller out of its own cache,
|
||||
* before the request ever reaches a header.
|
||||
* 3. It must resolve its session through `getUnswitchedSession()`, never
|
||||
* `getSession()`. The first two rules keep one account's record away from
|
||||
* the NEXT caller; this one keeps it away from the CURRENT one. Under an
|
||||
* account switch `getSession()` answers with the delegate — that is its
|
||||
* documented job — so a page that prefetches off it dehydrates the
|
||||
* delegate's own numbers into a document opened on the owner's record.
|
||||
*
|
||||
* The guard walks the app tree rather than naming files, so a NEW prefetching
|
||||
* page is covered the moment it lands.
|
||||
* page is covered the moment it lands. That is the whole point of rule 3 being
|
||||
* here rather than a line each page remembers: the five pages that prefetch
|
||||
* today all made the same mistake independently, which is what a rule looks
|
||||
* like when nothing states it.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
@@ -116,3 +126,45 @@ describe("server-prefetching pages cannot leak a cacheable record", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-prefetching pages cannot seed a switched session", () => {
|
||||
/**
|
||||
* Both halves are asserted, and the pair is the guard.
|
||||
*
|
||||
* "Calls the right helper" alone passes a page that calls both — the import
|
||||
* sits there, the accessor is used for a null-check, and the read underneath
|
||||
* still goes through `getSession()`. "Does not call the wrong one" alone
|
||||
* passes a page that resolves no session at all and prefetches nothing,
|
||||
* which is green for a reason nobody wanted. Together they say the one thing
|
||||
* meant: the session a prefetching page acts on came from the accessor that
|
||||
* refuses under a switch.
|
||||
*/
|
||||
const UNSWITCHED = /\bgetUnswitchedSession\s*\(/;
|
||||
const RAW_SESSION = /\bgetSession\s*\(/;
|
||||
|
||||
it("resolves every prefetching page's session through getUnswitchedSession", () => {
|
||||
// Non-vacuous, and it has to be said again in this block: `missing` is
|
||||
// empty both when every page complies and when the walker found no pages
|
||||
// at all. The first describe asserts the same thing, and asserting it once
|
||||
// there would let a drifted detector take this leg down with it silently.
|
||||
expect(prefetchingPages.length).toBeGreaterThan(0);
|
||||
|
||||
const missing = prefetchingPages
|
||||
.filter((p) => !UNSWITCHED.test(p.source))
|
||||
.map((p) => p.rel);
|
||||
expect(
|
||||
missing,
|
||||
"a prefetching page must take its session from getUnswitchedSession()",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets no prefetching page reach getSession directly", () => {
|
||||
const offenders = prefetchingPages
|
||||
.filter((p) => RAW_SESSION.test(p.source))
|
||||
.map((p) => p.rel);
|
||||
expect(
|
||||
offenders,
|
||||
"getSession() answers who is CALLING — prefetching off it seeds the delegate's record onto the owner's page",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -104,13 +104,27 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// RSC prefetch: the server component hydrates the client query cache
|
||||
// with the session user's own data. Absence of a session skips the
|
||||
// prefetch rather than failing the render.
|
||||
"app/coach/page.tsx": "RSC prefetch of the session user's coach state",
|
||||
"app/insights/page.tsx": "RSC prefetch of the session user's insights",
|
||||
//
|
||||
// These five resolve through `getUnswitchedSession()`, not `getSession()`,
|
||||
// and they stay listed here BECAUSE of it. The helper is still a session
|
||||
// derivation outside `requireAuth` — moving the call one file deeper into
|
||||
// `lib/` would have taken them off this list while changing nothing about
|
||||
// what they do, and a guard that a refactor can walk out of is not one.
|
||||
// What the helper adds is the record identity: it answers null while the
|
||||
// browser acts on somebody else's record, so the cache these pages seed
|
||||
// can only ever hold the caller's own. See
|
||||
// `prefetch-page-caching-guard.test.ts` for the rule that keeps a sixth
|
||||
// prefetching page from reaching for the bare helper.
|
||||
"app/coach/page.tsx":
|
||||
"RSC prefetch of the session user's coach state, skipped under a switch",
|
||||
"app/insights/page.tsx":
|
||||
"RSC prefetch of the session user's insights, skipped under a switch",
|
||||
"app/insights/workouts/page.tsx":
|
||||
"RSC prefetch of the session user's workouts",
|
||||
"RSC prefetch of the session user's workouts, skipped under a switch",
|
||||
"app/medications/page.tsx":
|
||||
"RSC prefetch of the session user's medications",
|
||||
"app/page.tsx": "RSC prefetch of the session user's dashboard snapshot",
|
||||
"RSC prefetch of the session user's medications, skipped under a switch",
|
||||
"app/page.tsx":
|
||||
"RSC prefetch of the session user's dashboard snapshot, skipped under a switch",
|
||||
// Onboarding routing. Reads the actor's own onboarding step to redirect;
|
||||
// renders no record data.
|
||||
"app/onboarding/[step]/page.tsx":
|
||||
@@ -119,15 +133,20 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The identity-deriving helpers exported by `lib/auth/session.ts`. The
|
||||
* lifecycle exports (`createSession`, `destroySession`, `destroyAllSessions`,
|
||||
* `destroySessionById`, `destroyOtherSessions`) are deliberately NOT here:
|
||||
* they act on a session, they do not hand the handler an identity to scope
|
||||
* a query by. Adding a sixth deriving helper to `session.ts` means adding
|
||||
* it to this regex.
|
||||
* The identity-deriving helpers exported by `lib/auth/session.ts`, plus the
|
||||
* one that wraps it. The lifecycle exports (`createSession`,
|
||||
* `destroySession`, `destroyAllSessions`, `destroySessionById`,
|
||||
* `destroyOtherSessions`) are deliberately NOT here: they act on a session,
|
||||
* they do not hand the handler an identity to scope a query by. Adding a
|
||||
* further deriving helper means adding it to this regex.
|
||||
*
|
||||
* `getUnswitchedSession` (`lib/auth/acting-carrier.ts`) is listed for the
|
||||
* reason a wrapper is the easiest way to disappear from a guard: it returns
|
||||
* `getSession()`'s answer or null, so a caller of it derives an identity
|
||||
* exactly as much as a caller of the thing it wraps.
|
||||
*/
|
||||
const SESSION_DERIVING_HELPERS =
|
||||
/\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/;
|
||||
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/;
|
||||
|
||||
/**
|
||||
* Reaching for the cookie by name is the way around the helpers. Both the
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Whose record a server-prefetching page serialises into the HTML.
|
||||
*
|
||||
* The prefetch is a server-side copy of what a route would answer. It is only
|
||||
* ever correct when the account the RSC read equals the account the client's
|
||||
* route would serve, and under an account switch those two are never the same
|
||||
* account:
|
||||
*
|
||||
* - `getSession()` answers who is CALLING and deliberately never the record
|
||||
* being acted on (`src/lib/auth/session.ts`), so the RSC read the
|
||||
* delegate's own rows;
|
||||
* - a NON-delegable route (`/api/dashboard/snapshot`, `/api/daily/digest`,
|
||||
* `/api/dashboard/widgets`) refuses the refetch outright, so the seeded
|
||||
* value is what stays on screen — under a banner naming somebody else;
|
||||
* - a DELEGABLE route (`/api/measurements/series-batch`, `/api/medications`)
|
||||
* answers with the owner's rows, so the same page paints two people at
|
||||
* once until the refetch lands.
|
||||
*
|
||||
* These tests assert what reaches the cache, not which branch ran. Each one
|
||||
* reads the dehydrated queries off the returned `<HydrationBoundary>` and
|
||||
* checks the payload by a marker unique to the account it came from, so a
|
||||
* prefetch that seeds the wrong person fails here even if every guard around
|
||||
* it stays green.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - restore `getSession()` in `src/app/page.tsx` → "seeds nothing while the
|
||||
* session is acting on another record" goes red, PRINTING the four cache
|
||||
* cells it found, each carrying the delegate's marker.
|
||||
* - restore it in `src/app/medications/page.tsx` → the medications leg goes
|
||||
* red the same way, printing the delegate's list.
|
||||
* - make `getUnswitchedSession` return the session regardless of the carrier
|
||||
* → both go red at once, which is the whole feature failing in one place.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const getSession = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
const readDashboardSnapshotCached = vi.fn();
|
||||
const loadDailyDigest = vi.fn();
|
||||
const readSeriesBatch = vi.fn();
|
||||
const readMedicationsListCached = vi.fn();
|
||||
|
||||
// The acting-carrier module is deliberately NOT mocked. The property under
|
||||
// test is what a real switched session does to a real page, so the only thing
|
||||
// stubbed on that path is the session row itself and the header context the
|
||||
// carrier decision reads. A test that mocked `getUnswitchedSession` would
|
||||
// prove the pages honour a boolean, which is not the same claim.
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => ({ get: () => null }),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (id: string) => resolveModuleMap(id),
|
||||
}));
|
||||
vi.mock("@/lib/dashboard/snapshot-read", () => ({
|
||||
readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u),
|
||||
}));
|
||||
vi.mock("@/lib/dashboard/snapshot-flag", () => ({
|
||||
isDashboardSnapshotEnabled: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/dashboard/batch-chart-types", () => ({
|
||||
computeBatchWindow: () => ({
|
||||
from: "2026-07-01T00:00:00.000Z",
|
||||
to: "2026-07-31T00:00:00.000Z",
|
||||
}),
|
||||
deriveBatchChartTypes: () => ["WEIGHT"],
|
||||
}));
|
||||
vi.mock("@/lib/dashboard-layout", () => ({
|
||||
resolveDashboardLayout: (layout: unknown) => layout,
|
||||
}));
|
||||
vi.mock("@/lib/daily/load-digest", () => ({
|
||||
loadDailyDigest: (u: unknown) => loadDailyDigest(u),
|
||||
}));
|
||||
vi.mock("@/lib/measurements/series-batch-read", () => ({
|
||||
readSeriesBatch: (...args: unknown[]) => readSeriesBatch(...args),
|
||||
}));
|
||||
vi.mock("@/lib/medications/list-read", () => ({
|
||||
readMedicationsListCached: (u: unknown) => readMedicationsListCached(u),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
vi.mock("../medications/page-client", () => ({ default: () => null }));
|
||||
|
||||
import DashboardPage from "../page";
|
||||
import MedicationsPage from "../medications/page";
|
||||
|
||||
/**
|
||||
* The account the RSC would read from. `DELEGATE_MARKER` rides every payload
|
||||
* these mocks return, so any assertion that finds it in the cache has found
|
||||
* the caller's own record dehydrated into somebody else's page.
|
||||
*/
|
||||
const DELEGATE_MARKER = "delegate-own-record";
|
||||
|
||||
const OWN_SESSION = {
|
||||
user: { id: "u-delegate", timezone: "Europe/Berlin" },
|
||||
session: { id: "s1", actingAsUserId: null },
|
||||
};
|
||||
|
||||
/** The same caller, stamped onto the owner's record by the switch endpoint. */
|
||||
const SWITCHED_SESSION = {
|
||||
user: { id: "u-delegate", timezone: "Europe/Berlin" },
|
||||
session: { id: "s1", actingAsUserId: "u-owner" },
|
||||
};
|
||||
|
||||
const SNAPSHOT = {
|
||||
body: {
|
||||
marker: DELEGATE_MARKER,
|
||||
layout: ["weight"],
|
||||
tiles: { summaries: { WEIGHT: { count: 3 } } },
|
||||
},
|
||||
locale: "en",
|
||||
};
|
||||
const DIGEST = { marker: DELEGATE_MARKER, headline: "3 kg down" };
|
||||
const SERIES = { WEIGHT: [{ marker: DELEGATE_MARKER, value: 81.4 }] };
|
||||
const MEDICATIONS = [{ id: "m1", name: DELEGATE_MARKER }];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
/** Every dehydrated query on a HydrationBoundary element, keyed by hash. */
|
||||
function seededCache(el: ReactElement): Record<string, unknown> {
|
||||
if (el.type !== HydrationBoundary) return {};
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const q of props.state?.queries ?? []) out[q.queryHash] = q.state.data;
|
||||
return out;
|
||||
}
|
||||
|
||||
function primeDashboard() {
|
||||
resolveModuleMap.mockResolvedValue({ insights: true });
|
||||
readDashboardSnapshotCached.mockResolvedValue(SNAPSHOT);
|
||||
loadDailyDigest.mockResolvedValue(DIGEST);
|
||||
readSeriesBatch.mockResolvedValue({ series: SERIES });
|
||||
}
|
||||
|
||||
describe("the dashboard prefetch and the record on screen", () => {
|
||||
it("seeds the caller's own record when no switch is active", async () => {
|
||||
// The positive half, and it is not decoration: it fixes what the negative
|
||||
// half is the absence of. Without it, a page that prefetched nothing at
|
||||
// all for anybody would pass the switch test perfectly.
|
||||
getSession.mockResolvedValue(OWN_SESSION);
|
||||
primeDashboard();
|
||||
|
||||
const cache = seededCache((await DashboardPage()) as ReactElement);
|
||||
|
||||
expect(cache[hashKey(queryKeys.dashboardSnapshot("en"))]).toMatchObject({
|
||||
marker: DELEGATE_MARKER,
|
||||
});
|
||||
expect(cache[hashKey(queryKeys.dailyDigest())]).toMatchObject({
|
||||
marker: DELEGATE_MARKER,
|
||||
});
|
||||
expect(
|
||||
cache[
|
||||
hashKey(
|
||||
queryKeys.chartSeriesBatch(
|
||||
"WEIGHT",
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
"2026-07-31T00:00:00.000Z",
|
||||
),
|
||||
)
|
||||
],
|
||||
).toMatchObject(SERIES);
|
||||
});
|
||||
|
||||
it("seeds nothing while the session is acting on another record", async () => {
|
||||
// The session row carries the owner's id, which is what a switch IS on the
|
||||
// cookie transport. Asserted on the CACHE rather than on a call count: a
|
||||
// future prefetch that reads through some other helper still has to put
|
||||
// its result somewhere, and this is where it would show up.
|
||||
getSession.mockResolvedValue(SWITCHED_SESSION);
|
||||
primeDashboard();
|
||||
|
||||
const el = (await DashboardPage()) as ReactElement;
|
||||
const cache = seededCache(el);
|
||||
|
||||
// Asserted as a whole-object equality on purpose: the failure message
|
||||
// then PRINTS the record that reached the cache, marker and all, instead
|
||||
// of saying a boolean was wrong.
|
||||
expect(cache).toEqual({});
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readDashboardSnapshotCached).not.toHaveBeenCalled();
|
||||
expect(loadDailyDigest).not.toHaveBeenCalled();
|
||||
expect(readSeriesBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the medications prefetch and the record on screen", () => {
|
||||
it("seeds the caller's own list when no switch is active", async () => {
|
||||
getSession.mockResolvedValue(OWN_SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockResolvedValue(MEDICATIONS);
|
||||
|
||||
const cache = seededCache((await MedicationsPage()) as ReactElement);
|
||||
expect(cache[hashKey(queryKeys.medications())]).toEqual(MEDICATIONS);
|
||||
});
|
||||
|
||||
it("seeds nothing while the session is acting on another record", async () => {
|
||||
// `/api/medications` IS delegable, so the client cell corrects itself on
|
||||
// its `refetchOnMount: "always"` pass. What it cannot undo is the frame
|
||||
// before that: somebody else's medicine cabinet, painted under a banner
|
||||
// naming the owner.
|
||||
getSession.mockResolvedValue(SWITCHED_SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockResolvedValue(MEDICATIONS);
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
const cache = seededCache(el);
|
||||
|
||||
// Asserted as a whole-object equality on purpose: the failure message
|
||||
// then PRINTS the record that reached the cache, marker and all, instead
|
||||
// of saying a boolean was wrong.
|
||||
expect(cache).toEqual({});
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readMedicationsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* error path fails soft. The streaming conversation is never prefetched here.
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const requireAssistantSurface = vi.fn();
|
||||
const readCoachNudgeStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: (s: string) => requireAssistantSurface(s),
|
||||
}));
|
||||
@@ -54,7 +56,9 @@ function dehydratedQuery(
|
||||
|
||||
describe("/coach RSC prefetch", () => {
|
||||
it("dehydrates the nudge status under the EXACT client key", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
readCoachNudgeStatus.mockResolvedValue(NUDGE);
|
||||
|
||||
@@ -66,7 +70,9 @@ describe("/coach RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("skips the prefetch when the user opted out of the Coach", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: true } });
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: true },
|
||||
});
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
@@ -74,7 +80,9 @@ describe("/coach RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("fails soft when the operator Coach flag is off (surface throws)", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockRejectedValue(new Error("assistant disabled"));
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
@@ -83,7 +91,9 @@ describe("/coach RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("fails soft when the read throws", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
getUnswitchedSession.mockResolvedValue({
|
||||
user: { id: "u1", disableCoach: false },
|
||||
});
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
readCoachNudgeStatus.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
@@ -91,8 +101,10 @@ describe("/coach RSC prefetch", () => {
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
@@ -101,6 +113,6 @@ describe("/coach RSC prefetch", () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -44,6 +44,11 @@ import CoachPageClient from "./page-client";
|
||||
* thread, snapshot, any provider-touching path) is NOT prefetched or warmed
|
||||
* — it depends on URL params + the nudge outcome and must never be triggered
|
||||
* server-side.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record. Every
|
||||
* read below scopes to the CALLER, so under a switch the prefetch would
|
||||
* serialise the delegate's own rows into a page opened on the owner's
|
||||
* record. The accessor's docblock carries the argument.
|
||||
* - Fail-soft: no session, the Coach being off, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cell owns the fetch.
|
||||
*/
|
||||
@@ -57,7 +62,7 @@ export default async function CoachPage() {
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
const session = await getUnswitchedSession();
|
||||
if (session && !session.user.disableCoach) {
|
||||
// Throws `AssistantDisabledError` when the operator flag is off → caught
|
||||
// below, prefetch skipped, client path stands (and redirects to /insights).
|
||||
|
||||
@@ -19,10 +19,12 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* soft to the bare client.
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const readDashboardSnapshotCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/dashboard/snapshot-read", () => ({
|
||||
readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u),
|
||||
}));
|
||||
@@ -49,7 +51,7 @@ function queriesOf(
|
||||
|
||||
describe("/insights RSC prefetch", () => {
|
||||
it("dehydrates the snapshot under the locale-keyed client key", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
readDashboardSnapshotCached.mockResolvedValue({
|
||||
body: {
|
||||
healthScore: { tension: "steady" },
|
||||
@@ -71,7 +73,7 @@ describe("/insights RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("JSON-round-trips the snapshot body to the wire shape", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
const cachedAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
readDashboardSnapshotCached.mockResolvedValue({
|
||||
body: { cachedAt, layout: { version: 1, widgets: [] } },
|
||||
@@ -88,15 +90,17 @@ describe("/insights RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
readDashboardSnapshotCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readDashboardSnapshotCached).not.toHaveBeenCalled();
|
||||
@@ -106,6 +110,6 @@ describe("/insights RSC prefetch", () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -43,6 +43,11 @@ import InsightsPageClient from "./page-client";
|
||||
* read; and the heavy thick-analytics / comprehensive aggregations, whose
|
||||
* COLD SWR cell is a multi-query scan — prefetching them synchronously would
|
||||
* trade FCP for TTFB. Both stay on the client cells that own them.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record. Every
|
||||
* read below scopes to the CALLER, so under a switch the prefetch would
|
||||
* serialise the delegate's own rows into a page opened on the owner's
|
||||
* record. The accessor's docblock carries the argument.
|
||||
* - Fail-soft: no session or a builder hiccup renders the page exactly as
|
||||
* before this wrapper existed — the client cells own the fetch.
|
||||
*/
|
||||
@@ -56,7 +61,7 @@ export default async function InsightsPage() {
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
|
||||
@@ -19,11 +19,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* client path (the client cell owns the fetch).
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
const readWorkoutsListCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (id: string) => resolveModuleMap(id),
|
||||
}));
|
||||
@@ -74,7 +76,7 @@ function dehydratedQuery(
|
||||
|
||||
describe("/insights/workouts RSC prefetch", () => {
|
||||
it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockResolvedValue(LIST);
|
||||
|
||||
@@ -88,7 +90,7 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("reads the same filter the client's `limit: 100` request would", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockResolvedValue(LIST);
|
||||
|
||||
@@ -105,7 +107,7 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
const startedAt = new Date("2026-07-18T08:30:00.000Z");
|
||||
readWorkoutsListCached.mockResolvedValue({
|
||||
@@ -129,7 +131,7 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
@@ -138,7 +140,7 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("skips the prefetch when the workouts module is off", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: false });
|
||||
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
@@ -146,8 +148,10 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
expect(readWorkoutsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readWorkoutsListCached).not.toHaveBeenCalled();
|
||||
@@ -157,6 +161,6 @@ describe("/insights/workouts RSC prefetch", () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { readWorkoutsListCached } from "@/lib/workouts/list-read";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -42,6 +42,11 @@ import InsightsWorkoutsPageClient from "./page-client";
|
||||
* fresh data, so the mounted cell paints immediately and only refetches once
|
||||
* the TTL lapses — the "empty then fills" flash is gone without dropping the
|
||||
* freshness path.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record. Every
|
||||
* read below scopes to the CALLER, so under a switch the prefetch would
|
||||
* serialise the delegate's own rows into a page opened on the owner's
|
||||
* record. The accessor's docblock carries the argument.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the
|
||||
* page exactly as before this wrapper existed — the client cell owns the
|
||||
* fetch. The prefetch is an accelerator, never a gate.
|
||||
@@ -57,7 +62,7 @@ export default async function InsightsWorkoutsPage() {
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
|
||||
@@ -16,11 +16,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* bare client path (the client cell owns the fetch).
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
const readMedicationsListCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (id: string) => resolveModuleMap(id),
|
||||
}));
|
||||
@@ -56,7 +58,7 @@ function dehydratedQuery(
|
||||
|
||||
describe("/medications RSC prefetch", () => {
|
||||
it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockResolvedValue([
|
||||
{ id: "m1", name: "Ramipril" },
|
||||
@@ -72,7 +74,7 @@ describe("/medications RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
const takenAt = new Date("2026-07-18T08:30:00.000Z");
|
||||
readMedicationsListCached.mockResolvedValue([
|
||||
@@ -89,7 +91,7 @@ describe("/medications RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
@@ -98,7 +100,7 @@ describe("/medications RSC prefetch", () => {
|
||||
});
|
||||
|
||||
it("skips the prefetch when the medications module is off", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: false });
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
@@ -106,8 +108,10 @@ describe("/medications RSC prefetch", () => {
|
||||
expect(readMedicationsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readMedicationsListCached).not.toHaveBeenCalled();
|
||||
@@ -117,6 +121,6 @@ describe("/medications RSC prefetch", () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -40,9 +40,16 @@ import MedicationsPageClient from "./page-client";
|
||||
* place the hydrated cards paint immediately and that refetch runs in the
|
||||
* background — the "empty then fills" flash is gone WITHOUT dropping the
|
||||
* freshness guarantee.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the
|
||||
* page exactly as before this wrapper existed — the client cell owns the
|
||||
* fetch. The prefetch is an accelerator, never a gate.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record.
|
||||
* `/api/medications` IS delegable, so the client cell corrects itself on its
|
||||
* `refetchOnMount: "always"` pass — but the frame before that correction
|
||||
* painted the DELEGATE's medicine cabinet under a banner naming the owner,
|
||||
* and a flash of the wrong person's medication is still the wrong person's
|
||||
* medication.
|
||||
* - Fail-soft: no session, an active switch, a module lookup hiccup, or a DB
|
||||
* blip renders the page exactly as before this wrapper existed — the client
|
||||
* cell owns the fetch. The prefetch is an accelerator, never a gate.
|
||||
*/
|
||||
export default async function MedicationsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
@@ -54,7 +61,7 @@ export default async function MedicationsPage() {
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
|
||||
+14
-5
@@ -4,7 +4,7 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
@@ -64,9 +64,18 @@ function softTimeout<T>(ms: number): Promise<T | undefined> {
|
||||
* key by construction, so the prefetched slice lands instead of refetching.
|
||||
* - Module-gate parity: the digest route gates on `insights`; skip its
|
||||
* prefetch when the module is off (the client hook is disabled then too).
|
||||
* - Fail-soft: no session, a builder hiccup, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cells own the fetch.
|
||||
* Each prefetch is independent; one failing never blocks the others.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record. Every
|
||||
* read below scopes to `user`, and under a switch `user` is the DELEGATE —
|
||||
* so the snapshot, the digest and the widget layout would seed one person's
|
||||
* numbers under a banner naming another, on routes that then refuse the
|
||||
* refetch that would have corrected them. The accessor's docblock carries
|
||||
* the full argument, including why resolving the owner here instead is the
|
||||
* worse answer.
|
||||
* - Fail-soft: no session, an active switch, a builder hiccup, or a DB blip
|
||||
* renders the page exactly as before this wrapper existed — the client cells
|
||||
* own the fetch. Each prefetch is independent; one failing never blocks the
|
||||
* others.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the
|
||||
@@ -79,7 +88,7 @@ export default async function DashboardPage() {
|
||||
let dehydratedState = null;
|
||||
let batchWindow: BatchWindow | undefined;
|
||||
try {
|
||||
const session = await getSession();
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
|
||||
@@ -50,7 +50,14 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
|
||||
// `/api/insights/chat` is not in the proxy's DEMO_MUTATION_ALLOWLIST,
|
||||
// so a tappable FAB in demo mode produces a raw 403 on send. The mount
|
||||
// must be conditioned on the demo flag.
|
||||
expect(source).toContain("{!demoMode && <LayoutCoachFab />}");
|
||||
//
|
||||
// v1.36.x — `!inSharedRecord` joins the same condition. What that gate is
|
||||
// FOR, and the paint it produces at both grant levels, is asserted against
|
||||
// rendered markup in `auth-shell-shared-record-doors.test.tsx`; this leg
|
||||
// only holds the demo half that predates it.
|
||||
expect(source).toContain(
|
||||
"{!demoMode && !inSharedRecord && <LayoutCoachFab />}",
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer mounts any Coach surface in the routed insights layout", () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The shell-level doors, under a switch.
|
||||
*
|
||||
* Three surfaces hang off `<AuthShell>` rather than off a route, so they paint
|
||||
* on every page the shell serves and on the unavailable panel too — the panel
|
||||
* whose entire message is that a page is not part of what was shared. All
|
||||
* three are owner-only:
|
||||
*
|
||||
* - the Coach FAB, whose drawer carries chat, conversation rename and
|
||||
* delete, attachments, feedback, an adopt action and suggested actions,
|
||||
* every one of them resolving `requireAuth()` and refusing under a switch —
|
||||
* and which fires `POST /api/insights/coach/seen` on being opened at all;
|
||||
* - the drawer mount behind it;
|
||||
* - the module tour, which `POST`s `/api/onboarding/tour`.
|
||||
*
|
||||
* The nav dropped Insights and Coach under a switch on purpose. These were the
|
||||
* doors left standing beside the removed entries.
|
||||
*
|
||||
* Rendered, not grepped. The shell is mounted through `renderToStaticMarkup`
|
||||
* with the three doors replaced by sentinels, so what is asserted is the paint
|
||||
* — which of them the shell put on the page — rather than the shape of a JSX
|
||||
* condition. The chrome around them is stubbed for the same reason: this file
|
||||
* is about the mount decision and nothing else.
|
||||
*
|
||||
* SSR-only, per project convention: `@testing-library/react` is not a
|
||||
* dependency, effects do not run, and no click is available here. That is
|
||||
* enough for this property, which is purely what renders. The click paths
|
||||
* behind these doors are driven in `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - drop `!inSharedRecord` from the FAB mount → "mounts none of them under a
|
||||
* read grant" and its write sibling both go red, naming the FAB sentinel
|
||||
* they found in the markup.
|
||||
* - drop it from the tour mount, or from `<LayoutCoachMount />` → the same
|
||||
* two cases go red naming that sentinel instead.
|
||||
* - make `resolveRecordCapabilities` return `inSharedRecord: false` always →
|
||||
* all three go red at once, while "mounts all three in the caller's own
|
||||
* record" stays green, which is how the pair distinguishes "gated" from
|
||||
* "removed".
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
AccountAccess,
|
||||
AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isAuthUnknown: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPathRef = { value: "/" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathRef.value,
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The three doors under test, reduced to sentinels: this file asserts whether
|
||||
// the shell mounted them, not what they would have rendered.
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => <div data-slot="sentinel-coach-fab" />,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => <div data-slot="sentinel-coach-mount" />,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => <div data-slot="sentinel-tour-launcher" />,
|
||||
}));
|
||||
|
||||
// Chrome that is not the subject. Each pulls its own query cells and portals;
|
||||
// rendering them here would measure the renderer, not the mount decision.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../demo-banner", () => ({ DemoBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("../shared-record-unavailable", () => ({
|
||||
SharedRecordUnavailable: () => <div data-slot="sentinel-unavailable" />,
|
||||
}));
|
||||
vi.mock("@/components/i18n/maintainership-banner", () => ({
|
||||
MaintainershipBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
|
||||
const OWNER_READ: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const OWNER_WRITE: AccountAccessEntry = {
|
||||
...OWNER_READ,
|
||||
access: "write",
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, pathname = "/"): string {
|
||||
mockAccessRef.value = access;
|
||||
mockPathRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuthShell>
|
||||
<div data-slot="sentinel-page" />
|
||||
</AuthShell>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
accounts: [],
|
||||
active: null,
|
||||
canSwitch: false,
|
||||
};
|
||||
const SWITCHED = (entry: AccountAccessEntry): AccountAccess => ({
|
||||
accounts: [entry],
|
||||
active: entry,
|
||||
canSwitch: true,
|
||||
});
|
||||
|
||||
const DOORS = [
|
||||
"sentinel-coach-fab",
|
||||
"sentinel-coach-mount",
|
||||
"sentinel-tour-launcher",
|
||||
] as const;
|
||||
|
||||
describe("<AuthShell> — the owner-only doors", () => {
|
||||
it("mounts all three in the caller's own record", () => {
|
||||
// The half that makes the rest mean something. Without it, a shell that
|
||||
// had simply dropped the Coach and the tour for everybody would satisfy
|
||||
// every assertion below.
|
||||
const html = render(OWN_RECORD);
|
||||
for (const slot of DOORS) {
|
||||
expect(html, `${slot} must exist in one's own record`).toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["read", OWNER_READ],
|
||||
["write", OWNER_WRITE],
|
||||
])("mounts none of them under a %s grant", (_level, entry) => {
|
||||
// Both levels, because the grant level is not the question. A WRITE grant
|
||||
// admits a short list of health-record creates and nothing about the
|
||||
// account around the record — the Coach and the tour are outside it either
|
||||
// way.
|
||||
const html = render(SWITCHED(entry));
|
||||
for (const slot of DOORS) {
|
||||
expect(html, `${slot} must not paint under a switch`).not.toContain(slot);
|
||||
}
|
||||
// And the page itself still renders: the doors went, the record did not.
|
||||
expect(html).toContain("sentinel-page");
|
||||
});
|
||||
|
||||
it("mounts none of them over the unavailable panel either", () => {
|
||||
// The panel says a page is not part of what was shared. A floating Coach
|
||||
// launcher on top of that sentence is the product contradicting itself in
|
||||
// one viewport.
|
||||
const html = render(SWITCHED(OWNER_READ), "/settings");
|
||||
// First prove this render actually reached the panel. Without this line
|
||||
// the case is indistinguishable from the previous one and would stay green
|
||||
// if `outsideSharedRecord` stopped resolving at all.
|
||||
expect(html).toContain("sentinel-unavailable");
|
||||
expect(html).not.toContain("sentinel-page");
|
||||
for (const slot of DOORS) {
|
||||
expect(html).not.toContain(slot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
@@ -72,14 +73,19 @@ export function AuthShell({
|
||||
pathname.startsWith("/about") ||
|
||||
pathname.startsWith("/c/");
|
||||
const isAdminPage = pathname.startsWith("/admin");
|
||||
// v1.36.0 — is this browser inside somebody else's record. Read from the
|
||||
// capability hook rather than off `accountAccess.active` directly: the shell
|
||||
// decides what to mount and the surfaces beneath it decide what to offer,
|
||||
// and the two disagreeing is the whole class of defect this hook exists to
|
||||
// close.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
// v1.36.0 — a page reached while acting on somebody else's record that
|
||||
// sharing does not cover. The nav already drops these entries, so arriving
|
||||
// here means a bookmark or a browser back button; the panel below explains
|
||||
// it instead of letting the page paint a row of 403 error cards. Paint only
|
||||
// — every route behind these pages refuses on its own.
|
||||
const outsideSharedRecord =
|
||||
user?.accountAccess?.active != null &&
|
||||
!isDestinationInSharedRecord(pathname);
|
||||
inSharedRecord && !isDestinationInSharedRecord(pathname);
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -405,20 +411,39 @@ export function AuthShell({
|
||||
<BottomNav />
|
||||
</div>
|
||||
</div>
|
||||
<LayoutCoachMount />
|
||||
{/*
|
||||
The three mounts below hang off the shell rather than off a route, so
|
||||
they paint on every page the shell serves — including the unavailable
|
||||
panel, which exists to say a page is not part of what was shared. All
|
||||
three are owner-only surfaces, and all three are gated on
|
||||
`!inSharedRecord` for the same reason: Insights and the Coach were
|
||||
deliberately kept out of what sharing covers (`nav-model.ts` — the
|
||||
owner's consent for server-managed LLM egress is theirs, not
|
||||
transferable), and the tour walks an account through modules a delegate
|
||||
does not administer. Every route behind them resolves `requireAuth()`
|
||||
and refuses under a switch, so leaving them mounted offered a delegate
|
||||
a door onto seven modules that answer 403 — chat, conversation delete
|
||||
and rename, attachments, feedback, adopt, suggested actions, and a
|
||||
`POST /api/insights/coach/seen` fired by merely opening the drawer.
|
||||
|
||||
Absent, not disabled: a greyed-out launcher would still say "this
|
||||
exists and does not work for you", and what is true is that it is not
|
||||
part of what this person was given.
|
||||
*/}
|
||||
{!inSharedRecord && <LayoutCoachMount />}
|
||||
{/* v1.18.6 — the module-tour launcher lives at the shell level so its
|
||||
overlay survives the cross-page `router.push`es the tour makes. It
|
||||
self-gates: it only auto-opens on the dashboard for a user who has
|
||||
not finished/dismissed the tour, and otherwise renders null. Stays
|
||||
out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the
|
||||
proxy blocks for demo visitors). */}
|
||||
{!demoMode && <TourLauncher />}
|
||||
{!demoMode && !inSharedRecord && <TourLauncher />}
|
||||
{/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits
|
||||
`/api/insights/chat`, which is not in the proxy's
|
||||
`DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent
|
||||
got a raw 403. Demo is read-only by design; the FAB has no job
|
||||
here, so it's mounted only outside demo mode. */}
|
||||
{!demoMode && <LayoutCoachFab />}
|
||||
{!demoMode && !inSharedRecord && <LayoutCoachFab />}
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* `getUnswitchedSession()` — the one gate every server-prefetching page sits
|
||||
* behind.
|
||||
*
|
||||
* The five pages that prefetch are otherwise unrelated to each other, so the
|
||||
* property they share has to live somewhere they all pass through, and this is
|
||||
* it. The four cases below are the whole state space of the cookie transport:
|
||||
* no session, a session in its own record, a session stamped onto somebody
|
||||
* else's, and a session carrying a selector header it has no business sending.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - return `session` unconditionally → "refuses while the session is
|
||||
* stamped onto another record" goes red ("expected { user: … } to be
|
||||
* null").
|
||||
* - drop the `header` argument from the `decideActingCarrier` call → "refuses
|
||||
* a session carrying a misplaced selector header" goes red the same way.
|
||||
* - return `null` unconditionally → "hands back the session in the caller's
|
||||
* own record" goes red, which is what stops the whole file from being
|
||||
* satisfiable by a function that refuses everybody.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const getSession = vi.fn();
|
||||
const headerGet = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => ({ get: (name: string) => headerGet(name) }),
|
||||
}));
|
||||
|
||||
import {
|
||||
ACCOUNT_SELECTOR_HEADER,
|
||||
getUnswitchedSession,
|
||||
} from "../acting-carrier";
|
||||
|
||||
const OWN = {
|
||||
user: { id: "u-delegate" },
|
||||
session: { id: "s1", actingAsUserId: null },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
headerGet.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("getUnswitchedSession", () => {
|
||||
it("hands back the session in the caller's own record", async () => {
|
||||
getSession.mockResolvedValue(OWN);
|
||||
await expect(getUnswitchedSession()).resolves.toBe(OWN);
|
||||
});
|
||||
|
||||
it("answers null when there is no session at all", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
await expect(getUnswitchedSession()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses while the session is stamped onto another record", async () => {
|
||||
// The stamp is what a switch IS on the cookie transport. A page that
|
||||
// prefetched through this would serialise `u-delegate`'s health record
|
||||
// into a document opened on `u-owner`'s.
|
||||
getSession.mockResolvedValue({
|
||||
...OWN,
|
||||
session: { id: "s1", actingAsUserId: "u-owner" },
|
||||
});
|
||||
await expect(getUnswitchedSession()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a session carrying a misplaced selector header", async () => {
|
||||
// The header names no account on this transport, so it is not a switch and
|
||||
// the request would be served as the caller. It is still a client saying
|
||||
// it meant to be somewhere else, and a prefetch has nothing to gain by
|
||||
// guessing which of the two it meant.
|
||||
getSession.mockResolvedValue(OWN);
|
||||
headerGet.mockImplementation((name: string) =>
|
||||
name === ACCOUNT_SELECTOR_HEADER ? "u-owner" : null,
|
||||
);
|
||||
await expect(getUnswitchedSession()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,60 @@ export function selectorNamesAnAccount(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session a server-rendered PAGE may prefetch from, or null.
|
||||
*
|
||||
* A server-prefetching page dehydrates a health record into the HTML document
|
||||
* and seeds it into the client's TanStack cache under the key the client cell
|
||||
* will read. That copy is only ever right when the account the page read is
|
||||
* the account the client's ROUTE would answer with, and under a switch it
|
||||
* never is. `getSession()` answers who is CALLING, deliberately and
|
||||
* permanently (`session.ts` — it is the same property that keeps `requireAdmin`
|
||||
* out of a delegate's reach), so a page that prefetches off it seeds the
|
||||
* DELEGATE's record onto a page whose banner names the owner. What happens
|
||||
* next depends only on which mode the route declared, and both endings are
|
||||
* wrong:
|
||||
*
|
||||
* - a non-delegable route refuses the client's refetch, so the delegate's
|
||||
* own numbers stay on the owner's dashboard until the tab is closed;
|
||||
* - a delegable route answers with the owner's rows, so the page shows two
|
||||
* people at once for as long as the refetch takes.
|
||||
*
|
||||
* Resolving the acting account here instead — reading the record the way a
|
||||
* route does — is the tempting other answer and is worse. The grant check
|
||||
* lives in exactly one function (`requireRecordAuth`), the pages cannot reach
|
||||
* it without an HTTP context, and re-deriving it here would be a second
|
||||
* program deciding "whose record is this" — the thing `grants.ts` warns must
|
||||
* have one answer. Worse, half of what these pages prefetch rides routes that
|
||||
* declared themselves NOT delegable; prefetching the owner's copy would hand a
|
||||
* delegate data the API is on record refusing them.
|
||||
*
|
||||
* So the prefetch is an accelerator that steps aside. Under a switch this
|
||||
* returns null, the page renders its bare client shell, and every cell fetches
|
||||
* through the routes — which is where the grant check is, and the only place
|
||||
* it should ever have been. The cost is one skeleton frame on a delegated
|
||||
* visit; nothing is lost that a fetch does not restore.
|
||||
*
|
||||
* Fail-closed on the ambiguous case too: a selector HEADER on this transport
|
||||
* is `misplaced-header`, which names no account and is not a switch, but it is
|
||||
* a client asserting it meant to be somewhere else. A page has nothing to gain
|
||||
* by prefetching through that, so it does not.
|
||||
*/
|
||||
export async function getUnswitchedSession(): Promise<Awaited<
|
||||
ReturnType<typeof getSession>
|
||||
> | null> {
|
||||
const session = await getSession();
|
||||
if (!session) return null;
|
||||
const carrier = decideActingCarrier({
|
||||
// A page render is a document request: cookie transport by construction,
|
||||
// and `getSession()` returning a session is what that means here.
|
||||
transport: "cookie",
|
||||
stamped: session.session.actingAsUserId ?? null,
|
||||
header: await readSelectorHeader(),
|
||||
});
|
||||
return carrier.kind === "none" ? session : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account this request CLAIMS, resolved without an auth context in hand.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user