diff --git a/index.html b/index.html index e1fa106..d0c700e 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,11 @@ Portfolio +
+ diff --git a/package.json b/package.json index 3269533..79f1a75 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "build:client": "vite build --outDir dist/client --manifest", "build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr", "prerender": "node scripts/prerender.mjs", + "verify:artifacts": "node scripts/verify-artifacts.mjs", "prepare:preview-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" + "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" }, "keywords": [], "author": "", diff --git a/scripts/prerender.d.mts b/scripts/prerender.d.mts new file mode 100644 index 0000000..8563e7b --- /dev/null +++ b/scripts/prerender.d.mts @@ -0,0 +1,21 @@ +import type { ProductionInputs } from '../src/content/types.ts'; + +export interface ViteManifestEntry { + file: string; + src?: string; + isEntry?: boolean; + css?: string[]; + imports?: string[]; +} + +export type ViteManifest = Record; + +export function parseExactConfigArgument(arguments_: string[]): string; +export function assertExpectedDistInputs(distRoot: string): Promise; +export function assemblePrerenderedHtml( + template: string, + manifest: ViteManifest, + markup: string, + inputs: Readonly, +): string; +export function prerender(configPath: string): Promise; diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs new file mode 100644 index 0000000..1d158f0 --- /dev/null +++ b/scripts/prerender.mjs @@ -0,0 +1,199 @@ +import { cp, lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { loadProductionInputs } from '../src/content/load-production-inputs.ts'; +import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts'; +import { serializeJsonLd } from '../src/security/serialize-json-ld.ts'; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = resolve(dirname(scriptPath), '..'); + +const escapeHtml = (value) => value + .replace(/&/gu, '&') + .replace(//gu, '>') + .replace(/"/gu, '"'); + +const exactlyOne = (source, expression, label) => { + const matches = source.match(expression) ?? []; + if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`); +}; + +export const parseExactConfigArgument = (arguments_) => { + if (arguments_.length !== 2 || arguments_[0] !== '--config') { + throw new Error('Prerender accepts exactly --config .'); + } + const configPath = arguments_[1]; + if ( + configPath.length === 0 || + configPath.startsWith('/') || + configPath.includes('\\') || + configPath.split('/').some((part) => part === '' || part === '.' || part === '..') || + !/^config\/[a-z0-9][a-z0-9._-]*\.json$/u.test(configPath) + ) { + throw new Error('Config must be a safe repository-relative JSON path inside config/.'); + } + return configPath; +}; + +const manifestEntry = (manifest) => { + const entries = Object.values(manifest).filter((entry) => entry.isEntry === true); + if (entries.length !== 1) throw new Error('Vite manifest must contain exactly one client entry.'); + const entry = entries[0]; + if (typeof entry.file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+\.js$/u.test(entry.file)) { + throw new Error('Vite manifest client entry is unsafe.'); + } + return entry; +}; + +const metadataMarkup = (metadata) => { + const image = metadata.openGraph.image === undefined + ? '' + : `\n `; + return [ + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + ` ${image}`, + ` `, + ].join('\n'); +}; + +export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => { + const entry = manifestEntry(manifest); + const entryUrl = `/${entry.file}`; + exactlyOne(template, /[^<]*<\/title>/gu, 'title'); + exactlyOne(template, /<div id="app"><\/div>/gu, 'empty app root'); + exactlyOne(template, /<script type="module"[^>]*><\/script>/gu, 'client module script'); + exactlyOne(template, /<!--portfolio-head-->/gu, 'portfolio head marker'); + exactlyOne(template, /<!--portfolio-body-->/gu, 'portfolio body marker'); + + const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site); + const snapshot = serializeJsonLd(inputs); + const preloads = [entry.file, ...(entry.imports ?? []).map((key) => manifest[key]?.file)] + .filter((file, index, files) => typeof file === 'string' && files.indexOf(file) === index) + .map((file) => ` <link rel="modulepreload" href="/${file}" />`) + .join('\n'); + const withoutModule = template.replace(/\s*<script type="module"[^>]*><\/script>/u, ''); + const withHead = withoutModule + .replace(/<title>[^<]*<\/title>/u, `<title>${escapeHtml(metadata.title)}`) + .replace('', `${preloads}\n${metadataMarkup(metadata)}`) + .replace('
', `
${markup}
`); + const clientBootstrap = [ + ``, + ``, + ].join('\n '); + return withHead.replace('', clientBootstrap); +}; + +const assertDirectoryMembers = async (root, expected, label) => { + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + if (!expected.has(entry.name)) throw new Error(`Unexpected build output in ${label}: ${entry.name}`); + if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output in ${label}: ${entry.name}`); + } + for (const name of expected) { + if (!entries.some((entry) => entry.name === name)) throw new Error(`Missing build output in ${label}: ${name}`); + } + return entries; +}; + +const manifestAssetFiles = (manifest) => { + const files = new Set(); + for (const entry of Object.values(manifest)) { + for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) { + if (typeof file !== 'string' || !/^assets\/[a-zA-Z0-9._-]+$/u.test(file)) { + throw new Error('Vite manifest contains an unsafe output path.'); + } + files.add(file.slice('assets/'.length)); + } + } + return files; +}; + +export const assertExpectedDistInputs = async (distRoot) => { + const allowed = new Set([ + 'client', 'assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example', + ]); + const rootEntries = await readdir(distRoot, { withFileTypes: true }); + for (const entry of rootEntries) { + if (!allowed.has(entry.name)) throw new Error(`Unexpected build output: ${entry.name}`); + if (entry.isSymbolicLink()) throw new Error(`Unexpected linked build output: ${entry.name}`); + } + const clientStats = await lstat(resolve(distRoot, 'client')); + if (clientStats.isSymbolicLink() || !clientStats.isDirectory()) { + throw new Error('Client output must be a non-symlink directory.'); + } + + await assertDirectoryMembers(resolve(distRoot, 'client'), new Set(['.vite', 'assets', 'index.html']), 'client'); + await assertDirectoryMembers(resolve(distRoot, 'client/.vite'), new Set(['manifest.json']), 'client/.vite'); + const manifest = JSON.parse(await readFile(resolve(distRoot, 'client/.vite/manifest.json'), 'utf8')); + await assertDirectoryMembers(resolve(distRoot, 'client/assets'), manifestAssetFiles(manifest), 'client/assets'); + + if (rootEntries.some((entry) => entry.name === 'assets')) { + if (!rootEntries.some((entry) => entry.name === 'index.html')) { + throw new Error('Existing root assets require their generated index.html inventory.'); + } + const existingHtml = await readFile(resolve(distRoot, 'index.html'), 'utf8'); + const referencedAssets = new Set( + [...existingHtml.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => match[1]), + ); + await assertDirectoryMembers(resolve(distRoot, 'assets'), referencedAssets, 'assets'); + } +}; + +const assertInsideRepository = (path) => { + const pathRelativeToRoot = relative(repositoryRoot, path); + if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) { + throw new Error('Build input escaped the repository root.'); + } +}; + +export const prerender = async (configPath) => { + const distRoot = resolve(repositoryRoot, 'dist'); + const clientRoot = resolve(distRoot, 'client'); + const configAbsolutePath = resolve(repositoryRoot, configPath); + assertInsideRepository(configAbsolutePath); + await assertExpectedDistInputs(distRoot); + + const inputs = await loadProductionInputs({ + 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')}${sep}`), + siteConfigJson: pathToFileURL(configAbsolutePath), + }); + const [{ render }, template, manifestSource] = await Promise.all([ + import(pathToFileURL(resolve(repositoryRoot, 'dist-ssr/entry-server.js')).href), + readFile(resolve(clientRoot, 'index.html'), 'utf8'), + readFile(resolve(clientRoot, '.vite/manifest.json'), 'utf8'), + ]); + if (typeof render !== 'function') throw new Error('SSR entry does not export render().'); + const manifest = JSON.parse(manifestSource); + const markup = await render('/', inputs); + const html = assemblePrerenderedHtml(template, manifest, markup, inputs); + const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site); + const artifacts = createStaticHostArtifacts(metadata, inputs.site); + + for (const path of ['assets', 'index.html', 'robots.txt', 'sitemap.xml', '_headers.example', '_redirects.example']) { + await rm(resolve(distRoot, path), { recursive: true, force: true }); + } + await cp(resolve(clientRoot, 'assets'), resolve(distRoot, 'assets'), { recursive: true, errorOnExist: true }); + await Promise.all([ + writeFile(resolve(distRoot, 'index.html'), html), + writeFile(resolve(distRoot, 'robots.txt'), artifacts.robots), + writeFile(resolve(distRoot, '_headers.example'), artifacts.headers), + writeFile(resolve(distRoot, '_redirects.example'), artifacts.redirects), + ...(artifacts.sitemap === undefined + ? [] + : [writeFile(resolve(distRoot, 'sitemap.xml'), artifacts.sitemap)]), + ]); + await rm(clientRoot, { recursive: true }); +}; + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { + const configPath = parseExactConfigArgument(process.argv.slice(2)); + await prerender(configPath); +} diff --git a/scripts/verify-artifacts.d.mts b/scripts/verify-artifacts.d.mts new file mode 100644 index 0000000..19f882a --- /dev/null +++ b/scripts/verify-artifacts.d.mts @@ -0,0 +1,7 @@ +import type { ProductionInputs } from '../src/content/types.ts'; + +export function verifyArtifactDirectory( + root: string, + inputs: Readonly, +): Promise; +export function verifyArtifacts(configPath: string): Promise; diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs new file mode 100644 index 0000000..7b84200 --- /dev/null +++ b/scripts/verify-artifacts.mjs @@ -0,0 +1,293 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile, readdir } from 'node:fs/promises'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { loadProductionInputs } from '../src/content/load-production-inputs.ts'; +import { parseExactConfigArgument } from './prerender.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const repositoryRoot = resolve(dirname(scriptPath), '..'); +const canonicalSectionIds = [ + 'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact', +]; +const prohibitedSurfaces = [ + /__PORTFOLIO_INPUTS__/iu, + /(?:evidenceId|sourcePath|sourceFingerprint|approvalOwner|privacyClassification|denylist|originalFilename)/iu, + /sourceMappingURL/iu, +]; + +const escapeRegularExpression = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +const escapeHtml = (value) => value + .replace(/&/gu, '&') + .replace(//gu, '>') + .replace(/"/gu, '"'); + +const readRequiredText = async (root, name) => { + try { + return await readFile(resolve(root, name), 'utf8'); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + throw new Error(`Missing required artifact: ${name}`); + } + throw error; + } +}; + +const listArtifacts = async (root) => { + const rootStats = await lstat(root); + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) { + throw new Error('Artifact root must be a non-symlink directory.'); + } + const files = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + const entryPath = resolve(root, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Unexpected linked artifact: ${entry.name}`); + if (entry.isDirectory()) { + if (entry.name !== 'assets') throw new Error(`Unexpected artifact directory: ${entry.name}`); + for (const asset of await readdir(entryPath, { withFileTypes: true })) { + if (asset.isSymbolicLink() || !asset.isFile()) { + throw new Error(`Unexpected linked or nested artifact: assets/${asset.name}`); + } + files.push(`assets/${asset.name}`); + } + continue; + } + if (!entry.isFile()) throw new Error(`Unexpected special artifact: ${entry.name}`); + files.push(entry.name); + } + return files.sort(); +}; + +const assertArtifactInventory = (files, html, inputs) => { + const expectedRootFiles = new Set(['index.html', 'robots.txt', '_headers.example', '_redirects.example']); + if (inputs.site.environment === 'production') expectedRootFiles.add('sitemap.xml'); + const referencedAssets = new Set( + [...html.matchAll(/(?:src|href)="\/assets\/([a-zA-Z0-9._-]+)"/gu)].map((match) => `assets/${match[1]}`), + ); + for (const asset of inputs.manifest.assets) referencedAssets.add(`assets/${asset.fileName}`); + + for (const file of files) { + if (file.endsWith('.map')) throw new Error(`Source map artifact is prohibited: ${file}`); + if (file.startsWith('assets/')) { + if (!referencedAssets.has(file)) throw new Error(`Unexpected artifact: ${file}`); + } else if (!expectedRootFiles.has(file)) { + if (inputs.site.environment === 'preview' && file === 'sitemap.xml') { + throw new Error('Preview artifacts must not publish a sitemap.'); + } + throw new Error(`Unexpected artifact: ${file}`); + } + } + for (const required of [...expectedRootFiles, ...referencedAssets]) { + if (!files.includes(required)) throw new Error(`Missing required artifact: ${required}`); + } +}; + +const assertNoProhibitedSurfaces = async (root, files) => { + for (const file of files) { + const bytes = await readFile(resolve(root, file)); + const text = bytes.toString('utf8'); + for (const pattern of prohibitedSurfaces) { + if (pattern.test(text)) throw new Error(`Prohibited surface detected in ${file}.`); + } + } +}; + +const exactlyOneMatch = (text, expression, label) => { + const matches = [...text.matchAll(expression)]; + if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`); + return matches[0]; +}; + +const assertSemanticHtml = (html, inputs) => { + if (!//u.test(html)) throw new Error('Final HTML must declare lang=en.'); + const h1 = exactlyOneMatch(html, /]*>([\s\S]*?)<\/h1>/gu, 'H1'); + if (h1[1] !== escapeHtml(inputs.content.identity.name)) throw new Error('H1 identity does not match public content.'); + const adjacentIdentity = `

${escapeHtml(inputs.content.identity.name)}

${escapeHtml(inputs.content.identity.role)}

`; + if (!html.includes(adjacentIdentity)) throw new Error('The canonical role must be immediately adjacent to the H1.'); + + for (const id of canonicalSectionIds) { + const sectionMatches = html.match(new RegExp(`
)`, 'gu')) ?? []; + if (sectionMatches.length !== 1) throw new Error(`Expected exactly one section: ${id}`); + const navigationMatches = html.match(new RegExp(`href="#${escapeRegularExpression(id)}"`, 'gu')) ?? []; + if (navigationMatches.length !== 1) throw new Error(`Expected one native navigation link for section: ${id}`); + const label = inputs.content.navigation.find((item) => item.id === id)?.label; + if (label === undefined || !html.includes(`

${escapeHtml(label.toUpperCase())}

`)) { + throw new Error(`Missing canonical section heading: ${id}`); + } + } + + const requiredVisibleCopy = [ + inputs.content.systemsOperations.title, + inputs.content.infrastructure.title, + ...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]), + ...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]), + ...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]), + inputs.content.contact.email, + ]; + for (const copy of requiredVisibleCopy) { + if (!html.includes(escapeHtml(copy))) throw new Error(`Missing required semantic evidence: ${copy}`); + } + if (!html.includes(`href="mailto:${escapeHtml(inputs.content.contact.email)}"`)) { + throw new Error('Missing visible mailto contact link.'); + } + + const moduleScript = exactlyOneMatch(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(' +
+`; + +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 = 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'), ''), + 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('', ''), + error: /exactly one canonical/i, + }, + { + name: 'duplicate H1 identity', + mutate: (html: string) => html.replace('', '

Luca Pîslaru

'), + error: /exactly one h1/i, + }, + { + name: 'malformed JSON-LD', + mutate: (html: string) => html.replace(/'), + 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('&\u2028\u2029' }); + + expect(serialized).toBe('{"value":"\\u003c/script\\u003e\\u003ctag\\u003e\\u0026\\u2028\\u2029"}'); + expect(serialized.toLowerCase()).not.toContain(' { + 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('https://portfolio.example.test/'); + 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=()', + ); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 951295e..f1f68cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], + build: { + sourcemap: false, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, },