fix: enforce approved media publication closure
This commit is contained in:
@@ -19,8 +19,8 @@
|
|||||||
"verify:assets": "node scripts/prepare-assets.mjs --verify",
|
"verify:assets": "node scripts/prepare-assets.mjs --verify",
|
||||||
"check:budgets": "node scripts/check-bundle-budgets.mjs dist",
|
"check:budgets": "node scripts/check-bundle-budgets.mjs dist",
|
||||||
"aggregate:performance": "node scripts/aggregate-drive-traces.mjs",
|
"aggregate:performance": "node scripts/aggregate-drive-traces.mjs",
|
||||||
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs",
|
"verify:approved-assets": "node scripts/materialize-empty-asset-set.mjs",
|
||||||
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets"
|
"build:preview": "npm run verify:approved-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
|
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const scriptPath = fileURLToPath(import.meta.url);
|
const scriptPath = fileURLToPath(import.meta.url);
|
||||||
@@ -50,16 +51,103 @@ const assertOpenedDirectoryIdentity = async (preparedAssetsPath, expectedIdentit
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readEmptyManifest = async (repositoryRoot) => {
|
const readJson = async (path, label) => {
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${label} must be valid JSON.`, { cause: error });
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be a JSON object.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readManifest = async (repositoryRoot) => {
|
||||||
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json');
|
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json');
|
||||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
const manifest = await readJson(manifestPath, 'Public asset manifest');
|
||||||
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest) ||
|
if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) {
|
||||||
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) {
|
throw new Error('Public asset manifest must contain exactly one assets array.');
|
||||||
throw new Error('Phase 1 asset manifest must contain exactly zero assets.');
|
}
|
||||||
|
const ids = new Set();
|
||||||
|
const fileNames = new Set();
|
||||||
|
const assets = manifest.assets.map((asset) => {
|
||||||
|
if (typeof asset !== 'object' || asset === null || Array.isArray(asset)) {
|
||||||
|
throw new Error('Every manifest asset must be an object.');
|
||||||
|
}
|
||||||
|
const { id, fileName, sha256 } = asset;
|
||||||
|
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) {
|
||||||
|
throw new Error('Manifest asset ID must be a generated safe identifier.');
|
||||||
|
}
|
||||||
|
if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`);
|
||||||
|
ids.add(id);
|
||||||
|
if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/u.test(fileName)) {
|
||||||
|
throw new Error(`Manifest filename must be a generated safe filename: ${id}`);
|
||||||
|
}
|
||||||
|
const foldedFileName = fileName.toLocaleLowerCase('en-US');
|
||||||
|
if (fileNames.has(foldedFileName)) throw new Error(`Duplicate manifest filename: ${fileName}`);
|
||||||
|
fileNames.add(foldedFileName);
|
||||||
|
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(sha256)) {
|
||||||
|
throw new Error(`Manifest asset must have a lowercase SHA-256 digest: ${id}`);
|
||||||
|
}
|
||||||
|
return { id, fileName, sha256 };
|
||||||
|
});
|
||||||
|
return assets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetReferences = (content) => {
|
||||||
|
const references = [];
|
||||||
|
const projects = content.projects;
|
||||||
|
if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) {
|
||||||
|
throw new Error('Portfolio content must contain project collections.');
|
||||||
|
}
|
||||||
|
for (const collectionName of ['selected', 'additional']) {
|
||||||
|
const collection = projects[collectionName];
|
||||||
|
if (!Array.isArray(collection)) throw new Error(`Portfolio ${collectionName} projects must be an array.`);
|
||||||
|
for (const project of collection) {
|
||||||
|
if (typeof project !== 'object' || project === null || Array.isArray(project) || !Array.isArray(project.assetIds)) {
|
||||||
|
throw new Error('Every public project must contain an assetIds array.');
|
||||||
|
}
|
||||||
|
references.push(...project.assetIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content.toolkit)) throw new Error('Portfolio toolkit must be an array.');
|
||||||
|
for (const group of content.toolkit) {
|
||||||
|
if (typeof group !== 'object' || group === null || Array.isArray(group) || !Array.isArray(group.technologies)) {
|
||||||
|
throw new Error('Every toolkit group must contain a technologies array.');
|
||||||
|
}
|
||||||
|
for (const technology of group.technologies) {
|
||||||
|
if (typeof technology !== 'object' || technology === null || Array.isArray(technology)) {
|
||||||
|
throw new Error('Every toolkit technology must be an object.');
|
||||||
|
}
|
||||||
|
if (technology.assetId !== undefined) references.push(technology.assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof content.metadata !== 'object' || content.metadata === null || Array.isArray(content.metadata)) {
|
||||||
|
throw new Error('Portfolio content must contain metadata.');
|
||||||
|
}
|
||||||
|
if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId);
|
||||||
|
return references;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertExactReferenceClosure = (assets, content) => {
|
||||||
|
const manifestIds = new Set(assets.map((asset) => asset.id));
|
||||||
|
const referencedIds = new Set();
|
||||||
|
for (const assetId of assetReferences(content)) {
|
||||||
|
if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(assetId)) {
|
||||||
|
throw new Error('Public asset reference must be a generated safe identifier.');
|
||||||
|
}
|
||||||
|
if (referencedIds.has(assetId)) throw new Error(`Duplicate public asset reference: ${assetId}`);
|
||||||
|
referencedIds.add(assetId);
|
||||||
|
if (!manifestIds.has(assetId)) throw new Error(`Unknown public asset reference: ${assetId}`);
|
||||||
|
}
|
||||||
|
for (const assetId of manifestIds) {
|
||||||
|
if (!referencedIds.has(assetId)) throw new Error(`Unreferenced manifest asset: ${assetId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
|
const ensurePreparedAssetDirectory = async (repositoryRoot) => {
|
||||||
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
|
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
|
||||||
let initialStats;
|
let initialStats;
|
||||||
try {
|
try {
|
||||||
@@ -74,19 +162,72 @@ const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
|
|||||||
const initialIdentity = directoryIdentity(initialStats);
|
const initialIdentity = directoryIdentity(initialStats);
|
||||||
await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||||
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||||
const members = await readdir(preparedAssetsPath);
|
return { preparedAssetsPath, initialIdentity };
|
||||||
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
};
|
||||||
if (members.length !== 0) {
|
|
||||||
throw new Error('Prepared asset directory must be empty during Phase 1.');
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
|
||||||
|
const readPreparedRegularFile = async (filePath, fileName) => {
|
||||||
|
const noFollowFlags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await open(filePath, noFollowFlags);
|
||||||
|
} catch (error) {
|
||||||
|
if (isErrno(error, 'ELOOP')) {
|
||||||
|
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stats = await handle.stat();
|
||||||
|
if (!stats.isFile() || stats.nlink !== 1) {
|
||||||
|
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
|
||||||
|
}
|
||||||
|
return await handle.readFile();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const materializeEmptyAssetSet = async (repositoryRoot) => {
|
const verifyPreparedAssetFiles = async (repositoryRoot, assets) => {
|
||||||
const resolvedRepositoryRoot = resolve(repositoryRoot);
|
const { preparedAssetsPath, initialIdentity } = await ensurePreparedAssetDirectory(repositoryRoot);
|
||||||
await readEmptyManifest(resolvedRepositoryRoot);
|
const expectedByName = new Map(assets.map((asset) => [asset.fileName, asset]));
|
||||||
await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot);
|
const foundNames = new Set();
|
||||||
|
const members = await readdir(preparedAssetsPath, { withFileTypes: true });
|
||||||
|
for (const member of members) {
|
||||||
|
const asset = expectedByName.get(member.name);
|
||||||
|
if (asset === undefined) throw new Error(`Extra prepared asset: ${member.name}`);
|
||||||
|
if (member.isSymbolicLink() || !member.isFile()) {
|
||||||
|
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
|
||||||
|
}
|
||||||
|
const filePath = join(preparedAssetsPath, member.name);
|
||||||
|
const stats = await lstat(filePath);
|
||||||
|
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
|
||||||
|
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
|
||||||
|
}
|
||||||
|
if (sha256(await readPreparedRegularFile(filePath, member.name)) !== asset.sha256) {
|
||||||
|
throw new Error(`Prepared asset hash does not match manifest: ${member.name}`);
|
||||||
|
}
|
||||||
|
foundNames.add(member.name);
|
||||||
|
}
|
||||||
|
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (!foundNames.has(asset.fileName)) throw new Error(`Missing prepared asset: ${asset.fileName}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const verifyApprovedAssetSet = async (repositoryRoot) => {
|
||||||
|
const resolvedRepositoryRoot = resolve(repositoryRoot);
|
||||||
|
const [assets, content] = await Promise.all([
|
||||||
|
readManifest(resolvedRepositoryRoot),
|
||||||
|
readJson(resolve(resolvedRepositoryRoot, 'content/public/portfolio-content.json'), 'Portfolio content'),
|
||||||
|
]);
|
||||||
|
assertExactReferenceClosure(assets, content);
|
||||||
|
await verifyPreparedAssetFiles(resolvedRepositoryRoot, assets);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const materializeEmptyAssetSet = verifyApprovedAssetSet;
|
||||||
|
|
||||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
|
||||||
await materializeEmptyAssetSet(defaultRepositoryRoot);
|
await verifyApprovedAssetSet(defaultRepositoryRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,11 +297,21 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
|
|||||||
technology.assetId === undefined ? [] : [technology.assetId])),
|
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||||
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
||||||
];
|
];
|
||||||
|
const seenReferences = new Set<string>();
|
||||||
for (const assetId of referencedAssetIds) {
|
for (const assetId of referencedAssetIds) {
|
||||||
|
if (seenReferences.has(assetId)) {
|
||||||
|
throw new Error(`Duplicate public asset reference: ${assetId}`);
|
||||||
|
}
|
||||||
|
seenReferences.add(assetId);
|
||||||
if (!manifestIds.has(assetId)) {
|
if (!manifestIds.has(assetId)) {
|
||||||
throw new Error(`Unmanifested asset reference: ${assetId}`);
|
throw new Error(`Unmanifested asset reference: ${assetId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const assetId of manifestIds) {
|
||||||
|
if (!seenReferences.has(assetId)) {
|
||||||
|
throw new Error(`Unreferenced manifest asset: ${assetId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return { content, manifest, site } as ProductionInputs;
|
return { content, manifest, site } as ProductionInputs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
assertPreparedAssetDirectoryIdentity,
|
assertPreparedAssetDirectoryIdentity,
|
||||||
materializeEmptyAssetSet,
|
verifyApprovedAssetSet,
|
||||||
} from '../../scripts/materialize-empty-asset-set.mjs';
|
} from '../../scripts/materialize-empty-asset-set.mjs';
|
||||||
|
|
||||||
const temporaryRoots = [];
|
const temporaryRoots = [];
|
||||||
@@ -14,35 +15,115 @@ const createScratchRepository = async () => {
|
|||||||
temporaryRoots.push(root);
|
temporaryRoots.push(root);
|
||||||
await mkdir(join(root, 'content/public'), { recursive: true });
|
await mkdir(join(root, 'content/public'), { recursive: true });
|
||||||
await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}');
|
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;
|
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 () => {
|
afterEach(async () => {
|
||||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('empty asset materialization', () => {
|
describe('approved asset publication closure', () => {
|
||||||
it('creates an empty prepared-asset directory in the supplied repository root', async () => {
|
it('creates an empty prepared-asset directory in the supplied repository root', async () => {
|
||||||
const root = await createScratchRepository();
|
const root = await createScratchRepository();
|
||||||
await materializeEmptyAssetSet(root);
|
await verifyApprovedAssetSet(root);
|
||||||
expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true);
|
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('rejects a symbolic-link prepared-asset directory', async () => {
|
it('rejects a symbolic-link prepared-asset directory', async () => {
|
||||||
const root = await createScratchRepository();
|
const root = await createScratchRepository();
|
||||||
const target = join(root, 'outside');
|
const target = join(root, 'outside');
|
||||||
await mkdir(target);
|
await mkdir(target);
|
||||||
await mkdir(join(root, 'inputs'), { recursive: true });
|
await mkdir(join(root, 'inputs'), { recursive: true });
|
||||||
await symlink(target, join(root, 'inputs/prepared-assets'));
|
await symlink(target, join(root, 'inputs/prepared-assets'));
|
||||||
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/non-symlink directory/i);
|
await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/non-symlink directory/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unexpected prepared-asset members in its supplied repository root', async () => {
|
it('rejects extra prepared files', async () => {
|
||||||
const root = await createScratchRepository();
|
const root = await createScratchRepository();
|
||||||
const preparedAssetsPath = join(root, 'inputs/prepared-assets');
|
const preparedAssetsPath = join(root, 'inputs/prepared-assets');
|
||||||
await mkdir(preparedAssetsPath, { recursive: true });
|
await mkdir(preparedAssetsPath, { recursive: true });
|
||||||
await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset');
|
await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset');
|
||||||
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/must be empty/i);
|
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 () => {
|
it('rejects a prepared-asset directory replaced after its identity was captured', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas';
|
import { validateProductionInputs } from '@/content/schemas';
|
||||||
|
|
||||||
const repositoryRoot = process.cwd();
|
const repositoryRoot = process.cwd();
|
||||||
const readJson = async (relativePath: string): Promise<unknown> =>
|
const readJson = async (relativePath: string): Promise<unknown> =>
|
||||||
@@ -8,9 +8,12 @@ const readJson = async (relativePath: string): Promise<unknown> =>
|
|||||||
|
|
||||||
describe('approved public portfolio projection', () => {
|
describe('approved public portfolio projection', () => {
|
||||||
it('contains approved identity and balanced evidence without prohibited surfaces', async () => {
|
it('contains approved identity and balanced evidence without prohibited surfaces', async () => {
|
||||||
const content = parsePortfolioContent(await readJson('content/public/portfolio-content.json'));
|
const { content, manifest, site } = validateProductionInputs(
|
||||||
const manifest = parsePublicAssetManifest(await readJson('content/public/asset-manifest.json'));
|
await readJson('content/public/portfolio-content.json'),
|
||||||
const site = parseSiteConfig(await readJson('config/site.preview.json'));
|
await readJson('content/public/asset-manifest.json'),
|
||||||
|
await readJson('config/site.preview.json'),
|
||||||
|
new URL('file:///prepared-assets/'),
|
||||||
|
);
|
||||||
|
|
||||||
expect(content.identity).toEqual(expect.objectContaining({
|
expect(content.identity).toEqual(expect.objectContaining({
|
||||||
name: 'Luca Pîslaru',
|
name: 'Luca Pîslaru',
|
||||||
@@ -25,8 +28,6 @@ describe('approved public portfolio projection', () => {
|
|||||||
.toBe('Private self-hosted medication tracker');
|
.toBe('Private self-hosted medication tracker');
|
||||||
expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title)
|
expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title)
|
||||||
.toBe('Private game prototype');
|
.toBe('Private game prototype');
|
||||||
expect([...content.projects.selected, ...content.projects.additional]
|
|
||||||
.every((project) => project.assetIds.length === 0)).toBe(true);
|
|
||||||
expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name))
|
expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name))
|
||||||
.toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java']));
|
.toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java']));
|
||||||
expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' }));
|
expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' }));
|
||||||
@@ -35,7 +36,14 @@ describe('approved public portfolio projection', () => {
|
|||||||
'https://gitlab.com/luca_pisl',
|
'https://gitlab.com/luca_pisl',
|
||||||
'https://linkedin.com/in/luca-pisl',
|
'https://linkedin.com/in/luca-pisl',
|
||||||
]);
|
]);
|
||||||
expect(manifest.assets).toEqual([]);
|
const publishedReferences = [
|
||||||
|
...content.projects.selected.flatMap((project) => project.assetIds),
|
||||||
|
...content.projects.additional.flatMap((project) => project.assetIds),
|
||||||
|
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||||
|
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||||
|
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
||||||
|
];
|
||||||
|
expect([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort());
|
||||||
expect(site).toEqual({
|
expect(site).toEqual({
|
||||||
environment: 'preview',
|
environment: 'preview',
|
||||||
canonicalUrl: 'https://portfolio.localhost/',
|
canonicalUrl: 'https://portfolio.localhost/',
|
||||||
|
|||||||
@@ -147,6 +147,18 @@ describe('content schemas', () => {
|
|||||||
.toThrow(/unmanifested asset/i);
|
.toThrow(/unmanifested asset/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires duplicate-free set equality between content references and manifest assets', () => {
|
||||||
|
const duplicateReference = cloneContent();
|
||||||
|
duplicateReference.projects.selected[1]!.assetIds = ['selected-build-one'];
|
||||||
|
expect(() => validateProductionInputs(duplicateReference, validManifest, validSite, new URL('file:///prepared/')))
|
||||||
|
.toThrow(/duplicate public asset reference/i);
|
||||||
|
|
||||||
|
const unreferencedManifestAsset = cloneContent();
|
||||||
|
unreferencedManifestAsset.projects.selected[0]!.assetIds = [];
|
||||||
|
expect(() => validateProductionInputs(unreferencedManifestAsset, validManifest, validSite, new URL('file:///prepared/')))
|
||||||
|
.toThrow(/unreferenced manifest asset/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => {
|
it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => {
|
||||||
const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> };
|
const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> };
|
||||||
manifest.assets[1]!.id = manifest.assets[0]!.id;
|
manifest.assets[1]!.id = manifest.assets[0]!.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user