fix: harden prerender publication boundary
This commit is contained in:
92
tests/unit/build-contract.test.ts
Normal file
92
tests/unit/build-contract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
tests/unit/entry-client.test.ts
Normal file
46
tests/unit/entry-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user