openpencil/packages/cli/tests/commands/app.test.ts
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* 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.

* fix: validate clipboard geometry bytes, library images and model catalogs

Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.

* refactor: extend the JSON validation lint to .json() results

no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.

* test: validate the RPC request body in the CLI app export test

* test: validate CLI JSON output in the tool and app command tests

* test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00

162 lines
5.5 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import * as v from 'valibot'
import { CLI_ENTRY } from '#cli-tests/helpers/paths'
setDefaultTimeout(60_000)
const TOKEN = 'cli-app-test-token'
const TARGET = {
documentId: 'doc-2',
documentName: 'Landing',
pageId: '0:1',
pageName: 'Page 1'
}
const RequestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) })
)
type Request = v.InferOutput<typeof RequestJSON>
const requests: Request[] = []
const responses = new Map<string, unknown>()
let server: Server
let discoveryPath: string
beforeAll(async () => {
server = createServer((request, response) => {
let body = ''
request.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
request.on('end', () => {
const parsed = v.parse(RequestJSON, body)
requests.push(parsed)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(
JSON.stringify({ ok: true, result: responses.get(parsed.command) ?? {}, target: TARGET })
)
})
})
await new Promise<void>((done) => {
server.listen(0, '127.0.0.1', done)
})
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-cli-app-'))
discoveryPath = join(dir, 'discovery.json')
await writeFile(
discoveryPath,
JSON.stringify({
pid: process.pid,
socketPath: join(dir, 'missing.sock'),
httpPort: (server.address() as AddressInfo).port,
authRequired: true,
authToken: TOKEN,
version: '0.0.0-test',
startedAt: new Date().toISOString()
})
)
})
afterAll(() => {
server.close()
})
beforeEach(() => {
requests.length = 0
responses.clear()
})
async function cli(args: string[]) {
const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, OPENPENCIL_MCP_DISCOVERY_PATH: discoveryPath }
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text()
])
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: await proc.exited }
}
describe('documents CLI', () => {
test('opens files by absolute path and reports the target document', async () => {
const opened = await cli(['documents', 'open', 'designs/landing.fig', '--json'])
expect(opened.exitCode).toBe(0)
expect(requests).toEqual([
{ command: 'open_file', args: { path: resolve('designs/landing.fig') } }
])
expect(JSON.parse(opened.stdout)).toEqual({ result: {}, target: TARGET })
})
test('saves, closes, and activates a targeted document', async () => {
await cli(['documents', 'save', '--document-id', 'doc-2', '--path', 'out.fig'])
await cli(['documents', 'close', '--document-id', 'doc-2', '--discard'])
await cli(['documents', 'activate', 'doc-2', '--page-id', '0:1'])
expect(requests).toEqual([
{ command: 'save_file', args: { document_id: 'doc-2', path: resolve('out.fig') } },
{ command: 'close_file', args: { document_id: 'doc-2', unsaved: 'discard' } },
{ command: 'activate_document', args: { document_id: 'doc-2', page_id: '0:1' } }
])
})
test('close --save forwards the save choice and an absolute path', async () => {
await cli(['documents', 'close', '--save', '--path', 'draft.fig'])
expect(requests).toEqual([
{ command: 'close_file', args: { unsaved: 'save', path: resolve('draft.fig') } }
])
const both = await cli(['documents', 'close', '--save', '--discard'])
expect(both.exitCode).toBe(1)
})
})
describe('history CLI', () => {
test('reports the undone change', async () => {
responses.set('undo', { applied: true, label: 'Agent: set_opacity' })
const undone = await cli(['undo', '--document-id', 'doc-2'])
expect(undone.exitCode).toBe(0)
expect(undone.stdout).toContain('Undid: Agent: set_opacity')
expect(requests).toEqual([{ command: 'undo', args: { document_id: 'doc-2' } }])
})
})
describe('settings CLI', () => {
test('sets a dotted key as a nested patch with a JSON or string value', async () => {
responses.set('update_settings', {
settings: { appearance: { theme: 'light' }, editing: { snapping: { pixelGrid: false } } }
})
await cli(['settings', 'set', 'editing.snapping.pixelGrid', 'false'])
const theme = await cli(['settings', 'set', 'appearance.theme', 'light'])
expect(theme.exitCode).toBe(0)
expect(requests).toEqual([
{
command: 'update_settings',
args: { settings: { editing: { snapping: { pixelGrid: false } } } }
},
{ command: 'update_settings', args: { settings: { appearance: { theme: 'light' } } } }
])
})
test('reads one setting by dotted key and rejects unknown keys', async () => {
responses.set('get_settings', { settings: { appearance: { theme: 'auto' } } })
const theme = await cli(['settings', 'get', 'appearance.theme'])
expect(theme.stdout).toBe('auto')
const inherited = await cli(['settings', 'get', 'appearance.constructor'])
expect(inherited.exitCode).toBe(1)
const missing = await cli(['settings', 'get', 'appearance.font'])
expect(missing.exitCode).toBe(1)
expect(missing.stderr).toContain('Unknown setting "appearance.font"')
})
})