openpencil/tests/engine/mcp/server/watchdog.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.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, describe, expect, test } from 'bun:test'
import { access } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { getDiscoveryPath } from '#mcp/transport/paths'
import { connectMockBrowser, readHealth, type HealthResponse } from '#tests/helpers/mcp/server'
// Issue #488: an MCP server spawned by the app but never claimed by one
// (renderer crash, forced reload) used to squat its port forever with a
// stale discovery file. appAttachTimeoutMs closes such a server automatically.
const TEST_AUTH_TOKEN = 'test-auth-token'
let testCounter = 0
function testSocketPath(): string | null {
if (process.platform === 'win32') return null
return join(tmpdir(), `openpencil-test-watchdog-${process.pid}-${++testCounter}.sock`)
}
async function fileExists(path: string): Promise<boolean> {
return access(path)
.then(() => true)
.catch(() => false)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function waitForHealthStatus(port: number, status: HealthResponse['status']): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
const response = await fetch(`http://127.0.0.1:${port}/health`)
const health = await readHealth(response)
if (health.status === status) return
await sleep(5)
}
throw new Error(`Health endpoint did not report ${status}`)
}
describe('MCP app-attach watchdog', () => {
let handle: ServerHandle | null = null
afterEach(async () => {
await handle?.close().catch(() => undefined)
handle = null
})
test('closes the server and removes its discovery file when no app registers in time', async () => {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null,
appAttachTimeoutMs: 50
})
const port = handle.httpPort
expect(port).toBeGreaterThan(0)
const discoveryPath = await getDiscoveryPath()
expect(await fileExists(discoveryPath)).toBe(true)
// Nobody ever registers as the app. Wait past the watchdog timeout.
await sleep(300)
await expect(fetch(`http://127.0.0.1:${port}/health`)).rejects.toThrow()
expect(await fileExists(discoveryPath)).toBe(false)
handle = null // already closed by the watchdog — afterEach must not double-close
})
test('keeps a registered app alive past the initial grace period', async () => {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null,
appAttachTimeoutMs: 100
})
const port = handle.httpPort
const browser = await connectMockBrowser(port, new SceneGraph(), TEST_AUTH_TOKEN)
try {
// Wait past the watchdog timeout — a registered app must not be evicted.
await sleep(300)
const health = await fetch(`http://127.0.0.1:${port}/health`)
expect(health.status).toBe(200)
const data = await readHealth(health)
expect(data.status).toBe('ok')
} finally {
browser.close()
}
})
test('closes the server after a registered app disconnects for the grace period', async () => {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null,
appAttachTimeoutMs: 75
})
const port = handle.httpPort
const browser = await connectMockBrowser(port, new SceneGraph(), TEST_AUTH_TOKEN)
browser.close()
await sleep(300)
await expect(fetch(`http://127.0.0.1:${port}/health`)).rejects.toThrow()
handle = null
})
test('cancels pending shutdown when the app reconnects', async () => {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null,
appAttachTimeoutMs: 150
})
const port = handle.httpPort
const firstBrowser = await connectMockBrowser(port, new SceneGraph(), TEST_AUTH_TOKEN)
firstBrowser.close()
await waitForHealthStatus(port, 'no_app')
const secondBrowser = await connectMockBrowser(port, new SceneGraph(), TEST_AUTH_TOKEN)
try {
await sleep(250)
const health = await fetch(`http://127.0.0.1:${port}/health`)
expect(health.status).toBe(200)
const data = await readHealth(health)
expect(data.status).toBe('ok')
} finally {
secondBrowser.close()
}
})
test('rejects timer values that cannot be represented safely', async () => {
await expect(
startServer({ appAttachTimeoutMs: 2_147_483_648, socketPath: testSocketPath() })
).rejects.toThrow('appAttachTimeoutMs must be in the range 0–2147483647')
await expect(
startServer({ appAttachTimeoutMs: Number.POSITIVE_INFINITY, socketPath: testSocketPath() })
).rejects.toThrow('appAttachTimeoutMs must be a safe integer')
})
})