mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge(reconcile): b1-dataroutes audit findings
This commit is contained in:
commit
3e0582d284
17 files changed
+430
-14
No files matched your search
@@ -107,11 +107,13 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/mood/tags/groups/[key]/route.ts",
|
||||
"src/app/api/mood/tags/[key]/hidden/route.ts",
|
||||
// ── DATA LAYER (workouts / labs) ──────────────────────────────────
|
||||
// Raw Workout + LabResult CRUD + the iOS batch ingest. Same data-layer
|
||||
// reasoning: the workouts / labs modules gate the surfaces (Insights
|
||||
// pill, dashboard tile, doctor-report section), not the row store.
|
||||
"src/app/api/workouts/route.ts",
|
||||
"src/app/api/workouts/[id]/route.ts",
|
||||
// The workout READ surfaces (`GET /api/workouts`, `GET /api/workouts/{id}`)
|
||||
// are now module-gated — they back the hidden Insights workouts surface, so
|
||||
// a disabled account must not read them even over a Bearer token. Only the
|
||||
// iOS batch INGEST stays exempt: synced workouts must keep landing in the
|
||||
// row store while the surface is hidden, so re-enabling reveals a complete
|
||||
// history rather than a gap. LabResult CRUD follows the same data-layer
|
||||
// reasoning (the labs module gates the surfaces, not the row store).
|
||||
"src/app/api/workouts/batch/route.ts",
|
||||
"src/app/api/labs/route.ts",
|
||||
"src/app/api/labs/[id]/route.ts",
|
||||
|
||||
@@ -363,6 +363,66 @@ describe("GET /api/analytics", () => {
|
||||
expect(body.data.lastSeenByType.PULSE).toBeNull();
|
||||
});
|
||||
|
||||
// v1.18.0 B1 — the default slice serves the glucose-only surfaces
|
||||
// (per-context summaries + the TIR/GMI/eA1C clinical panel) only when
|
||||
// the `glucose` module is enabled. A disabled-glucose account nulls
|
||||
// both fields rather than 403-ing the whole core-metrics payload.
|
||||
function seedGlucoseRows() {
|
||||
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async (args: { where?: { type?: string } }) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("serves glucose clinical + per-context when glucose enabled", async () => {
|
||||
seedGlucoseRows();
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true as never);
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
glucoseByContext: Record<string, unknown> | null;
|
||||
glucoseClinical: unknown | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.glucoseClinical).not.toBeNull();
|
||||
expect(body.data.glucoseByContext).not.toBeNull();
|
||||
expect(body.data.glucoseByContext?.FASTING).toBeDefined();
|
||||
});
|
||||
|
||||
it("nulls glucose clinical + per-context when glucose disabled", async () => {
|
||||
seedGlucoseRows();
|
||||
vi.mocked(isModuleEnabled).mockImplementation(
|
||||
(async (_userId: string, key: string) => key !== "glucose") as never,
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
glucoseByContext: Record<string, unknown> | null;
|
||||
glucoseClinical: unknown | null;
|
||||
summaries: Record<string, { count: number }>;
|
||||
};
|
||||
};
|
||||
// Core metrics still served — the route did NOT 403.
|
||||
expect(body.data.summaries.WEIGHT.count).toBe(0);
|
||||
// Glucose-only surfaces are nulled.
|
||||
expect(body.data.glucoseClinical).toBeNull();
|
||||
expect(body.data.glucoseByContext).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked`
|
||||
// fan-out was removed entirely; the default slice now delegates to
|
||||
// `computeSummariesSlice` which runs three rollup-tier `$queryRaw`
|
||||
|
||||
@@ -427,6 +427,14 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// already blanks the mood tile + the coach snapshot unions mood out;
|
||||
// this closes the matching gap on the Health Score.
|
||||
const moodEnabled = await isModuleEnabled(user.id, "mood");
|
||||
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
|
||||
// route serves core metrics too, so a disabled-glucose account must NOT
|
||||
// 403 the whole payload; instead the glucose-only surfaces (per-context
|
||||
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
|
||||
// never reach a hidden surface. Mirrors the mood-pillar gate above.
|
||||
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
const healthScore = await computeUserHealthScoreFastPath({
|
||||
userId: user.id,
|
||||
...bpInputs,
|
||||
@@ -447,8 +455,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
bpInTargetPctPriorYear,
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
glucoseByContext,
|
||||
glucoseClinical,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -39,6 +40,10 @@ export const GET = apiHandler(
|
||||
|
||||
annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } });
|
||||
|
||||
// v1.18.0 B1 — gate the detail surface behind the workouts module.
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
|
||||
@@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () => ({})),
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -113,6 +123,10 @@ describe("GET /api/workouts — canonical dedup", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
// clearAllMocks wipes the factory default; re-seed the gate to pass.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => {
|
||||
@@ -139,6 +153,20 @@ describe("GET /api/workouts — canonical dedup", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 module.disabled when the workouts module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
response: new Response(
|
||||
JSON.stringify({ data: null, error: "Module disabled" }),
|
||||
{ status: 403 },
|
||||
),
|
||||
} as never);
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
// The list query never runs once the gate refuses.
|
||||
expect(prisma.workout.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by ownership — only the caller's rows enter the picker", async () => {
|
||||
// The mock returns whatever the route asked Prisma for; the route's
|
||||
// `where: { userId: user.id }` filter is what enforces ownership.
|
||||
|
||||
@@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () => ({})),
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -94,6 +104,10 @@ describe("GET /api/workouts/{id}", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
// clearAllMocks wipes the factory default; re-seed the gate to pass.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("returns the workout with iOS-contract field names", async () => {
|
||||
@@ -123,6 +137,20 @@ describe("GET /api/workouts/{id}", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 module.disabled when the workouts module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
response: new Response(
|
||||
JSON.stringify({ data: null, error: "Module disabled" }),
|
||||
{ status: 403 },
|
||||
),
|
||||
} as never);
|
||||
const res = await GET(makeRequest(), makeParams("w-1"));
|
||||
expect(res.status).toBe(403);
|
||||
// The detail query never runs once the gate refuses.
|
||||
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when the row belongs to another user", async () => {
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
|
||||
...BASE_ROW,
|
||||
|
||||
@@ -55,6 +55,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
@@ -171,6 +172,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "workouts.list" } });
|
||||
|
||||
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled
|
||||
// account must not be able to read the list even with a valid Bearer
|
||||
// token. The batch INGEST route stays ungated so synced data still
|
||||
// lands while the surface is hidden.
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
Math.max(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Droplet } from "lucide-react";
|
||||
import { Droplet, Loader2 } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
@@ -29,9 +30,19 @@ const MGDL_PER_MMOL = 18.0182;
|
||||
export default function InsightsBlutzuckerPage() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("glucose");
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const isMmol = glucoseUnit === "mmol/L";
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
|
||||
|
||||
@@ -20,6 +23,16 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
|
||||
*/
|
||||
export default function InsightsRecoveryPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("recovery");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
import { Loader2, Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,9 +36,20 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const { ready } = useModulePageGuard("sleep");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
|
||||
// /insights instead of half-rendering the sleep surface.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -32,8 +35,18 @@ export default function InsightsWorkoutDetailPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error } = useWorkoutDetail(id);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
@@ -28,8 +29,18 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
*/
|
||||
export default function InsightsWorkoutsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
+8
-2
@@ -523,7 +523,11 @@ export default function DashboardPage() {
|
||||
const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodTile = isTileVisible("mood") && hasMood;
|
||||
const showSleepTile = isTileVisible("sleep") && hasSleep;
|
||||
const showStepsTile = isTileVisible("steps") && hasSteps;
|
||||
// v1.18.0 B1 — steps are activity data tied to the workouts module
|
||||
// surface; AND the resolved module flag (fail-open: only an explicit
|
||||
// `false` hides it) so a disabled-workouts account drops the tile.
|
||||
const showStepsTile =
|
||||
isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false;
|
||||
const showVo2Tile = isTileVisible("vo2Max") && hasVo2;
|
||||
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
|
||||
|
||||
@@ -559,7 +563,9 @@ export default function DashboardPage() {
|
||||
// no data floor, so it would otherwise flash in then retract for a user
|
||||
// who hid it.
|
||||
const showRecentWorkoutsTile =
|
||||
layoutResolved && isChartVisible("recentWorkouts");
|
||||
layoutResolved &&
|
||||
isChartVisible("recentWorkouts") &&
|
||||
user?.modules?.workouts !== false;
|
||||
|
||||
// v1.16.0 — shared reveal gate for the chart row. Every data-backed
|
||||
// chart mounts as soon as its visibility gate flips (so the per-chart
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.18.0 B1 — `useModulePageGuard` is the client-side direct-URL guard
|
||||
* for the insights sub-pages of a toggleable module. The repo has no
|
||||
* `@testing-library/react`, so the contract is exercised through an SSR
|
||||
* Probe that renders the resolved `ready` flag. The redirect side-effect
|
||||
* lives in a `useEffect` (no-op under SSR); the `ready` derivation is the
|
||||
* load-bearing decision both the redirect and the loader branch read, so
|
||||
* locking it covers the guard.
|
||||
*/
|
||||
const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
const useAuthMock = vi.fn();
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
import { useModulePageGuard } from "../use-module-page-guard";
|
||||
|
||||
function Probe({ moduleKey }: { moduleKey: "glucose" }) {
|
||||
const { ready } = useModulePageGuard(moduleKey);
|
||||
return <span data-testid="ready">{String(ready)}</span>;
|
||||
}
|
||||
|
||||
function readyOf(): string {
|
||||
const html = renderToStaticMarkup(<Probe moduleKey="glucose" />);
|
||||
return html.includes(">true<") ? "true" : "false";
|
||||
}
|
||||
|
||||
describe("useModulePageGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("is not ready while auth is loading", () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
expect(readyOf()).toBe("false");
|
||||
});
|
||||
|
||||
it("is not ready when unauthenticated", () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
expect(readyOf()).toBe("false");
|
||||
});
|
||||
|
||||
it("is not ready when the module is disabled (explicit false)", () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: { modules: { glucose: false } },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
expect(readyOf()).toBe("false");
|
||||
});
|
||||
|
||||
it("is ready when authenticated and the module is enabled", () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: { modules: { glucose: true } },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
expect(readyOf()).toBe("true");
|
||||
});
|
||||
|
||||
it("is ready (default-on) when the module key is absent", () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: { modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
expect(readyOf()).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/**
|
||||
* v1.18.0 B1 — client-side page guard for a toggleable module.
|
||||
*
|
||||
* Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated
|
||||
* visitor bounces to login; an authenticated account without the module
|
||||
* enabled bounces to `/insights`. The nav entry is already hidden for a
|
||||
* disabled account, so this only catches a direct URL hit. Every backing
|
||||
* `/api/*` route enforces the gate server-side, so this is a UX redirect,
|
||||
* not the security boundary.
|
||||
*
|
||||
* Returns `ready` — `true` only once auth has resolved, the visitor is
|
||||
* authenticated, AND the module is enabled. While `false` the caller
|
||||
* should render a calm loader / nothing rather than the (about-to-
|
||||
* redirect) module surface, so a disabled-module page never half-renders.
|
||||
*
|
||||
* Default-on (disabled allowlist): an absent map, a missing key, or any
|
||||
* non-`false` value reads as enabled — matching the server gate.
|
||||
*/
|
||||
export function useModulePageGuard(moduleKey: ModuleKey): {
|
||||
ready: boolean;
|
||||
} {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.modules?.[moduleKey] !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/insights");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
return { ready: !isLoading && isAuthenticated && enabled };
|
||||
}
|
||||
@@ -87,9 +87,12 @@ function moduleMap(
|
||||
|
||||
import {
|
||||
buildDashboardSnapshot,
|
||||
WIDGET_MODULE_BY_ID,
|
||||
SUMMARY_TYPE_MODULE,
|
||||
type SnapshotUserInput,
|
||||
} from "../snapshot";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
|
||||
const emptySummary = {
|
||||
count: 0,
|
||||
@@ -796,6 +799,67 @@ describe("buildDashboardSnapshot — additive proof", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gating maps — every toggleable-domain widget is mapped (v1.18.0)", () => {
|
||||
// The dashboard widget ids that belong to a toggleable module. A new
|
||||
// toggleable widget MUST be added here AND to `WIDGET_MODULE_BY_ID`, or
|
||||
// it would leak onto a disabled-module account's dashboard.
|
||||
const TOGGLEABLE_WIDGET_IDS = [
|
||||
"mood",
|
||||
"sleep",
|
||||
"glucose",
|
||||
"achievements",
|
||||
"recentWorkouts",
|
||||
"cardioRecovery",
|
||||
"sixMinuteWalk",
|
||||
"stairAscentSpeed",
|
||||
"stairDescentSpeed",
|
||||
"breathingDisturbances",
|
||||
] as const;
|
||||
|
||||
it("every toggleable-domain widget id has a module mapping", () => {
|
||||
for (const id of TOGGLEABLE_WIDGET_IDS) {
|
||||
expect(
|
||||
WIDGET_MODULE_BY_ID[id],
|
||||
`widget "${id}" is missing a WIDGET_MODULE_BY_ID entry`,
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("every mapped widget value is a real ModuleKey", () => {
|
||||
for (const [id, key] of Object.entries(WIDGET_MODULE_BY_ID)) {
|
||||
expect(
|
||||
MODULE_KEYS.includes(key as ModuleKey),
|
||||
`widget "${id}" maps to unknown module "${key}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every summary-type mapping value is a real ModuleKey", () => {
|
||||
for (const [type, key] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
expect(
|
||||
MODULE_KEYS.includes(key as ModuleKey),
|
||||
`summary type "${type}" maps to unknown module "${key}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("recovery-domain widgets resolve to the recovery module", () => {
|
||||
expect(WIDGET_MODULE_BY_ID.cardioRecovery).toBe("recovery");
|
||||
expect(WIDGET_MODULE_BY_ID.sixMinuteWalk).toBe("recovery");
|
||||
expect(WIDGET_MODULE_BY_ID.stairAscentSpeed).toBe("recovery");
|
||||
expect(WIDGET_MODULE_BY_ID.stairDescentSpeed).toBe("recovery");
|
||||
expect(SUMMARY_TYPE_MODULE.CARDIO_RECOVERY).toBe("recovery");
|
||||
expect(SUMMARY_TYPE_MODULE.SIX_MINUTE_WALK_DISTANCE).toBe("recovery");
|
||||
expect(SUMMARY_TYPE_MODULE.STAIR_ASCENT_SPEED).toBe("recovery");
|
||||
expect(SUMMARY_TYPE_MODULE.STAIR_DESCENT_SPEED).toBe("recovery");
|
||||
});
|
||||
|
||||
it("breathing-disturbance widget + type resolve to the sleep module", () => {
|
||||
expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep");
|
||||
expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
|
||||
const widget = (snap: { layout: { widgets: { id: string; visible: boolean; tileVisible?: boolean }[] } }, id: string) =>
|
||||
snap.layout.widgets.find((w) => w.id === id);
|
||||
|
||||
@@ -91,12 +91,19 @@ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
* (weight / bp / pulse / bodyFat / medications / bpInTarget and the
|
||||
* vital-derived HealthKit metrics) carry NO entry and are never hidden.
|
||||
*/
|
||||
const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
|
||||
export const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
|
||||
mood: "mood",
|
||||
sleep: "sleep",
|
||||
glucose: "glucose",
|
||||
achievements: "achievements",
|
||||
recentWorkouts: "workouts",
|
||||
// v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery
|
||||
// module; the per-night breathing-disturbance widget belongs to sleep.
|
||||
cardioRecovery: "recovery",
|
||||
sixMinuteWalk: "recovery",
|
||||
stairAscentSpeed: "recovery",
|
||||
stairDescentSpeed: "recovery",
|
||||
breathingDisturbances: "sleep",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -106,9 +113,18 @@ const WIDGET_MODULE_BY_ID: Partial<Record<string, ModuleKey>> = {
|
||||
* client data-floor gates also drop it) before the snapshot leaves the
|
||||
* server. Core vital types are absent here and always pass through.
|
||||
*/
|
||||
const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, ModuleKey>> = {
|
||||
export const SUMMARY_TYPE_MODULE: Partial<Record<MeasurementType, ModuleKey>> = {
|
||||
SLEEP_DURATION: "sleep",
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
// v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page +
|
||||
// its tiles are the recovery module's surface; when it is off these
|
||||
// device-native signals must drop from the dashboard snapshot too.
|
||||
CARDIO_RECOVERY: "recovery",
|
||||
SIX_MINUTE_WALK_DISTANCE: "recovery",
|
||||
STAIR_ASCENT_SPEED: "recovery",
|
||||
STAIR_DESCENT_SPEED: "recovery",
|
||||
// Per-night breathing-disturbance index is a sleep-page signal.
|
||||
BREATHING_DISTURBANCES: "sleep",
|
||||
};
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
|
||||
Reference in new issue
Block a user