fix: preserve approved assets when adding project media
This commit is contained in:
@@ -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<string, unknown>, 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user