fix(front): guard undefined nested output schema traversal
https://sonarly.com/issue/38306?type=bug Opening a specific workflow record crashes variable-chip rendering in the workflow UI with a handled React TypeError. The page can become unusable for that workflow because variable lookup throws during render instead of returning “Not Found”. Fix: Implemented a defensive traversal fix in workflow variable base-schema search so malformed non-leaf nodes no longer throw during render. ### What changed - In `searchVariableThroughBaseOutputSchema.ts`, `navigateToTargetField` now validates that a non-leaf node has an object-like `value` before traversing deeper. - If an intermediate node is malformed (`undefined`, non-object, or array), traversal now returns `undefined` variable metadata instead of throwing a `TypeError`. - This preserves intended UX behavior (“not found” fallback) and prevents workflow-page render crashes. ### Why this fixes the incident The crash happened when traversal assigned `currentSchema = field.value` where `field.value` was invalid, then accessed `currentSchema[nextSegment]`. The new guard blocks that invalid descent path. Authored by Sonarly by autonomous analysis (run 43714).
This commit is contained in:
+40
@@ -1,7 +1,19 @@
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { reportInvalidVariableSchemaTraversal } from '@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal';
|
||||
import type { BaseOutputSchemaV2 } from 'twenty-shared/workflow';
|
||||
|
||||
jest.mock(
|
||||
'@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal',
|
||||
() => ({
|
||||
reportInvalidVariableSchemaTraversal: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('searchVariableThroughBaseOutputSchema', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockBaseSchema: BaseOutputSchemaV2 = {
|
||||
message: {
|
||||
isLeaf: true,
|
||||
@@ -328,4 +340,32 @@ describe('searchVariableThroughBaseOutputSchema', () => {
|
||||
variableType: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined and report malformed nested schema nodes', () => {
|
||||
const malformedSchema: BaseOutputSchemaV2 = {
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'User Information',
|
||||
value: undefined as unknown as BaseOutputSchemaV2,
|
||||
},
|
||||
};
|
||||
|
||||
const result = searchVariableThroughBaseOutputSchema({
|
||||
stepName: 'HTTP Request',
|
||||
baseOutputSchema: malformedSchema,
|
||||
rawVariableName: '{{step1.user.phone}}',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
});
|
||||
expect(reportInvalidVariableSchemaTraversal).toHaveBeenCalledWith({
|
||||
stepName: 'HTTP Request',
|
||||
rawVariableName: '{{step1.user.phone}}',
|
||||
pathSegment: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
const reportedInvalidVariableSchemaTraversalKeys = new Set<string>();
|
||||
|
||||
export const reportInvalidVariableSchemaTraversal = ({
|
||||
stepName,
|
||||
rawVariableName,
|
||||
pathSegment,
|
||||
}: {
|
||||
stepName: string;
|
||||
rawVariableName: string;
|
||||
pathSegment: string;
|
||||
}) => {
|
||||
const issueKey = `${stepName}:${rawVariableName}:${pathSegment}`;
|
||||
|
||||
if (reportedInvalidVariableSchemaTraversalKeys.has(issueKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportedInvalidVariableSchemaTraversalKeys.add(issueKey);
|
||||
|
||||
void import('@sentry/react').then(({ captureMessage }) => {
|
||||
captureMessage('Workflow variable schema traversal skipped malformed node', {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
area: 'workflow-variables',
|
||||
},
|
||||
contexts: {
|
||||
workflowVariableSchemaTraversal: {
|
||||
stepName,
|
||||
rawVariableName,
|
||||
pathSegment,
|
||||
},
|
||||
},
|
||||
fingerprint: [
|
||||
'workflow-variable-schema-traversal-malformed-node',
|
||||
stepName,
|
||||
pathSegment,
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
+54
-9
@@ -1,4 +1,5 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { reportInvalidVariableSchemaTraversal } from '@/workflow/workflow-variables/utils/reportInvalidVariableSchemaTraversal';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
@@ -26,10 +27,17 @@ const parseVariableName = (rawVariableName: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const navigateToTargetField = (
|
||||
startingSchema: BaseOutputSchemaV2,
|
||||
pathSegments: string[],
|
||||
): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
|
||||
const navigateToTargetField = ({
|
||||
startingSchema,
|
||||
pathSegments,
|
||||
stepName,
|
||||
rawVariableName,
|
||||
}: {
|
||||
startingSchema: BaseOutputSchemaV2;
|
||||
pathSegments: string[];
|
||||
stepName: string;
|
||||
rawVariableName: string;
|
||||
}): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
|
||||
let currentSchema: BaseOutputSchemaV2 = startingSchema;
|
||||
const pathLabels: string[] = [];
|
||||
|
||||
@@ -40,6 +48,20 @@ const navigateToTargetField = (
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(field.value) ||
|
||||
typeof field.value !== 'object' ||
|
||||
Array.isArray(field.value)
|
||||
) {
|
||||
reportInvalidVariableSchemaTraversal({
|
||||
stepName,
|
||||
rawVariableName,
|
||||
pathSegment,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pathLabels.push(field.label);
|
||||
currentSchema = field.value;
|
||||
}
|
||||
@@ -78,13 +100,21 @@ export const searchBaseOutputSchema = ({
|
||||
baseOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
rawVariableName?: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateToTargetField(baseOutputSchema, path);
|
||||
const navigationResult = navigateToTargetField({
|
||||
startingSchema: baseOutputSchema,
|
||||
pathSegments: path,
|
||||
stepName,
|
||||
rawVariableName:
|
||||
rawVariableName ?? `${stepName}.${path.join('.')}.${selectedField}`,
|
||||
});
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
@@ -137,10 +167,25 @@ export const searchVariableThroughBaseOutputSchema = ({
|
||||
};
|
||||
}
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
const navigationResult = navigateToTargetField({
|
||||
startingSchema: baseOutputSchema,
|
||||
pathSegments,
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path: pathSegments,
|
||||
selectedField: targetFieldName,
|
||||
rawVariableName,
|
||||
});
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return buildVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
targetFieldName,
|
||||
);
|
||||
};
|
||||
|
||||
+1
@@ -109,6 +109,7 @@ export const searchVariableThroughIteratorOutputSchema = ({
|
||||
baseOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
rawVariableName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user