Files
HealthLog/src/app/api/sync/changes/__tests__/module-gate.test.ts
T
Marc-André Bombeck 076339c274 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.
2026-07-19 01:35:14 +02:00

199 lines
6.5 KiB
TypeScript

/**
* `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();
});
});