* 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
182 lines
6.5 KiB
TypeScript
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')
|
|
})
|
|
})
|