From c06fc18caddaf8b258f22f54207450ddd27fa733 Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 22:01:52 +0300 Subject: [PATCH] fix: preserve approved assets when adding project media --- scripts/lib/merge-assets.mjs | 37 ++++ scripts/prepare-assets.mjs | 146 +++++++++++++--- tests/integration/prepare-assets.test.ts | 210 ++++++++++++++++++++++- 3 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 scripts/lib/merge-assets.mjs diff --git a/scripts/lib/merge-assets.mjs b/scripts/lib/merge-assets.mjs new file mode 100644 index 0000000..a05a0c0 --- /dev/null +++ b/scripts/lib/merge-assets.mjs @@ -0,0 +1,37 @@ +const idPattern = /^[a-z][a-z0-9-]{0,63}$/u; +const fileNamePattern = /^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm|opus|mp3)$/u; +const sha256Pattern = /^[a-f0-9]{64}$/u; + +const assertSafeRecord = (asset) => { + if (!asset || typeof asset !== 'object' || Array.isArray(asset) || + typeof asset.id !== 'string' || !idPattern.test(asset.id) || + typeof asset.fileName !== 'string' || !fileNamePattern.test(asset.fileName) || + typeof asset.sha256 !== 'string' || !sha256Pattern.test(asset.sha256)) { + throw new Error('ASSET-MERGE-RECORD-001'); + } +}; + +export const mergeAssetRecords = (baseAssets, generatedAssets, managedIds) => { + if (!Array.isArray(baseAssets) || !Array.isArray(generatedAssets) || !(managedIds instanceof Set)) { + throw new Error('ASSET-MERGE-RECORD-001'); + } + for (const asset of [...baseAssets, ...generatedAssets]) { + if (!asset || typeof asset !== 'object' || Array.isArray(asset) || + typeof asset.id !== 'string' || typeof asset.fileName !== 'string') { + throw new Error('ASSET-MERGE-RECORD-001'); + } + } + const retained = baseAssets.filter(({ id }) => !managedIds.has(id)); + const combined = [...retained, ...generatedAssets]; + const ids = new Set(); + const names = new Set(); + for (const asset of combined) { + const folded = asset.fileName.toLocaleLowerCase('en-US'); + if (ids.has(asset.id)) throw new Error('ASSET-MERGE-ID-001'); + if (names.has(folded)) throw new Error('ASSET-MERGE-NAME-001'); + ids.add(asset.id); + names.add(folded); + } + for (const asset of [...baseAssets, ...generatedAssets]) assertSafeRecord(asset); + return combined.sort((left, right) => left.fileName.localeCompare(right.fileName, 'en-US')); +}; diff --git a/scripts/prepare-assets.mjs b/scripts/prepare-assets.mjs index dce6a19..f52c866 100644 --- a/scripts/prepare-assets.mjs +++ b/scripts/prepare-assets.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node -import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { copyFile, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; @@ -7,6 +9,7 @@ import { fileTypeFromFile } from 'file-type'; import { hashFile } from './lib/hash-file.mjs'; import { loadPrivateMapping, loadRecipes, readJson, resolvePrivateSource } from './lib/asset-recipes.mjs'; +import { mergeAssetRecords } from './lib/merge-assets.mjs'; import { loadDenylist, scanBinary, scanImage } from './lib/privacy-scan.mjs'; import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/video-frames.mjs'; @@ -161,14 +164,124 @@ const approvalsMatch = async (path, expected) => { if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002'); }; -const publish = async ({ candidates, stagingRoot, publishRoot, manifestPath }) => { +const manifestKeys = [ + 'id', 'fileName', 'sha256', 'mediaType', 'width', 'height', 'durationMs', + 'rightsHolder', 'license', 'attribution', +]; +const mediaExtension = new Map([ + ['image/avif', 'avif'], ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/svg+xml', 'svg'], + ['image/webp', 'webp'], ['video/mp4', 'mp4'], ['video/webm', 'webm'], + ['audio/ogg', 'opus'], ['audio/mpeg', 'mp3'], +]); +const isPositiveIntegerOrNull = (value) => value === null || (Number.isInteger(value) && value > 0); +const hasUnpairedSurrogate = (value) => { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +}; +const isVisibleString = (value) => typeof value === 'string' && value.trim().length > 0 && + value === value.normalize('NFC') && !hasUnpairedSurrogate(value) && !/[\u0000-\u001f\u007f]/u.test(value); + +const validateManifestRecord = (asset) => { + if (!asset || typeof asset !== 'object' || Array.isArray(asset) || + Object.keys(asset).length !== manifestKeys.length || manifestKeys.some((key) => !(key in asset)) || + typeof asset.id !== 'string' || typeof asset.fileName !== 'string' || typeof asset.sha256 !== 'string' || + !mediaExtension.has(asset.mediaType) || !asset.fileName.endsWith(`.${mediaExtension.get(asset.mediaType)}`) || + !isPositiveIntegerOrNull(asset.width) || !isPositiveIntegerOrNull(asset.height) || + !isPositiveIntegerOrNull(asset.durationMs) || + !isVisibleString(asset.rightsHolder) || !isVisibleString(asset.license) || !isVisibleString(asset.attribution)) { + fail('ASSET-MANIFEST-001'); + } + const isImage = asset.mediaType.startsWith('image/'); + const isVideo = asset.mediaType.startsWith('video/'); + const isAudio = asset.mediaType.startsWith('audio/'); + if ((isImage && (asset.width === null || asset.height === null || asset.durationMs !== null)) || + (isVideo && (asset.width === null || asset.height === null || asset.durationMs === null)) || + (isAudio && (asset.width !== null || asset.height !== null || asset.durationMs === null))) { + fail('ASSET-MANIFEST-001'); + } +}; + +const loadManifestAssets = async (manifestPath) => { + const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001'); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || + Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001'); + for (const asset of manifest.assets) validateManifestRecord(asset); + try { + mergeAssetRecords(manifest.assets, [], new Set()); + } catch { + fail('ASSET-MANIFEST-001'); + } + return manifest.assets; +}; + +const readVerifiedRegularFile = async (path, expectedHash, rule) => { + let handle; + try { + handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const details = await handle.stat(); + if (!details.isFile() || details.nlink !== 1) fail(rule); + const bytes = await handle.readFile(); + if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001'); + return bytes; + } catch (error) { + if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error; + fail(rule); + } finally { + await handle?.close(); + } +}; + +const verifyCurrentPublication = async (publishRoot, assets) => { + const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001'); + let rootStats; + try { rootStats = await lstat(safePublishRoot); } catch { fail('ASSET-EXACT-001'); } + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) fail('ASSET-EXACT-001'); + const expectedNames = new Set(assets.map(({ fileName }) => fileName)); + const entries = await readdir(safePublishRoot, { withFileTypes: true }); + if (entries.length !== expectedNames.size) fail('ASSET-EXACT-001'); + for (const entry of entries) { + if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001'); + } + for (const asset of assets) { + await readVerifiedRegularFile(join(safePublishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001'); + } + return safePublishRoot; +}; + +const publish = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => { if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) { fail('ASSET-RIGHTS-001'); } - const safePublishRoot = await resetDirectory(publishRoot, 'ASSET-PUBLISH-001'); + const baseAssets = await loadManifestAssets(manifestPath); + const safePublishRoot = await verifyCurrentPublication(publishRoot, baseAssets); + const managedIds = new Set(recipes.map(({ id }) => id)); + const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds); + for (const item of candidates) { + validateManifestRecord(item); + await readVerifiedRegularFile(join(stagingRoot, item.fileName), item.sha256, 'ASSET-EXACT-001'); + } + + const mergedNames = new Set(mergedAssets.map(({ fileName }) => fileName)); + const obsoleteNames = baseAssets + .filter(({ id, fileName }) => managedIds.has(id) && !mergedNames.has(fileName)) + .map(({ fileName }) => fileName); for (const item of candidates) await copyFile(join(stagingRoot, item.fileName), join(safePublishRoot, item.fileName)); + for (const item of candidates) { + await readVerifiedRegularFile(join(safePublishRoot, item.fileName), item.sha256, 'ASSET-EXACT-001'); + } + for (const fileName of obsoleteNames) await unlink(join(safePublishRoot, fileName)); + await verifyCurrentPublication(safePublishRoot, mergedAssets); await mkdir(dirname(manifestPath), { recursive: true }); - await writeFile(manifestPath, `${JSON.stringify({ assets: candidates }, null, 2)}\n`, 'utf8'); + await writeFile(manifestPath, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, 'utf8'); }; export const prepareAssets = async (options) => { @@ -199,7 +312,10 @@ export const prepareAssets = async (options) => { if (options.approvalRegisterPath !== undefined) { if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001'); await approvalsMatch(options.approvalRegisterPath, approval); - await publish({ candidates: finalCandidates, stagingRoot, publishRoot: options.publishRoot, manifestPath: options.manifestPath }); + await publish({ + candidates: finalCandidates, recipes, stagingRoot, + publishRoot: options.publishRoot, manifestPath: options.manifestPath, + }); approvalStatus = 'approved'; } return { candidates: finalCandidates, approvalStatus, reportDir }; @@ -207,23 +323,13 @@ export const prepareAssets = async (options) => { export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => { if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001'); - const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001'); - if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001'); - const expected = manifest.assets.map(({ fileName }) => fileName).sort(); - const actual = (await readdir(publishRoot, { withFileTypes: true })) - .map((entry) => { - if (!entry.isFile()) fail('ASSET-EXACT-001'); - return entry.name; - }).sort(); - if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-EXACT-001'); + const assets = await loadManifestAssets(manifestPath); + const safePublishRoot = await verifyCurrentPublication(publishRoot, assets); const denylist = await loadDenylist(denylistPath); - for (const item of manifest.assets) { - if (!item || typeof item.fileName !== 'string' || !/^project-[a-z0-9-]+\.(webp|png|webm|mp4)$/u.test(item.fileName)) fail('ASSET-MANIFEST-001'); - const path = join(publishRoot, item.fileName); - if ((await stat(path)).nlink !== 1) fail('ASSET-EXACT-001'); - if (await hashFile(path) !== item.sha256) fail('ASSET-HASH-001'); + for (const item of assets) { + const path = join(safePublishRoot, item.fileName); if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true }); - else await scanBinary(path, denylist); + else if (item.mediaType.startsWith('video/')) await scanBinary(path, denylist); } return true; }; diff --git a/tests/integration/prepare-assets.test.ts b/tests/integration/prepare-assets.test.ts index f97a819..27c4e6e 100644 --- a/tests/integration/prepare-assets.test.ts +++ b/tests/integration/prepare-assets.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { link, mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; @@ -13,6 +13,8 @@ import { hashFile } from '../../scripts/lib/hash-file.mjs'; // @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. import { scanImage, scanText } from '../../scripts/lib/privacy-scan.mjs'; // @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. +import { mergeAssetRecords } from '../../scripts/lib/merge-assets.mjs'; +// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. import { prepareAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs'; const execFileAsync = promisify(execFile); @@ -84,6 +86,63 @@ const makeInputs = async (root: string, recipe: Record, sourceN return { sourceRoot, reportDir, stagingRoot, recipesPath, mappingPath, denylistPath }; }; +const soundtrackRecord = (sha256: string) => ({ + id: 'soundtrack-main-opus', + fileName: 'nightdrive-main.opus', + sha256, + mediaType: 'audio/ogg', + width: null, + height: null, + durationMs: 178_605, + rightsHolder: 'Portfolio owner', + license: 'All rights reserved', + attribution: 'Portfolio owner', +}); + +describe('approved asset record merging', () => { + const retained = [{ id: 'soundtrack-main-opus', fileName: 'nightdrive-main.opus', sha256: 'a'.repeat(64) }]; + const generated = [{ id: 'medication-mobile-logs', fileName: 'project-medication-logs.webp', sha256: 'b'.repeat(64) }]; + + it('preserves unmanaged records while adding generated managed records', () => { + expect(mergeAssetRecords(retained, generated, new Set(['medication-mobile-logs']))).toEqual([ + generated[0]!, + retained[0]!, + ].sort((a, b) => a.fileName.localeCompare(b.fileName, 'en-US'))); + }); + + it('rejects duplicate IDs in the resulting manifest', () => { + expect(() => mergeAssetRecords(retained, [generated[0]!, generated[0]!], new Set(['medication-mobile-logs']))) + .toThrowError(/ASSET-MERGE-ID-001/u); + }); + + it('rejects case-folded filename collisions in the resulting manifest', () => { + const colliding = [{ ...generated[0]!, fileName: 'NIGHTDRIVE-MAIN.OPUS' }]; + expect(() => mergeAssetRecords(retained, colliding, new Set(['medication-mobile-logs']))) + .toThrowError(/ASSET-MERGE-NAME-001/u); + }); + + it('does not let generated records replace unmanaged soundtrack IDs', () => { + const impersonating = [{ ...generated[0]!, id: 'soundtrack-main-opus' }]; + expect(() => mergeAssetRecords(retained, impersonating, new Set(['medication-mobile-logs']))) + .toThrowError(/ASSET-MERGE-ID-001/u); + }); + + it('replaces an old managed recipe record with its newly generated record', () => { + const oldManaged = [{ ...generated[0]!, sha256: 'c'.repeat(64) }]; + expect(mergeAssetRecords([...retained, ...oldManaged], generated, new Set(['medication-mobile-logs']))) + .toEqual([generated[0]!, retained[0]!].sort((a, b) => a.fileName.localeCompare(b.fileName, 'en-US'))); + }); + + it.each([ + [{ ...generated[0]!, id: '../unsafe' }], + [{ ...generated[0]!, fileName: '../unsafe.webp' }], + [{ ...generated[0]!, sha256: 'ABC' }], + ])('rejects records whose safe manifest fields are invalid', (invalidGenerated) => { + expect(() => mergeAssetRecords(retained, invalidGenerated, new Set(['medication-mobile-logs']))) + .toThrowError(/ASSET-MERGE-RECORD-001/u); + }); +}); + describe('deterministic asset preparation', () => { it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => { const root = await createRoot(); @@ -175,10 +234,159 @@ describe('deterministic asset preparation', () => { }); const publishRoot = join(root, 'published'); const manifestPath = join(root, 'asset-manifest.json'); + await mkdir(publishRoot); + await writeJson(manifestPath, { assets: [] }); const approved = await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }); expect(approved.approvalStatus).toBe('approved'); expect(JSON.parse(await readFile(manifestPath, 'utf8')).assets).toHaveLength(1); await writeFile(join(publishRoot, 'project-unexpected.webp'), 'extra', 'utf8'); await expect(verifyPreparedAssets({ publishRoot, manifestPath, denylistPath: inputs.denylistPath })).rejects.toThrow(/ASSET-EXACT-001/u); }, 30_000); + + it('publishes approved project assets additively without changing manifest-bound soundtrack bytes', async () => { + const root = await createRoot(); + const inputs = await makeInputs(root, baseRecipe()); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const soundtrackBytes = Buffer.from('retained soundtrack bytes'); + const soundtrackPath = join(publishRoot, 'nightdrive-main.opus'); + await mkdir(publishRoot); + await writeFile(soundtrackPath, soundtrackBytes); + const soundtrackHash = await hashFile(soundtrackPath); + await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] }); + + const approved = await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }); + const project = pending.candidates[0]!; + const publishedManifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { assets: Candidate[] }; + expect(approved.approvalStatus).toBe('approved'); + expect(await readFile(join(publishRoot, 'nightdrive-main.opus'))).toEqual(soundtrackBytes); + expect(await hashFile(join(publishRoot, 'nightdrive-main.opus'))).toBe(soundtrackHash); + expect(await hashFile(join(publishRoot, project.fileName))).toBe(project.sha256); + expect(publishedManifest.assets.map(({ id }) => id)).toEqual(['soundtrack-main-opus', 'project-interface']); + await expect(verifyPreparedAssets({ publishRoot, manifestPath, denylistPath: inputs.denylistPath })).resolves.toBe(true); + }, 30_000); + + it('rejects unmanifested publish-root files before changing publication state', async () => { + const root = await createRoot(); + const inputs = await makeInputs(root, baseRecipe()); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const soundtrackBytes = Buffer.from('retained soundtrack bytes'); + await mkdir(publishRoot); + await writeFile(join(publishRoot, 'nightdrive-main.opus'), soundtrackBytes); + await writeFile(join(publishRoot, 'unmanifested.webp'), 'must block publication'); + const soundtrackHash = await hashFile(join(publishRoot, 'nightdrive-main.opus')); + await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + + await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath })) + .rejects.toThrow(/ASSET-EXACT-001/u); + expect(await readFile(join(publishRoot, 'nightdrive-main.opus'))).toEqual(soundtrackBytes); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 30_000); + + it('rejects changed retained bytes before changing publication state', async () => { + const root = await createRoot(); + const inputs = await makeInputs(root, baseRecipe()); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + await mkdir(publishRoot); + await writeFile(join(publishRoot, 'nightdrive-main.opus'), 'altered soundtrack bytes'); + await writeJson(manifestPath, { assets: [soundtrackRecord('a'.repeat(64))] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + + await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath })) + .rejects.toThrow(/ASSET-HASH-001/u); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 30_000); + + it('removes only the validated prior filename when a managed recipe output is renamed', async () => { + const root = await createRoot(); + const recipe = baseRecipe({ output: 'project-interface-new.webp' }); + const inputs = await makeInputs(root, recipe); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const oldBytes = Buffer.from('previously approved project image'); + const oldPath = join(publishRoot, 'project-interface-old.webp'); + await mkdir(publishRoot); + await writeFile(oldPath, oldBytes); + await writeJson(manifestPath, { assets: [{ + id: 'project-interface', + fileName: 'project-interface-old.webp', + sha256: await hashFile(oldPath), + mediaType: 'image/webp', + width: 1, + height: 1, + durationMs: null, + rightsHolder: 'Portfolio owner', + license: 'All rights reserved', + attribution: 'Portfolio owner', + }] }); + + await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }); + await expect(stat(oldPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await hashFile(join(publishRoot, pending.candidates[0]!.fileName))).toBe(pending.candidates[0]!.sha256); + }, 30_000); + + it('rejects a linked retained file before changing publication state', async () => { + const root = await createRoot(); + const inputs = await makeInputs(root, baseRecipe()); + await makePng(join(inputs.sourceRoot, 'source.png')); + const pending = await prepareAssets(inputs); + const approvalPath = join(root, 'approval.json'); + await writeJson(approvalPath, { + schema: 'portfolio-asset-approvals/v1', + assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }], + }); + + const publishRoot = join(root, 'published'); + const manifestPath = join(root, 'asset-manifest.json'); + const originalPath = join(root, 'retained-original.opus'); + const soundtrackBytes = Buffer.from('linked soundtrack bytes'); + await mkdir(publishRoot); + await writeFile(originalPath, soundtrackBytes); + await link(originalPath, join(publishRoot, 'nightdrive-main.opus')); + await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(originalPath))] }); + const initialManifest = await readFile(manifestPath, 'utf8'); + + await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath })) + .rejects.toThrow(/ASSET-EXACT-001/u); + expect(await readFile(originalPath)).toEqual(soundtrackBytes); + expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest); + await expect(stat(join(publishRoot, pending.candidates[0]!.fileName))).rejects.toMatchObject({ code: 'ENOENT' }); + }, 30_000); });