From c65b020655d87c2fc86450ed8449f1130fde5a70 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 11 Jun 2026 21:30:47 -0400 Subject: [PATCH] fix: Reject collections.update requests that include both description and data (#12648) --- .../api/collections/collections.test.ts | 61 +++++++++++++++ server/routes/api/collections/schema.ts | 77 +++++++++++-------- 2 files changed, 105 insertions(+), 33 deletions(-) diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index e6ad829fa6..c9a69402ac 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -1257,6 +1257,23 @@ describe("#collections.create", () => { expect(res.status).toEqual(400); }); + it("rejects providing both description and data", async () => { + const user = await buildUser(); + const res = await server.post("/api/collections.create", user, { + body: { + name: "Test", + description: "Test", + data: { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Test" }] }, + ], + }, + }, + }); + expect(res.status).toEqual(400); + }); + it("should allow setting sharing to false", async () => { const user = await buildUser(); const res = await server.post("/api/collections.create", user, { @@ -1448,6 +1465,50 @@ describe("#collections.update", () => { expect(collection.content).toBeTruthy(); }); + it("replaces rendered content when description is updated post-create", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + const createRes = await server.post("/api/collections.create", admin, { + headers: { "x-api-version": "3" }, + body: { name: "Foo", description: "Original" }, + }); + const { id } = (await createRes.json()).data; + + const updateRes = await server.post("/api/collections.update", admin, { + headers: { "x-api-version": "3" }, + body: { id, description: "Replaced" }, + }); + expect(updateRes.status).toEqual(200); + + const infoRes = await server.post("/api/collections.info", admin, { + headers: { "x-api-version": "3" }, + body: { id }, + }); + const content = JSON.stringify((await infoRes.json()).data.data); + expect(content).toContain("Replaced"); + expect(content).not.toContain("Original"); + }); + + it("rejects providing both description and data", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + const res = await server.post("/api/collections.update", admin, { + body: { + id: collection.id, + description: "Test", + data: { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Test" }] }, + ], + }, + }, + }); + expect(res.status).toEqual(400); + }); + it("allows editing data", async () => { const team = await buildTeam(); const admin = await buildAdmin({ teamId: team.id }); diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index 3d4dd3c0ec..a657c4ec9a 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -15,39 +15,50 @@ const BaseIdSchema = z.object({ id: zodIdType(), }); +/** The landing page can be set from description (markdown) or data (rich content), but not both. */ +const refineBodyContent = ( + body: T +) => isUndefined(body.description) || isUndefined(body.data); + +const bodyContentError = { + error: "Only one of description or data may be provided", +}; + export const CollectionsCreateSchema = BaseSchema.extend({ - body: z.object({ - name: z.string(), - color: z - .string() - .regex(ValidateColor.regex, { message: ValidateColor.message }) - .nullish(), - description: z.string().nullish(), - data: ProsemirrorSchema({ allowEmpty: true }).nullish(), - permission: z - .enum(CollectionPermission) - .nullish() - .transform((val) => (isUndefined(val) ? null : val)), - sharing: z.boolean().prefault(true), - icon: zodIconType().optional(), - sort: z - .object({ - field: z.union([z.literal("title"), z.literal("index")]), - direction: z.union([z.literal("asc"), z.literal("desc")]), - }) - .prefault(Collection.DEFAULT_SORT), - index: z - .string() - .regex(ValidateIndex.regex, { message: ValidateIndex.message }) - .max(ValidateIndex.maxLength, { - message: `Must be ${ValidateIndex.maxLength} or fewer characters long`, - }) - .optional(), - commenting: z.boolean().nullish(), - templateManagement: z - .enum([CollectionPermission.Admin, CollectionPermission.ReadWrite]) - .prefault(CollectionPermission.Admin), - }), + body: z + .object({ + name: z.string(), + color: z + .string() + .regex(ValidateColor.regex, { message: ValidateColor.message }) + .nullish(), + description: z.string().nullish(), + data: ProsemirrorSchema({ allowEmpty: true }).nullish(), + permission: z + .enum(CollectionPermission) + .nullish() + .transform((val) => (isUndefined(val) ? null : val)), + sharing: z.boolean().prefault(true), + icon: zodIconType().optional(), + sort: z + .object({ + field: z.union([z.literal("title"), z.literal("index")]), + direction: z.union([z.literal("asc"), z.literal("desc")]), + }) + .prefault(Collection.DEFAULT_SORT), + index: z + .string() + .regex(ValidateIndex.regex, { message: ValidateIndex.message }) + .max(ValidateIndex.maxLength, { + message: `Must be ${ValidateIndex.maxLength} or fewer characters long`, + }) + .optional(), + commenting: z.boolean().nullish(), + templateManagement: z + .enum([CollectionPermission.Admin, CollectionPermission.ReadWrite]) + .prefault(CollectionPermission.Admin), + }) + .refine(refineBodyContent, bodyContentError), }); export type CollectionsCreateReq = z.infer; @@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({ templateManagement: z .enum([CollectionPermission.Admin, CollectionPermission.ReadWrite]) .optional(), - }), + }).refine(refineBodyContent, bodyContentError), }); export type CollectionsUpdateReq = z.infer;