feat: prerender crawlable portfolio artifacts
This commit is contained in:
163
tests/integration/prerender.test.ts
Normal file
163
tests/integration/prerender.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { mkdir, mkdtemp, 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 { 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')),
|
||||
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}/`),
|
||||
siteConfigJson: pathToFileURL(resolve(repositoryRoot, 'config/site.preview.json')),
|
||||
};
|
||||
const builtTemplate = `<!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" crossorigin href="/assets/index-test.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!--portfolio-body-->
|
||||
</body>
|
||||
</html>`;
|
||||
const manifest: ViteManifest = {
|
||||
'index.html': {
|
||||
file: 'assets/index-test.js',
|
||||
src: 'index.html',
|
||||
isEntry: true,
|
||||
css: ['assets/index-test.css'],
|
||||
},
|
||||
};
|
||||
|
||||
const renderRawArtifact = async () => {
|
||||
const inputs = await loadProductionInputs(locations);
|
||||
const markup = await render('/', inputs);
|
||||
const html = assemblePrerenderedHtml(builtTemplate, manifest, markup, inputs);
|
||||
return { html, inputs };
|
||||
};
|
||||
|
||||
describe('raw prerendered document', () => {
|
||||
it('places complete semantic content and the inert snapshot before the executable module', async () => {
|
||||
const { html, inputs } = await renderRawArtifact();
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const moduleScript = document.querySelector('script[type="module"]');
|
||||
const snapshot = document.querySelector('#portfolio-inputs[type="application/json"]');
|
||||
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
expect(document.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||
expect(document.querySelectorAll('h1')).toHaveLength(1);
|
||||
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru');
|
||||
expect(document.querySelector('h1 + p')?.textContent).toBe('System Administrator & Full-Stack Developer');
|
||||
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
|
||||
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||
]);
|
||||
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
|
||||
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
|
||||
expect(document.body.textContent).toContain('Linux');
|
||||
expect(document.querySelector('a[href="mailto:luca.pislaru@gmail.com"]')?.textContent)
|
||||
.toBe('luca.pislaru@gmail.com');
|
||||
expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull();
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(moduleScript).not.toBeNull();
|
||||
expect(html.indexOf('id="portfolio-inputs"')).toBeLessThan(html.indexOf('<script type="module"'));
|
||||
expect(html.indexOf('Luca Pîslaru')).toBeLessThan(html.indexOf('<script type="module"'));
|
||||
expect(html).not.toContain('window.__PORTFOLIO_INPUTS__');
|
||||
expect(html).not.toContain('<!--portfolio-');
|
||||
expect(JSON.parse(snapshot!.textContent ?? '')).toEqual(inputs);
|
||||
expect(readProductionInputsSnapshot(document)).toEqual(inputs);
|
||||
});
|
||||
|
||||
it('emits one canonical, one safe ProfilePage, and no fabricated preview image', async () => {
|
||||
const { html } = await renderRawArtifact();
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
const jsonLdNodes = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
|
||||
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
||||
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://portfolio.localhost/');
|
||||
expect(document.querySelectorAll('meta[property="og:image"]')).toHaveLength(0);
|
||||
expect(jsonLdNodes).toHaveLength(1);
|
||||
const jsonLd = JSON.parse(jsonLdNodes[0]!.textContent ?? '');
|
||||
expect(jsonLd['@type']).toBe('ProfilePage');
|
||||
expect(jsonLd.mainEntity.name).toBe('Luca Pîslaru');
|
||||
expect(jsonLd.mainEntity.jobTitle).toBe('System Administrator & Full-Stack Developer');
|
||||
expect((jsonLdNodes[0]!.textContent ?? '').toLowerCase()).not.toContain('</script');
|
||||
});
|
||||
|
||||
it('adds deterministic entry preloads without duplicating the executable module', async () => {
|
||||
const { html } = await renderRawArtifact();
|
||||
|
||||
expect(html.match(/rel="modulepreload"/gu)).toHaveLength(1);
|
||||
expect(html.match(/<script type="module"/gu)).toHaveLength(1);
|
||||
expect(html).toContain('<link rel="modulepreload" href="/assets/index-test.js" />');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict prerender command line', () => {
|
||||
it('accepts exactly one explicit safe repository-relative config path', () => {
|
||||
expect(parseExactConfigArgument(['--config', 'config/site.preview.json']))
|
||||
.toBe('config/site.preview.json');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ arguments_: [] },
|
||||
{ arguments_: ['--config'] },
|
||||
{ arguments_: ['--config', 'config/site.preview.json', '--config', 'config/site.preview.json'] },
|
||||
{ arguments_: ['--config', 'config/site.preview.json', '--unknown'] },
|
||||
{ arguments_: ['--config', '../site.preview.json'] },
|
||||
{ arguments_: ['--config', '/tmp/site.preview.json'] },
|
||||
{ arguments_: ['--config', 'config\\site.preview.json'] },
|
||||
])('rejects missing, duplicate, unknown, or unsafe arguments: $arguments_', ({ arguments_ }) => {
|
||||
expect(() => parseExactConfigArgument(arguments_)).toThrow(/exactly --config|safe repository-relative/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved build template', () => {
|
||||
it('declares English and contains no executable input assignment', async () => {
|
||||
const template = await readFile(resolve(repositoryRoot, 'index.html'), 'utf8');
|
||||
|
||||
expect(template).toContain('<html lang="en">');
|
||||
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);
|
||||
});
|
||||
});
|
||||
156
tests/integration/verify-artifacts.test.ts
Normal file
156
tests/integration/verify-artifacts.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
123
tests/unit/metadata.test.ts
Normal file
123
tests/unit/metadata.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '@/content/metadata';
|
||||
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
|
||||
import { serializeJsonLd } from '@/security/serialize-json-ld';
|
||||
import type { SiteConfig } from '@/content/types';
|
||||
|
||||
const fixture = (path: string): unknown =>
|
||||
JSON.parse(readFileSync(resolve(process.cwd(), path), 'utf8')) as unknown;
|
||||
|
||||
const content = parsePortfolioContent(fixture('content/public/portfolio-content.json'));
|
||||
const manifest = parsePublicAssetManifest(fixture('content/public/asset-manifest.json'));
|
||||
const previewSite: SiteConfig = {
|
||||
environment: 'preview',
|
||||
canonicalUrl: 'https://portfolio.localhost/',
|
||||
buildId: 'preview-test',
|
||||
};
|
||||
const productionSite: SiteConfig = {
|
||||
environment: 'production',
|
||||
canonicalUrl: 'https://portfolio.example.test/',
|
||||
buildId: 'production-test',
|
||||
};
|
||||
|
||||
describe('safe metadata', () => {
|
||||
it('escapes every HTML-significant and JavaScript line-separator code point', () => {
|
||||
const serialized = serializeJsonLd({ value: '</script><tag>&\u2028\u2029' });
|
||||
|
||||
expect(serialized).toBe('{"value":"\\u003c/script\\u003e\\u003ctag\\u003e\\u0026\\u2028\\u2029"}');
|
||||
expect(serialized.toLowerCase()).not.toContain('</script');
|
||||
});
|
||||
|
||||
it('derives canonical identity and verified profiles from public content only', () => {
|
||||
const metadata = createPageMetadata(content, manifest, productionSite);
|
||||
|
||||
expect(metadata.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||
expect(metadata.description).toBe(
|
||||
'Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software.',
|
||||
);
|
||||
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||||
expect(metadata.openGraph.image).toBeUndefined();
|
||||
expect(metadata.jsonLd).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
name: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
mainEntity: {
|
||||
'@type': 'Person',
|
||||
name: 'Luca Pîslaru',
|
||||
jobTitle: 'System Administrator & Full-Stack Developer',
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
email: 'mailto:luca.pislaru@gmail.com',
|
||||
sameAs: [
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
],
|
||||
},
|
||||
});
|
||||
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||||
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||||
});
|
||||
|
||||
it('omits sameAs when no verified social profiles exist', () => {
|
||||
const withoutSocial = structuredClone(content);
|
||||
withoutSocial.contact.social = [];
|
||||
|
||||
const metadata = createPageMetadata(withoutSocial, manifest, productionSite);
|
||||
|
||||
expect(metadata.jsonLd.mainEntity).not.toHaveProperty('sameAs');
|
||||
});
|
||||
|
||||
it('rejects metadata title copy that diverges from the canonical identity and role', () => {
|
||||
const inconsistent = structuredClone(content);
|
||||
inconsistent.metadata.title = 'Luca Pîslaru — Developer';
|
||||
|
||||
expect(() => createPageMetadata(inconsistent, manifest, productionSite)).toThrow(/canonical identity.*role/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment-aware crawl and response artifacts', () => {
|
||||
it('keeps preview private, noindex, sitemap-free, and without HSTS', () => {
|
||||
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, previewSite), previewSite);
|
||||
|
||||
expect(artifacts.robots).toBe('User-agent: *\nDisallow: /\n');
|
||||
expect(artifacts.sitemap).toBeUndefined();
|
||||
expect(artifacts.headers).toContain('Cache-Control: private, no-store');
|
||||
expect(artifacts.headers).toContain('X-Robots-Tag: noindex, nofollow, noarchive');
|
||||
expect(artifacts.headers).not.toContain('Strict-Transport-Security');
|
||||
expect(artifacts.headers).not.toContain('Sitemap:');
|
||||
expect(artifacts.redirects).not.toContain('portfolio.example.test');
|
||||
});
|
||||
|
||||
it('generates production-only sitemap, allow policy, HSTS, and no preview directives', () => {
|
||||
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, productionSite), productionSite);
|
||||
|
||||
expect(artifacts.robots).toBe(
|
||||
'User-agent: *\nAllow: /\nSitemap: https://portfolio.example.test/sitemap.xml\n',
|
||||
);
|
||||
expect(artifacts.sitemap).toContain('<loc>https://portfolio.example.test/</loc>');
|
||||
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');
|
||||
});
|
||||
|
||||
it('emits the least-privilege browser security baseline', () => {
|
||||
const artifacts = createStaticHostArtifacts(createPageMetadata(content, manifest, previewSite), previewSite);
|
||||
|
||||
expect(artifacts.headers).toContain("default-src 'self'");
|
||||
expect(artifacts.headers).toContain("script-src 'self' 'sha256-");
|
||||
expect(artifacts.headers).toContain("connect-src 'none'");
|
||||
expect(artifacts.headers).toContain("frame-ancestors 'none'");
|
||||
expect(artifacts.headers).toContain('X-Frame-Options: DENY');
|
||||
expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff');
|
||||
expect(artifacts.headers).toContain('Referrer-Policy: no-referrer');
|
||||
expect(artifacts.headers).toContain(
|
||||
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user