194 lines
9.1 KiB
TypeScript
194 lines
9.1 KiB
TypeScript
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(
|
||
'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.',
|
||
);
|
||
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||
expect(metadata.openGraph.image).toBe(`https://portfolio.example.test/assets/${previewAsset.fileName}`);
|
||
expect(metadata.twitter).toEqual({
|
||
card: 'summary_large_image',
|
||
title: metadata.title,
|
||
description: metadata.description,
|
||
image: `https://portfolio.example.test/assets/${previewAsset.fileName}`,
|
||
});
|
||
expect(metadata.jsonLd).toEqual({
|
||
'@context': 'https://schema.org',
|
||
'@type': 'ProfilePage',
|
||
name: 'Luca Pîslaru',
|
||
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/',
|
||
homeLocation: {
|
||
'@type': 'Place',
|
||
address: {
|
||
'@type': 'PostalAddress',
|
||
addressLocality: 'Bucharest',
|
||
addressCountry: 'RO',
|
||
},
|
||
},
|
||
sameAs: [
|
||
'https://linkedin.com/in/luca-pisl',
|
||
'https://github.com/LucaPisl',
|
||
'https://gitlab.com/luca_pisl',
|
||
],
|
||
},
|
||
});
|
||
expect(JSON.stringify(metadata.jsonLd)).not.toMatch(
|
||
/streetAddress|postalCode|telephone|interactionStatistic|"image"|"email"/iu,
|
||
);
|
||
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||
});
|
||
|
||
it('uses a summary card without an image when no social preview is approved', () => {
|
||
const withoutImage = structuredClone(content);
|
||
delete withoutImage.metadata.socialPreviewAssetId;
|
||
|
||
const metadata = createPageMetadata(withoutImage, manifest, productionSite);
|
||
|
||
expect(metadata.openGraph).not.toHaveProperty('image');
|
||
expect(metadata.twitter).toEqual({
|
||
card: 'summary',
|
||
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||
description: content.metadata.description,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{ name: 'audio', mediaType: 'audio/mpeg' as const, fileName: 'project-social-preview.mp3', width: null, height: null, durationMs: 1000 },
|
||
{ name: 'video', mediaType: 'video/mp4' as const, fileName: 'project-social-preview.mp4', width: 1200, height: 630, durationMs: 1000 },
|
||
{ name: 'vector image', mediaType: 'image/svg+xml' as const, fileName: 'project-social-preview.svg', width: 1200, height: 630, durationMs: null },
|
||
{ name: 'wrong-size raster', mediaType: 'image/webp' as const, fileName: 'project-social-preview.webp', width: 1199, height: 630, durationMs: null },
|
||
])('falls back to a summary card when the configured preview is $name', ({ mediaType, fileName, width, height, durationMs }) => {
|
||
const unsafeManifest = structuredClone(manifest);
|
||
const previewAsset = unsafeManifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||
previewAsset.mediaType = mediaType;
|
||
previewAsset.fileName = fileName;
|
||
previewAsset.width = width;
|
||
previewAsset.height = height;
|
||
previewAsset.durationMs = durationMs;
|
||
|
||
const metadata = createPageMetadata(content, unsafeManifest, productionSite);
|
||
|
||
expect(metadata.openGraph).not.toHaveProperty('image');
|
||
expect(metadata.twitter).toEqual({
|
||
card: 'summary',
|
||
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||
description: content.metadata.description,
|
||
});
|
||
});
|
||
|
||
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 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',
|
||
);
|
||
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');
|
||
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' data:; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; 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', () => {
|
||
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 'self'");
|
||
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=()',
|
||
);
|
||
});
|
||
});
|