Files
reader/web/test/shadow.test.ts
T
cnb.dQC5J6ohAGA ca68a03e5d merge: 解决 auto/init-server-scaffold-81b3 与 main 的冲突
两个分支各自演进:一侧引入格式注册表、H5 客户端与 Android 外壳,另一侧
引入漫画目录书与逐页取图。冲突集中在扫描器、metadata、library 路由和 web
客户端,逐项按「两边能力都要保留」的原则合并。

合并后修复了 4 个被冲突掩盖的真实缺陷:

- scanner 同时保留了两套目录书实现,类型检查与测试均无法通过;改为统一
  走格式注册表,目录书在文件遍历之前发现,其内容不再重复入库。
- /manifest 的 files 对漫画目录书返回的是文件夹路径,客户端无法逐页取图;
  改为由 manifest 派生真实页路径。
- 单个文件端点只认 book_files 行,漫画目录书的每一页都 404;改为目录书
  额外认领其目录下的路径,并修正路径归一化使穿越尝试仍被拒绝。
- 目录书判定只看图片数量,会把夹着一本书的目录整体吞掉;改为注册表驱动
  的判定,含书籍文件的目录始终是书架。

同时修复了 web 端两套测试运行器互相无法执行对方用例的问题(vitest 与
node:test 混跑会静默跳过),以及 web 侧 5 处类型错误。

Ref: #9
2026-09-16 18:04:11 +08:00

104 lines
4.2 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
/**
* Sanitisation and style extraction.
*
* These run against a real DOM (jsdom) rather than a string, because the whole
* point is to catch what the HTML parser produces — a string-level rewrite cannot
* see the difference between a `<script>` in markup and one the parser moved.
*
* The security property being asserted: a book is data. It may describe how it
* should look; it may not run code or reach the network.
*/
function parse(html: string): HTMLElement {
const container = document.createElement('div');
container.innerHTML = html;
return container;
}
describe('sanitiseInjectedContent', () => {
it('removes script elements', () => {
const node = parse('<p>text</p><script>window.pwned = true</script>');
sanitiseInjectedContent(node);
expect(node.querySelector('script')).toBeNull();
expect(node.textContent).toContain('text');
});
it('strips inline event handlers', () => {
// These survive an innerHTML round trip, so they must be removed explicitly
// rather than trusted to the parser.
const node = parse('<img src="reader-res:a.png" onerror="window.pwned=1"/>');
sanitiseInjectedContent(node);
expect(node.querySelector('img')?.getAttribute('onerror')).toBeNull();
});
it('strips a javascript: href but keeps the link text', () => {
const node = parse('<a href="javascript:alert(1)">click</a>');
sanitiseInjectedContent(node);
const anchor = node.querySelector('a');
expect(anchor?.hasAttribute('href')).toBe(false);
expect(anchor?.textContent).toBe('click');
});
it('drops a remote src so a book cannot report a reading session', () => {
const node = parse('<img src="https://tracker.example/pixel.gif"/>');
sanitiseInjectedContent(node);
expect(node.querySelector('img')?.hasAttribute('src')).toBe(false);
});
it('keeps the internal resource scheme and blob/data URLs', () => {
const node = parse(
'<img src="reader-res:OEBPS/a.png"/><img src="data:image/png;base64,AA"/><img src="blob:xyz"/>',
);
sanitiseInjectedContent(node);
const sources = [...node.querySelectorAll('img')].map((img) => img.getAttribute('src'));
expect(sources).toEqual(['reader-res:OEBPS/a.png', 'data:image/png;base64,AA', 'blob:xyz']);
});
it('removes a base element and a meta refresh, which would navigate away', () => {
const node = parse('<base href="https://evil.example/"/><meta http-equiv="refresh" content="0;url=https://evil.example"/>');
sanitiseInjectedContent(node);
expect(node.querySelector('base')).toBeNull();
expect(node.querySelector('meta')).toBeNull();
});
it('removes target so a footnote link cannot replace the reader', () => {
const node = parse('<a href="reader-res:notes.xhtml" target="_blank">note</a>');
sanitiseInjectedContent(node);
expect(node.querySelector('a')?.hasAttribute('target')).toBe(false);
expect(node.querySelector('a')?.getAttribute('href')).toBe('reader-res:notes.xhtml');
});
it('leaves ordinary content untouched', () => {
const node = parse('<p>正文</p><em>强调</em><ruby>漢<rt>かん</rt></ruby>');
sanitiseInjectedContent(node);
expect(node.textContent).toBe('正文强调漢かん');
expect(node.querySelector('ruby')).not.toBeNull();
});
});
describe('extractBody', () => {
it('returns only the body of a full XHTML document', () => {
const html = '<html><head><title>t</title></head><body><p>内容</p></body></html>';
expect(extractBody(html)).toBe('<p>内容</p>');
});
it('returns the input unchanged when there is no body element', () => {
expect(extractBody('<p>fragment</p>')).toBe('<p>fragment</p>');
});
});
describe('extractInlineStyles', () => {
it('collects style blocks in document order', () => {
const html = '<style>a{color:red}</style><p>x</p><style>b{color:blue}</style>';
expect(extractInlineStyles(html)).toEqual(['a{color:red}', 'b{color:blue}']);
});
it('returns an empty list when there are none', () => {
expect(extractInlineStyles('<p>x</p>')).toEqual([]);
});
});