157 lines
6.9 KiB
TypeScript
157 lines
6.9 KiB
TypeScript
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { loadProductionInputs } from '@/content/load-production-inputs';
|
|
import { createPageMetadata, createStaticHostArtifacts } from '@/content/metadata';
|
|
import { render } from '@/entry-server';
|
|
import { assemblePrerenderedHtml, type ViteManifest } from '../../scripts/prerender.mjs';
|
|
import { verifyArtifactDirectory } from '../../scripts/verify-artifacts.mjs';
|
|
import type { ProductionInputs, SiteConfig } from '@/content/types';
|
|
|
|
const repositoryRoot = process.cwd();
|
|
const locations = {
|
|
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
|
|
assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')),
|
|
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}/`),
|
|
siteConfigJson: pathToFileURL(resolve(repositoryRoot, 'config/site.preview.json')),
|
|
};
|
|
const manifest: ViteManifest = {
|
|
'index.html': {
|
|
file: 'assets/index-test.js',
|
|
src: 'index.html',
|
|
isEntry: true,
|
|
css: ['assets/index-test.css'],
|
|
},
|
|
};
|
|
const template = `<!doctype html><html lang="en"><head><meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Portfolio</title>
|
|
<!--portfolio-head-->
|
|
<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 temporaryDirectories: string[] = [];
|
|
afterEach(async () => {
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
|
});
|
|
|
|
const writeValidArtifacts = async (site?: SiteConfig) => {
|
|
const loaded = await loadProductionInputs(locations);
|
|
const inputs: Readonly<ProductionInputs> = site === undefined ? loaded : { ...loaded, site };
|
|
const directory = await mkdtemp(join(tmpdir(), 'portfolio-artifacts-'));
|
|
temporaryDirectories.push(directory);
|
|
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);
|
|
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'),
|
|
...(hostArtifacts.sitemap === undefined
|
|
? []
|
|
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
|
]);
|
|
return { directory, html, inputs };
|
|
};
|
|
|
|
describe('artifact verification', () => {
|
|
it('accepts the complete private preview artifact set', async () => {
|
|
const { directory, inputs } = await writeValidArtifacts();
|
|
|
|
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('accepts the pure production artifact path with canonical sitemap and allow policy', async () => {
|
|
const productionSite: SiteConfig = {
|
|
environment: 'production',
|
|
canonicalUrl: 'https://portfolio.example.test/',
|
|
buildId: 'production-test',
|
|
};
|
|
const { directory, inputs } = await writeValidArtifacts(productionSite);
|
|
|
|
await expect(verifyArtifactDirectory(directory, inputs)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'an unexpected preview sitemap',
|
|
mutate: async (directory: string) => writeFile(join(directory, 'sitemap.xml'), '<urlset />'),
|
|
error: /preview.*sitemap/i,
|
|
},
|
|
{
|
|
name: 'a source map',
|
|
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js.map'), '{}'),
|
|
error: /source map|unexpected artifact/i,
|
|
},
|
|
{
|
|
name: 'an unknown artifact',
|
|
mutate: async (directory: string) => writeFile(join(directory, 'private-notes.txt'), 'notes'),
|
|
error: /unexpected artifact/i,
|
|
},
|
|
{
|
|
name: 'a prohibited private surface',
|
|
mutate: async (directory: string) => writeFile(join(directory, 'assets/index-test.js'), 'const evidenceId = "fixture";'),
|
|
error: /prohibited surface/i,
|
|
},
|
|
])('rejects $name', async ({ mutate, error }) => {
|
|
const { directory, inputs } = await writeValidArtifacts();
|
|
await mutate(directory);
|
|
|
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'duplicate canonical metadata',
|
|
mutate: (html: string) => html.replace('</head>', '<link rel="canonical" href="https://portfolio.localhost/" /></head>'),
|
|
error: /exactly one canonical/i,
|
|
},
|
|
{
|
|
name: 'duplicate H1 identity',
|
|
mutate: (html: string) => html.replace('</main>', '<h1>Luca Pîslaru</h1></main>'),
|
|
error: /exactly one h1/i,
|
|
},
|
|
{
|
|
name: 'malformed JSON-LD',
|
|
mutate: (html: string) => html.replace(/<script type="application\/ld\+json">.*?<\/script>/u, '<script type="application/ld+json">{</script>'),
|
|
error: /json-ld/i,
|
|
},
|
|
{
|
|
name: 'missing infrastructure evidence',
|
|
mutate: (html: string) => html.replace('id="infrastructure"', 'id="removed-infrastructure"'),
|
|
error: /section.*infrastructure/i,
|
|
},
|
|
{
|
|
name: 'executable snapshot assignment',
|
|
mutate: (html: string) => html.replace('<script id="portfolio-inputs" type="application/json">', '<script>window.__PORTFOLIO_INPUTS__='),
|
|
error: /inert input snapshot|prohibited surface/i,
|
|
},
|
|
])('rejects raw HTML with $name', async ({ mutate, error }) => {
|
|
const { directory, html, inputs } = await writeValidArtifacts();
|
|
await writeFile(join(directory, 'index.html'), mutate(html));
|
|
|
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(error);
|
|
});
|
|
|
|
it('rejects a missing crawl artifact', async () => {
|
|
const { directory, inputs } = await writeValidArtifacts();
|
|
await rm(join(directory, 'robots.txt'));
|
|
|
|
await expect(verifyArtifactDirectory(directory, inputs)).rejects.toThrow(/robots/i);
|
|
});
|
|
|
|
it('checks the CSP hash against the actual serialized JSON-LD bytes', async () => {
|
|
const { directory, inputs } = 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);
|
|
});
|
|
});
|