Files
Portfolio/tests/integration/prepare-assets.test.ts

696 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
import { link, mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, watch, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { BarcodeFormat, MultiFormatWriter } from '@zxing/library';
import { afterAll, describe, expect, it } from 'vitest';
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
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, publishPreparedAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs';
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
import { generateSocialPreview } from '../../scripts/generate-social-preview.mjs';
const execFileAsync = promisify(execFile);
interface Candidate { id: string; fileName: string; sha256: string; }
const roots: string[] = [];
const createRoot = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-assets-test-'));
roots.push(root);
return root;
};
afterAll(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const writeJson = async (path: string, value: unknown): Promise<void> => {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
};
const onTransactionDirectory = async (
root: string,
prefix: string,
action: (path: string) => Promise<void>,
): Promise<void> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_000);
try {
for await (const event of watch(root, { signal: controller.signal })) {
const name = event.filename?.toString();
if (name?.startsWith(prefix)) {
await action(join(root, name));
return;
}
}
} finally {
clearTimeout(timeout);
}
};
const baseRecipe = (overrides: Record<string, unknown> = {}) => ({
id: 'project-interface',
kind: 'image',
output: 'project-interface.webp',
maxWidth: 720,
rightsHolder: 'Portfolio owner',
license: 'All rights reserved',
attribution: 'Portfolio owner',
...overrides,
});
const makePng = async (path: string, text = 'SAFE DEMO', includeMetadata = true): Promise<void> => {
const svg = Buffer.from(`<svg width="1000" height="600" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="600" fill="#f7f4f7"/>
<text x="80" y="320" font-size="96" font-family="sans-serif" fill="#16131a">${text}</text>
</svg>`);
const pipeline = sharp(svg).png();
if (includeMetadata) await pipeline.withMetadata({ orientation: 6 }).toFile(path);
else await pipeline.toFile(path);
};
const makeQrPng = async (path: string): Promise<void> => {
const matrix = new MultiFormatWriter().encode('https://private.invalid/account', BarcodeFormat.QR_CODE, 280, 280, new Map());
const pixels = Buffer.alloc(matrix.getWidth() * matrix.getHeight() * 3);
for (let y = 0; y < matrix.getHeight(); y += 1) {
for (let x = 0; x < matrix.getWidth(); x += 1) {
const value = matrix.get(x, y) ? 0 : 255;
const offset = (y * matrix.getWidth() + x) * 3;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
}
}
await sharp(pixels, { raw: { width: matrix.getWidth(), height: matrix.getHeight(), channels: 3 } }).png().toFile(path);
};
const makeVideo = async (path: string): Promise<void> => {
await execFileAsync('ffmpeg', [
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
'-f', 'lavfi', '-i', 'color=c=red:s=640x360:d=1:r=24',
'-f', 'lavfi', '-i', 'color=c=blue:s=640x360:d=2:r=24',
'-filter_complex', '[0:v][1:v]concat=n=2:v=1:a=0[outv]',
'-map', '[outv]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-threads', '1', path,
], { timeout: 20_000 });
};
const makeInputs = async (root: string, recipe: Record<string, unknown>, sourceName = 'source.png') => {
const sourceRoot = join(root, 'source');
const reportDir = join(root, 'private-report');
const stagingRoot = join(root, 'staging');
await mkdir(sourceRoot, { recursive: true });
const recipesPath = join(root, 'recipes.json');
const mappingPath = join(root, 'mapping.json');
const denylistPath = join(root, 'denylist.txt');
await writeJson(recipesPath, { schema: 'portfolio-asset-recipes/v1', assets: [recipe] });
await writeJson(mappingPath, { schema: 'portfolio-private-source-map/v1', sources: { [String(recipe.id)]: sourceName } });
await writeFile(denylistPath, 'SECRET CANARY\nprivate-source-name\n', 'utf8');
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('prepared candidate publication', () => {
it('re-publishes the complete base, MP4, and poster record set for a video recipe', async () => {
const root = await createRoot();
const stagingRoot = join(root, 'staging');
const publishRoot = join(root, 'published');
const manifestPath = join(root, 'asset-manifest.json');
await mkdir(stagingRoot);
await mkdir(publishRoot);
await writeJson(manifestPath, { assets: [] });
const definitions = [
['private-game-loop', 'project-private-game-loop.webm', 'video/webm', 640, 360, 1_500],
['private-game-loop-mp4', 'project-private-game-loop.mp4', 'video/mp4', 640, 360, 1_500],
['private-game-loop-poster', 'project-private-game-loop-poster.webp', 'image/webp', 640, 360, null],
] as const;
const candidates = [];
for (const [id, fileName, mediaType, width, height, durationMs] of definitions) {
const path = join(stagingRoot, fileName);
await writeFile(path, Buffer.from(`approved bytes for ${id}`));
candidates.push({
id, fileName, sha256: await hashFile(path), mediaType, width, height, durationMs,
rightsHolder: 'Portfolio owner', license: 'All rights reserved', attribution: 'Portfolio owner',
});
}
const recipes = [{ id: 'private-game-loop', kind: 'video', output: 'project-private-game-loop.webm' }];
await publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath });
await publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath });
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { assets: Candidate[] };
expect(manifest.assets.map(({ id }) => id).sort()).toEqual([
'private-game-loop', 'private-game-loop-mp4', 'private-game-loop-poster',
]);
expect((await readdir(publishRoot)).sort()).toEqual([
'project-private-game-loop-poster.webp', 'project-private-game-loop.mp4', 'project-private-game-loop.webm',
]);
});
it('rejects a candidate changed after validation and rolls back the complete publication', async () => {
const root = await createRoot();
const stagingRoot = join(root, 'staging');
const publishRoot = join(root, 'published');
const manifestPath = join(root, 'asset-manifest.json');
const candidatePath = join(stagingRoot, 'project-interface.webp');
const soundtrackPath = join(publishRoot, 'nightdrive-main.opus');
const soundtrackBytes = Buffer.from('transaction-protected soundtrack bytes');
await mkdir(stagingRoot);
await mkdir(publishRoot);
await writeFile(candidatePath, 'approved candidate bytes');
await writeFile(soundtrackPath, soundtrackBytes);
await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] });
const initialManifest = await readFile(manifestPath, 'utf8');
const candidates = [{
id: 'project-interface',
fileName: 'project-interface.webp',
sha256: await hashFile(candidatePath),
mediaType: 'image/webp',
width: 640,
height: 360,
durationMs: null,
rightsHolder: 'Portfolio owner',
license: 'All rights reserved',
attribution: 'Portfolio owner',
}];
const recipes = [{ id: 'project-interface', kind: 'image', output: 'project-interface.webp' }];
const mutation = onTransactionDirectory(root, '.asset-publication-', async () => {
await writeFile(candidatePath, 'changed after validation');
});
const outcome = publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath })
.then(() => ({ error: undefined }), (error: unknown) => ({ error }));
await mutation;
const { error } = await outcome;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/ASSET-(HASH|PUBLISH)-001/u);
expect(await readFile(soundtrackPath)).toEqual(soundtrackBytes);
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']);
});
it('restores both protected paths when manifest installation fails after the directory swap', async () => {
const root = await createRoot();
const stagingRoot = join(root, 'staging');
const publishRoot = join(root, 'published');
const manifestPath = join(root, 'asset-manifest.json');
const candidatePath = join(stagingRoot, 'project-interface.webp');
const soundtrackPath = join(publishRoot, 'nightdrive-main.opus');
const soundtrackBytes = Buffer.alloc(16 * 1024 * 1024, 0x5a);
await mkdir(stagingRoot);
await mkdir(publishRoot);
await writeFile(candidatePath, 'approved rollback candidate');
await writeFile(soundtrackPath, soundtrackBytes);
const soundtrackHash = await hashFile(soundtrackPath);
await writeJson(manifestPath, { assets: [soundtrackRecord(soundtrackHash)] });
const initialManifest = await readFile(manifestPath, 'utf8');
const candidates = [{
id: 'project-interface', fileName: 'project-interface.webp', sha256: await hashFile(candidatePath),
mediaType: 'image/webp', width: 640, height: 360, durationMs: null,
rightsHolder: 'Portfolio owner', license: 'All rights reserved', attribution: 'Portfolio owner',
}];
const recipes = [{ id: 'project-interface', kind: 'image', output: 'project-interface.webp' }];
const sabotage = onTransactionDirectory(root, '.asset-manifest-', async (transactionRoot) => {
const nextManifest = join(transactionRoot, 'next-manifest.json');
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
await rm(nextManifest);
return;
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1));
}
}
throw new Error('next manifest was not observable');
});
const outcome = publishPreparedAssets({ candidates, recipes, stagingRoot, publishRoot, manifestPath })
.then(() => ({ error: undefined }), (error: unknown) => ({ error }));
await sabotage;
const { error } = await outcome;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/ASSET-PUBLISH-001/u);
expect(await hashFile(soundtrackPath)).toBe(soundtrackHash);
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']);
expect((await readdir(root)).filter((name) => name.startsWith('.asset-'))).toEqual([]);
});
});
describe('deterministic asset preparation', () => {
it('renders the deterministic social preview at 1200×630 as opaque privacy-safe pixels', async () => {
const root = await createRoot();
const firstPath = join(root, 'social-preview-one.png');
const secondPath = join(root, 'social-preview-two.png');
await generateSocialPreview(firstPath);
await generateSocialPreview(secondPath);
const metadata = await sharp(firstPath).metadata();
const stats = await sharp(firstPath).stats();
expect(metadata.width).toBe(1200);
expect(metadata.height).toBe(630);
expect(stats.isOpaque).toBe(true);
expect(await hashFile(secondPath)).toBe(await hashFile(firstPath));
await expect(scanImage(firstPath, [
'private canary identity',
'private canary domain',
'private canary medication',
], { runOcr: true })).resolves.toMatchObject({ width: 1200, height: 630 });
}, 30_000);
it('rejects social-preview output paths outside private or temporary storage', async () => {
await expect(generateSocialPreview(join(process.cwd(), 'public', 'unsafe.png')))
.rejects.toThrow(/SOCIAL-PREVIEW-PATH-001/u);
});
it('rejects a leaf symlink without changing its out-of-root target', async () => {
const root = await createRoot();
const outsideRoot = await mkdtemp(join(process.cwd(), '.portfolio-social-preview-test-'));
const targetPath = join(outsideRoot, 'protected-target.png');
const outputPath = join(root, 'social-preview.png');
const originalTarget = Buffer.from('outside target must remain unchanged');
let error: unknown;
let targetHashBefore = '';
let targetHashAfter = '';
try {
await writeFile(targetPath, originalTarget);
targetHashBefore = await hashFile(targetPath);
await symlink(targetPath, outputPath);
try {
await generateSocialPreview(outputPath);
} catch (caught) {
error = caught;
}
targetHashAfter = await hashFile(targetPath);
} finally {
await rm(outsideRoot, { recursive: true, force: true });
}
expect.soft(error).toBeInstanceOf(Error);
expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u);
expect(targetHashAfter).toBe(targetHashBefore);
});
it('rejects a hard-linked destination without changing the linked file', async () => {
const root = await createRoot();
const targetPath = join(root, 'protected-target.png');
const outputPath = join(root, 'social-preview.png');
const originalTarget = Buffer.from('hard-linked target must remain unchanged');
await writeFile(targetPath, originalTarget);
const targetHashBefore = await hashFile(targetPath);
await link(targetPath, outputPath);
let error: unknown;
try {
await generateSocialPreview(outputPath);
} catch (caught) {
error = caught;
}
expect.soft(error).toBeInstanceOf(Error);
expect.soft((error as Error | undefined)?.message).toMatch(/SOCIAL-PREVIEW-PATH-001/u);
expect(await hashFile(targetPath)).toBe(targetHashBefore);
});
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
const root = await createRoot();
const inputs = await makeInputs(root, baseRecipe());
await makePng(join(inputs.sourceRoot, 'source.png'));
const first = await prepareAssets(inputs);
const firstFile = first.candidates[0];
expect(firstFile?.fileName).toMatch(/^project-[a-z0-9-]+\.[a-z0-9]+$/);
expect(first.approvalStatus).toBe('pending');
expect(await stat(join(inputs.stagingRoot, firstFile!.fileName))).toBeDefined();
const firstHash = await hashFile(join(inputs.stagingRoot, firstFile!.fileName));
const metadata = await sharp(join(inputs.stagingRoot, firstFile!.fileName)).metadata();
expect(metadata.orientation).toBeUndefined();
expect(metadata.exif).toBeUndefined();
expect(metadata.icc).toBeUndefined();
const second = await prepareAssets({ ...inputs, stagingRoot: join(root, 'staging-two'), reportDir: join(root, 'report-two') });
expect(await hashFile(join(root, 'staging-two', second.candidates[0]!.fileName))).toBe(firstHash);
expect(await stat(join(inputs.reportDir, 'contact-sheet.webp'))).toBeDefined();
}, 30_000);
it('returns only opaque rule identifiers for OCR and byte denylist matches', async () => {
const root = await createRoot();
const secretCanary = 'SECRET CANARY';
const imagePath = join(root, 'secret.png');
await makePng(imagePath, secretCanary, false);
expect(() => scanText(Buffer.from(`prefix ${secretCanary} suffix`), [secretCanary])).toThrowError(/PRIVACY-BYTES-001/u);
try {
await scanImage(imagePath, [secretCanary], { runOcr: true });
throw new Error('expected privacy scan to fail');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
expect(message).toMatch(/PRIVACY-(OCR|BYTES)-001/u);
expect(message).not.toContain(secretCanary);
}
}, 30_000);
it('rejects QR/barcodes and malformed media closed', async () => {
const root = await createRoot();
const qrPath = join(root, 'qr.png');
await makeQrPng(qrPath);
await expect(scanImage(qrPath, [], { runOcr: false })).rejects.toThrow(/PRIVACY-BARCODE-001/u);
const inputs = await makeInputs(root, baseRecipe(), 'broken.png');
await writeFile(join(inputs.sourceRoot, 'broken.png'), 'not an image', 'utf8');
await expect(prepareAssets(inputs)).rejects.toThrow(/ASSET-(TYPE|DECODE)-001/u);
}, 20_000);
it('trims title frames, strips audio, samples video, and emits WebM, MP4, and a poster', async () => {
const root = await createRoot();
const recipe = baseRecipe({
id: 'private-game-loop', kind: 'video', output: 'project-private-game-loop.webm',
startSeconds: 1.1, durationSeconds: 1.5, maxWidth: 640,
});
const inputs = await makeInputs(root, recipe, 'clip.mp4');
await makeVideo(join(inputs.sourceRoot, 'clip.mp4'));
const result = await prepareAssets(inputs);
expect(result.candidates.map((candidate: Candidate) => candidate.fileName).sort()).toEqual([
'project-private-game-loop-poster.webp',
'project-private-game-loop.mp4',
'project-private-game-loop.webm',
]);
const poster = await sharp(join(inputs.stagingRoot, 'project-private-game-loop-poster.webp')).stats();
expect(poster.channels[2]!.mean).toBeGreaterThan(poster.channels[0]!.mean * 2);
const sampled = await readdir(join(inputs.reportDir, 'video-frames'));
expect(sampled.length).toBeGreaterThanOrEqual(3);
const second = await prepareAssets({
...inputs,
stagingRoot: join(root, 'video-staging-two'),
reportDir: join(root, 'video-report-two'),
});
expect(second.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })))
.toEqual(result.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })));
}, 60_000);
it('publishes only when an approval register exactly matches every final hash and rejects extras', 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 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);
it('rejects a recipe that impersonates an unmanaged soundtrack ID without changing its closure', async () => {
const root = await createRoot();
const recipe = baseRecipe({ id: 'soundtrack-main-opus', output: 'project-soundtrack-impostor.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: 'soundtrack-main-opus', 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('protected retained soundtrack bytes');
const soundtrackPath = join(publishRoot, 'nightdrive-main.opus');
await mkdir(publishRoot);
await writeFile(soundtrackPath, soundtrackBytes);
await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] });
const initialManifest = await readFile(manifestPath, 'utf8');
await expect(prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath }))
.rejects.toThrow(/ASSET-MERGE-ID-001|ASSET-OWNERSHIP-001/u);
expect(await readFile(soundtrackPath)).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.each([
['staging root equals publish root', async (publishRoot: string) => ({ stagingRoot: publishRoot })],
['report root equals publish root', async (publishRoot: string) => ({ reportDir: publishRoot })],
['staging root descends from publish root', async (publishRoot: string) => ({ stagingRoot: join(publishRoot, 'scratch') })],
['staging root is a symlink alias of publish root', async (publishRoot: string) => {
const alias = join(dirname(publishRoot), 'staging-alias');
await symlink(publishRoot, alias, 'dir');
return { stagingRoot: alias };
}],
])('rejects destructive path overlap when %s and preserves the soundtrack closure', async (_label, overlap) => {
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('path-protected soundtrack bytes');
const soundtrackPath = join(publishRoot, 'nightdrive-main.opus');
await mkdir(publishRoot);
await writeFile(soundtrackPath, soundtrackBytes);
await writeJson(manifestPath, { assets: [soundtrackRecord(await hashFile(soundtrackPath))] });
const initialManifest = await readFile(manifestPath, 'utf8');
await expect(prepareAssets({
...inputs,
...await overlap(publishRoot),
approvalRegisterPath: approvalPath,
publishRoot,
manifestPath,
})).rejects.toThrow(/ASSET-PATH-001/u);
expect(await readFile(soundtrackPath)).toEqual(soundtrackBytes);
expect(await readFile(manifestPath, 'utf8')).toBe(initialManifest);
expect(await readdir(publishRoot)).toEqual(['nightdrive-main.opus']);
}, 30_000);
});