Files
ArtPlayer/refactor/scripts/release-verify.test.mjs

214 lines
10 KiB
JavaScript

import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Release boundary tests use the existing Node baseline runner.
import test from 'node:test'
import { prepareReleaseBundle } from '../../scripts/release/bundle.ts'
import { verifyReleaseBundle } from '../../scripts/release/verify.ts'
const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
// The successful reports below are synthetic. No real ArtPlayer release gate is waived.
function fixture(t) {
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-verify-test-'))
fs.mkdirSync(path.join(repository, 'refactor/.cache'), { recursive: true })
t.after(() => {
assert.equal(path.dirname(fs.realpathSync(repository)), fs.realpathSync(os.tmpdir()))
assert(path.basename(repository).startsWith('artplayer-verify-test-'))
fs.rmSync(repository, { recursive: true })
})
const names = ['artplayer', 'artplayer-plugin-chapter']
const packages = names.map((name) => {
const source = path.join(repository, name, 'package')
fs.mkdirSync(source, { recursive: true })
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name, version: '6.0.0', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = 42\n')
const file = `refactor/.cache/${name}.tgz`
execFileSync('tar', ['-czf', path.join(repository, file), '-C', path.dirname(source), 'package'], { stdio: 'pipe' })
const bytes = fs.readFileSync(path.join(repository, file))
return { name, version: '6.0.0', distribution: 'npm', fingerprint: `input-${name}`, status: 'evidence-complete', blockers: [], candidate: { path: file, version: '6.0.0', sourceCommit: 'a'.repeat(40), inputFingerprint: `input-${name}`, integrity: `sha512-${digest(bytes, 'sha512', 'base64')}`, errors: [] } }
})
const report = { schemaVersion: 1, sourceCommit: 'b'.repeat(40), evidenceComplete: true, publicationAuthorized: false, toolchain: { node: 'v24.21.0', canonicalNode: '24.21.0', packageManager: 'yarn@1.22.22', lock: { sha256: 'c'.repeat(64) } }, packages }
const inspect = () => structuredClone(report)
const prepared = prepareReleaseBundle(repository, names, 'next', inspect)
const directory = path.join(repository, 'downloaded')
fs.cpSync(prepared.directory, directory, { recursive: true })
const manifestFile = path.join(directory, 'manifest.json')
const expected = { sourceCommit: report.sourceCommit, manifestSha256: digest(fs.readFileSync(manifestFile)), names, tag: 'next' }
const verify = (inspector = inspect) => verifyReleaseBundle(repository, directory, expected, inspector)
const modifyManifest = (change) => {
const manifest = JSON.parse(fs.readFileSync(manifestFile))
change(manifest)
fs.writeFileSync(manifestFile, JSON.stringify(manifest))
expected.manifestSha256 = digest(fs.readFileSync(manifestFile))
}
return { repository, directory, report, expected, verify, inspect, modifyManifest, manifestFile }
}
test('Downloaded bundle is checked against independent inputs and two fresh ledger reads without gaining authority', (t) => {
const f = fixture(t)
const before = fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))])
let reads = 0
const result = f.verify(() => {
reads++
return f.inspect()
})
assert.equal(reads, 2)
assert.equal(result.packages.length, 2)
assert.equal(result.contentVerified, true)
assert.equal(result.publicationAuthorized, false)
assert.equal(result.workflowProvenanceVerified, false)
assert.equal(result.registryOccupancyVerified, false)
assert.deepEqual(fs.readdirSync(f.directory).map(name => [name, digest(fs.readFileSync(path.join(f.directory, name)))]), before)
})
test('A changed manifest is rejected before interpreting its report or inspecting the repository', (t) => {
const f = fixture(t)
fs.appendFileSync(f.manifestFile, ' ')
assert.throws(() => f.verify(() => {
throw new Error('must not inspect')
}), /independent digest/)
})
test('Self-consistent forged manifest fields cannot replace fresh repository evidence', (t) => {
const f = fixture(t)
const original = fs.readFileSync(f.manifestFile)
const cases = [
m => m.publicationAuthorized = true,
m => m.registry = 'https://example.invalid/',
m => m.tag = 'latest',
m => m.sourceCommit = 'd'.repeat(40),
m => m.toolchain.lock.sha256 = 'e'.repeat(64),
m => m.preflight.sha256 = 'e'.repeat(64),
m => m.preflight.file = '../preflight.json',
m => m.packages.reverse(),
m => m.packages.push(m.packages[0]),
m => m.packages[0].version = '7.0.0',
m => m.packages[0].file = '../escape.tgz',
m => m.packages[0].sha256 = 'e'.repeat(64),
m => m.packages[0].integrity = 'sha512-forged',
m => m.packages[0].sourceCommit = 'd'.repeat(40),
m => m.packages[0].inputFingerprint = 'new-inputs',
m => m.kind = 'other-bundle',
m => m.extraApproval = true,
]
for (const change of cases) {
fs.writeFileSync(f.manifestFile, original)
f.modifyManifest(change)
assert.throws(() => f.verify(), /metadata differs/)
}
})
test('A freshly blocked or changed ledger rejects even an intact formerly accepted bundle', (t) => {
const f = fixture(t)
const changes = [
r => r.evidenceComplete = false,
r => r.packages[0].status = 'blocked',
r => r.packages[0].blockers.push('new finding'),
r => r.packages[0].distribution = 'site',
r => r.packages[0].candidate.errors.push('changed candidate'),
r => r.packages[0].candidate.inputFingerprint = 'stale',
r => r.sourceCommit = 'd'.repeat(40),
r => r.toolchain.lock.sha256 = 'd'.repeat(64),
]
for (const change of changes) {
assert.throws(() => f.verify(() => {
const report = f.inspect()
change(report)
return report
}))
}
fs.writeFileSync(path.join(f.directory, 'preflight.json'), JSON.stringify({ ...f.report, extraApproval: true }))
f.modifyManifest(m => m.preflight.sha256 = digest(fs.readFileSync(path.join(f.directory, 'preflight.json'))))
assert.throws(() => f.verify(), /Downloaded preflight differs/)
})
test('Changed archive bytes, even if copied into the registered location, cannot reuse candidate integrity', (t) => {
const f = fixture(t)
const file = path.join(f.directory, 'artplayer-6.0.0.tgz')
fs.appendFileSync(file, 'tampered')
assert.throws(() => f.verify(), /downloaded tarball differs/)
fs.copyFileSync(file, path.join(f.repository, f.report.packages[0].candidate.path))
assert.throws(() => f.verify(), /candidate integrity changed/)
})
test('Extra files and missing archives fail without deleting downloaded evidence', (t) => {
const f = fixture(t)
fs.writeFileSync(path.join(f.directory, 'extra.sh'), 'exit 1')
assert.throws(() => f.verify(), /Unexpected or missing bundle files/)
assert(fs.existsSync(path.join(f.directory, 'extra.sh')))
fs.unlinkSync(path.join(f.directory, 'extra.sh'))
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
assert.throws(() => f.verify(), /ENOENT/)
assert(fs.existsSync(f.manifestFile))
})
test('Redirected directories, redirected parents and non-file members are rejected', (t) => {
const f = fixture(t)
const link = path.join(f.repository, 'redirect')
fs.symlinkSync(f.directory, link, 'junction')
assert.throws(() => verifyReleaseBundle(f.repository, link, f.expected, f.inspect), /regular directory|Redirected bundle/)
const parent = path.join(f.repository, 'redirect-parent')
fs.symlinkSync(f.repository, parent, 'junction')
assert.throws(() => verifyReleaseBundle(f.repository, path.join(parent, 'downloaded'), f.expected, f.inspect), /Redirected bundle/)
fs.unlinkSync(path.join(f.directory, 'artplayer-6.0.0.tgz'))
fs.symlinkSync(path.join(f.repository, 'artplayer'), path.join(f.directory, 'artplayer-6.0.0.tgz'), 'junction')
assert.throws(() => f.verify(), /not a regular file/)
})
test('Source or downloaded content changed during verification cannot produce a successful result', (t) => {
const mutations = [
f => fs.appendFileSync(f.manifestFile, ' '),
f => fs.appendFileSync(path.join(f.directory, 'preflight.json'), ' '),
f => fs.appendFileSync(path.join(f.directory, 'artplayer-6.0.0.tgz'), ' '),
f => fs.writeFileSync(path.join(f.directory, 'late-file'), ' '),
f => f.report.toolchain.lock.sha256 = 'd'.repeat(64),
]
for (const mutate of mutations) {
const f = fixture(t)
let reads = 0
assert.throws(() => f.verify(() => {
if (++reads === 2)
mutate(f)
return f.inspect()
}), /changed during verification/)
assert.equal(reads, 2)
}
})
test('Invalid independent selection, tag, source and digest fail before reading downloaded contents', (t) => {
const f = fixture(t)
for (const change of [{ names: [] }, { names: ['artplayer', 'artplayer'] }, { names: ['../artplayer'] }, { tag: 'arbitrary' }, { sourceCommit: 'master' }, { manifestSha256: '' }])
assert.throws(() => verifyReleaseBundle(f.repository, 'not-a-directory', { ...f.expected, ...change }, f.inspect), /Supply|Select/)
})
test('Verifier CLI rejects omitted inputs, unknown options and an unsupported package manager', (t) => {
const f = fixture(t)
const script = path.resolve('scripts/verify-release.mjs')
const full = ['--directory', f.directory, '--packages', f.expected.names.join(','), '--tag', 'next', '--source-commit', f.expected.sourceCommit, '--manifest-sha256', f.expected.manifestSha256]
for (const [args, message] of [[[], /are required/], [['--unknown'], /Unknown option/], [full, /Use yarn release:verify-bundle/]]) {
assert.throws(() => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe', env: { ...process.env, npm_config_user_agent: 'npm/11.5.1' } }), (error) => {
assert.equal(error.status, 1)
assert.match(error.stderr.toString(), message)
return true
})
}
})
test('A second-inspection exception is preserved and downloaded evidence is retained', (t) => {
const f = fixture(t)
const failure = new Error('fresh evidence read failed')
let reads = 0
assert.throws(() => f.verify(() => {
if (++reads === 2)
throw failure
return f.inspect()
}), error => error === failure)
assert.equal(reads, 2)
assert(fs.existsSync(f.manifestFile))
})