fix: Reject collections.update requests that include both description and data (#12648)

This commit is contained in:
Tom Moor
2026-06-11 21:30:47 -04:00
committed by GitHub
parent 9791ff1170
commit c65b020655
2 changed files with 105 additions and 33 deletions
@@ -1257,6 +1257,23 @@ describe("#collections.create", () => {
expect(res.status).toEqual(400); 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 () => { it("should allow setting sharing to false", async () => {
const user = await buildUser(); const user = await buildUser();
const res = await server.post("/api/collections.create", user, { const res = await server.post("/api/collections.create", user, {
@@ -1448,6 +1465,50 @@ describe("#collections.update", () => {
expect(collection.content).toBeTruthy(); 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 () => { it("allows editing data", async () => {
const team = await buildTeam(); const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id }); const admin = await buildAdmin({ teamId: team.id });
+44 -33
View File
@@ -15,39 +15,50 @@ const BaseIdSchema = z.object({
id: zodIdType(), id: zodIdType(),
}); });
/** The landing page can be set from description (markdown) or data (rich content), but not both. */
const refineBodyContent = <T extends { description?: unknown; data?: unknown }>(
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({ export const CollectionsCreateSchema = BaseSchema.extend({
body: z.object({ body: z
name: z.string(), .object({
color: z name: z.string(),
.string() color: z
.regex(ValidateColor.regex, { message: ValidateColor.message }) .string()
.nullish(), .regex(ValidateColor.regex, { message: ValidateColor.message })
description: z.string().nullish(), .nullish(),
data: ProsemirrorSchema({ allowEmpty: true }).nullish(), description: z.string().nullish(),
permission: z data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
.enum(CollectionPermission) permission: z
.nullish() .enum(CollectionPermission)
.transform((val) => (isUndefined(val) ? null : val)), .nullish()
sharing: z.boolean().prefault(true), .transform((val) => (isUndefined(val) ? null : val)),
icon: zodIconType().optional(), sharing: z.boolean().prefault(true),
sort: z icon: zodIconType().optional(),
.object({ sort: z
field: z.union([z.literal("title"), z.literal("index")]), .object({
direction: z.union([z.literal("asc"), z.literal("desc")]), field: z.union([z.literal("title"), z.literal("index")]),
}) direction: z.union([z.literal("asc"), z.literal("desc")]),
.prefault(Collection.DEFAULT_SORT), })
index: z .prefault(Collection.DEFAULT_SORT),
.string() index: z
.regex(ValidateIndex.regex, { message: ValidateIndex.message }) .string()
.max(ValidateIndex.maxLength, { .regex(ValidateIndex.regex, { message: ValidateIndex.message })
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`, .max(ValidateIndex.maxLength, {
}) message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
.optional(), })
commenting: z.boolean().nullish(), .optional(),
templateManagement: z commenting: z.boolean().nullish(),
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite]) templateManagement: z
.prefault(CollectionPermission.Admin), .enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
}), .prefault(CollectionPermission.Admin),
})
.refine(refineBodyContent, bodyContentError),
}); });
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>; export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
@@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
templateManagement: z templateManagement: z
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite]) .enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
.optional(), .optional(),
}), }).refine(refineBodyContent, bodyContentError),
}); });
export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>; export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;