Files
worldmonitor/tests/deploy-config.test.mjs
Elie Habib e10c088229 harden: expand Permissions-Policy and tighten CSP connect-src (#779)
Expand Permissions-Policy from 3 to 19 directives (16 fully disabled,
3 delegated to YouTube origins for embed compatibility). Remove
unencrypted ws: and dev-only http://localhost:5173 from production CSP
connect-src. Add 5 guardrail tests to prevent regressions.
2026-03-02 18:36:01 +04:00

142 lines
5.3 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const vercelConfig = JSON.parse(readFileSync(resolve(__dirname, '../vercel.json'), 'utf-8'));
const viteConfigSource = readFileSync(resolve(__dirname, '../vite.config.ts'), 'utf-8');
const getCacheHeaderValue = (sourcePath) => {
const rule = vercelConfig.headers.find((entry) => entry.source === sourcePath);
const header = rule?.headers?.find((item) => item.key.toLowerCase() === 'cache-control');
return header?.value ?? null;
};
describe('deploy/cache configuration guardrails', () => {
it('disables caching for HTML entry routes on Vercel', () => {
assert.equal(getCacheHeaderValue('/'), 'no-cache, no-store, must-revalidate');
assert.equal(getCacheHeaderValue('/index.html'), 'no-cache, no-store, must-revalidate');
});
it('keeps immutable caching for hashed static assets', () => {
assert.equal(
getCacheHeaderValue('/assets/(.*)'),
'public, max-age=31536000, immutable'
);
});
it('keeps PWA precache glob free of HTML files', () => {
assert.match(
viteConfigSource,
/globPatterns:\s*\['\*\*\/\*\.\{js,css,ico,png,svg,woff2\}'\]/
);
assert.doesNotMatch(viteConfigSource, /globPatterns:\s*\['\*\*\/\*\.\{js,css,html/);
});
it('explicitly disables navigateFallback when HTML is not precached', () => {
assert.match(viteConfigSource, /navigateFallback:\s*null/);
assert.doesNotMatch(viteConfigSource, /navigateFallbackDenylist:\s*\[/);
});
it('uses network-first runtime caching for navigation requests', () => {
assert.match(viteConfigSource, /request\.mode === 'navigate'/);
assert.match(viteConfigSource, /handler:\s*'NetworkFirst'/);
assert.match(viteConfigSource, /cacheName:\s*'html-navigation'/);
});
it('contains variant-specific metadata fields used by html replacement and manifest', () => {
assert.match(viteConfigSource, /shortName:\s*'/);
assert.match(viteConfigSource, /subject:\s*'/);
assert.match(viteConfigSource, /classification:\s*'/);
assert.match(viteConfigSource, /categories:\s*\[/);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="subject" content="\.\*\?" \\\/>\/,\s*`<meta name="subject"/
);
assert.match(
viteConfigSource,
/\.replace\(\/<meta name="classification" content="\.\*\?" \\\/>\/,\s*`<meta name="classification"/
);
});
});
const getSecurityHeaders = () => {
const rule = vercelConfig.headers.find((entry) => entry.source === '/(.*)');
return rule?.headers ?? [];
};
const getHeaderValue = (key) => {
const headers = getSecurityHeaders();
const header = headers.find((h) => h.key.toLowerCase() === key.toLowerCase());
return header?.value ?? null;
};
describe('security header guardrails', () => {
it('includes all 6 required security headers on catch-all route', () => {
const required = [
'X-Content-Type-Options',
'X-Frame-Options',
'Strict-Transport-Security',
'Referrer-Policy',
'Permissions-Policy',
'Content-Security-Policy',
];
const headerKeys = getSecurityHeaders().map((h) => h.key);
for (const name of required) {
assert.ok(headerKeys.includes(name), `Missing security header: ${name}`);
}
});
it('Permissions-Policy disables all expected browser APIs', () => {
const policy = getHeaderValue('Permissions-Policy');
const expectedDisabled = [
'camera=()',
'microphone=()',
'geolocation=()',
'accelerometer=()',
'bluetooth=()',
'display-capture=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'magnetometer=()',
'midi=()',
'payment=()',
'screen-wake-lock=()',
'serial=()',
'usb=()',
'xr-spatial-tracking=()',
];
for (const directive of expectedDisabled) {
assert.ok(policy.includes(directive), `Permissions-Policy missing: ${directive}`);
}
});
it('Permissions-Policy delegates YouTube APIs to YouTube origins', () => {
const policy = getHeaderValue('Permissions-Policy');
const ytDelegated = ['autoplay', 'encrypted-media', 'picture-in-picture'];
for (const api of ytDelegated) {
assert.match(
policy,
new RegExp(`${api}=\\(self "https://www\\.youtube\\.com" "https://www\\.youtube-nocookie\\.com"\\)`),
`Permissions-Policy should delegate ${api} to YouTube origins`
);
}
});
it('CSP connect-src does not allow unencrypted WebSocket (ws:)', () => {
const csp = getHeaderValue('Content-Security-Policy');
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(!connectSrc.includes(' ws:'), 'CSP connect-src must not contain ws: (unencrypted WebSocket)');
assert.ok(connectSrc.includes('wss:'), 'CSP connect-src should keep wss: for secure WebSocket');
});
it('CSP connect-src does not contain localhost in production', () => {
const csp = getHeaderValue('Content-Security-Policy');
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(!connectSrc.includes('http://localhost'), 'CSP connect-src must not contain http://localhost in production');
});
});