openpencil/tools/release/release-packages/tests/workflow.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

182 lines
6.5 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative, sep } from 'node:path'
import {
buildReleasePackages,
createPublicationPlan,
packReleasePackages,
prepareReleasePackages,
releasePaths,
validatePublicationArtifacts
} from '#release/workflow'
import { createTarGzip } from 'nanotar'
import * as v from 'valibot'
import { readTarball } from '@open-pencil/package-artifacts-tools/tarball'
const FIXTURE_FILES = ['dist/index.js', 'dist/index.d.ts']
async function listFiles(directory: string, base = directory): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true })
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) return listFiles(path, base)
return [relative(base, path).split(sep).join('/')]
})
)
return nested.flat().sort()
}
const ManifestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ name: v.string(), version: v.string() })
)
/** Packs a prepared directory the way npm lays out a tarball, without starting npm. */
async function packInProcess(directory: string, destination: string): Promise<string> {
const manifest = v.parse(ManifestJSON, await readFile(join(directory, 'package.json'), 'utf8'))
const files = await Promise.all(
(await listFiles(directory)).map(async (name) => ({
name: `package/${name}`,
data: new Uint8Array(await readFile(join(directory, name)))
}))
)
const filename = `${manifest.name.replace(/^@/, '').replace('/', '-')}-${manifest.version}.tgz`
await writeFile(join(destination, filename), await createTarGzip(files))
return filename
}
async function createWorkspace() {
const root = join(tmpdir(), `open-pencil-release-workflow-${crypto.randomUUID()}`)
await mkdir(join(root, 'packages/library'), { recursive: true })
await mkdir(join(root, 'packages/app'), { recursive: true })
await writeFile(
join(root, 'package.json'),
JSON.stringify({ workspaces: ['packages/app', 'packages/library'] })
)
await writeFile(
join(root, 'packages/library/package.json'),
JSON.stringify({
name: '@fixture/library',
version: '1.0.0',
scripts: { build: "bun -e \"await Bun.write('../../order.txt', 'library')\"" }
})
)
await writeFile(
join(root, 'packages/app/package.json'),
JSON.stringify({
name: '@fixture/app',
version: '1.0.0',
dependencies: { '@fixture/library': 'workspace:*' },
scripts: {
build:
"bun -e \"const previous = await Bun.file('../../order.txt').text(); await Bun.write('../../order.txt', previous + ',app')\""
}
})
)
return root
}
describe('release workflow', () => {
test('uses conventional release artifact locations', () => {
expect(releasePaths('/repo')).toEqual({
root: '/repo',
artifacts: '/repo/.npm-packages',
prepared: '/repo/.publish'
})
})
test('creates a dependency-ordered publication plan from registry results', async () => {
const root = await createWorkspace()
const plan = await createPublicationPlan(
root,
async ({ manifest }) => manifest.name === '@fixture/library'
)
expect(plan.map(({ package: pkg, status }) => ({ name: pkg.manifest.name, status }))).toEqual([
{ name: '@fixture/library', status: 'published' },
{ name: '@fixture/app', status: 'unpublished' }
])
})
test('rejects incomplete or stale artifact sets before publication', () => {
const packageEntry = {
directory: 'packages/example',
manifest: { name: '@fixture/example', version: '1.0.0' }
}
expect(() =>
validatePublicationArtifacts([{ package: packageEntry, status: 'unpublished' }], new Map())
).toThrow('Missing verified package artifact for @fixture/example@1.0.0')
expect(() =>
validatePublicationArtifacts(
[{ package: packageEntry, status: 'published' }],
new Map([['@fixture/example@1.0.0', '/artifact.tgz']])
)
).not.toThrow()
expect(() =>
validatePublicationArtifacts([], new Map([['@fixture/unknown@1.0.0', '/artifact.tgz']]))
).toThrow('Unexpected package artifact for @fixture/unknown@1.0.0')
})
test('packs and validates an unpublished release artifact', async () => {
const root = join(tmpdir(), `open-pencil-release-pack-${crypto.randomUUID()}`)
await mkdir(join(root, 'packages/example/dist'), { recursive: true })
await writeFile(
join(root, 'package.json'),
JSON.stringify({ name: 'fixture', version: '1.0.0', workspaces: ['packages/example'] })
)
await writeFile(join(root, 'packages/example/dist/index.js'), 'export const ready = true\n')
await writeFile(join(root, 'LICENSE'), 'fixture license\n')
await writeFile(
join(root, 'packages/example/dist/index.d.ts'),
'export declare const ready: true\n'
)
await writeFile(
join(root, 'packages/example/package.json'),
JSON.stringify({
name: '@fixture/release-package',
version: '1.0.0',
files: ['dist'],
exports: {
'.': {
types: './dist/index.d.ts',
import: './dist/index.js',
default: './dist/index.js'
}
},
publishConfig: { access: 'public' }
})
)
await prepareReleasePackages(root, { listPackageFiles: async () => FIXTURE_FILES })
const plan = await packReleasePackages(root, async () => false, {
packDirectory: packInProcess
})
expect(plan.map(({ package: pkg, status }) => [pkg.manifest.name, status])).toEqual([
['@fixture/release-package', 'unpublished']
])
const artifacts = releasePaths(root).artifacts
const tarballs = (await readdir(artifacts)).filter((name) => name.endsWith('.tgz'))
expect(tarballs).toEqual(['fixture-release-package-1.0.0.tgz'])
expect([...(await readTarball(join(artifacts, tarballs[0]))).entries].sort()).toEqual([
'package/LICENSE',
'package/dist/index.d.ts',
'package/dist/index.js',
'package/package.json'
])
})
test('builds discovered packages in dependency order', async () => {
const root = await createWorkspace()
const packages = await buildReleasePackages(root, { output: 'capture' })
expect(packages.map(({ manifest }) => manifest.name)).toEqual([
'@fixture/library',
'@fixture/app'
])
expect(await Bun.file(join(root, 'order.txt')).text()).toBe('library,app')
})
})