openpencil/tests/e2e/native/mcp.spec.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

180 lines
6.2 KiB
TypeScript

import { strict as assert } from 'node:assert'
import { readFileSync, renameSync } from 'node:fs'
import * as v from 'valibot'
import { invokeNative } from '#tests/helpers/tauri/invoke'
const HealthJSON = v.pipe(v.string(), v.parseJson(), v.object({ status: v.string() }))
const DiscoveryFileJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ httpPort: v.number(), authToken: v.string() })
)
const NOT_INSTALLED = 'MCP automation is not installed'
async function openMCPSettings(): Promise<void> {
// The platform shortcut is how the existing native settings spec opens Settings.
await browser.keys([process.platform === 'darwin' ? 'Meta' : 'Control', ','])
await browser.waitUntil(
() =>
browser.execute(() =>
Boolean(document.querySelector('[data-test-id="app-settings-dialog"]'))
),
{ timeout: 10_000, timeoutMsg: 'Settings did not open' }
)
await (await $('[data-test-id="settings-section-mcp"]')).click()
await browser.waitUntil(
() =>
browser.execute(() =>
Boolean(document.querySelector('[data-test-id="settings-mcp-automation-panel"]'))
),
{ timeout: 10_000, timeoutMsg: 'MCP settings panel did not render' }
)
}
function mcpStatus(): Promise<string | null> {
return browser.execute(
() =>
document
.querySelector<HTMLElement>(
'[data-test-id="settings-mcp-automation-panel"] [role="status"]'
)
?.textContent?.trim() ?? null
)
}
function mcpAlert(): Promise<string | null> {
return browser.execute(
() =>
document
.querySelector<HTMLElement>(
'[data-test-id="settings-mcp-failure"] [data-slot="alert-heading"]'
)
?.textContent?.trim() ?? null
)
}
/** Expand the failure disclosure and read the technical payload. */
function mcpFailureDetail(): Promise<string | null> {
return browser.executeAsync((done) => {
document
.querySelector<HTMLElement>('[data-test-id="settings-mcp-failure-details"] button')
?.click()
requestAnimationFrame(() => {
done(
document
.querySelector<HTMLElement>('[data-test-id="settings-mcp-failure-detail"]')
?.textContent?.trim() ?? null
)
})
})
}
/**
* Restart is offered by the healthy-state button and by the failure alert, so
* click whichever one is currently rendered.
*/
function clickRestart(): Promise<void> {
return browser.execute(() => {
const panel = document.querySelector<HTMLElement>(
'[data-test-id="settings-mcp-automation-panel"]'
)
for (const button of panel?.querySelectorAll<HTMLButtonElement>('button') ?? []) {
if (button.textContent?.includes('MCP server') && button.offsetParent !== null) {
button.click()
return
}
}
})
}
async function waitForStatus(expected: string): Promise<void> {
try {
await browser.waitUntil(async () => (await mcpStatus()) === expected, { timeout: 30_000 })
} catch {
throw new Error(
`MCP status was "${await mcpStatus()}" instead of "${expected}"; ` +
`alert "${await mcpAlert()}"; detail "${await mcpFailureDetail()}"`
)
}
}
describe('native MCP server lifecycle', () => {
// Native specs share one app process, which remembers the open Settings
// section. Restore General before closing so the next spec finds it.
afterEach(async () => {
await browser.execute(() => {
document.querySelector<HTMLElement>('[data-test-id="settings-section-general"]')?.click()
document.querySelector<HTMLElement>('[data-test-id="app-settings-done"]')?.click()
})
await browser.waitUntil(
() => browser.execute(() => !document.querySelector('[data-test-id="app-settings-dialog"]')),
{ timeout: 10_000, timeoutMsg: 'Settings did not close after the MCP spec' }
)
})
it('uses the server it resolves, or reports why it cannot', async () => {
await browser.waitUntil(
async () => browser.execute(() => Boolean(window.openPencil?.getStore?.())),
{ timeout: 30_000, timeoutMsg: 'OpenPencil editor did not initialize' }
)
await openMCPSettings()
// The app resolves the binary through the PATH it derives from the login
// shell, so assert what it resolved rather than injecting a fixture.
const lookup = await invokeNative<{ available: boolean; path: string | null }>('mcp_lookup')
if (!lookup.available || !lookup.path) {
// Without a global install the reason must be the install itself.
await waitForStatus('Error')
assert.equal(await mcpAlert(), NOT_INSTALLED)
return
}
// A resolvable binary either becomes healthy, or is refused with a reason.
await browser.waitUntil(async () => (await mcpStatus()) !== 'Starting', { timeout: 30_000 })
if ((await mcpStatus()) === 'Error') {
const detail = await mcpFailureDetail()
assert.match(
detail ?? '',
/requires @open-pencil\/mcp|major\.minor compatibility/,
`resolved ${lookup.path} but failed without a version explanation: ${detail}`
)
return
}
await waitForStatus('Running')
const discoveryPath = process.env.OPENPENCIL_MCP_DISCOVERY_PATH
assert.ok(discoveryPath, 'discovery path missing from the wdio configuration')
const discovery = v.parse(DiscoveryFileJSON, readFileSync(discoveryPath, 'utf8'))
assert.ok(discovery.authToken, 'discovery file omitted the auth token')
// The spawned server serves the app: real port, real token.
const health = await fetch(`http://127.0.0.1:${discovery.httpPort}/health`, {
headers: { authorization: `Bearer ${discovery.authToken}` }
})
assert.equal(health.status, 200)
assert.equal(v.parse(HealthJSON, await health.text()).status, 'ok')
// Hiding the resolved binary must report the missing install, and restoring
// it must recover without relaunching the app.
const hidden = `${lookup.path}.native-test-hidden`
renameSync(lookup.path, hidden)
try {
await clickRestart()
await browser.waitUntil(async () => (await mcpAlert()) === NOT_INSTALLED, {
timeout: 30_000,
timeoutMsg: `MCP failure alert was: ${await mcpAlert()}`
})
} finally {
renameSync(hidden, lookup.path)
}
await clickRestart()
await waitForStatus('Running')
})
})