Files
superset2/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
T

687 lines
20 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
parseEChartOptions,
safeParseEChartOptions,
EChartOptionsParseError,
} from './safeEChartOptionsParser';
test('parseEChartOptions returns undefined for empty input', () => {
expect(parseEChartOptions(undefined)).toEqual({
success: true,
data: undefined,
});
expect(parseEChartOptions('')).toEqual({ success: true, data: undefined });
expect(parseEChartOptions(' ')).toEqual({ success: true, data: undefined });
});
test('parseEChartOptions parses simple object literals', () => {
const input = `{ title: { text: 'My Chart' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { text: 'My Chart' },
});
});
test('parseEChartOptions parses nested objects and arrays', () => {
const input = `{
grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
xAxis: { type: 'category' },
yAxis: [{ type: 'value' }, { type: 'log' }],
series: [
{ name: 'Series 1', type: 'line' },
{ name: 'Series 2', type: 'bar' }
]
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
xAxis: { type: 'category' },
yAxis: [{ type: 'value' }, { type: 'log' }],
series: [
{ name: 'Series 1', type: 'line' },
{ name: 'Series 2', type: 'bar' },
],
});
});
test('parseEChartOptions handles negative numbers in valid properties', () => {
const input = `{ xAxis: { nameRotate: -45, offset: -10 } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ xAxis: { nameRotate: -45, offset: -10 } });
});
test('parseEChartOptions handles boolean values in valid properties', () => {
const input = `{ animation: true, useUTC: false }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ animation: true, useUTC: false });
});
test('parseEChartOptions throws for special numeric values like -Infinity', () => {
// Special values like -Infinity are not valid JSON-serializable values
const input = `{ xAxis: { min: -Infinity, splitNumber: 5 } }`;
// -Infinity is not a valid value for 'min' in the schema
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
});
test('parseEChartOptions throws for function expressions', () => {
const input = `{ formatter: function(value) { return value; } }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
}
});
test('parseEChartOptions throws for arrow functions', () => {
const input = `{ formatter: (value) => value }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
}
});
test('parseEChartOptions throws for function calls', () => {
const input = `{ value: eval('1+1') }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe('security_error');
}
});
test('parseEChartOptions throws for dangerous identifiers', () => {
const dangerousInputs = [
`{ x: window }`,
`{ x: document }`,
`{ x: globalThis }`,
`{ x: process }`,
`{ x: require }`,
`{ x: constructor }`,
`{ x: __proto__ }`,
];
dangerousInputs.forEach(input => {
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'security_error',
);
}
});
});
test('parseEChartOptions throws for computed properties', () => {
const input = `{ [dynamicKey]: 'value' }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
});
test('parseEChartOptions throws for template literals with expressions', () => {
const input = '{ text: `Hello ${name}` }';
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
});
test('parseEChartOptions allows simple template literals in valid properties', () => {
const input = '{ title: { text: `Hello World` } }';
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ title: { text: 'Hello World' } });
});
test('parseEChartOptions throws for new expressions', () => {
const input = `{ date: new Date() }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
});
test('parseEChartOptions throws for member expressions', () => {
const input = `{ value: Math.PI }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
});
test('parseEChartOptions handles trailing commas (JSON5-like)', () => {
const input = `{
title: { text: 'Chart', },
series: [
{ name: 'A', },
{ name: 'B', },
],
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { text: 'Chart' },
series: [{ name: 'A' }, { name: 'B' }],
});
});
test('parseEChartOptions handles unquoted keys in nested objects', () => {
// Unknown top-level keys are filtered, but valid nested keys work
const input = `{ title: { text: 'value', show: true } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { text: 'value', show: true },
});
});
test('safeParseEChartOptions throws on parse error', () => {
expect(() => safeParseEChartOptions('{ invalid')).toThrow(
EChartOptionsParseError,
);
});
test('safeParseEChartOptions throws on security error', () => {
expect(() => safeParseEChartOptions('{ fn: () => {} }')).toThrow(
EChartOptionsParseError,
);
});
test('safeParseEChartOptions returns data on success', () => {
const result = safeParseEChartOptions(`{ title: { text: 'Test' } }`);
expect(result).toEqual({ title: { text: 'Test' } });
});
test('parseEChartOptions handles complex real-world EChart options', () => {
const input = `{
title: {
text: 'Sales Overview',
subtext: 'Monthly Data',
left: 'center',
textStyle: {
color: '#333',
fontSize: 18,
fontWeight: 'bold'
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 50
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
xAxis: {
type: 'category',
boundaryGap: false,
axisLine: {
lineStyle: {
color: '#999'
}
}
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed'
}
}
},
dataZoom: [
{
type: 'slider',
start: 0,
end: 100
},
{
type: 'inside'
}
]
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.title).toBeDefined();
expect(result.data?.legend).toBeDefined();
expect(result.data?.grid).toBeDefined();
expect(result.data?.tooltip).toBeDefined();
expect(result.data?.xAxis).toBeDefined();
expect(result.data?.yAxis).toBeDefined();
expect(result.data?.dataZoom).toHaveLength(2);
});
// =============================================================================
// Schema Validation Tests
// =============================================================================
test('parseEChartOptions throws when title is a string instead of object', () => {
// title should be TitleOption (object), not string
const input = `{ title: 'text' }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
expect(
(error as EChartOptionsParseError).validationErrors.length,
).toBeGreaterThan(0);
}
});
test('parseEChartOptions throws when grid is a string instead of object', () => {
const input = `{ grid: 'invalid' }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
}
});
test('parseEChartOptions throws when nested property has wrong type', () => {
// textStyle should be object, not string - this invalidates the entire title
const input = `{ title: { text: 'Chart', textStyle: 'invalid' } }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
}
});
test('parseEChartOptions keeps valid nested objects', () => {
const input = `{ title: { text: 'Chart', textStyle: { color: '#333', fontSize: 14 } } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.title).toEqual({
text: 'Chart',
textStyle: { color: '#333', fontSize: 14 },
});
});
test('parseEChartOptions throws when some properties are invalid', () => {
const input = `{
title: { text: 'Valid Title' },
legend: 'invalid',
grid: { top: 50 }
}`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
expect(
(error as EChartOptionsParseError).validationErrors.some(e =>
e.includes('legend'),
),
).toBe(true);
}
});
test('parseEChartOptions ignores unknown top-level properties', () => {
const input = `{
title: { text: 'Chart' },
unknownProperty: 'should be filtered',
anotherUnknown: { nested: 'value' }
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.title).toEqual({ text: 'Chart' });
expect(
(result.data as Record<string, unknown>)?.unknownProperty,
).toBeUndefined();
expect(
(result.data as Record<string, unknown>)?.anotherUnknown,
).toBeUndefined();
});
test('parseEChartOptions throws when array has invalid items', () => {
// dataZoom array should contain objects, not strings
const input = `{
dataZoom: [
{ type: 'slider', start: 0 },
'invalid',
{ type: 'inside' }
]
}`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
expect(
(error as EChartOptionsParseError).validationErrors.some(e =>
e.includes('dataZoom'),
),
).toBe(true);
}
});
test('parseEChartOptions validates tooltip properties', () => {
const input = `{
tooltip: {
trigger: 'axis',
show: true,
padding: 10
}
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({
trigger: 'axis',
show: true,
padding: 10,
});
});
test('parseEChartOptions validates xAxis type property', () => {
const input = `{
xAxis: {
type: 'category',
name: 'X Axis',
axisLabel: {
rotate: 45,
fontSize: 12
}
}
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.xAxis).toEqual({
type: 'category',
name: 'X Axis',
axisLabel: {
rotate: 45,
fontSize: 12,
},
});
});
test('parseEChartOptions throws when number is used where string expected', () => {
// backgroundColor should be string, not number
const input = `{ backgroundColor: 123 }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
}
});
test('parseEChartOptions accepts valid animation options', () => {
const input = `{
animation: true,
animationDuration: 1000,
animationEasing: 'cubicOut',
animationDelay: 100
}`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({
animation: true,
animationDuration: 1000,
animationEasing: 'cubicOut',
animationDelay: 100,
});
});
test('EChartOptionsParseError contains validation error details', () => {
const input = `{ title: 'invalid', grid: 123 }`;
expect.assertions(5);
try {
parseEChartOptions(input);
} catch (error) {
expect(error).toBeInstanceOf(EChartOptionsParseError);
const parseError = error as EChartOptionsParseError;
expect(parseError.errorType).toBe('validation_error');
expect(parseError.validationErrors.length).toBe(2);
expect(parseError.validationErrors.some(e => e.includes('title'))).toBe(
true,
);
expect(parseError.validationErrors.some(e => e.includes('grid'))).toBe(
true,
);
}
});
// =============================================================================
// Creator-authored options must not reach the tooltip's innerHTML/
// navigation sinks unsanitized.
// =============================================================================
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
});
test('keeps presentational tags in tooltip string formatters', () => {
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
});
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
const result = parseEChartOptions(
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ series: [{ type: 'line' }] });
});
test('accepts markup-free tooltip placeholder formatters', () => {
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
const result = parseEChartOptions(input);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
});
test('rejects javascript: URLs in title link and sublink', () => {
expect(() =>
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
).toThrow(EChartOptionsParseError);
expect(() =>
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
).toThrow(EChartOptionsParseError);
});
test('accepts http(s) and same-origin relative title links', () => {
const result = parseEChartOptions(
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
});
});
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
const result = parseEChartOptions(
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ tooltip: { show: true } });
});
test('accepts a decal pattern on itemStyle', () => {
const result = parseEChartOptions(
`{ series: { itemStyle: { decal: {
symbol: 'rect',
dashArrayX: [1, 0],
dashArrayY: [2, 4],
rotation: -0.7853981633974483,
color: 'rgba(0, 0, 0, 0.2)',
} } } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
series: {
itemStyle: {
decal: {
symbol: 'rect',
dashArrayX: [1, 0],
dashArrayY: [2, 4],
rotation: -0.7853981633974483,
color: 'rgba(0, 0, 0, 0.2)',
},
},
},
});
});
test('accepts the full decal shape, including per-row dash arrays', () => {
// `dashArrayX` nests one level to offset rows from each other; `dashArrayY`
// has no equivalent.
const result = parseEChartOptions(
`{ series: { itemStyle: { decal: {
symbol: ['rect', 'circle'],
symbolSize: 0.8,
symbolKeepAspect: false,
color: '#383838',
backgroundColor: 'transparent',
dashArrayX: [[1, 0], [0, 1]],
dashArrayY: 5,
rotation: 0,
maxTileWidth: 512,
maxTileHeight: 512,
} } } }`,
);
expect(result.success).toBe(true);
expect(
(result.data as { series: { itemStyle: { decal: unknown } } }).series
.itemStyle.decal,
).toEqual({
symbol: ['rect', 'circle'],
symbolSize: 0.8,
symbolKeepAspect: false,
color: '#383838',
backgroundColor: 'transparent',
dashArrayX: [
[1, 0],
[0, 1],
],
dashArrayY: 5,
rotation: 0,
maxTileWidth: 512,
maxTileHeight: 512,
});
});
test('strips unknown keys from a decal rather than passing them through', () => {
const result = parseEChartOptions(
`{ series: { itemStyle: { decal: { symbol: 'rect', onclick: 'alert(1)' } } } }`,
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
series: { itemStyle: { decal: { symbol: 'rect' } } },
});
});
test('rejects a decal whose values are of the wrong type', () => {
// Unknown keys are stripped; a known key with a bad type is an error.
const input = `{ series: { itemStyle: { decal: { rotation: 'sideways' } } } }`;
expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
try {
parseEChartOptions(input);
} catch (error) {
expect((error as EChartOptionsParseError).errorType).toBe(
'validation_error',
);
}
});