diff --git a/src/app/api/sync/changes/__tests__/module-gate.test.ts b/src/app/api/sync/changes/__tests__/module-gate.test.ts new file mode 100644 index 000000000..5b5e004c9 --- /dev/null +++ b/src/app/api/sync/changes/__tests__/module-gate.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index b790d4350..750ea47f5 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -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), },