638 lines
32 KiB
TypeScript
638 lines
32 KiB
TypeScript
// @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 { 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 createRoot = async (): Promise<string> => mkdtemp(join(tmpdir(), 'portfolio-assets-test-'));
|
||
|
||
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('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);
|
||
});
|