Files
Literature-Management/tests/unit/main/document-csp.test.ts
2026-10-21 14:42:59 +08:00

115 lines
5.4 KiB
TypeScript

// tests/unit/main/document-csp.test.ts
//
// 守卫:`frame-ancestors` 只能、也只该出现在**应用主文档的响应头**里。
//
// 为什么要有这个守卫(audit r169,2026-10-19):
// 1. 该 directive 只在响应头生效 —— 放 <meta> 里 Chromium 忽略并每次
// 启动打一条 error(实测:启动 console 有
// "directive 'frame-ancestors' is ignored when delivered via a
// <meta> element")。历史上 renderer/index.html 的 meta CSP 里就挂着
// 一份,防护是空操作,却一直刷错。
// 2. 加在 **rkms:// 资源**上也不对 —— 实测 Chromium 只对 web 系 scheme
// 强制 frame-ancestors,自定义 scheme 不强制(加了也不生效);而一旦
// Chromium 改为强制,PDFReader / ImageReader 的 <iframe src="rkms://…">
// 会被自家 directive 打死。
// → 正确位置:主进程给主文档注入 CSP 响应头(src/main/document-csp.ts)。
//
// 本测守三件事:
// A. buildDocumentHeaders 纯函数行为(补头 / 覆盖大小写不同的旧 CSP /
// 保留其它响应头)。
// B. installDocumentCspHeaders 真的注册了 onHeadersReceived,且只对
// mainFrame 注入(subFrame 放行 —— 否则会误伤 rkms:// iframe)。
// C. source-text 锁:meta CSP 里不再有 frame-ancestors、protocol.ts 里
// 不再下发 Content-Security-Policy(防止回退到错误位置)。
import { readFileSync } from 'fs';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// vi.mock 工厂会被提升到 import 之前,必须用 vi.hoisted 拿到 mock 函数引用
const { onHeadersReceived } = vi.hoisted(() => ({ onHeadersReceived: vi.fn() }));
vi.mock('electron', () => ({
session: {
defaultSession: {
webRequest: { onHeadersReceived },
},
},
}));
const ROOT = process.cwd();
function read(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf8');
}
describe('document-csp', () => {
beforeEach(() => {
onHeadersReceived.mockReset();
});
it('A1 buildDocumentHeaders 补上 CSP 且保留其它响应头', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
const out = buildDocumentHeaders({ 'Content-Type': 'text/html', 'X-Test': '1' });
expect(out['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
expect(out['Content-Type']).toBe('text/html');
expect(out['X-Test']).toBe('1');
});
it('A2 buildDocumentHeaders 大小写不敏感地覆盖旧 CSP,不产生重复键', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
const out = buildDocumentHeaders({ 'content-security-policy': "default-src 'none'", 'X-Keep': 'y' });
const cspKeys = Object.keys(out).filter((k) => k.toLowerCase() === 'content-security-policy');
expect(cspKeys).toEqual(['Content-Security-Policy']);
expect(out['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
expect(out['X-Keep']).toBe('y');
});
it('A3 buildDocumentHeaders 接受 undefined 入参', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
expect(buildDocumentHeaders(undefined)['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
});
it('B1 installDocumentCspHeaders 注册 onHeadersReceived 且只对 mainFrame 注入', async () => {
const { installDocumentCspHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
installDocumentCspHeaders();
expect(onHeadersReceived).toHaveBeenCalledTimes(1);
const [filter, listener] = onHeadersReceived.mock.calls[0] as unknown as [
{ urls: string[] },
(
details: { resourceType: string; responseHeaders?: Record<string, string> },
cb: (r: { responseHeaders?: Record<string, string> }) => void
) => void,
];
// 过滤必须覆盖 prod(file://)与 dev(localhost)两种文档来源
expect(filter.urls).toContain('file://*/*');
expect(filter.urls.some((u) => u.includes('localhost'))).toBe(true);
// mainFrame → 注入
const mainCb = vi.fn();
listener({ resourceType: 'mainFrame', responseHeaders: { 'X-A': '1' } }, mainCb);
expect(mainCb).toHaveBeenCalledTimes(1);
expect(mainCb.mock.calls[0][0].responseHeaders?.['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
// subFrame → 不注入(rkms:// iframe 等子帧不能被误伤)
const subCb = vi.fn();
listener({ resourceType: 'subFrame', responseHeaders: { 'X-A': '1' } }, subCb);
expect(subCb).toHaveBeenCalledTimes(1);
expect(subCb.mock.calls[0][0].responseHeaders).toBeUndefined();
});
it('C1 meta CSP 里不再有 frame-ancestors(Chromium 忽略 + 每次启动报错)', () => {
const html = read('src/renderer/index.html');
const metaTag = html.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]*)"/);
expect(metaTag).not.toBeNull();
expect(metaTag![1]).not.toContain('frame-ancestors');
// 其余 directive 必须在(meta CSP 仍然有效,别把防护整体删掉)
expect(metaTag![1]).toContain("script-src 'self'");
expect(metaTag![1]).toContain("object-src 'none'");
});
it('C2 protocol.ts 不再下发 Content-Security-Policy(自定义 scheme 上不生效且有风险)', () => {
const src = read('src/main/protocol.ts');
expect(src).not.toMatch(/headers\.set\(\s*['"]Content-Security-Policy['"]/);
});
});