Files
HealthLog/src/lib/cycle/__tests__/day-log-write.test.ts
T
Marc-André Bombeck 3596720905 fix(cycle): let a period logged as flow count as a period
MenstrualCycle rows were only ever written by the one-tap period
boundary. Every other way of recording bleeding wrote a day-log and
stopped there: the log sheet's flow chips, the bulk drain, an Apple
Health export full of menstrual-flow samples. The engine reads cycle
rows, so a person could log every period they had and the module still
held that none had happened. No cycle length, no forecast, an empty
chart, and an offer to log a first period. An Apple Health user who
never found the one-tap button had the whole module dead.

A first bleeding day now opens the cycle it belongs to, through one
shared seam in the day-log write helper, so the single capture, the
bulk drain and the Apple Health fold all infer a start the same way and
the one-tap boundary's own write lands on a date that already has a
cycle, which the rule declines.

The rule is conservative, because a false start corrupts every length
derived after it. Spotting does not open a cycle: it is as often the
tail or the herald of a period as its first day. Bleeding flagged as
between periods does not, since that flag exists to say so. Neither does
a bleeding day within the hard physiological minimum of an existing
start, which is what keeps day three of a period, or a forgotten day
two, from starting a cycle of its own without needing a contiguity
check. A genuinely short cycle is not inferred from flow alone; the
one-tap start still opens one on any date.

An edit is symmetric. Setting a flow opens the cycle; clearing it, or
flagging it as between periods, removes the cycle that day opened and
re-opens the one before it. A cycle that an edit can create and not undo
leaves the record holding a period nothing on the day says happened.

- the period route's start arm and the flow inference now share one
  boundary helper, so a start means the same thing however it is entered
- back-dating re-anchors both neighbours, as the one-tap start does
- integration: eleven cases through the real routes, of which five are
  about what must NOT open a cycle
2026-08-07 15:50:23 +02:00

188 lines
6.5 KiB
TypeScript

/**
* Cycle day-log upsert helper — C-1 encryption write-path data-loss
* regression coverage.
*
* The partial-update upsert loads the existing row before writing. When the
* stored encrypted note / sensitive envelope cannot be decrypted (rotation
* gap, GCM corruption), the helper must NOT collapse the column to a re-encrypt
* of a failed soft-decrypt (which would permanently wipe the user's data when
* they edit an unrelated field). The stored ciphertext is preserved verbatim
* unless the user explicitly sets that field.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => {
const client: Record<string, unknown> = {
cycleDayLog: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
// The write path asks whether the logged day opens a cycle. These cases
// are about the encryption columns, so the fake answers "no cycles on
// record" and lets the boundary work run for real against itself — the
// inference itself is pinned by its own unit + integration cases.
menstrualCycle: {
findFirst: vi.fn(async () => null),
upsert: vi.fn(async () => ({ id: "cyc-1", endDate: null })),
update: vi.fn(),
},
cycleSymptom: { findMany: vi.fn() },
cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
};
client.$transaction = async (fn: (tx: unknown) => unknown) => fn(client);
return client as {
cycleDayLog: {
findUnique: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
menstrualCycle: {
findFirst: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
cycleSymptom: { findMany: ReturnType<typeof vi.fn> };
cycleSymptomLink: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
};
});
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/cycle/profile", () => ({
getOrCreateCycleProfile: vi.fn(async () => ({
sensitiveCategoryEncryption: true,
})),
}));
// `decrypt` round-trips an `enc:` prefix and THROWS on any other ciphertext,
// simulating an undecryptable column. The write path must never let that throw
// reach the column write — it preserves the stored ciphertext verbatim.
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (c: string) => {
if (!c.startsWith("enc:")) throw new Error("undecryptable");
return c.replace(/^enc:/, "");
},
}));
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import type { CycleDayLogInput } from "@/lib/validations/cycle";
function existingRow(overrides: Record<string, unknown> = {}) {
return {
id: "cdl-1",
date: "2026-06-16",
flow: null,
intermenstrualBleeding: false,
basalBodyTempC: null,
temperatureExcluded: false,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: null,
notesEncrypted: null,
deletedAt: null,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" });
db.menstrualCycle.findFirst.mockResolvedValue(null);
db.menstrualCycle.upsert.mockResolvedValue({ id: "cyc-1", endDate: null });
});
describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => {
it("preserves an undecryptable note verbatim when an unrelated field is edited", async () => {
db.cycleDayLog.findUnique.mockResolvedValue(
existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }),
);
// Edit only `flow`; `note` is omitted.
const entry = {
date: "2026-06-16",
source: "MANUAL",
flow: "LIGHT",
} as unknown as CycleDayLogInput;
await upsertCycleDayLog("u1", entry, "Europe/Berlin");
const args = db.cycleDayLog.upsert.mock.calls[0][0];
// The stored ciphertext is carried through unchanged — not nulled, not
// re-encrypted from a failed soft-decrypt.
expect(args.update.notesEncrypted).toBe("CORRUPT-NOTE-CIPHERTEXT");
expect(args.update.flow).toBe("LIGHT");
});
it("clears the note on an explicit null even when the stored one is undecryptable", async () => {
db.cycleDayLog.findUnique.mockResolvedValue(
existingRow({ notesEncrypted: "CORRUPT-NOTE-CIPHERTEXT" }),
);
const entry = {
date: "2026-06-16",
source: "MANUAL",
note: null,
} as unknown as CycleDayLogInput;
await upsertCycleDayLog("u1", entry, "Europe/Berlin");
const args = db.cycleDayLog.upsert.mock.calls[0][0];
expect(args.update.notesEncrypted).toBeNull();
});
});
describe("upsertCycleDayLog — C-1 undecryptable sensitive envelope preservation", () => {
it("preserves an undecryptable sensitive envelope verbatim when an unrelated field is edited", async () => {
db.cycleDayLog.findUnique.mockResolvedValue(
existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }),
);
// Edit only a non-sensitive field; no sensitive field supplied.
const entry = {
date: "2026-06-16",
source: "MANUAL",
basalBodyTempC: 36.5,
} as unknown as CycleDayLogInput;
await upsertCycleDayLog("u1", entry, "Europe/Berlin");
const args = db.cycleDayLog.upsert.mock.calls[0][0];
// The envelope ciphertext is preserved, not overwritten with a defaults
// envelope minted from a failed soft-decrypt.
expect(args.update.sensitiveEncrypted).toBe("CORRUPT-ENVELOPE-CIPHERTEXT");
expect(args.update.basalBodyTempC).toBe(36.5);
});
it("re-encrypts the envelope when a sensitive field is explicitly supplied", async () => {
db.cycleDayLog.findUnique.mockResolvedValue(
existingRow({ sensitiveEncrypted: "CORRUPT-ENVELOPE-CIPHERTEXT" }),
);
const entry = {
date: "2026-06-16",
source: "MANUAL",
sexualActivity: true,
} as unknown as CycleDayLogInput;
await upsertCycleDayLog("u1", entry, "Europe/Berlin");
const args = db.cycleDayLog.upsert.mock.calls[0][0];
// An explicit sensitive write mints a fresh envelope carrying the user's
// intent — the stored (undecryptable) one is replaced on purpose.
expect(args.update.sensitiveEncrypted).not.toBe(
"CORRUPT-ENVELOPE-CIPHERTEXT",
);
expect(String(args.update.sensitiveEncrypted)).toContain(
'"sexualActivity":true',
);
});
});