@seed-ship/mcp-ui-solid 6.8.1 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/components/ChartJSRenderer.cjs +27 -13
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +28 -14
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/DegradedFallback.cjs +73 -0
- package/dist/components/DegradedFallback.cjs.map +1 -0
- package/dist/components/DegradedFallback.d.ts +37 -0
- package/dist/components/DegradedFallback.d.ts.map +1 -0
- package/dist/components/DegradedFallback.js +73 -0
- package/dist/components/DegradedFallback.js.map +1 -0
- package/dist/components/GraphRenderer.cjs +30 -15
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +31 -16
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/MapRenderer.cjs +128 -107
- package/dist/components/MapRenderer.cjs.map +1 -1
- package/dist/components/MapRenderer.d.ts.map +1 -1
- package/dist/components/MapRenderer.js +129 -108
- package/dist/components/MapRenderer.js.map +1 -1
- package/dist/index.cjs +4 -4
- package/dist/index.js +1 -1
- package/dist/services/validation.cjs +43 -9
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +43 -9
- package/dist/services/validation.js.map +1 -1
- package/dist/utils/degraded-projections.cjs +87 -0
- package/dist/utils/degraded-projections.cjs.map +1 -0
- package/dist/utils/degraded-projections.d.ts +64 -0
- package/dist/utils/degraded-projections.d.ts.map +1 -0
- package/dist/utils/degraded-projections.js +87 -0
- package/dist/utils/degraded-projections.js.map +1 -0
- package/package.json +1 -1
- package/src/components/ChartJSRenderer.tsx +94 -85
- package/src/components/DegradedFallback.test.tsx +61 -0
- package/src/components/DegradedFallback.tsx +93 -0
- package/src/components/GraphRenderer.tsx +26 -4
- package/src/components/MapRenderer.tsx +446 -392
- package/src/services/validation.test.ts +298 -232
- package/src/services/validation.ts +210 -136
- package/src/utils/degraded-projections.test.ts +113 -0
- package/src/utils/degraded-projections.ts +149 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* without UNKNOWN_COMPONENT_TYPE errors.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
9
9
|
import {
|
|
10
10
|
validateComponent,
|
|
11
11
|
validateChartComponent,
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
getIframeSandbox,
|
|
14
14
|
validateIframeDomain,
|
|
15
15
|
DEFAULT_RESOURCE_LIMITS,
|
|
16
|
-
} from './validation'
|
|
17
|
-
import type { UIComponent, ComponentType } from '../types'
|
|
16
|
+
} from './validation';
|
|
17
|
+
import type { UIComponent, ComponentType } from '../types';
|
|
18
18
|
|
|
19
19
|
/** Helper to create a minimal valid UIComponent for testing */
|
|
20
20
|
function makeComponent(type: ComponentType, params: Record<string, any> = {}): UIComponent {
|
|
@@ -23,180 +23,224 @@ function makeComponent(type: ComponentType, params: Record<string, any> = {}): U
|
|
|
23
23
|
type,
|
|
24
24
|
position: { colStart: 1, colSpan: 12 },
|
|
25
25
|
params: params as any,
|
|
26
|
-
}
|
|
26
|
+
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** Types that have explicit validation cases in validateComponent */
|
|
30
30
|
const VALIDATED_TYPES: ComponentType[] = [
|
|
31
|
-
'chart',
|
|
31
|
+
'chart',
|
|
32
|
+
'table',
|
|
33
|
+
'metric',
|
|
34
|
+
'text',
|
|
35
|
+
'iframe',
|
|
36
|
+
'image',
|
|
37
|
+
'link',
|
|
38
|
+
'action',
|
|
32
39
|
'artifact',
|
|
33
|
-
]
|
|
40
|
+
];
|
|
34
41
|
|
|
35
42
|
/** Types that hit the default case (no specific validation) */
|
|
36
43
|
const PASSTHROUGH_TYPES: ComponentType[] = [
|
|
37
|
-
'code',
|
|
38
|
-
'
|
|
44
|
+
'code',
|
|
45
|
+
'map',
|
|
46
|
+
'form',
|
|
47
|
+
'modal',
|
|
48
|
+
'action-group',
|
|
49
|
+
'image-gallery',
|
|
50
|
+
'video',
|
|
51
|
+
'grid',
|
|
52
|
+
'carousel',
|
|
39
53
|
'footer',
|
|
40
|
-
]
|
|
54
|
+
];
|
|
41
55
|
|
|
42
56
|
describe('validateComponent', () => {
|
|
43
57
|
describe('passthrough types (no specific validation case)', () => {
|
|
44
58
|
it.each(PASSTHROUGH_TYPES)('"%s" does NOT produce UNKNOWN_COMPONENT_TYPE error', (type) => {
|
|
45
|
-
const component = makeComponent(type)
|
|
46
|
-
const result = validateComponent(component)
|
|
59
|
+
const component = makeComponent(type);
|
|
60
|
+
const result = validateComponent(component);
|
|
47
61
|
|
|
48
|
-
const unknownTypeError = result.errors?.find(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
})
|
|
53
|
-
})
|
|
62
|
+
const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
|
|
63
|
+
expect(unknownTypeError).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
54
66
|
|
|
55
67
|
describe('validated types still work', () => {
|
|
56
68
|
it('validates a valid chart component', () => {
|
|
57
69
|
const component = makeComponent('chart', {
|
|
58
70
|
type: 'bar',
|
|
59
71
|
data: { labels: ['A'], datasets: [{ data: [1] }] },
|
|
60
|
-
})
|
|
61
|
-
const result = validateComponent(component)
|
|
62
|
-
expect(result.valid).toBe(true)
|
|
63
|
-
})
|
|
72
|
+
});
|
|
73
|
+
const result = validateComponent(component);
|
|
74
|
+
expect(result.valid).toBe(true);
|
|
75
|
+
});
|
|
64
76
|
|
|
65
77
|
it('validates a valid table component', () => {
|
|
66
78
|
const component = makeComponent('table', {
|
|
67
79
|
columns: [{ key: 'name', label: 'Name' }],
|
|
68
80
|
rows: [{ name: 'test' }],
|
|
69
|
-
})
|
|
70
|
-
const result = validateComponent(component)
|
|
71
|
-
expect(result.valid).toBe(true)
|
|
72
|
-
})
|
|
81
|
+
});
|
|
82
|
+
const result = validateComponent(component);
|
|
83
|
+
expect(result.valid).toBe(true);
|
|
84
|
+
});
|
|
73
85
|
|
|
74
86
|
it('validates a valid text component', () => {
|
|
75
87
|
const component = makeComponent('text', {
|
|
76
88
|
content: 'Hello world',
|
|
77
|
-
})
|
|
78
|
-
const result = validateComponent(component)
|
|
79
|
-
expect(result.valid).toBe(true)
|
|
80
|
-
})
|
|
89
|
+
});
|
|
90
|
+
const result = validateComponent(component);
|
|
91
|
+
expect(result.valid).toBe(true);
|
|
92
|
+
});
|
|
81
93
|
|
|
82
94
|
it('validates a valid metric component', () => {
|
|
83
95
|
const component = makeComponent('metric', {
|
|
84
96
|
value: 42,
|
|
85
97
|
title: 'Count',
|
|
86
|
-
})
|
|
87
|
-
const result = validateComponent(component)
|
|
88
|
-
expect(result.valid).toBe(true)
|
|
89
|
-
})
|
|
90
|
-
})
|
|
98
|
+
});
|
|
99
|
+
const result = validateComponent(component);
|
|
100
|
+
expect(result.valid).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
91
103
|
|
|
92
104
|
describe('regression: code type renders without validation error', () => {
|
|
93
105
|
it('type "code" passes validation', () => {
|
|
94
106
|
const component = makeComponent('code', {
|
|
95
107
|
code: 'console.log("hello")',
|
|
96
108
|
language: 'typescript',
|
|
97
|
-
})
|
|
98
|
-
const result = validateComponent(component)
|
|
99
|
-
const unknownTypeError = result.errors?.find(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(unknownTypeError).toBeUndefined()
|
|
103
|
-
})
|
|
109
|
+
});
|
|
110
|
+
const result = validateComponent(component);
|
|
111
|
+
const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
|
|
112
|
+
expect(unknownTypeError).toBeUndefined();
|
|
113
|
+
});
|
|
104
114
|
|
|
105
115
|
it('type "map" passes validation', () => {
|
|
106
116
|
const component = makeComponent('map', {
|
|
107
117
|
center: { lat: 48.8566, lng: 2.3522 },
|
|
108
118
|
zoom: 13,
|
|
109
|
-
})
|
|
110
|
-
const result = validateComponent(component)
|
|
111
|
-
const unknownTypeError = result.errors?.find(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
})
|
|
116
|
-
})
|
|
119
|
+
});
|
|
120
|
+
const result = validateComponent(component);
|
|
121
|
+
const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
|
|
122
|
+
expect(unknownTypeError).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
117
125
|
|
|
118
126
|
describe('truly unknown types are rejected', () => {
|
|
119
127
|
it('rejects a typo like "chrt" with UNKNOWN_COMPONENT_TYPE', () => {
|
|
120
|
-
const component = makeComponent('chrt' as any)
|
|
121
|
-
const result = validateComponent(component)
|
|
122
|
-
const unknownTypeError = result.errors?.find(
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
expect(result.valid).toBe(false)
|
|
127
|
-
})
|
|
128
|
+
const component = makeComponent('chrt' as any);
|
|
129
|
+
const result = validateComponent(component);
|
|
130
|
+
const unknownTypeError = result.errors?.find((e) => e.code === 'UNKNOWN_COMPONENT_TYPE');
|
|
131
|
+
expect(unknownTypeError).toBeDefined();
|
|
132
|
+
expect(result.valid).toBe(false);
|
|
133
|
+
});
|
|
128
134
|
|
|
129
135
|
it('rejects garbage type "foobar"', () => {
|
|
130
|
-
const component = makeComponent('foobar' as any)
|
|
131
|
-
const result = validateComponent(component)
|
|
132
|
-
expect(result.valid).toBe(false)
|
|
133
|
-
expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true)
|
|
134
|
-
})
|
|
136
|
+
const component = makeComponent('foobar' as any);
|
|
137
|
+
const result = validateComponent(component);
|
|
138
|
+
expect(result.valid).toBe(false);
|
|
139
|
+
expect(result.errors?.some((e) => e.code === 'UNKNOWN_COMPONENT_TYPE')).toBe(true);
|
|
140
|
+
});
|
|
135
141
|
|
|
136
142
|
it('rejects empty string type', () => {
|
|
137
|
-
const component = makeComponent('' as any)
|
|
138
|
-
const result = validateComponent(component)
|
|
139
|
-
expect(result.valid).toBe(false)
|
|
140
|
-
})
|
|
141
|
-
})
|
|
143
|
+
const component = makeComponent('' as any);
|
|
144
|
+
const result = validateComponent(component);
|
|
145
|
+
expect(result.valid).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
142
148
|
|
|
143
149
|
describe('H2: missing component.params guard', () => {
|
|
144
150
|
it('rejects component with undefined params', () => {
|
|
145
|
-
const component = {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
const component = {
|
|
152
|
+
id: 'test',
|
|
153
|
+
type: 'chart' as any,
|
|
154
|
+
position: { colStart: 1, colSpan: 12 },
|
|
155
|
+
params: undefined as any,
|
|
156
|
+
};
|
|
157
|
+
const result = validateComponent(component);
|
|
158
|
+
expect(result.valid).toBe(false);
|
|
159
|
+
expect(result.errors?.some((e) => e.code === 'MISSING_PARAMS')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
152
163
|
|
|
153
164
|
describe('component-specific validation', () => {
|
|
154
165
|
it('rejects video without url', () => {
|
|
155
|
-
const result = validateComponent(makeComponent('video'))
|
|
156
|
-
expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true)
|
|
157
|
-
})
|
|
166
|
+
const result = validateComponent(makeComponent('video'));
|
|
167
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true);
|
|
168
|
+
});
|
|
158
169
|
|
|
159
170
|
it('rejects carousel with empty items', () => {
|
|
160
|
-
const result = validateComponent(makeComponent('carousel', { items: [] }))
|
|
161
|
-
expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true)
|
|
162
|
-
})
|
|
171
|
+
const result = validateComponent(makeComponent('carousel', { items: [] }));
|
|
172
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true);
|
|
173
|
+
});
|
|
163
174
|
|
|
164
175
|
it('rejects image-gallery with empty images', () => {
|
|
165
|
-
const result = validateComponent(makeComponent('image-gallery', { images: [] }))
|
|
166
|
-
expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true)
|
|
167
|
-
})
|
|
176
|
+
const result = validateComponent(makeComponent('image-gallery', { images: [] }));
|
|
177
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true);
|
|
178
|
+
});
|
|
168
179
|
|
|
169
180
|
it('rejects form with empty fields', () => {
|
|
170
|
-
const result = validateComponent(makeComponent('form', { fields: [] }))
|
|
171
|
-
expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
|
|
172
|
-
})
|
|
181
|
+
const result = validateComponent(makeComponent('form', { fields: [] }));
|
|
182
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true);
|
|
183
|
+
});
|
|
173
184
|
|
|
174
185
|
it('rejects action-group with empty actions', () => {
|
|
175
|
-
const result = validateComponent(makeComponent('action-group', { actions: [] }))
|
|
176
|
-
expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true)
|
|
177
|
-
})
|
|
186
|
+
const result = validateComponent(makeComponent('action-group', { actions: [] }));
|
|
187
|
+
expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true);
|
|
188
|
+
});
|
|
178
189
|
|
|
179
190
|
it('rejects code without code content', () => {
|
|
180
|
-
const result = validateComponent(makeComponent('code'))
|
|
181
|
-
expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true)
|
|
182
|
-
})
|
|
191
|
+
const result = validateComponent(makeComponent('code'));
|
|
192
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true);
|
|
193
|
+
});
|
|
183
194
|
|
|
184
195
|
it('rejects map without center or markers', () => {
|
|
185
|
-
const result = validateComponent(makeComponent('map'))
|
|
186
|
-
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true)
|
|
187
|
-
})
|
|
196
|
+
const result = validateComponent(makeComponent('map'));
|
|
197
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true);
|
|
198
|
+
});
|
|
188
199
|
|
|
189
200
|
it('accepts map with markers but no center', () => {
|
|
190
|
-
const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }))
|
|
191
|
-
const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP')
|
|
192
|
-
expect(mapError).toBeUndefined()
|
|
193
|
-
})
|
|
201
|
+
const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }));
|
|
202
|
+
const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP');
|
|
203
|
+
expect(mapError).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// v6.8.2 — a map may render purely from geojson / layers / pmtiles
|
|
207
|
+
// (spec@5.2.0 contract). These must NOT be rejected as INVALID_MAP.
|
|
208
|
+
it('accepts map with geojson but no center/markers', () => {
|
|
209
|
+
const result = validateComponent(
|
|
210
|
+
makeComponent('map', {
|
|
211
|
+
geojson: { type: 'FeatureCollection', features: [] },
|
|
212
|
+
fitBounds: true,
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('accepts map with named layers but no center/markers', () => {
|
|
219
|
+
const result = validateComponent(
|
|
220
|
+
makeComponent('map', {
|
|
221
|
+
layers: [{ name: 'Communes', geojson: { type: 'FeatureCollection', features: [] } }],
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('accepts map with a pmtiles source but no center/markers', () => {
|
|
228
|
+
const result = validateComponent(
|
|
229
|
+
makeComponent('map', { pmtiles: { url: 'https://cdn.example.com/x.pmtiles' } })
|
|
230
|
+
);
|
|
231
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBeFalsy();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('still rejects an empty map (no center/markers/geojson/layers/pmtiles)', () => {
|
|
235
|
+
const result = validateComponent(makeComponent('map', {}));
|
|
236
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true);
|
|
237
|
+
});
|
|
194
238
|
|
|
195
239
|
it('accepts modal with no params beyond type', () => {
|
|
196
|
-
const result = validateComponent(makeComponent('modal', { title: 'Test' }))
|
|
197
|
-
expect(result.valid).toBe(true)
|
|
198
|
-
})
|
|
199
|
-
})
|
|
240
|
+
const result = validateComponent(makeComponent('modal', { title: 'Test' }));
|
|
241
|
+
expect(result.valid).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
200
244
|
|
|
201
245
|
describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)', () => {
|
|
202
246
|
/**
|
|
@@ -209,8 +253,8 @@ describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)',
|
|
|
209
253
|
center: { lat: 48.8566, lng: 2.3522 },
|
|
210
254
|
zoom: 12,
|
|
211
255
|
geojson: { type: 'FeatureCollection', features: [] as unknown[] },
|
|
212
|
-
})
|
|
213
|
-
const features = (component.params as any).geojson.features as unknown[]
|
|
256
|
+
});
|
|
257
|
+
const features = (component.params as any).geojson.features as unknown[];
|
|
214
258
|
while (JSON.stringify(component).length < targetBytes) {
|
|
215
259
|
// Grow in batches to keep the size loop cheap.
|
|
216
260
|
for (let i = 0; i < 200; i++) {
|
|
@@ -218,194 +262,216 @@ describe('validatePayloadSize — payload size guard (v6.8.0: 50KB → 512KB)',
|
|
|
218
262
|
type: 'Feature',
|
|
219
263
|
geometry: { type: 'Point', coordinates: [2.3522, 48.8566] },
|
|
220
264
|
properties: { i: features.length },
|
|
221
|
-
})
|
|
265
|
+
});
|
|
222
266
|
}
|
|
223
267
|
}
|
|
224
|
-
return component
|
|
268
|
+
return component;
|
|
225
269
|
}
|
|
226
270
|
|
|
227
271
|
it('the default ceiling is 512KB', () => {
|
|
228
|
-
expect(DEFAULT_RESOURCE_LIMITS.maxPayloadSize).toBe(512 * 1024)
|
|
229
|
-
})
|
|
272
|
+
expect(DEFAULT_RESOURCE_LIMITS.maxPayloadSize).toBe(512 * 1024);
|
|
273
|
+
});
|
|
230
274
|
|
|
231
275
|
it('accepts a map with valid GeoJSON ~350KB (rejected under the old 50KB/256KB limits)', () => {
|
|
232
|
-
const component = mapComponentOfSize(350 * 1024)
|
|
233
|
-
const size = JSON.stringify(component).length
|
|
234
|
-
expect(size).toBeGreaterThan(256 * 1024) // exceeds the interim 256KB ceiling
|
|
235
|
-
expect(size).toBeLessThan(512 * 1024) // under the NEW 512KB limit
|
|
276
|
+
const component = mapComponentOfSize(350 * 1024);
|
|
277
|
+
const size = JSON.stringify(component).length;
|
|
278
|
+
expect(size).toBeGreaterThan(256 * 1024); // exceeds the interim 256KB ceiling
|
|
279
|
+
expect(size).toBeLessThan(512 * 1024); // under the NEW 512KB limit
|
|
236
280
|
|
|
237
|
-
expect(validatePayloadSize(component).valid).toBe(true)
|
|
281
|
+
expect(validatePayloadSize(component).valid).toBe(true);
|
|
238
282
|
// Full component validation also passes — no PAYLOAD_TOO_LARGE.
|
|
239
|
-
const result = validateComponent(component)
|
|
240
|
-
expect(result.errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBeFalsy()
|
|
241
|
-
expect(result.valid).toBe(true)
|
|
242
|
-
})
|
|
283
|
+
const result = validateComponent(component);
|
|
284
|
+
expect(result.errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBeFalsy();
|
|
285
|
+
expect(result.valid).toBe(true);
|
|
286
|
+
});
|
|
243
287
|
|
|
244
288
|
it('still rejects a map payload over 512KB', () => {
|
|
245
|
-
const component = mapComponentOfSize(600 * 1024)
|
|
246
|
-
expect(JSON.stringify(component).length).toBeGreaterThan(512 * 1024)
|
|
289
|
+
const component = mapComponentOfSize(600 * 1024);
|
|
290
|
+
expect(JSON.stringify(component).length).toBeGreaterThan(512 * 1024);
|
|
247
291
|
|
|
248
|
-
const sizeResult = validatePayloadSize(component)
|
|
249
|
-
expect(sizeResult.valid).toBe(false)
|
|
250
|
-
expect(sizeResult.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE')
|
|
292
|
+
const sizeResult = validatePayloadSize(component);
|
|
293
|
+
expect(sizeResult.valid).toBe(false);
|
|
294
|
+
expect(sizeResult.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE');
|
|
251
295
|
|
|
252
296
|
expect(validateComponent(component).errors?.some((e) => e.code === 'PAYLOAD_TOO_LARGE')).toBe(
|
|
253
297
|
true
|
|
254
|
-
)
|
|
255
|
-
})
|
|
298
|
+
);
|
|
299
|
+
});
|
|
256
300
|
|
|
257
301
|
it('still rejects an oversized NON-map payload (guard-rail intact for every type)', () => {
|
|
258
|
-
const component = makeComponent('text', { content: 'x'.repeat(600 * 1024) })
|
|
259
|
-
const result = validatePayloadSize(component)
|
|
260
|
-
expect(result.valid).toBe(false)
|
|
261
|
-
expect(result.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE')
|
|
262
|
-
})
|
|
302
|
+
const component = makeComponent('text', { content: 'x'.repeat(600 * 1024) });
|
|
303
|
+
const result = validatePayloadSize(component);
|
|
304
|
+
expect(result.valid).toBe(false);
|
|
305
|
+
expect(result.errors?.[0].code).toBe('PAYLOAD_TOO_LARGE');
|
|
306
|
+
});
|
|
263
307
|
|
|
264
308
|
it('honours a caller-supplied lower limit (validation is not disabled)', () => {
|
|
265
309
|
// A consumer passing stricter limits keeps full control.
|
|
266
|
-
const component = mapComponentOfSize(60 * 1024)
|
|
267
|
-
const strict = { ...DEFAULT_RESOURCE_LIMITS, maxPayloadSize: 50 * 1024 }
|
|
268
|
-
expect(validatePayloadSize(component, strict).valid).toBe(false)
|
|
269
|
-
})
|
|
270
|
-
})
|
|
310
|
+
const component = mapComponentOfSize(60 * 1024);
|
|
311
|
+
const strict = { ...DEFAULT_RESOURCE_LIMITS, maxPayloadSize: 50 * 1024 };
|
|
312
|
+
expect(validatePayloadSize(component, strict).valid).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
271
315
|
|
|
272
316
|
describe('validateChartComponent — scatter/bubble/time-series', () => {
|
|
273
317
|
it('validates scatter chart without labels', () => {
|
|
274
318
|
const result = validateChartComponent({
|
|
275
319
|
type: 'scatter',
|
|
276
|
-
data: {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
320
|
+
data: {
|
|
321
|
+
datasets: [
|
|
322
|
+
{
|
|
323
|
+
label: 'Test',
|
|
324
|
+
data: [
|
|
325
|
+
{ x: 1, y: 2 },
|
|
326
|
+
{ x: 3, y: 4 },
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
} as any);
|
|
332
|
+
expect(result.valid).toBe(true);
|
|
333
|
+
});
|
|
280
334
|
|
|
281
335
|
it('validates bubble chart without labels', () => {
|
|
282
336
|
const result = validateChartComponent({
|
|
283
337
|
type: 'bubble',
|
|
284
338
|
data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2, r: 5 }] }] },
|
|
285
|
-
} as any)
|
|
286
|
-
expect(result.valid).toBe(true)
|
|
287
|
-
})
|
|
339
|
+
} as any);
|
|
340
|
+
expect(result.valid).toBe(true);
|
|
341
|
+
});
|
|
288
342
|
|
|
289
343
|
it('validates line chart with time-series object data', () => {
|
|
290
344
|
const result = validateChartComponent({
|
|
291
345
|
type: 'line',
|
|
292
|
-
data: {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
346
|
+
data: {
|
|
347
|
+
datasets: [
|
|
348
|
+
{
|
|
349
|
+
label: 'Prix',
|
|
350
|
+
data: [
|
|
351
|
+
{ x: '2024-01-01', y: 42 },
|
|
352
|
+
{ x: '2024-02-01', y: 45 },
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
} as any);
|
|
358
|
+
expect(result.valid).toBe(true);
|
|
359
|
+
});
|
|
296
360
|
|
|
297
361
|
it('rejects scatter with number data instead of {x,y}', () => {
|
|
298
362
|
const result = validateChartComponent({
|
|
299
363
|
type: 'scatter',
|
|
300
364
|
data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
|
|
301
|
-
} as any)
|
|
302
|
-
expect(result.valid).toBe(false)
|
|
303
|
-
expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true)
|
|
304
|
-
})
|
|
365
|
+
} as any);
|
|
366
|
+
expect(result.valid).toBe(false);
|
|
367
|
+
expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true);
|
|
368
|
+
});
|
|
305
369
|
|
|
306
370
|
it('rejects bar chart without labels', () => {
|
|
307
371
|
const result = validateChartComponent({
|
|
308
372
|
type: 'bar',
|
|
309
373
|
data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
|
|
310
|
-
} as any)
|
|
311
|
-
expect(result.valid).toBe(false)
|
|
312
|
-
expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true)
|
|
313
|
-
})
|
|
374
|
+
} as any);
|
|
375
|
+
expect(result.valid).toBe(false);
|
|
376
|
+
expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true);
|
|
377
|
+
});
|
|
314
378
|
|
|
315
379
|
it('accepts empty dataset without length mismatch', () => {
|
|
316
380
|
const result = validateChartComponent({
|
|
317
381
|
type: 'bar',
|
|
318
382
|
data: { labels: ['A', 'B'], datasets: [{ label: 'Test', data: [] }] },
|
|
319
|
-
} as any)
|
|
383
|
+
} as any);
|
|
320
384
|
// Empty dataset should not trigger DATA_LENGTH_MISMATCH
|
|
321
|
-
const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH')
|
|
322
|
-
expect(mismatch).toBeUndefined()
|
|
323
|
-
})
|
|
324
|
-
})
|
|
385
|
+
const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH');
|
|
386
|
+
expect(mismatch).toBeUndefined();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
325
389
|
|
|
326
390
|
describe('validateChartComponent — H1 null guards', () => {
|
|
327
|
-
|
|
328
391
|
it('rejects chart with undefined data', () => {
|
|
329
|
-
const result = validateChartComponent({ type: 'bar', data: undefined as any } as any)
|
|
330
|
-
expect(result.valid).toBe(false)
|
|
331
|
-
expect(result.errors?.[0].code).toBe('MISSING_DATA')
|
|
332
|
-
})
|
|
392
|
+
const result = validateChartComponent({ type: 'bar', data: undefined as any } as any);
|
|
393
|
+
expect(result.valid).toBe(false);
|
|
394
|
+
expect(result.errors?.[0].code).toBe('MISSING_DATA');
|
|
395
|
+
});
|
|
333
396
|
|
|
334
397
|
it('rejects chart with missing datasets', () => {
|
|
335
|
-
const result = validateChartComponent({ type: 'bar', data: { labels: ['A'] } } as any)
|
|
336
|
-
expect(result.valid).toBe(false)
|
|
337
|
-
expect(result.errors?.[0].code).toBe('MISSING_DATASETS')
|
|
338
|
-
})
|
|
398
|
+
const result = validateChartComponent({ type: 'bar', data: { labels: ['A'] } } as any);
|
|
399
|
+
expect(result.valid).toBe(false);
|
|
400
|
+
expect(result.errors?.[0].code).toBe('MISSING_DATASETS');
|
|
401
|
+
});
|
|
339
402
|
|
|
340
403
|
it('rejects chart with missing labels', () => {
|
|
341
|
-
const result = validateChartComponent({
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
404
|
+
const result = validateChartComponent({
|
|
405
|
+
type: 'bar',
|
|
406
|
+
data: { datasets: [{ label: 'X', data: [1] }] },
|
|
407
|
+
} as any);
|
|
408
|
+
expect(result.valid).toBe(false);
|
|
409
|
+
expect(result.errors?.[0].code).toBe('MISSING_LABELS');
|
|
410
|
+
});
|
|
345
411
|
|
|
346
412
|
it('validates chart with proper data', () => {
|
|
347
413
|
const result = validateChartComponent({
|
|
348
414
|
type: 'bar',
|
|
349
415
|
data: { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] },
|
|
350
|
-
} as any)
|
|
351
|
-
expect(result.valid).toBe(true)
|
|
352
|
-
})
|
|
353
|
-
})
|
|
416
|
+
} as any);
|
|
417
|
+
expect(result.valid).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
354
420
|
|
|
355
421
|
describe('getIframeSandbox — tiered sandbox', () => {
|
|
356
422
|
it('gives full sandbox to trusted domains (Google)', () => {
|
|
357
|
-
const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123')
|
|
358
|
-
expect(sandbox).toContain('allow-same-origin')
|
|
359
|
-
expect(sandbox).toContain('allow-scripts')
|
|
360
|
-
expect(sandbox).toContain('allow-forms')
|
|
361
|
-
})
|
|
423
|
+
const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123');
|
|
424
|
+
expect(sandbox).toContain('allow-same-origin');
|
|
425
|
+
expect(sandbox).toContain('allow-scripts');
|
|
426
|
+
expect(sandbox).toContain('allow-forms');
|
|
427
|
+
});
|
|
362
428
|
|
|
363
429
|
it('gives full sandbox to Deposium domains', () => {
|
|
364
|
-
const sandbox = getIframeSandbox('https://deposium.com/embed/123')
|
|
365
|
-
expect(sandbox).toContain('allow-same-origin')
|
|
366
|
-
})
|
|
430
|
+
const sandbox = getIframeSandbox('https://deposium.com/embed/123');
|
|
431
|
+
expect(sandbox).toContain('allow-same-origin');
|
|
432
|
+
});
|
|
367
433
|
|
|
368
434
|
it('gives full sandbox to payment domains (Stripe)', () => {
|
|
369
|
-
const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123')
|
|
370
|
-
expect(sandbox).toContain('allow-same-origin')
|
|
371
|
-
expect(sandbox).toContain('allow-forms')
|
|
372
|
-
})
|
|
435
|
+
const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123');
|
|
436
|
+
expect(sandbox).toContain('allow-same-origin');
|
|
437
|
+
expect(sandbox).toContain('allow-forms');
|
|
438
|
+
});
|
|
373
439
|
|
|
374
440
|
it('gives full sandbox to Polar.sh', () => {
|
|
375
|
-
const sandbox = getIframeSandbox('https://polar.sh/checkout/123')
|
|
376
|
-
expect(sandbox).toContain('allow-same-origin')
|
|
377
|
-
})
|
|
441
|
+
const sandbox = getIframeSandbox('https://polar.sh/checkout/123');
|
|
442
|
+
expect(sandbox).toContain('allow-same-origin');
|
|
443
|
+
});
|
|
378
444
|
|
|
379
445
|
it('gives restrictive sandbox to untrusted whitelisted domains (quickchart)', () => {
|
|
380
|
-
const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}')
|
|
381
|
-
expect(sandbox).toContain('allow-scripts')
|
|
382
|
-
expect(sandbox).toContain('allow-popups')
|
|
383
|
-
expect(sandbox).not.toContain('allow-same-origin')
|
|
384
|
-
expect(sandbox).not.toContain('allow-forms')
|
|
385
|
-
})
|
|
446
|
+
const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}');
|
|
447
|
+
expect(sandbox).toContain('allow-scripts');
|
|
448
|
+
expect(sandbox).toContain('allow-popups');
|
|
449
|
+
expect(sandbox).not.toContain('allow-same-origin');
|
|
450
|
+
expect(sandbox).not.toContain('allow-forms');
|
|
451
|
+
});
|
|
386
452
|
|
|
387
453
|
it('gives restrictive sandbox to YouTube', () => {
|
|
388
|
-
const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123')
|
|
389
|
-
expect(sandbox).not.toContain('allow-same-origin')
|
|
390
|
-
})
|
|
454
|
+
const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123');
|
|
455
|
+
expect(sandbox).not.toContain('allow-same-origin');
|
|
456
|
+
});
|
|
391
457
|
|
|
392
458
|
it('gives restrictive sandbox to unknown domains', () => {
|
|
393
|
-
const sandbox = getIframeSandbox('https://evil.example.com/page')
|
|
394
|
-
expect(sandbox).not.toContain('allow-same-origin')
|
|
395
|
-
})
|
|
459
|
+
const sandbox = getIframeSandbox('https://evil.example.com/page');
|
|
460
|
+
expect(sandbox).not.toContain('allow-same-origin');
|
|
461
|
+
});
|
|
396
462
|
|
|
397
463
|
it('handles invalid URLs gracefully', () => {
|
|
398
|
-
const sandbox = getIframeSandbox('not-a-url')
|
|
399
|
-
expect(sandbox).toBe('allow-scripts allow-popups')
|
|
400
|
-
})
|
|
464
|
+
const sandbox = getIframeSandbox('not-a-url');
|
|
465
|
+
expect(sandbox).toBe('allow-scripts allow-popups');
|
|
466
|
+
});
|
|
401
467
|
|
|
402
468
|
it('supports custom trusted domains', () => {
|
|
403
469
|
const sandbox = getIframeSandbox('https://my-internal-tool.corp.com/dash', {
|
|
404
470
|
customTrustedDomains: ['my-internal-tool.corp.com'],
|
|
405
|
-
})
|
|
406
|
-
expect(sandbox).toContain('allow-same-origin')
|
|
407
|
-
})
|
|
408
|
-
})
|
|
471
|
+
});
|
|
472
|
+
expect(sandbox).toContain('allow-same-origin');
|
|
473
|
+
});
|
|
474
|
+
});
|
|
409
475
|
|
|
410
476
|
describe('validateIframeDomain — security regression (v5.5.1)', () => {
|
|
411
477
|
// Pre-v5.5.1 bug: the predicate was
|
|
@@ -415,43 +481,43 @@ describe('validateIframeDomain — security regression (v5.5.1)', () => {
|
|
|
415
481
|
// every URL was accepted. These tests lock the fixed behavior in place.
|
|
416
482
|
|
|
417
483
|
it('REJECTS a non-whitelisted external domain (this used to silently pass)', () => {
|
|
418
|
-
const result = validateIframeDomain('https://evil.example.com/x')
|
|
419
|
-
expect(result.valid).toBe(false)
|
|
420
|
-
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
|
|
421
|
-
})
|
|
484
|
+
const result = validateIframeDomain('https://evil.example.com/x');
|
|
485
|
+
expect(result.valid).toBe(false);
|
|
486
|
+
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED');
|
|
487
|
+
});
|
|
422
488
|
|
|
423
489
|
it('REJECTS a typo-squat that is NOT a subdomain of any whitelisted entry', () => {
|
|
424
490
|
// youtube-evil.com is not youtube.com nor a subdomain of it
|
|
425
|
-
const result = validateIframeDomain('https://youtube-evil.com/embed/x')
|
|
426
|
-
expect(result.valid).toBe(false)
|
|
427
|
-
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
|
|
428
|
-
})
|
|
491
|
+
const result = validateIframeDomain('https://youtube-evil.com/embed/x');
|
|
492
|
+
expect(result.valid).toBe(false);
|
|
493
|
+
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED');
|
|
494
|
+
});
|
|
429
495
|
|
|
430
496
|
it('still accepts a whitelisted domain (quickchart.io)', () => {
|
|
431
|
-
expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true)
|
|
432
|
-
})
|
|
497
|
+
expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true);
|
|
498
|
+
});
|
|
433
499
|
|
|
434
500
|
it('still accepts subdomains of whitelisted entries (player.vimeo.com)', () => {
|
|
435
|
-
expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true)
|
|
436
|
-
})
|
|
501
|
+
expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true);
|
|
502
|
+
});
|
|
437
503
|
|
|
438
504
|
it('still accepts localhost (dev convenience)', () => {
|
|
439
|
-
expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true)
|
|
440
|
-
})
|
|
505
|
+
expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true);
|
|
506
|
+
});
|
|
441
507
|
|
|
442
508
|
it('still accepts 127.0.0.1 (loopback equivalent of localhost)', () => {
|
|
443
|
-
expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true)
|
|
444
|
-
})
|
|
509
|
+
expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true);
|
|
510
|
+
});
|
|
445
511
|
|
|
446
512
|
it('respects allow-all policy bypass', () => {
|
|
447
|
-
expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true)
|
|
448
|
-
})
|
|
513
|
+
expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true);
|
|
514
|
+
});
|
|
449
515
|
|
|
450
516
|
it('extend policy adds custom domains', () => {
|
|
451
517
|
const result = validateIframeDomain('https://my-internal-tool.corp.com/x', {
|
|
452
518
|
policy: 'extend',
|
|
453
519
|
customDomains: ['my-internal-tool.corp.com'],
|
|
454
|
-
})
|
|
455
|
-
expect(result.valid).toBe(true)
|
|
456
|
-
})
|
|
457
|
-
})
|
|
520
|
+
});
|
|
521
|
+
expect(result.valid).toBe(true);
|
|
522
|
+
});
|
|
523
|
+
});
|