diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 827b168..f8b3e71 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -74,6 +74,19 @@ make mend-scan - `RENOVATE_TOKEN_FILE` - `gh auth token` (GitHub CLI fallback) +## Renderer CSP Policy + +- The renderer HTML (`src/renderer/public/index.html`) defines a strict CSP via + ``. +- Policy baseline: + - `script-src 'self'` + - `style-src 'self'` + - `object-src 'none'` + - `base-uri 'none'` +- No `'unsafe-inline'` or `'unsafe-eval'` exceptions are allowed. +- Dark mode bootstrap is intentionally externalized to `src/renderer/public/theme-bootstrap.js` + so startup theme logic works without inline scripts. + ## Manual Setup (Without Make) ```bash diff --git a/jest.config.js b/jest.config.js index 9925390..f378a18 100755 --- a/jest.config.js +++ b/jest.config.js @@ -22,6 +22,7 @@ module.exports = { coverageDirectory: '/coverage', collectCoverageFrom: [ '/src/renderer/components/**/*.{js,jsx,ts,tsx}', + '/src/renderer/public/**/*.js', '/src/main/index.ts', '/src/main/preload.ts', '/src/main/security/navigation-guard.ts', diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index b1b6fc9..6ab9a46 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -17,6 +17,15 @@ const DEFAULT_SCREENSHOT_NAME = `ui-${process.platform}-${process.arch}.png`; const DEFAULT_LOCALE = 'en'; const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de']; const FIXED_MTIME = 1700000000000; +const QA_DISABLE_ANIMATIONS_STYLESHEET_PATH = '/__qa__/disable-animations.css'; +const QA_DISABLE_ANIMATIONS_STYLESHEET_CONTENT = ` +*, *::before, *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; + animation-delay: 0s !important; + scroll-behavior: auto !important; +} +`; function loadSecretScannerHelpers() { const compiledSecretScannerPath = path.join( @@ -133,6 +142,22 @@ function resolveFilePath(requestUrl) { function createStaticServer() { return http.createServer((request, response) => { + let requestPath; + + try { + requestPath = decodeURIComponent((request.url || '/').split('?')[0]); + } catch { + response.writeHead(400, { 'Content-Type': 'text/plain; charset=UTF-8' }); + response.end('Bad Request'); + return; + } + + if (requestPath === QA_DISABLE_ANIMATIONS_STYLESHEET_PATH) { + response.writeHead(200, { 'Content-Type': 'text/css; charset=UTF-8' }); + response.end(QA_DISABLE_ANIMATIONS_STYLESHEET_CONTENT); + return; + } + const requestedPath = resolveFilePath(request.url || '/'); if (!requestedPath) { @@ -575,16 +600,24 @@ async function captureLocaleScreenshots(page) { async function captureAppStateScreenshots(page) { await runStep('Disable animations for stable screenshots', async () => { - await page.addStyleTag({ - content: ` - *, *::before, *::after { - transition-duration: 0s !important; - animation-duration: 0s !important; - animation-delay: 0s !important; - scroll-behavior: auto !important; - } - `, - }); + await page.evaluate(async (stylesheetHref) => { + const existingStylesheet = document.querySelector('link[data-qa-disable-animations="true"]'); + if (existingStylesheet instanceof HTMLLinkElement) { + return; + } + + await new Promise((resolve, reject) => { + const stylesheetLink = document.createElement('link'); + stylesheetLink.rel = 'stylesheet'; + stylesheetLink.href = stylesheetHref; + stylesheetLink.setAttribute('data-qa-disable-animations', 'true'); + stylesheetLink.onload = resolve; + stylesheetLink.onerror = () => { + reject(new Error('Failed to load QA disable-animations stylesheet')); + }; + document.head.appendChild(stylesheetLink); + }); + }, QA_DISABLE_ANIMATIONS_STYLESHEET_PATH); }); await runStep('Wait for app root', async () => { diff --git a/src/renderer/public/index.html b/src/renderer/public/index.html index cdbaf09..e3e7a40 100755 --- a/src/renderer/public/index.html +++ b/src/renderer/public/index.html @@ -3,23 +3,13 @@ + AI Code Fusion - + realFs.readFileSync(rendererIndexPath, 'utf8'); + +describe('renderer CSP policy', () => { + it('defines a strict CSP policy without unsafe script/style exceptions', () => { + const indexHtml = readRendererIndex(); + const cspMatch = indexHtml.match( + / { + const indexHtml = readRendererIndex(); + + expect(indexHtml).toContain(''); + + const inlineScriptTags = [...indexHtml.matchAll(/]*\bsrc=)[^>]*>/g)]; + expect(inlineScriptTags).toHaveLength(0); + }); +}); diff --git a/tests/unit/renderer/theme-bootstrap.test.ts b/tests/unit/renderer/theme-bootstrap.test.ts new file mode 100644 index 0000000..c4b4029 --- /dev/null +++ b/tests/unit/renderer/theme-bootstrap.test.ts @@ -0,0 +1,69 @@ +const loadThemeBootstrapScript = () => { + jest.isolateModules(() => { + require('../../../src/renderer/public/theme-bootstrap.js'); + }); +}; + +const createMatchMediaMock = (matches) => + jest.fn().mockImplementation(() => ({ + matches, + media: '(prefers-color-scheme: dark)', + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + +describe('theme-bootstrap script', () => { + let addSpy; + let removeSpy; + let matchMediaSpy; + + beforeEach(() => { + jest.resetModules(); + addSpy = jest.spyOn(document.documentElement.classList, 'add'); + removeSpy = jest.spyOn(document.documentElement.classList, 'remove'); + matchMediaSpy = jest.spyOn(window, 'matchMedia').mockImplementation(createMatchMediaMock(false)); + window.localStorage.removeItem('darkMode'); + }); + + afterEach(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + matchMediaSpy.mockRestore(); + jest.restoreAllMocks(); + document.documentElement.classList.remove('dark'); + }); + + it('enables dark class when persisted setting is true', () => { + window.localStorage.setItem('darkMode', 'true'); + + loadThemeBootstrapScript(); + + expect(addSpy).toHaveBeenCalledWith('dark'); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it('falls back to system preference when persisted value is absent', () => { + matchMediaSpy.mockImplementation(createMatchMediaMock(false)); + + loadThemeBootstrapScript(); + + expect(addSpy).not.toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalledWith('dark'); + }); + + it('keeps light mode when storage access throws', () => { + const warnSpy = jest.spyOn(console, 'warn'); + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + loadThemeBootstrapScript(); + + expect(removeSpy).toHaveBeenCalledWith('dark'); + expect(warnSpy).toHaveBeenCalled(); + }); +});