fix(validation): moodEntry.tags must parse as JSON string-array (v1.4.15 H2)

`MoodEntry.tags` is stored as a JSON-array-as-string in
`mood_entries.tags`. The backup schema accepted any string here, so a
malformed blob (or a tampered backup) would persist into the DB and
crash downstream readers that blindly `JSON.parse` it. Restore-time
validation now refines the field to one of:

  - null (or omitted) — no tags on this entry
  - "" — legacy moodLog wire format (treated as null)
  - a JSON string that parses to an array of strings

Anything else fails Zod with a 422 from the restore endpoint instead
of bubbling out of `prisma.createMany` with an opaque message. New
unit-test file covers the three accept cases plus three reject cases
(non-JSON, JSON object, JSON array of non-strings).
This commit is contained in:
Marc-André Bombeck committed 2026-05-10 00:27:20 +02:00
1 parent d914f766dc
commit 7f1a4ded25
2 files changed
+115 -1

No files matched your search

@@ -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);
});
});
+29 -1
View File
@@ -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,
})