fix: validate blocknote JSON in rich text fields (#18902)

## Summary
- **Backend**: Add JSON validation for the `blocknote` subfield in rich
text API inputs — rejects values that aren't valid JSON or aren't arrays
(BlockNote content is always `PartialBlock[]`). This prevents corrupted
data from being persisted to the database.
- **Frontend**: Replace all 5 unprotected `JSON.parse` calls on
blocknote content with the safe `parseJson` utility from
`twenty-shared`. Invalid content now degrades gracefully (empty block /
empty string / unchanged passthrough) instead of crashing the app.
- **Tests**: Added integration tests for invalid blocknote JSON (both
GraphQL and REST), unit tests for the new validation, and updated
existing test constants to use valid BlockNote JSON.

## Context
A user reported a `SyntaxError: Expected ',' or ']' after array element`
crash caused by malformed blocknote JSON stored in the database. The
data had `"children":[]` nested inside the `content` array instead of as
a sibling property. The API accepted this invalid JSON because it only
validated that `blocknote` was a string, not that it contained valid
JSON. On the frontend, 5 call sites used bare `JSON.parse` with no error
handling, causing a white-screen crash.

## Test plan
- [x] Unit tests pass: `validate-rich-text-field-or-throw.util.spec.ts`
(10/10)
- [x] Integration tests pass: `rich-text-field-create-input-validation`
(8/8)
- [ ] Verify creating a note with valid rich text still works end-to-end
- [ ] Verify API returns clear error when blocknote contains invalid
JSON
- [ ] Verify frontend renders empty block instead of crashing when
encountering corrupted data

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Charles Bochet
2026-03-24 14:53:15 +01:00
committed by GitHub
parent 56ea79d98c
commit 7b6fb52df7
14 changed files with 206 additions and 142 deletions
@@ -172,11 +172,11 @@ exports[`DataArgProcessorService failing inputs validation RELATION should throw
exports[`DataArgProcessorService failing inputs validation RELATION should throw for invalid input #5: "non-uuid" 1`] = `"Invalid UUID value 'non-uuid' for field "manyToOneRelationFieldId""`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #1: "not-a-rich-text" 1`] = `"Invalid rich text v2 value 'not-a-rich-text' for field "richTextField" - Should be an object"`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #1: "not-a-rich-text" 1`] = `"Invalid object value 'not-a-rich-text' for field "richTextField""`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #2: 1 1`] = `"Invalid rich text v2 value 1 for field "richTextField" - Should be an object"`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #2: 1 1`] = `"Invalid object value 1 for field "richTextField""`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #3: true 1`] = `"Invalid rich text v2 value true for field "richTextField" - Should be an object"`;
exports[`DataArgProcessorService failing inputs validation RICH_TEXT should throw for invalid input #3: true 1`] = `"Invalid object value true for field "richTextField""`;
exports[`DataArgProcessorService failing inputs validation SELECT should throw for invalid input #1: "not-a-select-option" 1`] = `"Invalid value 'not-a-select-option' for field "selectField""`;
@@ -385,9 +385,19 @@ export const successfulInputsByFieldMetadataType: {
],
[FieldMetadataType.RICH_TEXT]: [
{
input: { richTextField: { blocknote: 'test', markdown: 'test' } },
input: {
richTextField: {
blocknote:
'[{"type":"paragraph","content":[{"type":"text","text":"test"}]}]',
markdown: 'test',
},
},
expectedOutput: {
richTextField: { blocknote: 'test', markdown: 'test' },
richTextField: {
blocknote:
'[{"type":"paragraph","content":[{"type":"text","text":"test"}]}]',
markdown: 'test',
},
},
},
{
@@ -9,21 +9,36 @@ describe('validateRichTextFieldOrThrow', () => {
expect(result).toBeNull();
});
it('should return null when value is an empty object', () => {
it('should return value when value is an empty object', () => {
const result = validateRichTextFieldOrThrow({}, 'testField');
expect(result).toBeNull();
expect(result).toEqual({});
});
it('should return the value when it has valid subfields', () => {
const value = {
blocknote: 'some blocknote content',
blocknote:
'[{"type":"paragraph","content":[{"type":"text","text":"test"}]}]',
markdown: '# Heading\nContent',
};
const result = validateRichTextFieldOrThrow(value, 'testField');
expect(result).toEqual(value);
});
it('should return the value when blocknote is null', () => {
const value = { blocknote: null, markdown: 'test' };
const result = validateRichTextFieldOrThrow(value, 'testField');
expect(result).toEqual(value);
});
it('should return the value when only markdown is provided', () => {
const value = { markdown: '# Heading' };
const result = validateRichTextFieldOrThrow(value, 'testField');
expect(result).toEqual(value);
});
});
describe('invalid inputs', () => {
@@ -46,7 +61,29 @@ describe('validateRichTextFieldOrThrow', () => {
CommonQueryRunnerException,
);
expect(() => validateRichTextFieldOrThrow(value, 'testField')).toThrow(
/Should have only blocknote, markdown subfields/,
/Invalid subfield.*invalidField.*rich text field/,
);
});
it('should throw when blocknote contains invalid JSON', () => {
const value = { blocknote: 'not-valid-json' };
expect(() => validateRichTextFieldOrThrow(value, 'testField')).toThrow(
CommonQueryRunnerException,
);
expect(() => validateRichTextFieldOrThrow(value, 'testField')).toThrow(
/must contain valid JSON/,
);
});
it('should throw when blocknote is valid JSON but not an array', () => {
const value = { blocknote: '{"type":"paragraph"}' };
expect(() => validateRichTextFieldOrThrow(value, 'testField')).toThrow(
CommonQueryRunnerException,
);
expect(() => validateRichTextFieldOrThrow(value, 'testField')).toThrow(
/must be a JSON array of blocks/,
);
});
});
@@ -1,55 +1,79 @@
import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isNull, isObject } from '@sniptt/guards';
import {
compositeTypeDefinitions,
FieldMetadataType,
} from 'twenty-shared/types';
import { isNonEmptyString, isNull } from '@sniptt/guards';
import { validateRawJsonFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-raw-json-field-or-throw.util';
import { validateTextFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-text-field-or-throw.util';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
export const validateRichTextFieldOrThrow = (
const validateBlocknoteFieldOrThrow = (
value: unknown,
fieldName: string,
): {
blocknote: string | null | undefined;
markdown: string | null | undefined;
} | null => {
if (isNull(value)) return null;
): string | null => {
const textValue = validateTextFieldOrThrow(value, fieldName);
if (!isNonEmptyString(textValue)) return textValue;
let parsed: unknown;
try {
const parsedValue = JSON.parse(JSON.stringify(value));
if (!isObject(parsedValue)) throw new Error('Should be an object');
if (Object.keys(parsedValue).length === 0) return null;
const subfields = Object.keys(parsedValue);
const richTextSubfields = compositeTypeDefinitions
.get(FieldMetadataType.RICH_TEXT)
?.properties.filter(
(prop) => prop.hidden !== true && prop.hidden !== 'input',
)
.map((prop) => prop.name);
if (!subfields.every((subfield) => richTextSubfields?.includes(subfield)))
throw new Error(
`Should have only ${richTextSubfields?.join(', ')} subfields`,
);
return value as {
blocknote: string | null | undefined;
markdown: string | null | undefined;
};
} catch (error) {
parsed = JSON.parse(textValue);
} catch {
throw new CommonQueryRunnerException(
`Invalid rich text v2 value ${inspect(value)} for field "${fieldName}" - ${error.message}`,
`Invalid blocknote value for field "${fieldName}" - must contain valid JSON`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: msg`Invalid value for rich text.` },
);
}
if (!Array.isArray(parsed)) {
throw new CommonQueryRunnerException(
`Invalid blocknote value for field "${fieldName}" - must be a JSON array of blocks`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: msg`Invalid value for rich text.` },
);
}
return textValue;
};
export const validateRichTextFieldOrThrow = (
value: unknown,
fieldName: string,
): {
blocknote?: string | null;
markdown?: string | null;
} | null => {
const preValidatedValue = validateRawJsonFieldOrThrow(value, fieldName);
if (isNull(preValidatedValue)) return null;
for (const [subField, subFieldValue] of Object.entries(preValidatedValue)) {
switch (subField) {
case 'blocknote':
validateBlocknoteFieldOrThrow(
subFieldValue,
`${fieldName}.${subField}`,
);
break;
case 'markdown':
validateTextFieldOrThrow(subFieldValue, `${fieldName}.${subField}`);
break;
default:
throw new CommonQueryRunnerException(
`Invalid subfield ${inspect(subField)} for rich text field "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: msg`Invalid value for rich text.` },
);
}
}
return value as {
blocknote?: string | null;
markdown?: string | null;
};
};