diff --git a/packages/client/news/129.bugfix b/packages/client/news/129.bugfix new file mode 100644 index 000000000..d38f79e05 --- /dev/null +++ b/packages/client/news/129.bugfix @@ -0,0 +1 @@ +Stopped the content and user data schemas from silently stripping fields they do not declare (e.g. `changeNote`, custom dexterity fields, custom member properties). Unknown keys are now passed through to the backend, which is the authority on which fields exist. @jmevissen diff --git a/packages/client/src/validation/content.ts b/packages/client/src/validation/content.ts index 58e870902..1bbd06e92 100644 --- a/packages/client/src/validation/content.ts +++ b/packages/client/src/validation/content.ts @@ -60,57 +60,61 @@ export const RelatedItemPayloadSchema = z title: true, }); -export const createContentDataSchema = z.object({ - '@id': z.string().optional(), - '@static_behaviors': z.unknown().optional(), - '@type': z.string(), - allow_discussion: z.boolean().optional(), - blocks: z.unknown().optional(), - blocks_layout: z.object({ items: z.array(z.string()) }).optional(), - contributors: z.array(z.string()).optional(), - creators: z.array(z.string()).optional(), - description: z.string().optional(), - effective: z.string().nullable().optional(), - exclude_from_nav: z.boolean().optional(), - expires: z.string().nullable().optional(), - file: z - .object({ - 'content-type': z.string(), - data: z.string(), - encoding: z.string(), - filename: z.string(), - }) - .optional(), - id: z.string().optional(), - image: z - .object({ - 'content-type': z.string(), - data: z.string(), - encoding: z.string(), - filename: z.string(), - }) - .optional(), - language: z.string().optional(), - preview_caption: z.string().optional(), - preview_image: z - .object({ - 'content-type': z.string(), - data: z.string(), - encoding: z.string(), - filename: z.string(), - }) - .optional(), - relatedItems: z.array(RelatedItemPayloadSchema).optional(), - rights: z.string().nullable().optional(), - title: z.string(), - versioning_enabled: z.boolean().optional(), -}); +export const createContentDataSchema = z + .object({ + '@id': z.string().optional(), + '@static_behaviors': z.unknown().optional(), + '@type': z.string(), + allow_discussion: z.boolean().optional(), + blocks: z.unknown().optional(), + blocks_layout: z.object({ items: z.array(z.string()) }).optional(), + changeNote: z.string().optional(), + contributors: z.array(z.string()).optional(), + creators: z.array(z.string()).optional(), + description: z.string().optional(), + effective: z.string().nullable().optional(), + exclude_from_nav: z.boolean().optional(), + expires: z.string().nullable().optional(), + file: z + .object({ + 'content-type': z.string(), + data: z.string(), + encoding: z.string(), + filename: z.string(), + }) + .optional(), + id: z.string().optional(), + image: z + .object({ + 'content-type': z.string(), + data: z.string(), + encoding: z.string(), + filename: z.string(), + }) + .optional(), + language: z.string().optional(), + preview_caption: z.string().optional(), + preview_image: z + .object({ + 'content-type': z.string(), + data: z.string(), + encoding: z.string(), + filename: z.string(), + }) + .optional(), + relatedItems: z.array(RelatedItemPayloadSchema).optional(), + rights: z.string().nullable().optional(), + title: z.string(), + versioning_enabled: z.boolean().optional(), + }) + .passthrough(); export const updateContentDataSchema = z .object({ allow_discussion: z.boolean().optional(), blocks: z.unknown().optional(), blocks_layout: z.object({ items: z.array(z.string()) }).optional(), + changeNote: z.string().optional(), contributors: z.array(z.string()).optional(), creators: z.array(z.string()).optional(), description: z.string().optional(), @@ -141,7 +145,8 @@ export const updateContentDataSchema = z title: z.string().optional(), versioning_enabled: z.boolean().optional(), }) - .partial(); + .partial() + .passthrough(); export const copyMoveContentDataSchema = z.object({ path: z.string(), diff --git a/packages/client/src/validation/passthrough.test.ts b/packages/client/src/validation/passthrough.test.ts new file mode 100644 index 000000000..e12b2bd6e --- /dev/null +++ b/packages/client/src/validation/passthrough.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; +import { createContentDataSchema, updateContentDataSchema } from './content'; +import { createUserDataSchema, updateUserDataSchema } from './users'; + +describe('content data schemas', () => { + test('createContentDataSchema keeps unknown (custom dexterity) fields', () => { + const parsed = createContentDataSchema.parse({ + '@type': 'Document', + title: 'My Page', + my_custom_field: 'custom value', + }); + + expect(parsed.my_custom_field).toBe('custom value'); + }); + + test('updateContentDataSchema keeps unknown (custom dexterity) fields', () => { + const parsed = updateContentDataSchema.parse({ + title: 'My Page', + my_custom_field: 'custom value', + }); + + expect(parsed.my_custom_field).toBe('custom value'); + }); + + test('updateContentDataSchema still rejects mistyped declared fields', () => { + expect(() => + updateContentDataSchema.parse({ exclude_from_nav: 'yes' }), + ).toThrow(); + }); +}); + +describe('user data schemas', () => { + test('createUserDataSchema keeps custom member properties', () => { + const parsed = createUserDataSchema.parse({ + email: 'jane@example.com', + username: 'jane', + phone: '+1 555 0100', + }); + + expect(parsed.phone).toBe('+1 555 0100'); + }); + + test('updateUserDataSchema keeps custom member properties', () => { + const parsed = updateUserDataSchema.parse({ + fullname: 'Jane Doe', + department: 'Engineering', + }); + + expect(parsed.department).toBe('Engineering'); + }); + + test('updateUserDataSchema still rejects mistyped declared fields', () => { + expect(() => + updateUserDataSchema.parse({ email: 'not-an-email' }), + ).toThrow(); + }); +}); diff --git a/packages/client/src/validation/users.ts b/packages/client/src/validation/users.ts index 750818eb7..c726c5d42 100644 --- a/packages/client/src/validation/users.ts +++ b/packages/client/src/validation/users.ts @@ -1,16 +1,18 @@ import { z } from 'zod'; -export const createUserDataSchema = z.object({ - description: z.string().optional(), - email: z.string().email(), - fullname: z.string().optional(), - home_page: z.string().url().optional(), - location: z.string().optional(), - sendPasswordReset: z.boolean().optional(), - username: z.string(), - roles: z.array(z.string()).optional(), - password: z.string().optional(), -}); +export const createUserDataSchema = z + .object({ + description: z.string().optional(), + email: z.string().email(), + fullname: z.string().optional(), + home_page: z.string().url().optional(), + location: z.string().optional(), + sendPasswordReset: z.boolean().optional(), + username: z.string(), + roles: z.array(z.string()).optional(), + password: z.string().optional(), + }) + .passthrough(); const portraitSchema = z.object({ 'content-type': z.string(), @@ -20,15 +22,17 @@ const portraitSchema = z.object({ scale: z.boolean().optional(), }); -export const updateUserDataSchema = z.object({ - description: z.string().optional(), - email: z.string().email().optional(), - fullname: z.string().optional(), - home_page: z.string().url().optional(), - location: z.string().optional(), - username: z.string().optional(), - portrait: portraitSchema.optional(), -}); +export const updateUserDataSchema = z + .object({ + description: z.string().optional(), + email: z.string().email().optional(), + fullname: z.string().optional(), + home_page: z.string().url().optional(), + location: z.string().optional(), + username: z.string().optional(), + portrait: portraitSchema.optional(), + }) + .passthrough(); export const resetPasswordWithTokenDataSchema = z.object({ reset_token: z.string(),