diff --git a/README.md b/README.md index 60828e49b..85d0b0bde 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Przeczytaj [AGENTS.md](AGENTS.md). Edytuj Markdown, nie kopię porady w kodzie. ## Kontrole -Potrzebujesz Node.js 22 lub nowszego. Playwright pozostaje jedyną nową zależnością, wyłącznie testową. Czytnik nie ma zależności uruchomieniowych. Na Linuksie przeglądarki wymagają też bibliotek systemowych; samo pobranie binarek nie wystarcza. CI instaluje je przez --with-deps w jednorazowym runnerze. Instalacja pakietów na hoście roboczym wymaga zgody jego właściciela. +Potrzebujesz Node.js 22 lub nowszego. Playwright służy do testów przeglądarkowych. Testy regresji odziedziczonego eksportera EPUB używają Marked w tej samej przypiętej wersji co eksporter; w głównym package.json jest to zależność wyłącznie testowa. Czytnik nie ma zależności uruchomieniowych. Na Linuksie przeglądarki wymagają też bibliotek systemowych; samo pobranie binarek nie wystarcza. CI instaluje je przez --with-deps w jednorazowym runnerze. Instalacja pakietów na hoście roboczym wymaga zgody jego właściciela. ```bash npm ci --ignore-scripts @@ -130,7 +130,7 @@ npx playwright install chromium firefox webkit npm run test:browser ``` -Testy obejmują wszystkie decyzje źródłowe, stany, pochodzenie, liczby, odsyłacze i workflow. Przeglądarki wczytują rzeczywiste pliki i sprawdzają rozdziały, wyszukiwanie, filtry, klawiaturę, mały ekran i błędy. Playwright uruchamia Chromium na komputerze i z emulacją telefonu oraz Firefox i WebKit na komputerze. WebKit na Linuksie nie jest testem rzeczywistego Safari. Nie zastępuje to fizycznych urządzeń ani audytu technologii asystujących. Zielony CI nie potwierdza sam naukowej lub prawnej poprawności. Osobny przegląd musi dotyczyć dokładnego opublikowanego HEAD. +Testy obejmują wszystkie decyzje źródłowe, stany, pochodzenie, liczby, odsyłacze i workflow. Osobne testy budują syntetyczny EPUB w katalogu tymczasowym, bez publikowania e-booka ani uruchamiania eksportera na polskiej treści. Sprawdzają granice wierszy opisu, kodowanie tekstu i etykiet nawigacji oraz wynik parsowania HTML i XML. Przeglądarki wczytują rzeczywiste pliki i sprawdzają rozdziały, wyszukiwanie, filtry, klawiaturę, mały ekran i błędy. Playwright uruchamia Chromium na komputerze i z emulacją telefonu oraz Firefox i WebKit na komputerze. WebKit na Linuksie nie jest testem rzeczywistego Safari. Nie zastępuje to fizycznych urządzeń ani audytu technologii asystujących. Zielony CI nie potwierdza sam naukowej lub prawnej poprawności. Osobny przegląd musi dotyczyć dokładnego opublikowanego HEAD. [Dependency Review](.github/workflows/dependency-review.yml) sprawdza zmieniane zależności, także deweloperskie, i kończy się błędem po wykryciu nowych znanych podatności high lub critical. Nie ocenia prawdziwości porad. [Dependabot](.github/dependabot.yml) proponuje cotygodniowe, grupowane aktualizacje npm i GitHub Actions; sam ich nie scala. CodeQL działa w zarządzanym trybie GitHuba dla JavaScript i Actions. Jego kanoniczną konfiguracją są ustawienia repozytorium, nie drugi workflow. Alerty wymagają oceny; poprawne wykonanie analizy nie oznacza braku podatności. diff --git a/package-lock.json b/package-lock.json index 3ab355463..b8cf769f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "jak-zyc-lepiej-pilot", "devDependencies": { - "@playwright/test": "1.60.0" + "@playwright/test": "1.60.0", + "marked": "18.0.13" }, "engines": { "node": ">=22" @@ -43,6 +44,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/marked": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", diff --git a/package.json b/package.json index 807fb54ef..9f23e7110 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:browser": "playwright test" }, "devDependencies": { - "@playwright/test": "1.60.0" + "@playwright/test": "1.60.0", + "marked": "18.0.13" } } diff --git a/tests/browser/legacy-epub.spec.mjs b/tests/browser/legacy-epub.spec.mjs new file mode 100644 index 000000000..f1780786b --- /dev/null +++ b/tests/browser/legacy-epub.spec.mjs @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { attackChapter, attackDescription, buildLegacyEpub } from '../helpers/legacy-epub.mjs'; + +let files; +test.beforeAll(() => { files = buildLegacyEpub({ chapter: attackChapter, description: attackDescription }); }); + +test('legacy EPUB output stays inert under HTML parsing and well-formed under XML parsing', async ({ page }) => { + // No exporter is served or published. The synthetic archive is parsed in a blank, + // offline browser; any attacker-controlled request is aborted even on a regression. + await page.context().setOffline(true); + await page.route('**/*', route => route.abort()); + for (const [name, text] of files) { + if (!/\.(?:xhtml|ncx|opf)$/.test(name)) continue; + const parsed = await page.evaluate(text => { + const xml = new DOMParser().parseFromString(text, 'application/xml'); + return { + errors: [...xml.querySelectorAll('parsererror')].map(n => n.textContent), + scripts: xml.getElementsByTagName('script').length, + eventAttributes: [...xml.querySelectorAll('*')].flatMap(n => [...n.attributes].filter(a => /^on/i.test(a.name)).map(a => a.name)), + }; + }, text); + expect(parsed, name).toEqual({ errors: [], scripts: 0, eventAttributes: [] }); + await page.setContent(text); + await expect(page.locator('script, [onerror], [onload]'), name).toHaveCount(0); + if (!name.endsWith('cover.xhtml')) await expect(page.locator('img'), name).toHaveCount(0); + expect(await page.evaluate(() => globalThis.__legacyEpubProbe), name).toBeUndefined(); + } +}); + +test('EPUB labels preserve nested formatting text, entities and working internal targets', async ({ page }) => { + await page.context().setOffline(true); + await page.route('**/*', route => route.abort()); + const labels = ['Bold link and ', 'A & B < C 🙂', '<literal>']; + for (const name of ['OEBPS/nav.xhtml', 'OEBPS/toc.ncx']) { + const result = await page.evaluate(({ text, isNav }) => { + const xml = new DOMParser().parseFromString(text, 'application/xml'); + const nodes = [...xml.querySelectorAll(isNav ? 'a' : 'navLabel text')]; + return nodes.map(n => n.textContent); + }, { text: files.get(name), isNav: name.endsWith('.xhtml') }); + for (const label of labels) expect(result, `${name}: ${label}`).toContain(label); + } + const targets = await page.evaluate(text => { + const xml = new DOMParser().parseFromString(text, 'application/xml'); + return [...xml.querySelectorAll('a[href*="#"]')].map(a => a.getAttribute('href')); + }, files.get('OEBPS/nav.xhtml')); + expect(targets.length).toBeGreaterThan(2); + for (const href of targets) { + const [name, id] = href.split('#'); + expect(files.has('OEBPS/' + name), href).toBe(true); + expect(await page.evaluate(({ text, id }) => { + const xml = new DOMParser().parseFromString(text, 'application/xml'); + return [...xml.querySelectorAll('[id]')].some(n => n.getAttribute('id') === id); + }, { text: files.get('OEBPS/' + name), id }), href).toBe(true); + } +}); diff --git a/tests/helpers/legacy-epub.mjs b/tests/helpers/legacy-epub.mjs new file mode 100644 index 000000000..70e2f852b --- /dev/null +++ b/tests/helpers/legacy-epub.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateRawSync } from 'node:zlib'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +export const marker = 'globalThis.__legacyEpubProbe=1'; +export const truncatedImage = `${truncatedImage} + +##