From 05d06d205f2b93342907ce3731b981bbc7d1bc9a Mon Sep 17 00:00:00 2001 From: Jan Mevissen Date: Fri, 12 Jun 2026 15:03:15 +0200 Subject: [PATCH 1/3] #129 use zod 3 passthrough to not drop unconfigured fields --- packages/client/news/129.bugfix | 1 + .../client/src/restapi/content/update.test.ts | 23 +++++ packages/client/src/validation/content.ts | 95 ++++++++++--------- .../client/src/validation/passthrough.test.ts | 68 +++++++++++++ packages/client/src/validation/users.ts | 44 +++++---- 5 files changed, 165 insertions(+), 66 deletions(-) create mode 100644 packages/client/news/129.bugfix create mode 100644 packages/client/src/validation/passthrough.test.ts 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/restapi/content/update.test.ts b/packages/client/src/restapi/content/update.test.ts index ac37109da..bec038e7b 100644 --- a/packages/client/src/restapi/content/update.test.ts +++ b/packages/client/src/restapi/content/update.test.ts @@ -54,4 +54,27 @@ describe('Update Content', () => { expect((err as RequestError).status).toBe(404); } }); + + test('Saves the changeNote as the version comment', async () => { + const path = '/'; + const data = { + '@type': 'Document', + title: 'My Page', + }; + await cli.createContent({ path, data }); + + const dataPatch = { + title: 'My Page updated', + changeNote: 'Updated the title', + }; + const pagePath = '/my-page'; + + const result = await cli.updateContent({ path: pagePath, data: dataPatch }); + + expect(result.status).toBe(204); + + const history = await cli.getHistory({ path: pagePath }); + + expect(history.data[0].comments).toBe('Updated the title'); + }); }); diff --git a/packages/client/src/validation/content.ts b/packages/client/src/validation/content.ts index 58e870902..aa9b2a469 100644 --- a/packages/client/src/validation/content.ts +++ b/packages/client/src/validation/content.ts @@ -60,51 +60,53 @@ 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(), + 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({ @@ -141,7 +143,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..b4066d2be --- /dev/null +++ b/packages/client/src/validation/passthrough.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { createContentDataSchema, updateContentDataSchema } from './content'; +import { createUserDataSchema, updateUserDataSchema } from './users'; + +describe('content data schemas', () => { + test('updateContentDataSchema keeps the changeNote', () => { + const parsed = updateContentDataSchema.parse({ + title: 'My Page', + changeNote: 'Fixed a typo', + }); + + expect(parsed.changeNote).toBe('Fixed a typo'); + }); + + 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(); + }); + + test('createContentDataSchema keeps changeNote and unknown fields', () => { + const parsed = createContentDataSchema.parse({ + '@type': 'Document', + title: 'My Page', + changeNote: 'Initial version', + my_custom_field: 'custom value', + }); + + expect(parsed.changeNote).toBe('Initial version'); + expect(parsed.my_custom_field).toBe('custom value'); + }); +}); + +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(), From fd8f82c3c1ffc042a0a4ed29002df82e65c8e031 Mon Sep 17 00:00:00 2001 From: Jan Mevissen Date: Fri, 12 Jun 2026 15:52:53 +0200 Subject: [PATCH 2/3] #129 configure changeNote field in zod schema --- packages/client/src/validation/content.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/client/src/validation/content.ts b/packages/client/src/validation/content.ts index aa9b2a469..1bbd06e92 100644 --- a/packages/client/src/validation/content.ts +++ b/packages/client/src/validation/content.ts @@ -68,6 +68,7 @@ export const createContentDataSchema = z 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(), @@ -113,6 +114,7 @@ export const updateContentDataSchema = z 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(), From 47464db3f1784ef761fea2a7b6fffba7e08adc72 Mon Sep 17 00:00:00 2001 From: Jan Mevissen Date: Fri, 12 Jun 2026 15:59:57 +0200 Subject: [PATCH 3/3] #129 remove changeNote specific tests --- .../client/src/restapi/content/update.test.ts | 23 ------------------- .../client/src/validation/passthrough.test.ts | 21 ++++------------- 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/packages/client/src/restapi/content/update.test.ts b/packages/client/src/restapi/content/update.test.ts index bec038e7b..ac37109da 100644 --- a/packages/client/src/restapi/content/update.test.ts +++ b/packages/client/src/restapi/content/update.test.ts @@ -54,27 +54,4 @@ describe('Update Content', () => { expect((err as RequestError).status).toBe(404); } }); - - test('Saves the changeNote as the version comment', async () => { - const path = '/'; - const data = { - '@type': 'Document', - title: 'My Page', - }; - await cli.createContent({ path, data }); - - const dataPatch = { - title: 'My Page updated', - changeNote: 'Updated the title', - }; - const pagePath = '/my-page'; - - const result = await cli.updateContent({ path: pagePath, data: dataPatch }); - - expect(result.status).toBe(204); - - const history = await cli.getHistory({ path: pagePath }); - - expect(history.data[0].comments).toBe('Updated the title'); - }); }); diff --git a/packages/client/src/validation/passthrough.test.ts b/packages/client/src/validation/passthrough.test.ts index b4066d2be..e12b2bd6e 100644 --- a/packages/client/src/validation/passthrough.test.ts +++ b/packages/client/src/validation/passthrough.test.ts @@ -3,13 +3,14 @@ import { createContentDataSchema, updateContentDataSchema } from './content'; import { createUserDataSchema, updateUserDataSchema } from './users'; describe('content data schemas', () => { - test('updateContentDataSchema keeps the changeNote', () => { - const parsed = updateContentDataSchema.parse({ + test('createContentDataSchema keeps unknown (custom dexterity) fields', () => { + const parsed = createContentDataSchema.parse({ + '@type': 'Document', title: 'My Page', - changeNote: 'Fixed a typo', + my_custom_field: 'custom value', }); - expect(parsed.changeNote).toBe('Fixed a typo'); + expect(parsed.my_custom_field).toBe('custom value'); }); test('updateContentDataSchema keeps unknown (custom dexterity) fields', () => { @@ -26,18 +27,6 @@ describe('content data schemas', () => { updateContentDataSchema.parse({ exclude_from_nav: 'yes' }), ).toThrow(); }); - - test('createContentDataSchema keeps changeNote and unknown fields', () => { - const parsed = createContentDataSchema.parse({ - '@type': 'Document', - title: 'My Page', - changeNote: 'Initial version', - my_custom_field: 'custom value', - }); - - expect(parsed.changeNote).toBe('Initial version'); - expect(parsed.my_custom_field).toBe('custom value'); - }); }); describe('user data schemas', () => {