Files
worldmonitor/tests/deploy-config.test.mjs
Elie Habib d101c03009 fix: unblock geolocation and fix stale CSP hash (#1709)
* fix: unblock geolocation and fix stale CSP hash for SW nuke script

Permissions-Policy had geolocation=() which blocked navigator.geolocation
used by user-location.ts. Changed to geolocation=(self).

CSP script-src had a stale SHA-256 hash (903UI9my...) that didn't match the
current SW nuke script content. The script was silently blocked in production,
preventing recovery from stale service workers after deploys. Replaced with
the correct hash (4Z2xtr1B...) in both vercel.json and index.html meta tag.

* test: update permissions-policy test for geolocation=(self)

Move geolocation from "disabled" list to "delegated" assertions since
it now allows self-origin access for user-location.ts.
2026-03-16 08:37:40 +04:00

164 lines
6.6 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', () => {
const spaNoCache = getCacheHeaderValue('/((?!api|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known).*)');
assert.equal(spaNoCache, '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-only runtime caching for navigation requests', () => {
assert.match(viteConfigSource, /request\.mode === 'navigate'/);
assert.match(viteConfigSource, /handler:\s*'NetworkOnly'/);
});
it('contains variant-specific metadata fields used by html replacement and manifest', () => {
const variantMetaSource = readFileSync(resolve(__dirname, '../src/config/variant-meta.ts'), 'utf-8');
assert.match(variantMetaSource, /shortName:\s*'/);
assert.match(variantMetaSource, /subject:\s*'/);
assert.match(variantMetaSource, /classification:\s*'/);
assert.match(variantMetaSource, /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 5 required security headers on catch-all route', () => {
const required = [
'X-Content-Type-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=()',
'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 media APIs to allowed origins', () => {
const policy = getHeaderValue('Permissions-Policy');
// autoplay and encrypted-media delegate to self + YouTube
for (const api of ['autoplay', 'encrypted-media']) {
assert.match(
policy,
new RegExp(`${api}=\\(self "https://www\\.youtube\\.com" "https://www\\.youtube-nocookie\\.com"\\)`),
`Permissions-Policy should delegate ${api} to YouTube origins`
);
}
// geolocation delegates to self (used by user-location.ts)
assert.ok(
policy.includes('geolocation=(self)'),
'Permissions-Policy should delegate geolocation to self'
);
// picture-in-picture delegates to self + YouTube
assert.match(
policy,
/picture-in-picture=\(self "https:\/\/www\.youtube\.com" "https:\/\/www\.youtube-nocookie\.com"\)/,
'Permissions-Policy should delegate picture-in-picture 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');
});
it('CSP script-src includes wasm-unsafe-eval for WebAssembly support', () => {
const csp = getHeaderValue('Content-Security-Policy');
const scriptSrc = csp.match(/script-src\s+([^;]+)/)?.[1] ?? '';
assert.ok(scriptSrc.includes("'wasm-unsafe-eval'"), 'CSP script-src must include wasm-unsafe-eval for WASM support');
assert.ok(scriptSrc.includes("'self'"), 'CSP script-src must include self');
});
it('security.txt exists in public/.well-known/', () => {
const secTxt = readFileSync(resolve(__dirname, '../public/.well-known/security.txt'), 'utf-8');
assert.match(secTxt, /^Contact:/m, 'security.txt must have a Contact field');
assert.match(secTxt, /^Expires:/m, 'security.txt must have an Expires field');
});
});