185 lines
11 KiB
JavaScript
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)
|
|
})
|