diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts new file mode 100644 index 000000000..ec22c03b8 --- /dev/null +++ b/src/lib/validations/__tests__/backup.test.ts @@ -0,0 +1,86 @@ +/** + * v1.4.16 H2 — moodEntrySchema.tags must reject malformed tag blobs + * before the restore transaction inserts them. The DB column stores + * tags as a JSON-array-as-string ("[\"work\",\"sleep\"]"), so an + * arbitrary string slipping through Zod is a foot-gun: + * - downstream readers `JSON.parse` blindly and crash on a + * non-array + * - the iOS native client renders tags one-per-row and would + * either render garbage or throw + * + * We accept three shapes: + * - `null` (and omitted/undefined): "no tags on this entry" + * - empty string `""`: legacy moodLog wire format (handled as null) + * - a JSON-string that parses to an array of strings. + * + * Anything else (object, JSON-with-numbers, garbage) is rejected at + * schema time with a 422 response from the restore endpoint instead + * of bubbling out of `prisma.createMany` with a useless message. + */ +import { describe, expect, it } from "vitest"; +import { backupPayloadSchema } from "../backup"; + +const baseEntry = { + date: "2026-05-08", + mood: "GUT", + score: 4, + source: "MOODLOG", + loggedAt: "2026-05-08T20:00:00.000Z", +}; + +function parseWithMoodTags(tags: unknown) { + return backupPayloadSchema.safeParse({ + schemaVersion: "1", + exportedAt: "2026-05-08T07:00:00.000Z", + userId: "u1", + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [{ ...baseEntry, tags }], + }); +} + +describe("moodEntrySchema.tags strict validation", () => { + it("accepts null", () => { + const r = parseWithMoodTags(null); + expect(r.success).toBe(true); + }); + + it("accepts an empty string (legacy moodLog wire format)", () => { + const r = parseWithMoodTags(""); + expect(r.success).toBe(true); + }); + + it("accepts a JSON-string that parses to an array of strings", () => { + const r = parseWithMoodTags('["work","sleep"]'); + expect(r.success).toBe(true); + }); + + it("rejects a non-JSON garbage string", () => { + const r = parseWithMoodTags("not-json{"); + expect(r.success).toBe(false); + }); + + it("rejects a JSON object that is not an array", () => { + const r = parseWithMoodTags('{"a":1}'); + expect(r.success).toBe(false); + }); + + it("rejects a JSON array containing non-strings", () => { + const r = parseWithMoodTags("[1,2,3]"); + expect(r.success).toBe(false); + }); + + it("accepts an omitted tags field (optional)", () => { + const r = backupPayloadSchema.safeParse({ + schemaVersion: "1", + exportedAt: "2026-05-08T07:00:00.000Z", + userId: "u1", + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [{ ...baseEntry }], + }); + expect(r.success).toBe(true); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 2e015d5a1..1d9a899b3 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -69,12 +69,40 @@ const intakeEventSchema = z }) .passthrough(); +/** + * `MoodEntry.tags` is stored as a JSON-array-as-string in the + * `mood_entries.tags` column ("[\"work\",\"sleep\"]"). The previous + * schema accepted any `string` here, so a malformed blob in a backup + * (or one tampered with mid-restore) would land in the DB and crash + * downstream readers that `JSON.parse` it. We now refine to one of + * `null` / empty-string (legacy null wire format) / a JSON string + * that parses to a `string[]`. v1.4.15 H2. + */ +const moodEntryTagsSchema = z + .union([z.null(), z.string()]) + .nullable() + .optional() + .refine( + (v) => { + if (v == null || v === "") return true; + try { + const parsed = JSON.parse(v) as unknown; + return ( + Array.isArray(parsed) && parsed.every((x) => typeof x === "string") + ); + } catch { + return false; + } + }, + { message: "tags must be null, empty, or a JSON array of strings" }, + ); + const moodEntrySchema = z .object({ date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), - tags: z.string().nullable().optional(), + tags: moodEntryTagsSchema, source: z.string().min(1).optional(), loggedAt: isoDateTime, })