fix: beforeValidate deleting value when access returns false (#11549)

### What?
Regression caused by https://github.com/payloadcms/payload/pull/11433 

If a beforeChange hook was checking for a missing or undefined `value`
in order to change the value before inserting into the database, data
could be lost.

### Why?
In #11433 the logic for setting the fallback field value was moved above
the logic that cleared the value when access control returned false.

### How?
This change ensures that the fallback value is passed into the
beforeValidate function _and_ still available with the fallback value on
siblingData if access control returns false.

Fixes https://github.com/payloadcms/payload/issues/11543
This commit is contained in:
Jarrod Flesch
2025-03-05 13:34:08 -05:00
committed by GitHub
parent 143b6e3b8e
commit 6939a835ca
8 changed files with 290 additions and 110 deletions

View File

@@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { hooksSlug } from '../../shared.js'
export const Hooks: CollectionConfig = {
slug: hooksSlug,
access: {
update: () => true,
},
fields: [
{
name: 'cannotMutateRequired',
type: 'text',
access: {
update: () => false,
},
required: true,
},
{
name: 'cannotMutateNotRequired',
type: 'text',
access: {
update: () => false,
},
hooks: {
beforeChange: [
({ value }) => {
if (!value) {
return 'no value found'
}
return value
},
],
},
},
{
name: 'canMutate',
type: 'text',
access: {
update: () => true,
},
},
],
}

View File

@@ -10,6 +10,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../fields/collections/LexicalLocalized/textToLexicalJSON.js'
import { Disabled } from './collections/Disabled/index.js'
import { Hooks } from './collections/hooks/index.js'
import { Regression1 } from './collections/Regression-1/index.js'
import { Regression2 } from './collections/Regression-2/index.js'
import { RichText } from './collections/RichText/index.js'
@@ -567,6 +568,7 @@ export default buildConfigWithDefaults(
RichText,
Regression1,
Regression2,
Hooks,
],
globals: [
{

View File

@@ -8,7 +8,7 @@ import type {
} from 'payload'
import path from 'path'
import { Forbidden } from 'payload'
import { Forbidden, ValidationError } from 'payload'
import { fileURLToPath } from 'url'
import type { FullyRestricted, Post } from './payload-types.js'
@@ -21,6 +21,7 @@ import {
hiddenAccessCountSlug,
hiddenAccessSlug,
hiddenFieldsSlug,
hooksSlug,
relyOnRequestHeadersSlug,
restrictedVersionsSlug,
secondArrayText,
@@ -58,115 +59,181 @@ describe('Access Control', () => {
}
})
it('should not affect hidden fields when patching data', async () => {
const doc = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
describe('Fields', () => {
it('should not affect hidden fields when patching data', async () => {
const doc = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
},
})
await payload.update({
id: doc.id,
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
})
const updatedDoc = await payload.findByID({
id: doc.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedDoc.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedDoc.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
await payload.update({
id: doc.id,
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
})
const updatedDoc = await payload.findByID({
id: doc.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedDoc.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedDoc.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
it('should not affect hidden fields when patching data - update many', async () => {
const docsMany = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
it('should not affect hidden fields when patching data - update many', async () => {
const docsMany = await payload.create({
collection: hiddenFieldsSlug,
data: {
partiallyHiddenArray: [
{
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
],
partiallyHiddenGroup: {
name: 'public_name',
value: 'private_value',
},
},
})
await payload.update({
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
where: {
id: { equals: docsMany.id },
},
})
const updatedMany = await payload.findByID({
id: docsMany.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
})
expect(updatedMany.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedMany.partiallyHiddenArray[0].value).toStrictEqual('private_value')
})
await payload.update({
collection: hiddenFieldsSlug,
data: {
title: 'Doc Title',
},
where: {
id: { equals: docsMany.id },
},
it('should be able to restrict access based upon siblingData', async () => {
const { id } = await payload.create({
collection: siblingDataSlug,
data: {
array: [
{
allowPublicReadability: true,
text: firstArrayText,
},
{
allowPublicReadability: false,
text: secondArrayText,
},
],
},
})
const doc = await payload.findByID({
id,
collection: siblingDataSlug,
overrideAccess: false,
})
expect(doc.array?.[0].text).toBe(firstArrayText)
// Should respect PublicReadabilityAccess function and not be sent
expect(doc.array?.[1].text).toBeUndefined()
// Retrieve with default of overriding access
const docOverride = await payload.findByID({
id,
collection: siblingDataSlug,
})
expect(docOverride.array?.[0].text).toBe(firstArrayText)
expect(docOverride.array?.[1].text).toBe(secondArrayText)
})
const updatedMany = await payload.findByID({
id: docsMany.id,
collection: hiddenFieldsSlug,
showHiddenFields: true,
it('should use fallback value when trying to update a field without permission', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateRequired: 'new',
canMutate: 'canMutate',
},
})
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
expect(updatedMany.partiallyHiddenGroup.value).toStrictEqual('private_value')
expect(updatedMany.partiallyHiddenArray[0].value).toStrictEqual('private_value')
it('should use fallback value when required data is missing', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'original',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
canMutate: 'canMutate',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('original')
})
it('should pass fallback value through to beforeChange hook when access returns false', async () => {
const doc = await payload.create({
collection: hooksSlug,
data: {
cannotMutateRequired: 'cannotMutateRequired',
cannotMutateNotRequired: 'cannotMutateNotRequired',
},
})
const updatedDoc = await payload.update({
id: doc.id,
collection: hooksSlug,
overrideAccess: false,
data: {
cannotMutateNotRequired: 'updated',
},
})
// should fallback to original data and not throw validation error
expect(updatedDoc.cannotMutateRequired).toBe('cannotMutateRequired')
expect(updatedDoc.cannotMutateNotRequired).toBe('cannotMutateNotRequired')
})
})
it('should be able to restrict access based upon siblingData', async () => {
const { id } = await payload.create({
collection: siblingDataSlug,
data: {
array: [
{
allowPublicReadability: true,
text: firstArrayText,
},
{
allowPublicReadability: false,
text: secondArrayText,
},
],
},
})
const doc = await payload.findByID({
id,
collection: siblingDataSlug,
overrideAccess: false,
})
expect(doc.array?.[0].text).toBe(firstArrayText)
// Should respect PublicReadabilityAccess function and not be sent
expect(doc.array?.[1].text).toBeUndefined()
// Retrieve with default of overriding access
const docOverride = await payload.findByID({
id,
collection: siblingDataSlug,
})
expect(docOverride.array?.[0].text).toBe(firstArrayText)
expect(docOverride.array?.[1].text).toBe(secondArrayText)
})
describe('Collections', () => {
describe('restricted collection', () => {
it('field without read access should not show', async () => {

View File

@@ -89,6 +89,7 @@ export interface Config {
'rich-text': RichText;
regression1: Regression1;
regression2: Regression2;
hooks: Hook;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -117,6 +118,7 @@ export interface Config {
'rich-text': RichTextSelect<false> | RichTextSelect<true>;
regression1: Regression1Select<false> | Regression1Select<true>;
regression2: Regression2Select<false> | Regression2Select<true>;
hooks: HooksSelect<false> | HooksSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -680,6 +682,18 @@ export interface Regression2 {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks".
*/
export interface Hook {
id: string;
cannotMutateRequired: string;
cannotMutateNotRequired?: string | null;
canMutate?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -774,6 +788,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'regression2';
value: string | Regression2;
} | null)
| ({
relationTo: 'hooks';
value: string | Hook;
} | null);
globalSlug?: string | null;
user:
@@ -1168,6 +1186,17 @@ export interface Regression2Select<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks_select".
*/
export interface HooksSelect<T extends boolean = true> {
cannotMutateRequired?: T;
cannotMutateNotRequired?: T;
canMutate?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,6 +5,7 @@ export const slug = 'posts'
export const unrestrictedSlug = 'unrestricted'
export const readOnlySlug = 'read-only-collection'
export const readOnlyGlobalSlug = 'read-only-global'
export const hooksSlug = 'hooks'
export const userRestrictedCollectionSlug = 'user-restricted-collection'
export const fullyRestrictedSlug = 'fully-restricted'

View File

@@ -156,6 +156,8 @@ export interface BeforeValidate {
id: string;
title?: string | null;
selection?: ('a' | 'b') | null;
cannotMutate?: string | null;
canMutate?: string | null;
updatedAt: string;
createdAt: string;
}
@@ -741,6 +743,8 @@ export interface BeforeChangeHooksSelect<T extends boolean = true> {
export interface BeforeValidateSelect<T extends boolean = true> {
title?: T;
selection?: T;
cannotMutate?: T;
canMutate?: T;
updatedAt?: T;
createdAt?: T;
}