mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Gate the cycle domains in the sync delta feed
The multi-domain feed at GET /api/sync/changes read cycleDayLog and menstrualCycle without consulting the module gate, so an account with cycle tracking off still received period days and per-day symptom rows over the same token that every /api/cycle/* route refuses with a 403. Resolve the cycle module once per request and skip both queries when it is off, rather than filtering after the read. The blocks come back empty and the request still succeeds: the feed multiplexes five domains over one cursor, so refusing it outright would wedge measurement, mood and intake catch-up for every account that has cycle tracking off. The two cycle watermarks stay untouched while the module is off, so re-enabling resumes where the client left off. A client that synced cycle rows before the module was turned off keeps its stale local mirror. Tombstones would be the wrong signal there -- they mean deleted, and disabling a module must leave the rows intact -- so purging a paired mirror needs an explicit signal in the feed contract and is left for a coordinated contract cycle. Wire shape unchanged.
This commit is contained in:
1 parent
40ad2dfc96
commit
076339c274
2 files changed
+250
-11
No files matched your search
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* `GET /api/sync/changes` — cycle module gate.
|
||||
*
|
||||
* The delta feed multiplexes five domains over one cursor and used to serve
|
||||
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
|
||||
* period days and per-day symptom rows over the very token that
|
||||
* `/api/cycle/*` refuses with a 403.
|
||||
*
|
||||
* Behavioural, not structural: the assertions read the response body and the
|
||||
* Prisma call log, so reverting the gate in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decodeCursor } from "@/lib/sync/cursor";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-07-01T12:00:00.000Z");
|
||||
|
||||
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
|
||||
const CYCLE_DAY_ROW = {
|
||||
id: "cd-1",
|
||||
userId: "user-1",
|
||||
externalId: "ext-cd-1",
|
||||
date: NOW,
|
||||
flow: "MEDIUM",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 3,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
|
||||
};
|
||||
|
||||
const CYCLE_ROW = {
|
||||
id: "mc-1",
|
||||
userId: "user-1",
|
||||
startDate: NOW,
|
||||
endDate: null,
|
||||
cycleLength: 28,
|
||||
periodLength: 5,
|
||||
predicted: false,
|
||||
syncVersion: 2,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
};
|
||||
|
||||
interface FeedBody {
|
||||
data: {
|
||||
changes: {
|
||||
measurements: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycles: { upserts: unknown[]; tombstones: unknown[] };
|
||||
};
|
||||
cursor: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function req(query = ""): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/sync/changes${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Non-cycle domains always return rows so a 403/empty-everything result
|
||||
// cannot be mistaken for a passing "cycle omitted" assertion.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
externalId: "ext-m-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: NOW,
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
CYCLE_DAY_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
|
||||
CYCLE_ROW,
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes — cycle module gate", () => {
|
||||
it("omits cycle rows and never queries them when the module is off", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await GET(req());
|
||||
// OMIT, not refuse — the other four domains must keep syncing.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
|
||||
expect(body.data.changes.cycles.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycles.tombstones).toEqual([]);
|
||||
|
||||
// The rest of the feed is untouched — proves the empty cycle blocks are
|
||||
// the gate, not a wholesale refusal.
|
||||
expect(body.data.changes.measurements.upserts).toHaveLength(1);
|
||||
|
||||
// Not read out of Postgres at all.
|
||||
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
|
||||
|
||||
// No symptom string anywhere in the serialised payload.
|
||||
expect(JSON.stringify(body)).not.toContain("CRAMPS");
|
||||
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
|
||||
});
|
||||
|
||||
it("serves cycle rows when the module is on", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
|
||||
expect(body.data.changes.cycles.upserts).toHaveLength(1);
|
||||
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gates on the cycle module key specifically", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
await GET(req());
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
|
||||
});
|
||||
|
||||
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
|
||||
// A skipped domain must not advance its watermark, so re-enabling
|
||||
// resumes from where the client left off rather than skipping the rows
|
||||
// that changed while the module was hidden.
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as FeedBody;
|
||||
|
||||
const cursor = decodeCursor(body.data.cursor as string);
|
||||
expect(cursor).not.toBeNull();
|
||||
expect(cursor!.cycleDays).toBeUndefined();
|
||||
expect(cursor!.cycles).toBeUndefined();
|
||||
// The domain that did return rows advanced normally.
|
||||
expect(cursor!.measurements).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@
|
||||
* been pruned, so an incremental delta could silently miss it).
|
||||
* - `syncVersion` is echoed per row so the client can keep its mirror's
|
||||
* version monotonic.
|
||||
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
|
||||
* Read-only — no idempotency, no write side-effect (unlike the legacy
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
type CycleDayLogDTO,
|
||||
type MenstrualCycleDTO,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
@@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const serverNow = new Date();
|
||||
|
||||
// ── module gate: cycle ────────────────────────────────────────────
|
||||
// The `cycleDays` / `cycles` domains carry period days and per-day
|
||||
// symptom rows — the same data every `/api/cycle/*` route refuses with a
|
||||
// 403 when the module is off. Without this the delta feed re-served them
|
||||
// ungated to the very same token.
|
||||
//
|
||||
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
|
||||
// This feed multiplexes five domains over one cursor: refusing the whole
|
||||
// request would wedge measurement / mood / intake catch-up for every
|
||||
// account with cycle tracking off, which (the gate delegates to
|
||||
// `isCycleEnabled`) is the default for most accounts. A partial payload
|
||||
// is the only outcome that keeps the client syncing while still honouring
|
||||
// the promise that a disabled module hands out nothing.
|
||||
//
|
||||
// The two cycle watermarks are deliberately NOT advanced while the module
|
||||
// is off (the queries are skipped, so `advanceCursor` sees an empty page
|
||||
// and leaves the prior watermark untouched). Re-enabling therefore resumes
|
||||
// from where the client actually left off instead of silently skipping the
|
||||
// rows that changed while the module was hidden.
|
||||
//
|
||||
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
|
||||
// module was turned off keeps its stale local mirror. Emitting tombstones
|
||||
// for them would be wrong — a tombstone means deleted, and disabling a
|
||||
// module must leave the rows intact so re-enabling finds a complete
|
||||
// history. Purging a paired client's mirror needs an explicit
|
||||
// "module went dark" signal in the feed contract plus client support for
|
||||
// it; that is an additive wire change and an iOS-client change, not a
|
||||
// server-side gate fix, so it is left for a coordinated contract cycle.
|
||||
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
|
||||
|
||||
// The retention horizon: tombstones older than this may have been
|
||||
// pruned by the cleanup job, so a cursor that predates it can no longer
|
||||
// be trusted to deliver every deletion incrementally.
|
||||
@@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
}),
|
||||
// Skipped outright when the module is off — not filtered after the
|
||||
// fact — so the disabled rows are never read out of Postgres at all.
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const nextCursor: SyncCursor = { ...cursor };
|
||||
@@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cycle_day_tombstones: cycleDayTombstones.length,
|
||||
cycle_upserts: cycleUpserts.length,
|
||||
cycle_tombstones: cycleTombstones.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(parsed.data.cursor),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user