83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
buildContentSecurityPolicy,
|
|
createSecurityHeaders,
|
|
serializeStaticHostHeaders,
|
|
} from '@/security/security-policy';
|
|
import type { SiteConfig } from '@/content/types';
|
|
|
|
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',
|
|
};
|
|
|
|
describe('static-host security policy', () => {
|
|
it('builds the exact restrictive CSP around the serialized JSON-LD hash', () => {
|
|
expect(buildContentSecurityPolicy(jsonLdHash)).toBe(
|
|
[
|
|
"default-src 'self'",
|
|
`script-src 'self' '${jsonLdHash}'`,
|
|
"style-src 'self'",
|
|
"img-src 'self' data:",
|
|
"media-src 'self'",
|
|
"font-src 'self'",
|
|
"connect-src 'none'",
|
|
"object-src 'none'",
|
|
"base-uri 'none'",
|
|
"frame-ancestors 'none'",
|
|
"form-action 'self'",
|
|
'upgrade-insecure-requests',
|
|
].join('; '),
|
|
);
|
|
});
|
|
|
|
it('emits production security and revalidation headers without widening HSTS', () => {
|
|
const headers = createSecurityHeaders(productionSite, jsonLdHash);
|
|
|
|
expect(headers).toEqual({
|
|
csp: buildContentSecurityPolicy(jsonLdHash),
|
|
contentTypeOptions: 'nosniff',
|
|
referrerPolicy: 'no-referrer',
|
|
permissionsPolicy:
|
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
|
frameOptions: 'DENY',
|
|
cacheControl: 'public, max-age=0, must-revalidate',
|
|
hsts: 'max-age=31536000',
|
|
});
|
|
expect(headers.hsts).not.toContain('includeSubDomains');
|
|
expect(headers).not.toHaveProperty('xRobotsTag');
|
|
});
|
|
|
|
it('keeps preview responses private, noindex, and outside HSTS policy', () => {
|
|
const headers = createSecurityHeaders(previewSite, jsonLdHash);
|
|
|
|
expect(headers.cacheControl).toBe('private, no-store');
|
|
expect(headers.xRobotsTag).toBe('noindex, nofollow, noarchive');
|
|
expect(headers).not.toHaveProperty('hsts');
|
|
});
|
|
|
|
it.each([
|
|
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' 'unsafe-inline",
|
|
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' *",
|
|
'sha256-not-base64!',
|
|
"'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='",
|
|
])('rejects an invalid or policy-injecting JSON-LD hash: %s', (value) => {
|
|
expect(() => buildContentSecurityPolicy(value)).toThrow(/JSON-LD CSP hash/i);
|
|
});
|
|
|
|
it('serializes one exact response-header template with no wildcard or external source expressions', () => {
|
|
const serialized = serializeStaticHostHeaders(productionSite, jsonLdHash);
|
|
|
|
expect(serialized).toContain(`Content-Security-Policy: ${buildContentSecurityPolicy(jsonLdHash)}`);
|
|
expect(serialized).toContain('Strict-Transport-Security: max-age=31536000');
|
|
expect(serialized).not.toMatch(/unsafe-inline|unsafe-eval|https?:\/\/|\*\.(?:js|css)/u);
|
|
});
|
|
});
|