Files
xuetang/TongjiUniApp/build/tang-detective-source-validation.test.mjs
2026-09-09 14:47:29 +08:00

185 lines
11 KiB
JavaScript

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import nativePlugin, { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { validateCosMediaManifest } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt, writeFixture } from './tang-detective-test-fixtures.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
function fixture(t, { prune = false } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-source-test-')))
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const source = createSyntheticSource(temporary)
const mediaManifest = fakeVerifiedManifest(source)
const pruneReceipt = fakePruneReceipt(mediaManifest)
const pruneReceiptPath = path.join(temporary, 'test-only-prune-receipt.json')
writeFixture(temporary, 'test-only-prune-receipt.json', JSON.stringify(pruneReceipt))
if (prune) for (const file of source.sourceMedia) fs.unlinkSync(path.join(source.sourceDirectory, file.path))
const outputDirectory = path.join(temporary, 'output')
writeFixture(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'] }))
writeFixture(outputDirectory, 'app.js', '/* host unchanged */')
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
overlayDirectory: path.join(projectRoot, 'native-adapter/tang-detective'), outputDirectory,
mediaManifest, pruneReceiptPath }
return { temporary, ...source, options, mediaManifest, pruneReceipt, pruneReceiptPath, outputDirectory }
}
const snapshot = directory => listFiles(directory).map(file => [file, sha256(fs.readFileSync(path.join(directory, file)))])
test('the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files', () => {
const source = readSourceManifest()
assert.equal(source.files.size, 473)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.jpg')).length, 160)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.mp3')).length, 44)
})
test('a fully pruned clone builds and validates from 269 intact files with no external backup access', t => {
const context = fixture(t, { prune: true })
// A portable receipt records the archive hash. It never requires opening a
// prior machine's absolute path, and no archive exists in this offline test.
context.pruneReceipt.backup.originalPath = '/unavailable-on-another-machine/test-only.tar.gz'
writeFixture(context.temporary, 'test-only-prune-receipt.json', JSON.stringify(context.pruneReceipt))
const media = validateCosMediaManifest(context.mediaManifest, context.options)
assert.equal(media.sourceSnapshot.actual.size, 269)
assert.equal(media.sourceSnapshot.missingMedia.length, 204)
assert.equal(media.sourceSnapshot.prune.backupSha256, context.pruneReceipt.backup.sha256)
const report = copyNativeProgram(context.options)
assert.equal(report.source.omittedMediaFiles, 204)
assert.equal(report.source.presentFiles, 269)
assert.equal(report.sizes.mediaFileCount, 0)
const validation = validateNativeOutput(context.outputDirectory, context.options)
assert.deepEqual(validation.errors, [])
assert.equal(validation.passed, true)
const before = snapshot(context.outputDirectory)
copyNativeProgram(context.options)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('missing local media and missing remote prune receipt fail before any output changes', t => {
const context = fixture(t)
copyNativeProgram({ ...context.options, mediaManifest: null })
const before = snapshot(context.outputDirectory)
fs.unlinkSync(path.join(context.sourceDirectory, context.sourceMedia[0].path))
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: null }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
fs.unlinkSync(context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
assert.deepEqual(snapshot(context.outputDirectory), before)
const report = validateNativeOutput(context.outputDirectory, { ...context.options, mediaManifest: null })
assert.equal(report.passed, false)
assert.ok(report.errors.some(error => error.includes('local mode requires all media')))
})
test('the Vite buildStart preflight rejects missing local media before bundle output can begin', t => {
const context = fixture(t, { prune: true })
const plugin = nativePlugin({ ...context.options, mediaManifest: null })
plugin.configResolved({ root: context.temporary, build: { outDir: 'output' } })
const before = snapshot(context.outputDirectory)
assert.throws(() => plugin.buildStart.call({ addWatchFile() {} }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash', t => {
const context = fixture(t, { prune: true })
const mutations = [
receipt => { receipt.schemaVersion = 2 },
receipt => { receipt.status = 'planned' },
receipt => { receipt.sourceManifestSha256 = '0'.repeat(64) },
receipt => { receipt.mediaManifestSha256 = '0'.repeat(64) },
receipt => { receipt.uploadRunId = 'different-run' },
receipt => { receipt.backup.sha256 = 'not-a-hash' },
receipt => { receipt.backup.bytes = 0 },
receipt => { receipt.backup.format = 'zip' },
receipt => { receipt.entries.pop() },
receipt => { receipt.entries[1] = { ...receipt.entries[0] } },
receipt => { receipt.entries[0].sourcePath = '../escape.jpg' },
receipt => { receipt.entries[0].sourcePath = 'app.json' },
receipt => { receipt.entries[0].sha256 = '0'.repeat(64) },
receipt => { receipt.entries[0].bytes++ },
receipt => { receipt.entries[0].url += '?tampered' },
receipt => { receipt.entries[0].objectKey = 'wrong.jpg' },
receipt => { receipt.entries[0].backupSha256 = '0'.repeat(64) },
]
const before = snapshot(context.outputDirectory)
for (const mutate of mutations) {
const receipt = structuredClone(context.pruneReceipt)
mutate(receipt)
assert.throws(() => copyNativeProgram({ ...context.options, pruneReceipt: receipt }), /Invalid Tang Detective source snapshot/)
assert.deepEqual(snapshot(context.outputDirectory), before)
}
// Even a single omitted file needs evidence for the entire upload inventory.
const partial = fixture(t)
fs.unlinkSync(path.join(partial.sourceDirectory, partial.sourceMedia[0].path))
assert.equal(validateCosMediaManifest(partial.mediaManifest, partial.options).sourceSnapshot.missingMedia.length, 1)
const invalidManifest = { ...context.mediaManifest, uploadRunId: undefined }
assert.throws(() => validateCosMediaManifest(invalidManifest, {
...context.options, pruneReceipt: fakePruneReceipt(invalidManifest),
}), /upload run mismatch/)
})
test('remote pruning never allows missing nonmedia, changed surviving bytes or extra source files', t => {
const context = fixture(t, { prune: true })
const nonmedia = 'utils/storage.js'
const original = fs.readFileSync(path.join(context.sourceDirectory, nonmedia))
fs.unlinkSync(path.join(context.sourceDirectory, nonmedia))
assert.throws(() => copyNativeProgram(context.options), /missing non-media source: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, 'changed code')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, original)
for (const relative of ['assets/unexpected.jpg', 'package-game/data/unexpected.js']) {
writeFixture(context.sourceDirectory, relative, 'unexpected')
assert.throws(() => copyNativeProgram(context.options), /unexpected source file/)
fs.unlinkSync(path.join(context.sourceDirectory, relative))
}
fs.mkdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
assert.throws(() => copyNativeProgram(context.options), /unexpected source directory/)
fs.rmdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
const sourceMedia = context.sourceMedia[0]
writeFixture(context.sourceDirectory, sourceMedia.path, 'changed surviving media')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed/)
assert.equal(listFiles(context.outputDirectory).length, 2)
})
test('source and evidence symlinks, including dangling media links, cannot authorize or hide omission', t => {
const context = fixture(t, { prune: true })
const target = path.join(context.sourceDirectory, context.sourceMedia[0].path)
fs.symlinkSync(path.join(context.temporary, 'missing-media.jpg'), target)
assert.throws(() => copyNativeProgram(context.options), /source symbolic links are forbidden/)
fs.unlinkSync(target)
const outside = path.join(context.temporary, 'original-source-directory')
fs.renameSync(context.sourceDirectory, outside)
fs.symlinkSync(outside, context.sourceDirectory, 'dir')
try { assert.throws(() => copyNativeProgram(context.options), /source root must be a real directory/) }
finally { fs.unlinkSync(context.sourceDirectory); fs.renameSync(outside, context.sourceDirectory) }
const savedReceipt = path.join(context.temporary, 'original-prune-receipt.json')
fs.renameSync(context.pruneReceiptPath, savedReceipt)
fs.symlinkSync(savedReceipt, context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
fs.unlinkSync(context.pruneReceiptPath)
fs.renameSync(savedReceipt, context.pruneReceiptPath)
const savedSource = path.join(context.temporary, 'original-source-manifest.json')
fs.renameSync(context.sourceManifestPath, savedSource)
fs.symlinkSync(savedSource, context.sourceManifestPath)
assert.throws(() => validateSourceSnapshot(context.options), /source manifest must be a regular file/)
})
test('remote output validation rejects later receipt drift or removal', t => {
const context = fixture(t, { prune: true })
copyNativeProgram(context.options)
const invalid = { ...context.pruneReceipt, uploadRunId: 'changed-run' }
const failed = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: invalid })
assert.equal(failed.passed, false)
assert.ok(failed.errors.some(error => error.includes('upload run mismatch')))
const rebound = structuredClone(context.pruneReceipt)
rebound.backup.sha256 = '1'.repeat(64)
for (const entry of rebound.entries) entry.backupSha256 = rebound.backup.sha256
const changedReceipt = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: rebound })
assert.ok(changedReceipt.errors.includes('Media prune receipt changed after import'))
fs.unlinkSync(context.pruneReceiptPath)
assert.equal(validateNativeOutput(context.outputDirectory, context.options).passed, false)
})