mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user