fix: harden prerender publication boundary

This commit is contained in:
2026-09-02 02:44:23 +03:00
parent 3dd4a818d0
commit c16c4c57da
22 changed files with 1275 additions and 231 deletions

View File

@@ -1,23 +1,17 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { loadProductionInputs } from '@/content/load-production-inputs';
import { readProductionInputsSnapshot } from '@/entry-client';
import { render } from '@/entry-server';
import {
assemblePrerenderedHtml,
assertExpectedDistInputs,
parseExactConfigArgument,
type ViteManifest,
} from '../../scripts/prerender.mjs';
const repositoryRoot = process.cwd();
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
const locations = {
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')),
@@ -138,26 +132,3 @@ describe('approved build template', () => {
expect(template).not.toContain('window.__PORTFOLIO_INPUTS__');
});
});
describe('deterministic staging inventory', () => {
it.each([
{ location: 'client/unknown.txt' },
{ location: 'assets/unknown.txt' },
])('rejects an unknown staged output at $location', async ({ location }) => {
const directory = await mkdtemp(join(tmpdir(), 'portfolio-dist-'));
temporaryDirectories.push(directory);
await mkdir(join(directory, 'client/.vite'), { recursive: true });
await mkdir(join(directory, 'client/assets'), { recursive: true });
await mkdir(join(directory, 'assets'), { recursive: true });
await writeFile(join(directory, 'client/index.html'), builtTemplate);
await writeFile(join(directory, 'client/.vite/manifest.json'), JSON.stringify(manifest));
await writeFile(join(directory, 'client/assets/index-test.js'), 'export {};');
await writeFile(join(directory, 'client/assets/index-test.css'), 'body {}');
await writeFile(join(directory, 'index.html'), builtTemplate);
await writeFile(join(directory, 'assets/index-test.js'), 'export {};');
await writeFile(join(directory, 'assets/index-test.css'), 'body {}');
await writeFile(join(directory, location), 'unknown');
await expect(assertExpectedDistInputs(directory)).rejects.toThrow(/unexpected build output/i);
});
});

View File

@@ -0,0 +1,139 @@
import { mkdtemp, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
captureDirectoryIdentity,
publishStagedDirectory,
} from '../../scripts/safe-publication.mjs';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
const repository = async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-publication-'));
temporaryDirectories.push(root);
await mkdir(join(root, 'dist'));
await mkdir(join(root, '.dist-stage-test'));
await writeFile(join(root, 'dist/old.txt'), 'old');
await writeFile(join(root, '.dist-stage-test/new.txt'), 'new');
return {
root,
finalRoot: join(root, 'dist'),
stagingRoot: join(root, '.dist-stage-test'),
};
};
describe('safe artifact publication', () => {
it('rejects a symlinked final output before mutating its target', async () => {
const { root, finalRoot, stagingRoot } = await repository();
const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-'));
temporaryDirectories.push(outside);
await writeFile(join(outside, 'protected.txt'), 'protected');
await rm(finalRoot, { recursive: true });
await symlink(outside, finalRoot);
await expect(publishStagedDirectory({
repositoryRoot: root,
finalRoot,
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
})).rejects.toThrow(/symlink|safe directory/i);
expect(await readFile(join(outside, 'protected.txt'), 'utf8')).toBe('protected');
});
it('rejects an output path outside the repository', async () => {
const { root, stagingRoot } = await repository();
const outside = await mkdtemp(join(tmpdir(), 'portfolio-publication-outside-'));
temporaryDirectories.push(outside);
await expect(publishStagedDirectory({
repositoryRoot: root,
finalRoot: outside,
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
})).rejects.toThrow(/outside.*repository/i);
});
it('rejects a repository path with a symlinked ancestor', async () => {
const { root, stagingRoot } = await repository();
const parent = await mkdtemp(join(tmpdir(), 'portfolio-publication-link-'));
temporaryDirectories.push(parent);
const linkedRoot = join(parent, 'linked-root');
await symlink(root, linkedRoot);
await expect(publishStagedDirectory({
repositoryRoot: linkedRoot,
finalRoot: join(linkedRoot, 'dist'),
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
})).rejects.toThrow(/ancestor|canonical|symlink/i);
});
it('rejects a staging directory replaced after verification without touching the final output', async () => {
const { root, finalRoot, stagingRoot } = await repository();
const expectedStagingIdentity = await captureDirectoryIdentity(stagingRoot);
await rm(stagingRoot, { recursive: true });
await mkdir(stagingRoot);
await writeFile(join(stagingRoot, 'attacker.txt'), 'attacker');
await expect(publishStagedDirectory({ repositoryRoot: root, finalRoot, stagingRoot, expectedStagingIdentity }))
.rejects.toThrow(/identity changed/i);
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
});
it('rolls back the previous output when the staged rename fails mid-publication', async () => {
const { root, finalRoot, stagingRoot } = await repository();
let renameCount = 0;
const failSecondRename = async (from: string, to: string) => {
renameCount += 1;
if (renameCount === 2) throw new Error('injected staged rename failure');
await rename(from, to);
};
await expect(publishStagedDirectory({
repositoryRoot: root,
finalRoot,
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
renameDirectory: failSecondRename,
})).rejects.toThrow(/injected staged rename failure/i);
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow();
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
});
it('rolls back the previous output when the pre-commit authority update fails', async () => {
const { root, finalRoot, stagingRoot } = await repository();
await expect(publishStagedDirectory({
repositoryRoot: root,
finalRoot,
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
beforeCommit: async () => {
throw new Error('injected authority update failure');
},
})).rejects.toThrow(/injected authority update failure/i);
expect(await readFile(join(finalRoot, 'old.txt'), 'utf8')).toBe('old');
await expect(readFile(join(finalRoot, 'new.txt'), 'utf8')).rejects.toThrow();
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
});
it('publishes the verified staging directory and removes the superseded output', async () => {
const { root, finalRoot, stagingRoot } = await repository();
await publishStagedDirectory({
repositoryRoot: root,
finalRoot,
stagingRoot,
expectedStagingIdentity: await captureDirectoryIdentity(stagingRoot),
});
expect(await readFile(join(finalRoot, 'new.txt'), 'utf8')).toBe('new');
await expect(readFile(join(finalRoot, 'old.txt'), 'utf8')).rejects.toThrow();
expect((await readdir(root)).some((name) => name.startsWith('.dist-backup-'))).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -31,6 +32,9 @@ const template = `<!doctype html><html lang="en"><head><meta charset="UTF-8" />
<script type="module" crossorigin src="/assets/index-test.js"></script>
<link rel="stylesheet" href="/assets/index-test.css"></head><body><div id="app"></div>
<!--portfolio-body--></body></html>`;
const clientJavaScript = 'export {};\n';
const clientStylesheet = 'body { margin: 0; }\n';
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
const temporaryDirectories: string[] = [];
afterEach(async () => {
@@ -45,25 +49,39 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
await mkdir(join(directory, 'assets'));
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site);
const contract = {
schema: 'portfolio-artifact-contract/v1',
buildId: inputs.site.buildId,
entryModule: 'assets/index-test.js',
executableModules: ['assets/index-test.js'],
modulePreloads: ['assets/index-test.js'],
styles: ['assets/index-test.css'],
viteAssets: [],
publicAssets: [],
files: [
{ path: 'assets/index-test.css', sha256: sha256(clientStylesheet) },
{ path: 'assets/index-test.js', sha256: sha256(clientJavaScript) },
],
} as const;
await Promise.all([
writeFile(join(directory, 'index.html'), html),
writeFile(join(directory, 'robots.txt'), hostArtifacts.robots),
writeFile(join(directory, '_headers.example'), hostArtifacts.headers),
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
writeFile(join(directory, 'assets/index-test.js'), 'export {};\n'),
writeFile(join(directory, 'assets/index-test.css'), 'body { margin: 0; }\n'),
writeFile(join(directory, 'assets/index-test.js'), clientJavaScript),
writeFile(join(directory, 'assets/index-test.css'), clientStylesheet),
...(hostArtifacts.sitemap === undefined
? []
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
]);
return { directory, html, inputs };
return { directory, html, inputs, contract };
};
describe('artifact verification', () => {
it('accepts the complete private preview artifact set', async () => {
const { directory, inputs } = await writeValidArtifacts();
const { directory, inputs, contract } = await writeValidArtifacts();
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
});
it('accepts the pure production artifact path with canonical sitemap and allow policy', async () => {
@@ -72,9 +90,9 @@ describe('artifact verification', () => {
canonicalUrl: 'https://portfolio.example.test/',
buildId: 'production-test',
};
const { directory, inputs } = await writeValidArtifacts(productionSite);
const { directory, inputs, contract } = await writeValidArtifacts(productionSite);
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
});
it.each([
@@ -96,13 +114,13 @@ describe('artifact verification', () => {
{
name: 'a prohibited private surface',
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js'), 'const evidenceId = "fixture";'),
error: /prohibited surface/i,
error: /prohibited surface|digest does not match/i,
},
])('rejects $name', async ({ mutate, error }) => {
const { directory, inputs } = await writeValidArtifacts();
const { directory, inputs, contract } = await writeValidArtifacts();
await mutate(directory);
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
});
it.each([
@@ -114,7 +132,7 @@ describe('artifact verification', () => {
{
name: 'duplicate H1 identity',
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
error: /exactly one h1/i,
error: /exactly one (?:visible )?h1/i,
},
{
name: 'malformed JSON-LD',
@@ -124,7 +142,7 @@ describe('artifact verification', () => {
{
name: 'missing infrastructure evidence',
mutate: (html: string) => html.replace('id="infrastructure"', 'id="removed-infrastructure"'),
error: /section.*infrastructure/i,
error: /section.*infrastructure|visible sections/i,
},
{
name: 'executable snapshot assignment',
@@ -132,25 +150,145 @@ describe('artifact verification', () => {
error: /inert input snapshot|prohibited surface/i,
},
])('rejects raw HTML with $name', async ({ mutate, error }) => {
const { directory, html, inputs } = await writeValidArtifacts();
const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), mutate(html));
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
});
it('rejects a missing crawl artifact', async () => {
const { directory, inputs } = await writeValidArtifacts();
const { directory, inputs, contract } = await writeValidArtifacts();
await rm(join(directory, 'robots.txt'));
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/robots/i);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/robots/i);
});
it('checks the CSP hash against the actual serialized JSON-LD bytes', async () => {
const { directory, inputs } = await writeValidArtifacts();
const { directory, inputs, contract } = await writeValidArtifacts();
const headersPath = join(directory, '_headers.example');
const headers = await readFile(headersPath, 'utf8');
await writeFile(headersPath, headers.replace(/sha256-[A-Za-z0-9+/=]+/u, 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='));
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/csp.*json-ld|json-ld.*csp/i);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/csp.*json-ld|json-ld.*csp|exact.*headers/i);
});
it.each([
{
name: 'a final-HTML-authorized substitute module',
mutate: async (directory: string, html: string) => {
await rm(join(directory, 'assets/index-test.js'));
await writeFile(join(directory, 'assets/substitute.js'), clientJavaScript);
return html.replaceAll('assets/index-test.js', 'assets/substitute.js');
},
error: /trusted.*module|artifact contract|unexpected artifact/i,
},
{
name: 'an extra inline executable script',
mutate: async (_directory: string, html: string) => html.replace('</body>', '<script>void 0</script></body>'),
error: /unexpected executable script/i,
},
{
name: 'an extra external executable script',
mutate: async (_directory: string, html: string) => html.replace('</body>', '<script src="https://example.test/evil.js"></script></body>'),
error: /unexpected executable script|external executable/i,
},
{
name: 'a final-HTML-authorized extra stylesheet',
mutate: async (directory: string, html: string) => {
await writeFile(join(directory, 'assets/extra.css'), 'body {}');
return html.replace('</head>', '<link rel="stylesheet" href="/assets/extra.css" /></head>');
},
error: /trusted.*style|artifact contract|unexpected artifact/i,
},
{
name: 'a missing trusted module preload',
mutate: async (_directory: string, html: string) => html.replace(/<link rel="modulepreload"[^>]+>\n?/u, ''),
error: /trusted.*preload|module preload/i,
},
])('rejects $name instead of trusting final HTML', async ({ mutate, error }) => {
const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), await mutate(directory, html));
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(error);
});
it.each([
{
name: 'an appended CSP directive',
mutate: (headers: string) => `${headers} script-src https://example.test\n`,
},
{
name: 'an external script-src relaxation',
mutate: (headers: string) => headers.replace("script-src 'self'", "script-src 'self' https://example.test"),
},
{
name: 'a duplicate partial CSP policy',
mutate: (headers: string) => `${headers} Content-Security-Policy: default-src 'self'\n`,
},
{
name: 'commented header impostors',
mutate: (headers: string) => headers.split('\n').map((line) => `# ${line}`).join('\n'),
},
])('rejects $name by exact header bytes', async ({ mutate }) => {
const { directory, inputs, contract } = await writeValidArtifacts();
const path = join(directory, '_headers.example');
await writeFile(path, mutate(await readFile(path, 'utf8')));
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*headers|headers.*exact/i);
});
it('rejects modified redirect example bytes', async () => {
const { directory, inputs, contract } = await writeValidArtifacts();
const path = join(directory, '_redirects.example');
await writeFile(path, `${await readFile(path, 'utf8')}# appended\n`);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*redirect|redirect.*exact/i);
});
it('rejects modified production sitemap bytes', async () => {
const productionSite: SiteConfig = {
environment: 'production',
canonicalUrl: 'https://portfolio.example.test/',
buildId: 'production-test',
};
const { directory, inputs, contract } = await writeValidArtifacts(productionSite);
const path = join(directory, 'sitemap.xml');
await writeFile(path, `${await readFile(path, 'utf8')}<!-- appended -->\n`);
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/exact.*sitemap|sitemap.*exact/i);
});
it.each([
{
name: 'a hidden application root',
mutate: (html: string) => html.replace('<div id="app">', '<div id="app" hidden>'),
},
{
name: 'identity rendered only inside a template',
mutate: (html: string) => html.replace(
'<h1>Luca Pîslaru</h1><p>System Administrator &amp; Full-Stack Developer</p>',
'<template><h1>Luca Pîslaru</h1><p>System Administrator &amp; Full-Stack Developer</p></template>',
),
},
{
name: 'an aria-hidden native navigation',
mutate: (html: string) => html.replace('<nav class="site-navigation"', '<nav aria-hidden="true" class="site-navigation"'),
},
{
name: 'an inert canonical section',
mutate: (html: string) => html.replace('<section id="infrastructure"', '<section id="infrastructure" inert'),
},
{
name: 'infrastructure evidence present only in hydration JSON',
mutate: (html: string) => {
const snapshotAt = html.indexOf('<script id="portfolio-inputs"');
return `${html.slice(0, snapshotAt).replaceAll('Secure self-hosted platform and lifecycle', 'Removed evidence')}${html.slice(snapshotAt)}`;
},
},
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), mutate(html));
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { resolveViteAssetClosure } from '../../scripts/build-contract.mjs';
import type { ViteManifest } from '../../scripts/prerender.mjs';
const recursiveManifest = (): ViteManifest => ({
'index.html': {
file: 'assets/entry-a1.js',
src: 'index.html',
isEntry: true,
css: ['assets/entry-a1.css'],
assets: ['assets/entry-logo-a1.webp'],
imports: ['src/shared.ts'],
dynamicImports: ['src/lazy.ts'],
},
'src/shared.ts': {
file: 'assets/shared-b2.js',
src: 'src/shared.ts',
css: ['assets/shared-b2.css'],
imports: ['src/deep.ts'],
},
'src/deep.ts': {
file: 'assets/deep-c3.js',
src: 'src/deep.ts',
},
'src/lazy.ts': {
file: 'assets/lazy-d4.js',
src: 'src/lazy.ts',
css: ['assets/lazy-d4.css'],
},
});
describe('trusted Vite asset closure', () => {
it('resolves the exact recursive executable, preload, style, and Vite asset sets', () => {
expect(resolveViteAssetClosure(recursiveManifest())).toEqual({
entryModule: 'assets/entry-a1.js',
executableModules: [
'assets/deep-c3.js',
'assets/entry-a1.js',
'assets/lazy-d4.js',
'assets/shared-b2.js',
],
modulePreloads: [
'assets/deep-c3.js',
'assets/entry-a1.js',
'assets/shared-b2.js',
],
styles: [
'assets/entry-a1.css',
'assets/lazy-d4.css',
'assets/shared-b2.css',
],
viteAssets: ['assets/entry-logo-a1.webp'],
});
});
it.each([
{
name: 'a missing exact index.html entry',
mutate: (manifest: ViteManifest) => {
manifest['src/entry-client.ts'] = manifest['index.html']!;
delete manifest['index.html'];
},
error: /exact index\.html/i,
},
{
name: 'an unreachable manifest record',
mutate: (manifest: ViteManifest) => {
manifest['src/orphan.ts'] = { file: 'assets/orphan.js', src: 'src/orphan.ts' };
},
error: /unreachable manifest/i,
},
{
name: 'an unsafe emitted path',
mutate: (manifest: ViteManifest) => {
manifest['src/shared.ts']!.file = '../outside.js';
},
error: /unsafe.*path/i,
},
{
name: 'a missing recursive import',
mutate: (manifest: ViteManifest) => {
manifest['src/shared.ts']!.imports = ['src/missing.ts'];
},
error: /missing manifest import/i,
},
])('rejects $name', ({ mutate, error }) => {
const manifest = recursiveManifest();
mutate(manifest);
expect(() => resolveViteAssetClosure(manifest)).toThrow(error);
});
});

View File

@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { readProductionInputsSnapshot } from '@/entry-client';
import { serializeJsonLd } from '@/security/serialize-json-ld';
import type { ProductionInputs } from '@/content/types';
const validInputs = (): ProductionInputs => ({
content: JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8')),
manifest: JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/asset-manifest.json'), 'utf8')),
site: JSON.parse(readFileSync(resolve(process.cwd(), 'config/site.preview.json'), 'utf8')),
}) as ProductionInputs;
const snapshotDocument = (value: unknown): Document => new DOMParser().parseFromString(
`<script id="portfolio-inputs" type="application/json">${serializeJsonLd(value)}</script>`,
'text/html',
);
describe('client production-input snapshot', () => {
it('schema-validates and recursively freezes the complete browser snapshot', () => {
const inputs = readProductionInputsSnapshot(snapshotDocument(validInputs()));
expect(Object.isFrozen(inputs)).toBe(true);
expect(Object.isFrozen(inputs.content.projects.selected)).toBe(true);
expect(Object.isFrozen(inputs.content.projects.selected[0]?.technicalDecisions)).toBe(true);
expect(Object.isFrozen(inputs.manifest.assets)).toBe(true);
expect(Object.isFrozen(inputs.site)).toBe(true);
expect(() => {
(inputs.content.identity as { role: string }).role = 'mutated';
}).toThrow();
});
it('rejects JSON that does not satisfy the production-input schema', () => {
const invalid = validInputs() as ProductionInputs & { unexpected?: string };
invalid.unexpected = 'not allowed';
expect(() => readProductionInputsSnapshot(snapshotDocument(invalid))).toThrow(/unrecognized|invalid|expected/i);
});
it('rejects a supplied asset reference absent from the supplied public manifest', () => {
const invalid = validInputs();
invalid.content.projects.selected[0]!.assetIds = ['unmanifested-snapshot-asset'];
expect(() => readProductionInputsSnapshot(snapshotDocument(invalid))).toThrow(/unmanifested asset reference/i);
});
});

View File

@@ -95,7 +95,8 @@ describe('environment-aware crawl and response artifacts', () => {
});
it('generates production-only sitemap, allow policy, HSTS, and no preview directives', () => {
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, productionSite), productionSite);
const metadata = createPageMetadata(content, manifest, productionSite);
const artifacts = createStaticHostArtifacts(metadata, productionSite);
expect(artifacts.robots).toBe(
'User-agent: *\nAllow: /\nSitemap: https://portfolio.example.test/sitemap.xml\n',
@@ -104,6 +105,20 @@ describe('environment-aware crawl and response artifacts', () => {
expect(artifacts.headers).toContain('Strict-Transport-Security: max-age=31536000');
expect(artifacts.headers).not.toContain('X-Robots-Tag');
expect(artifacts.headers).not.toContain('private, no-store');
expect(artifacts.headers).toBe([
'/assets/*',
' Cache-Control: public, max-age=31536000, immutable',
'',
'/*',
` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self'; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests`,
' X-Content-Type-Options: nosniff',
' Referrer-Policy: no-referrer',
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
' X-Frame-Options: DENY',
' Cache-Control: public, max-age=0, must-revalidate',
' Strict-Transport-Security: max-age=31536000',
'',
].join('\n'));
});
it('emits the least-privilege browser security baseline', () => {