Files
reader/web/test/shadow.test.ts
T

502 lines
25 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent } from '../src/ui/shadow.ts';
import { BOOK_RESOURCE_MARKER } from '../src/formats/book-resource.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('agrees with the server on the name of the book-resource marker', () => {
// The server writes this string into the URLs it rewrites
// (`server/src/indexer/formats/epub.ts`) and the check below reads it. The two
// are the server and the client, so neither can import the other's constant —
// each asserts the literal, and a rename that landed on one side would silently
// stop every illustration in every EPUB from rendering.
expect(BOOK_RESOURCE_MARKER).toBe('__reader-book-resource__');
});
it('keeps a marked absolute asset URL, which is how an illustrated EPUB renders', () => {
// The report: "epub的图片没有显示出来" — every illustration in the book came
// out as the browser's broken-image placeholder, and the cause was here.
//
// The server rewrites a chapter's relative references to *absolute* URLs,
// because the chapter is fetched from the asset endpoint and `images/pic.png`
// resolves to nothing from there. This function then kept an absolute URL only
// when it started with the page's own `location.origin` — which is the app's
// origin, not the API's — so every rewritten `src` was dropped. The check has
// to be "is this the book's resource", which is what the marker says, rather
// than "does this look like our origin", which is a guess that was wrong.
const node = parse(
`<img src="/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=OEBPS%2Fimages%2Fpic.png"/>`,
);
sanitiseInjectedContent(node);
const src = node.querySelector('img')?.getAttribute('src') ?? '';
expect(src).toContain('ref=OEBPS%2Fimages%2Fpic.png');
});
it('resolves a relative URL from a book loaded locally', () => {
// A book read straight from the file system never went through the server's
// rewriter, so its references are still relative. Resolving them against the
// document is what makes those illustrations work too — and it is why the
// rewrite is a resolution rather than an allowlist of strings.
const node = parse('<img src="images/pic.png"/>');
sanitiseInjectedContent(node);
const src = node.querySelector('img')?.getAttribute('src') ?? '';
expect(src).not.toBe('');
expect(src.startsWith('http')).toBe(true);
expect(src.endsWith('images/pic.png')).toBe(true);
});
it('still drops a remote URL, marked or not', () => {
// The marker is a *statement by the server*, so a book cannot forge it into
// permission to reach a third party: an absolute URL is resolved and then
// checked against the same rule, and a host that is not the document's own is
// dropped either way. Stated as a test because the marker must not have
// weakened the property this function exists for.
const node = parse(`<img src="https://tracker.example/x.png?${BOOK_RESOURCE_MARKER}=1"/>`);
sanitiseInjectedContent(node);
expect(node.querySelector('img')?.hasAttribute('src')).toBe(false);
});
it('drops a remote srcset and keeps the local candidates', () => {
const node = parse(
'<img srcset="images/a.png 1x, https://tracker.example/b.png 2x, images/c.png 3x"/>',
);
sanitiseInjectedContent(node);
const srcset = node.querySelector('img')?.getAttribute('srcset') ?? '';
expect(srcset).not.toContain('tracker.example');
expect(srcset).toContain('images/a.png 1x');
expect(srcset).toContain('images/c.png 3x');
});
it('signs a book resource with the session token, which the browser cannot do itself', () => {
// The report: "图片还是加载不出来". The address allow-list was already right —
// the illustration survived with its `src` intact — and the request was still
// refused, because the asset endpoint is authenticated and a sub-resource request
// cannot carry an `Authorization` header. The only credential an `<img src>` can
// present is one in its own URL, and nothing was putting it there.
//
// This is the case a functional test cannot see: the request is issued by the
// browser, not by the client, so "did the client ask correctly" has nothing to do
// with it. What can be asserted is that the address the browser is *handed* is
// self-authenticating.
const node = parse(
`<img src="/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=OEBPS%2Fimages%2Fpic.png"/>`,
);
sanitiseInjectedContent(node, {
signAssetUrl: (url) => `${url}&access_token=tok`,
});
const src = node.querySelector('img')?.getAttribute('src') ?? '';
expect(src).toContain('access_token=tok');
expect(src).toContain('ref=OEBPS%2Fimages%2Fpic.png');
});
it('never hands the token to an address the allow-list refused', () => {
// Ordering, and it is a security property rather than a nicety: the signer runs
// *after* the decision that a URL may be fetched. If it ran before, or on the raw
// markup, a book could name a third party and have the reader's session token
// appended to the request for it — which is precisely the leak the allow-list
// exists to prevent.
const seen: string[] = [];
const node = parse(
`<img src="https://tracker.example/x.png?${BOOK_RESOURCE_MARKER}=1"/><img src="reader-res:a.png"/>`,
);
sanitiseInjectedContent(node, {
signAssetUrl: (url) => {
seen.push(url);
return `${url}?access_token=tok`;
},
});
expect(seen.some((url) => url.includes('tracker.example'))).toBe(false);
// The remote one lost its `src`; the internal scheme is signed, because it is a
// resource of this book and never touches the network.
expect(node.querySelectorAll('img')[0]?.hasAttribute('src')).toBe(false);
expect(seen).toEqual(['reader-res:a.png']);
});
it('signs a css url inside an injected style block', () => {
// A publisher ships a background image or a `@font-face` as a CSS URL, and the
// browser fetches that itself too — the same constraint, the same refusal. The
// element's `style` attribute and its `<style>` blocks are where an injected
// chapter's CSS text lives.
const node = parse(
`<style>.cover { background: url("/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=OEBPS%2Fcover.jpg"); }</style>`,
);
sanitiseInjectedContent(node, { signAssetUrl: (url) => `${url}&access_token=tok` });
expect(node.querySelector('style')?.textContent ?? '').toContain('access_token=tok');
});
it('leaves addresses alone when there is no session', () => {
// No signer means no session (a test, a local file, a signed-out shell), and the
// chapter must still render: the function returns null and the address is passed
// through exactly as the allow-list left it.
const node = parse(
`<img src="/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=a.png"/>`,
);
sanitiseInjectedContent(node, { signAssetUrl: () => null });
expect(node.querySelector('img')?.getAttribute('src')).toBe(
`/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=a.png`,
);
});
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('the sheet for content inside a chapter', () => {
/**
* The rules that keep a book's own markup from breaking the page.
*
* Asserted on the *host's* adopted sheets rather than on a rendered layout,
* because jsdom does no layout: what is checkable here is that the sheet exists,
* that it names the properties that matter, and — the one that decides whether the
* author's design survives — that it sits *before* the book's own stylesheet in
* the shadow root, so a chapter that styles its own images still wins.
*/
it('clamps images that are wider than the column', () => {
const host = createBookHost();
const css = [...host.shadow.querySelectorAll('style')].map((el) => el.textContent ?? '').join('\n');
// The clamp itself, and the `height: auto` that keeps the aspect ratio: a
// `max-width` alone squashes the plate, which is the failure a fix for the
// width introduces.
expect(css).toMatch(/\.book-flow img[^}]*max-width:\s*min\(100%/);
expect(css).toMatch(/\.book-flow img[^}]*height:\s*auto/);
});
it('keeps the sheet below the book, so an author who sizes an image still wins', () => {
const host = createBookHost();
const sheets = [...host.shadow.querySelectorAll('style')];
// The order is the fix, not decoration: same specificity means the later sheet
// wins, and the book's own stylesheet is the one that has to.
const content = sheets.findIndex((el) => (el.textContent ?? '').includes('reader-image-max'));
const book = sheets.findIndex((el) => el === (host as unknown as { styleEl: HTMLStyleElement }).styleEl);
expect(content).toBeGreaterThanOrEqual(0);
expect(book).toBeGreaterThan(content);
});
});
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([]);
});
});
/**
* The host's own styling.
*
* These exist because the reading column was, for a while, completely unstyled
* and nothing said so. `.book-flow` is inside a shadow root, so the rules in
* `styles/reader.css` matched nothing at all — a document stylesheet cannot reach
* into a shadow root, and the failure is invisible from the outside: the book's
* own CSS still applied, so the page looked *styled*, just without a measure, a
* margin, a font scale or any columns. What is asserted here is the mechanism,
* not the appearance: the layout sheet is in the shadow root, it survives a
* chapter change, and it reaches the host's own state attribute through `:host()`
* rather than through a class selector that cannot match.
*/
describe('BookShadowHost', () => {
it('injects the reading column rules into the shadow root', () => {
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
// The measure, the margins, the font scale and the columns: the four things
// the document stylesheet was supposedly providing.
expect(sheet).toContain('.book-flow');
expect(sheet).toContain('max-inline-size: var(--reader-measure');
expect(sheet).toContain('padding-inline: var(--reader-page-margin');
expect(sheet).toContain('font-size: calc(1em * var(--reader-font-scale');
// A column is one *page*, so it is sized to the box it is paginated into rather
// than to the viewport. `100vw` was the same number only while the reading
// surface happened to span the whole screen — and it does not: the two floating
// bars inset it, and the reader's page margin is a property of the column. A
// column wider than its page makes the stride arithmetic walk positions that are
// not page boundaries, and the last column of every chapter becomes unreachable.
const pagedRule = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? '';
expect(pagedRule).toContain('column-width: 100%');
expect(pagedRule).not.toContain('100vw');
});
it('keeps the page margin in paged mode, and drops only the vertical padding', () => {
// The same book looked deliberate in scroll mode and unfinished in paged mode,
// because the paged rule zeroed the whole `padding` — so a chapter was typeset
// flush against the edge of the screen the moment the reader chose columns.
// There is no screenshot assertion that catches this and no functional one
// either: the text is *there*, it is simply in the wrong place.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
const paged = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? '';
expect(paged).toContain('padding-block: 0');
expect(paged).not.toMatch(/padding:\s*0/);
});
it('reaches the host state through :host(), not through a class selector', () => {
// `data-paginated` and `data-animating` are set on the host element, which is
// *outside* the shadow tree. `.book-host[data-paginated] .book-flow` cannot
// match it from inside — which is why paged mode silently produced no columns
// and a page turn had nowhere to go.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain(":host([data-paginated='true']) .book-flow");
// The page-turn selector carries the *axis* as well as the style and direction:
// a paged turn is horizontal and a scroll-mode turn is vertical, and the axis
// has to come from the selector because the two modes share one host.
expect(sheet).toContain(":host([data-animating='slide-next-x']) .book-flow");
expect(sheet).toContain(":host([data-animating='slide-next-y']) .book-flow");
expect(sheet).not.toContain('.book-host[');
});
it('clips a page turn on the box the reader does not scroll', () => {
// The clip has to land on a box that is *not* the scroll container, and which box
// that is depends on the mode — `ReaderView.scroller` says so. An `overflow: clip`
// on the scroller replaces it with a box that cannot scroll, and a box that cannot
// scroll has no scroll offset: the turn had already written the new offset, the
// attribute then discarded it, the animation played over the *old* page, and the
// offset reappeared one frame after `data-animating` was cleared. That is the
// reader's report — a slide, then the page jumping to where it was always going.
//
// So this asserts the *pair*: the clip is on the host in paged mode (where the flow
// is the scroller) and on the flow in scroll mode (where the host is). A single
// selector for both would be the bug, whichever one it named.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
const paged = /:host\(\[data-animating\]\[data-paginated='true'\]\) \{[^}]*\}/.exec(sheet)?.[0] ?? '';
const scroll = /:host\(\[data-animating\]:not\(\[data-paginated='true'\]\)\) \.book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? '';
expect(paged).toContain('overflow: clip');
expect(scroll).toContain('overflow: clip');
// And the old form — the clip unconditionally on the flow, which is the scroller in
// paged mode — is gone, because it is the defect rather than a simplification.
expect(sheet).not.toMatch(/:host\(\[data-animating\]\) \.book-flow \{[^}]*overflow: clip/);
});
it('separates two paginated columns by exactly two page margins', () => {
// The page margin is padding *inside* the scrolling box, so the page the reader
// sees is `clientWidth` wide while a page *step* was `clientWidth - 2 * margin`.
// The two disagree by the margin, which is why the right edge of every page
// showed the first characters of the next one — the bleed reported as
// "左右翻页的样式不对". A gap of both margins makes the step equal the page and
// draws the gutter between columns at the same time.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain('column-gap: calc(var(--reader-page-margin, 1.5rem) * 2)');
});
it('declares border-box for the shadow tree, which the document cannot reach', () => {
// The app's own universal `box-sizing: border-box` is a *document* selector, and
// a document selector cannot match a shadow tree's elements. The reading column
// is width 100% plus the reader's page margin, which under the initial
// `content-box` is a box wider than the page it is paginated into.
const host = createBookHost();
const sheet = host.shadow.querySelector('style')?.textContent ?? '';
expect(sheet).toContain('box-sizing: border-box');
});
it('keeps the layout sheet when a chapter replaces the book styles', () => {
// The book's styles change with every chapter; the reader's layout rules must
// not, or the column loses its measure for a frame on every chapter change.
const host = createBookHost();
const before = sheets(host)[0];
host.setContent('<p>正文</p>', ['p { color: red }']);
const after = sheets(host);
expect(after[0]).toBe(before);
expect(after[after.length - 1]).toContain('p { color: red }');
// The layout sheet is first, so the book's own rules still win the cascade.
expect(after[0]).toContain('.book-flow');
});
it('keeps the layout sheet through clear()', () => {
const host = createBookHost();
host.clear();
const after = sheets(host);
expect(after[0]).toContain('.book-flow');
// Only the *book's* sheet is emptied by a clear; the reader's own sheets are
// not this method's business.
expect(after[after.length - 1]).toBe('');
});
it('injects the plain-text typesheet only when the book asks for it', () => {
// A TXT has no author stylesheet to defer to, so the reader supplies one; an
// EPUB does, so it must be absent rather than merely overridable. The absence
// is the contract, which is why it is asserted and not just the presence.
const host = createBookHost();
expect(sheets(host).join('\n')).not.toContain('.txt-body');
host.setPlainText(true);
expect(sheets(host).join('\n')).toContain('.txt-body > p');
expect(host.hasAttribute('data-plain-text')).toBe(true);
host.setPlainText(false);
expect(sheets(host).join('\n')).not.toContain('.txt-body');
expect(host.hasAttribute('data-plain-text')).toBe(false);
});
it('puts the plain-text sheet before the book sheet, so a book still wins', () => {
// A TXT has no styles to lose, but a book *classified* as one (a `.txt` that is
// really an XHTML fragment) does, and the cascade has to leave room for it.
const host = createBookHost();
host.setPlainText(true);
host.setContent('<p>正文</p>', ['p { color: red }']);
const after = sheets(host);
const txt = after.findIndex((sheet) => sheet.includes('.txt-body'));
const book = after.findIndex((sheet) => sheet.includes('p { color: red }'));
expect(txt).toBeGreaterThanOrEqual(0);
expect(txt).toBeLessThan(book);
});
it('keeps the plain-text decision across a chapter change', () => {
// Which typesheet applies is a property of the *book*, not of the chapter on
// screen; a TXT that lost its stylesheet on the second chapter would read as a
// rendering bug at exactly the moment the reader turned the page.
const host = createBookHost();
host.setPlainText(true);
host.setContent('<div class="txt-body"><p>第一章</p></div>', []);
host.setContent('<div class="txt-body"><p>第二章</p></div>', []);
expect(host.shadow.querySelector('.txt-body')).not.toBeNull();
expect(sheets(host).join('\n')).toContain('.txt-body > p');
});
});
/** The shadow root's sheets, in cascade order. */
function sheets(host: ReturnType<typeof createBookHost>): string[] {
return [...host.shadow.querySelectorAll('style')].map((node) => node.textContent ?? '');
}
describe('plain-text detection', () => {
it('is driven by the format, and confirmed against the markup', async () => {
// The two have to agree, and the client cannot assume they do. A server that
// windows a TXT as `reflowable` — a reasonable choice, since a TXT is reflowable
// — declares `format: 'reflowable'` while sending the reader's own plain-text
// markup; believing the label alone left those paragraphs unstyled and the indent
// control doing nothing.
const { ReaderView } = await import('../src/ui/reader-view.ts');
const container = document.createElement('div');
document.body.append(container);
const declared = new ReaderView({ container, doc: textDoc('txt', '<div class="txt-body"><p>一</p></div>') });
expect(declared.isPlainText()).toBe(true);
const observed = new ReaderView({ container, doc: textDoc('epub', '<div class="txt-body"><p>一</p></div>') });
expect(observed.isPlainText()).toBe(true);
// An EPUB chapter that merely *mentions* the marker in its prose is not one.
const prose = new ReaderView({ container, doc: textDoc('epub', '<p>他说 div class="txt-body" 是纯文本用的</p>') });
expect(prose.isPlainText()).toBe(false);
// Neither is one with no markup at all — *unless* the section declares itself.
// And that declaration is the case the marker cannot cover: the server now sends
// a TXT chapter as bare characters with no wrapper at all, so a chapter that is
// nothing but plain text carries nothing to sniff. Sniffing for the marker
// answered "not plain text" and the reader drew a novel with no paragraphs, no
// indent and no stylesheet — a defect that reads as a typography preference
// rather than a bug.
const bare = new ReaderView({ container, doc: textDoc('epub', '<p>正文</p>') });
expect(bare.isPlainText()).toBe(false);
const declaredBare = new ReaderView({
container,
doc: textDoc('epub', '第一章 惊蛰\n\n第 1 段。', { plainText: true }),
});
expect(declaredBare.isPlainText()).toBe(true);
});
});
/** A one-section document, for the detection test. */
function textDoc(format: string, html: string, section: Record<string, unknown> = {}) {
return {
format,
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections: [{ id: 'c0', label: '一', html, depth: 0, ...section }],
toc: [],
styles: [],
resources: new Map(),
orderedByBook: true,
} as never;
}