diff --git a/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts new file mode 100644 index 00000000..ea902567 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/opds-content.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { getPublication } from 'foliate-js/opds.js'; +import { getOPDSDescriptionHtml } from '@/app/opds/utils/opdsContent'; +import { SYMBOL, type OPDSPublication } from '@/types/opds'; + +// Render an HTML string the way `dangerouslySetInnerHTML` would and return the +// visible text, so a test can tell "renders as markup" from "shows raw tags". +const renderedText = (html: string): string => { + const el = document.createElement('div'); + el.innerHTML = html; + return el.textContent ?? ''; +}; + +const parsePublication = (entryInner: string): OPDSPublication => { + const xml = `T${entryInner}`; + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + return getPublication(doc.documentElement) as OPDSPublication; +}; + +describe('getOPDSDescriptionHtml', () => { + it('returns empty string for missing content', () => { + expect(getOPDSDescriptionHtml(undefined)).toBe(''); + expect(getOPDSDescriptionHtml({ value: '', type: 'text' })).toBe(''); + }); + + it('renders real (single-escaped) HTML in a text summary as markup', () => { + // <p>Hi &quot;q&quot;</p> + const content = { value: '

Hi "q"

', type: 'text' as const }; + const html = getOPDSDescriptionHtml(content); + expect(html).toContain('

'); + expect(renderedText(html)).toBe('Hi "q"'); + }); + + it('renders HTML for type="html" content', () => { + const content = { value: '

Hello world

', type: 'html' as const }; + const html = getOPDSDescriptionHtml(content); + expect(html).toContain(''); + expect(renderedText(html)).toBe('Hello world'); + }); + + it('renders xhtml content and unwraps the namespaced wrapper div', () => { + const content = { + value: '

Hi

', + type: 'xhtml' as const, + }; + const html = getOPDSDescriptionHtml(content); + expect(renderedText(html)).toBe('Hi'); + }); + + // The bug: issue #4503. An aggregator feed serves the description as a + // type="text" summary whose HTML has been escaped twice, so it survives + // parsing as entity *text* ("<p>...") and showed literal tags. + it('decodes double-escaped HTML so it renders instead of showing raw tags', () => { + const content = { + value: + '<p>Creators</p><p>&quot;Wall&quot; Sollenar&#x27;s</p>', + type: 'text' as const, + }; + const html = getOPDSDescriptionHtml(content); + // No literal tag/entity text should remain visible to the user. + const text = renderedText(html); + expect(text).not.toContain('

'); + expect(text).not.toContain('"'); + expect(text).not.toContain('''); + expect(text).toContain('Creators'); + expect(text).toContain('"Wall"'); + expect(text).toContain("Sollenar's"); + // The markup itself contains real paragraph elements. + expect(html).toContain('

'); + }); + + it('end-to-end: a double-escaped Atom entry (issue #4503) renders as HTML', () => { + const pub = parsePublication( + '

&lt;p&gt;Creators: Algis Budrys&lt;/p&gt;' + + '&lt;p&gt;&amp;quot;Wall of Crystal&amp;quot; by Budrys&lt;/p&gt;', + ); + const content = pub.metadata[SYMBOL.CONTENT]; + const html = getOPDSDescriptionHtml(content); + const text = renderedText(html); + expect(text).not.toContain('

'); + expect(text).toContain('Creators: Algis Budrys'); + expect(text).toContain('"Wall of Crystal"'); + expect(html).toContain('

'); + }); + + it('leaves mixed real-and-escaped markup untouched (no over-decoding)', () => { + // Author intentionally shows a literal tag inside a real paragraph. + const content = { value: '

Use <code> here

', type: 'text' as const }; + const text = renderedText(getOPDSDescriptionHtml(content)); + expect(text).toBe('Use here'); + }); + + it('strips scripts from untrusted feed HTML', () => { + const content = { + value: '

Hi

', + type: 'html' as const, + }; + const html = getOPDSDescriptionHtml(content); + expect(html).not.toContain('