import { createHash } from 'node:crypto'; import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { assertPreparedAssetDirectoryIdentity, verifyApprovedAssetSet, } from '../../scripts/materialize-empty-asset-set.mjs'; const temporaryRoots = []; const createScratchRepository = async () => { const root = await mkdtemp(join(tmpdir(), 'portfolio-empty-assets-')); temporaryRoots.push(root); await mkdir(join(root, 'content/public'), { recursive: true }); await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}'); await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [], additional: [] }, toolkit: [], metadata: {}, })); return root; }; const assetBytes = Buffer.from('approved public asset'); const assetHash = createHash('sha256').update(assetBytes).digest('hex'); const writeApprovedSet = async (root) => { await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [{ id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash, }] })); await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [{ assetIds: ['approved-image'] }], additional: [] }, toolkit: [], metadata: {}, })); await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true }); await writeFile(join(root, 'inputs/prepared-assets/approved-image.webp'), assetBytes); }; afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe('approved asset publication closure', () => { it('creates an empty prepared-asset directory in the supplied repository root', async () => { const root = await createScratchRepository(); await verifyApprovedAssetSet(root); expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true); }); it('accepts an approved manifest, public reference, and prepared file with exact closure', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined(); }); it('includes every soundtrack codec variant in the approved publication closure', async () => { const root = await createScratchRepository(); const assets = [ ['soundtrack-intro-opus', 'nightdrive-intro.opus'], ['soundtrack-intro-mp3', 'nightdrive-intro.mp3'], ['soundtrack-main-opus', 'nightdrive-main.opus'], ['soundtrack-main-mp3', 'nightdrive-main.mp3'], ['soundtrack-afterglow-opus', 'nightdrive-afterglow.opus'], ['soundtrack-afterglow-mp3', 'nightdrive-afterglow.mp3'], ['soundtrack-drive-lift-opus', 'nightdrive-drive-lift.opus'], ['soundtrack-drive-lift-mp3', 'nightdrive-drive-lift.mp3'], ].map(([id, fileName], index) => { const bytes = Buffer.from(`approved soundtrack asset ${index}`); return { id, fileName, bytes, sha256: createHash('sha256').update(bytes).digest('hex') }; }); await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: assets.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })), })); await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [], additional: [] }, toolkit: [], metadata: {}, soundtrack: { intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' }, main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' }, afterglow: { opusAssetId: 'soundtrack-afterglow-opus', mp3AssetId: 'soundtrack-afterglow-mp3' }, driveLift: { opusAssetId: 'soundtrack-drive-lift-opus', mp3AssetId: 'soundtrack-drive-lift-mp3' }, }, })); await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true }); await Promise.all(assets.map(({ fileName, bytes }) => writeFile(join(root, 'inputs/prepared-assets', fileName), bytes))); await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined(); }); it('rejects an omitted adaptive soundtrack asset', async () => { const root = await createScratchRepository(); const adaptiveAsset = Buffer.from('approved adaptive asset'); const adaptiveHash = createHash('sha256').update(adaptiveAsset).digest('hex'); await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [{ id: 'soundtrack-afterglow-opus', fileName: 'nightdrive-afterglow.opus', sha256: adaptiveHash, }] })); await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [{ assetIds: ['soundtrack-afterglow-opus'] }], additional: [] }, toolkit: [], metadata: {}, })); await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true }); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/missing prepared asset/i); }); it('rejects an altered adaptive soundtrack asset', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); const adaptiveAsset = Buffer.from('approved adaptive asset'); const adaptiveHash = createHash('sha256').update(adaptiveAsset).digest('hex'); await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [{ id: 'soundtrack-afterglow-opus', fileName: 'nightdrive-afterglow.opus', sha256: adaptiveHash, }] })); await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [{ assetIds: ['soundtrack-afterglow-opus'] }], additional: [] }, toolkit: [], metadata: {}, })); await rm(join(root, 'inputs/prepared-assets/approved-image.webp')); await writeFile(join(root, 'inputs/prepared-assets/nightdrive-afterglow.opus'), Buffer.from('altered adaptive asset')); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/digest|hash/i); }); it('rejects an unreferenced extra adaptive soundtrack asset', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); await writeFile(join(root, 'inputs/prepared-assets/nightdrive-afterglow-extra.opus'), Buffer.from('extra adaptive asset')); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset/i); }); it('rejects a symbolic-link prepared-asset directory', async () => { const root = await createScratchRepository(); const target = join(root, 'outside'); await mkdir(target); await mkdir(join(root, 'inputs'), { recursive: true }); await symlink(target, join(root, 'inputs/prepared-assets')); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/non-symlink directory/i); }); it('rejects extra prepared files', async () => { const root = await createScratchRepository(); const preparedAssetsPath = join(root, 'inputs/prepared-assets'); await mkdir(preparedAssetsPath, { recursive: true }); await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset'); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset/i); }); it('rejects missing and path-mismatched prepared files', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); await rm(join(root, 'inputs/prepared-assets/approved-image.webp')); await writeFile(join(root, 'inputs/prepared-assets/wrong-image.webp'), assetBytes); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset|missing prepared asset/i); }); it('rejects unknown, unreferenced, and duplicate publication IDs', async () => { const unknown = await createScratchRepository(); await writeApprovedSet(unknown); await writeFile(join(unknown, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [{ assetIds: ['unknown-image'] }], additional: [] }, toolkit: [], metadata: {}, })); await expect(verifyApprovedAssetSet(unknown)).rejects.toThrow(/unknown public asset reference/i); const unreferenced = await createScratchRepository(); await writeApprovedSet(unreferenced); await writeFile(join(unreferenced, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [], additional: [] }, toolkit: [], metadata: {}, })); await expect(verifyApprovedAssetSet(unreferenced)).rejects.toThrow(/unreferenced manifest asset/i); const duplicate = await createScratchRepository(); await writeApprovedSet(duplicate); await writeFile(join(duplicate, 'content/public/portfolio-content.json'), JSON.stringify({ projects: { selected: [{ assetIds: ['approved-image', 'approved-image'] }], additional: [] }, toolkit: [], metadata: {}, })); await expect(verifyApprovedAssetSet(duplicate)).rejects.toThrow(/duplicate public asset reference/i); }); it('rejects duplicate manifest IDs', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash }; await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [duplicateAsset, { ...duplicateAsset, fileName: 'second-image.webp' }], })); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest asset id/i); }); it('rejects duplicate manifest filenames', async () => { const root = await createScratchRepository(); await writeApprovedSet(root); const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash }; await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [duplicateAsset, { ...duplicateAsset, id: 'second-image' }], })); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest filename/i); }); it('rejects a prepared-asset directory replaced after its identity was captured', async () => { const root = await createScratchRepository(); const preparedAssetsPath = join(root, 'inputs/prepared-assets'); await mkdir(preparedAssetsPath, { recursive: true }); const original = await lstat(preparedAssetsPath); await rm(preparedAssetsPath, { recursive: true }); await mkdir(preparedAssetsPath); await expect(assertPreparedAssetDirectoryIdentity(preparedAssetsPath, original)) .rejects.toThrow(/identity changed/i); }); });