156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { SiteConfig } from '@/content/types';
|
||
import {
|
||
parseHashedArtifactManifest,
|
||
renderNginxConfig,
|
||
// @ts-expect-error The executable ESM build script intentionally has no publishable declaration file.
|
||
} from '../../scripts/generate-host-config.mjs';
|
||
|
||
const jsonLdHash = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||
const productionSite: SiteConfig = {
|
||
environment: 'production',
|
||
canonicalUrl: 'https://portfolio.example.test/',
|
||
buildId: 'release-1',
|
||
};
|
||
const previewSite: SiteConfig = {
|
||
environment: 'preview',
|
||
canonicalUrl: 'https://portfolio.localhost/',
|
||
buildId: 'preview-1',
|
||
};
|
||
const manifest = {
|
||
schema: 'portfolio-host-artifact-manifest/v1',
|
||
files: [
|
||
{ path: 'assets/app-a1b2c3d4.css', sha256: 'b'.repeat(64) },
|
||
{ path: 'assets/app-a1b2c3d4.js', sha256: 'a'.repeat(64) },
|
||
{ path: 'assets/demo-c3d4e5f6.webm', sha256: 'c'.repeat(64) },
|
||
],
|
||
};
|
||
|
||
describe('generated Nginx static-host contract', () => {
|
||
it('binds immutable caching to the exact hashed artifact manifest', () => {
|
||
const config = renderNginxConfig({
|
||
site: productionSite,
|
||
jsonLdCspHash: jsonLdHash,
|
||
artifactManifest: manifest,
|
||
documentRoot: '/srv/portfolio/current',
|
||
alternateHosts: ['www.portfolio.example.test'],
|
||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||
});
|
||
|
||
for (const file of manifest.files) expect(config).toContain(`~^/${file.path.replaceAll('.', '\\.')}$`);
|
||
expect(config).toContain('public, max-age=31536000, immutable');
|
||
expect(config).toContain('public, max-age=0, must-revalidate');
|
||
expect(config).toContain(
|
||
'Requires standard MIME mappings and static-file defaults in the parent Nginx http context.',
|
||
);
|
||
expect(config).not.toContain('include /etc/nginx/mime.types;');
|
||
expect(config).not.toContain('default_type application/octet-stream;');
|
||
expect(config).not.toContain('sendfile on;');
|
||
expect(config).toContain('max_ranges 1;');
|
||
expect(config).not.toContain('max_ranges 0');
|
||
});
|
||
|
||
it('redirects HTTP and an alternate HTTPS host directly to the canonical HTTPS host', () => {
|
||
const config = renderNginxConfig({
|
||
site: productionSite,
|
||
jsonLdCspHash: jsonLdHash,
|
||
artifactManifest: manifest,
|
||
documentRoot: '/srv/portfolio/current',
|
||
alternateHosts: ['www.portfolio.example.test'],
|
||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||
});
|
||
|
||
expect(config).toContain('listen 80;');
|
||
expect(config).toContain('server_name portfolio.example.test www.portfolio.example.test;');
|
||
expect(config.match(/return 308 https:\/\/portfolio\.example\.test\$request_uri;/gu)).toHaveLength(2);
|
||
expect(config).not.toMatch(/return 30[127] /u);
|
||
expect(config).not.toContain('http://portfolio.example.test');
|
||
});
|
||
|
||
it('applies exact browser headers and production indexability without public source maps', () => {
|
||
const config = renderNginxConfig({
|
||
site: productionSite,
|
||
jsonLdCspHash: jsonLdHash,
|
||
artifactManifest: manifest,
|
||
documentRoot: '/srv/portfolio/current',
|
||
alternateHosts: [],
|
||
tlsCertificatePath: '/etc/portfolio/tls/fullchain.pem',
|
||
tlsKeyPath: '/etc/portfolio/tls/privkey.pem',
|
||
});
|
||
|
||
expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-");
|
||
expect(config).toContain("img-src 'self' data:");
|
||
expect(config).toContain("connect-src 'none'");
|
||
expect(config).toContain('add_header Strict-Transport-Security "max-age=31536000" always;');
|
||
expect(config).not.toContain('includeSubDomains');
|
||
expect(config).not.toContain('X-Robots-Tag');
|
||
expect(config).not.toMatch(/unsafe-inline|unsafe-eval|sourceMappingURL|\.map(?:\s|$)/u);
|
||
});
|
||
|
||
it('makes preview responses private and noindex without a sitemap or HSTS', () => {
|
||
const config = renderNginxConfig({
|
||
site: previewSite,
|
||
jsonLdCspHash: jsonLdHash,
|
||
artifactManifest: manifest,
|
||
documentRoot: '/srv/portfolio/preview',
|
||
alternateHosts: [],
|
||
});
|
||
|
||
expect(config).toContain('add_header X-Robots-Tag "noindex, nofollow, noarchive" always;');
|
||
expect(config).toContain('private, no-store');
|
||
expect(config).not.toContain('Strict-Transport-Security');
|
||
expect(config).not.toContain('sitemap.xml');
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
name: 'a source map',
|
||
value: {
|
||
...manifest,
|
||
files: [{ path: 'assets/app-a1b2c3d4.js.map', sha256: 'a'.repeat(64) }],
|
||
},
|
||
},
|
||
{
|
||
name: 'an external or unsafe path',
|
||
value: {
|
||
...manifest,
|
||
files: [{ path: '../app-a1b2c3d4.js', sha256: 'a'.repeat(64) }],
|
||
},
|
||
},
|
||
{
|
||
name: 'a non-canonical digest',
|
||
value: {
|
||
...manifest,
|
||
files: [{ path: 'assets/app-a1b2c3d4.js', sha256: 'A'.repeat(64) }],
|
||
},
|
||
},
|
||
])('rejects $name in the hashed artifact manifest', ({ value }) => {
|
||
expect(() => parseHashedArtifactManifest(value)).toThrow(/artifact manifest/i);
|
||
});
|
||
|
||
it('rejects document-root values that cannot be emitted as one Nginx token', () => {
|
||
expect(() =>
|
||
renderNginxConfig({
|
||
site: previewSite,
|
||
jsonLdCspHash: jsonLdHash,
|
||
artifactManifest: manifest,
|
||
documentRoot: '/srv/portfolio/injected root',
|
||
alternateHosts: [],
|
||
}),
|
||
).toThrow(/document root/i);
|
||
});
|
||
|
||
it('ships a domain-neutral example instead of inventing Luca’s production hostname', () => {
|
||
const example = readFileSync(resolve(process.cwd(), 'nginx.portfolio.conf.example'), 'utf8');
|
||
|
||
expect(example).toContain('__CANONICAL_HOST__');
|
||
expect(example).toContain('__JSON_LD_CSP_HASH__');
|
||
expect(example).not.toContain('portfolio.example.test');
|
||
expect(example).not.toMatch(/server_name\s+[a-z0-9.-]+\.[a-z]{2,}/iu);
|
||
});
|
||
});
|