* fix: validate parsed JSON at untrusted boundaries with Valibot Clipboard HTML, library revisions from shared storage, MCP and automation WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool arguments were JSON.parse'd and cast to their expected types, so a malformed payload reached the document or crashed paste. They now go through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON and a wrong shape as the same validation failure. The path_set tool rejects an invalid VectorNetwork and shares its parser with create_vector. The CLI library catalog validates its files and runs revisions through the same size, identity and content-hash checks as the app; reading image bytes as index-keyed records also stops them coming back empty. Hand-rolled typeof readers for plugin data, document metadata, caches and preferences become schemas with their behaviour preserved, and readCacheJSON takes a schema for its payload. open-pencil/no-unvalidated-json-parse rejects type assertions on JSON.parse results other than `as unknown` in src and packages/*/src. * refactor: validate parsed JSON in tests and tooling Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures. * refactor: define OpenPencil plugin data in one typed registry Every plugin-data key OpenPencil writes is now a field of OPEN_PENCIL_PLUGIN_DATA in scene-graph, with the Valibot schema that reads it; readPluginData and withPluginData replace per-key constants, JSON.parse and hand-matched pluginId/key filters across fig, core, and vue. Moving OkHCL onto it fixes picking a colour rewriting the layer's other plugin data as OkHCL entries.
84 lines
3.2 KiB
TypeScript
84 lines
3.2 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
||
|
||
import {
|
||
OPEN_PENCIL_PLUGIN_DATA,
|
||
OPEN_PENCIL_PLUGIN_ID,
|
||
readAllPluginData,
|
||
readPluginData,
|
||
SceneGraph,
|
||
withPluginData,
|
||
type PluginDataEntry
|
||
} from '@open-pencil/scene-graph'
|
||
import {
|
||
clearNodeFillOkHCL,
|
||
getNodeOkHCLPayloads,
|
||
setNodeFillOkHCL,
|
||
setNodeStrokeOkHCL
|
||
} from '@open-pencil/scene-graph/color'
|
||
|
||
const { exportSettings, okhcl, textDirection } = OPEN_PENCIL_PLUGIN_DATA
|
||
const OTHER_PLUGIN: PluginDataEntry = { pluginId: 'other-plugin', key: 'okhcl', value: 'theirs' }
|
||
|
||
function entry(key: string, value: string): PluginDataEntry {
|
||
return { pluginId: OPEN_PENCIL_PLUGIN_ID, key, value }
|
||
}
|
||
|
||
describe('OpenPencil plugin data', () => {
|
||
test('gives every field its own key', () => {
|
||
const keys = Object.values(OPEN_PENCIL_PLUGIN_DATA).map((field) => field.key)
|
||
expect(new Set(keys).size).toBe(keys.length)
|
||
})
|
||
|
||
test('reads a value that is not JSON or has the wrong shape as missing', () => {
|
||
for (const value of ['{not json', '{"scale":2}', '[{"scale":2,"format":"gif"}]']) {
|
||
expect(readPluginData([entry('exportSettings', value)], exportSettings)).toBeUndefined()
|
||
}
|
||
expect(readPluginData([entry('textDirection', 'SIDEWAYS')], textDirection)).toBeUndefined()
|
||
expect(readPluginData([entry('textDirection', 'RTL')], textDirection)).toBe('RTL')
|
||
})
|
||
|
||
test('ignores the same key written by another plugin', () => {
|
||
const entries = [{ ...entry('textDirection', 'RTL'), pluginId: 'other-plugin' }]
|
||
expect(readPluginData(entries, textDirection)).toBeUndefined()
|
||
})
|
||
|
||
test('replaces its own entry and keeps everything else', () => {
|
||
const settings = [{ scale: 2, format: 'png' as const }]
|
||
const entries = withPluginData(
|
||
[entry('exportSettings', '[]'), OTHER_PLUGIN, entry('textDirection', 'LTR')],
|
||
exportSettings,
|
||
settings
|
||
)
|
||
expect(entries).toEqual([
|
||
OTHER_PLUGIN,
|
||
entry('textDirection', 'LTR'),
|
||
entry('exportSettings', JSON.stringify(settings))
|
||
])
|
||
expect(readPluginData(entries, exportSettings)).toEqual(settings)
|
||
expect(withPluginData(entries, exportSettings, undefined)).toEqual(entries.slice(0, 2))
|
||
})
|
||
|
||
test('picking an OkHCL color keeps the node’s other plugin data', () => {
|
||
const graph = new SceneGraph()
|
||
const paint = { type: 'SOLID' as const, visible: true, opacity: 1, color: { r: 1, g: 0, b: 0, a: 1 } }
|
||
const node = graph.createNode('FRAME', graph.getPages()[0].id, {
|
||
fills: [paint],
|
||
strokes: [{ ...paint, weight: 1, align: 'INSIDE' as const }],
|
||
pluginData: [OTHER_PLUGIN, entry('exportSettings', '[{"scale":1,"format":"png"}]')]
|
||
})
|
||
const color = { h: 30, c: 0.1, l: 0.6, a: 1 }
|
||
|
||
graph.updateNode(node.id, setNodeFillOkHCL(node, 0, color))
|
||
graph.updateNode(node.id, setNodeStrokeOkHCL(node, 0, color))
|
||
graph.updateNode(node.id, clearNodeFillOkHCL(node, 0))
|
||
|
||
const pluginData = graph.getNode(node.id)?.pluginData ?? []
|
||
expect(pluginData.slice(0, 2)).toEqual([
|
||
OTHER_PLUGIN,
|
||
entry('exportSettings', '[{"scale":1,"format":"png"}]')
|
||
])
|
||
expect(readAllPluginData(pluginData, okhcl).map(({ kind }) => kind)).toEqual(['stroke'])
|
||
expect(getNodeOkHCLPayloads({ ...node, pluginData })).toHaveLength(1)
|
||
})
|
||
})
|