feat: add deterministic cleared project media
This commit is contained in:
1
tests/fixtures/assets/malformed.bin
vendored
Normal file
1
tests/fixtures/assets/malformed.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is deliberately not a supported media container.
|
||||
184
tests/integration/prepare-assets.test.ts
Normal file
184
tests/integration/prepare-assets.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment node
|
||||
import { 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';
|
||||
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 { prepareAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.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 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 };
|
||||
};
|
||||
|
||||
describe('deterministic asset preparation', () => {
|
||||
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');
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user