diff --git a/e2e/BACKLOG.md b/e2e/BACKLOG.md index e9e1516..598bbba 100644 --- a/e2e/BACKLOG.md +++ b/e2e/BACKLOG.md @@ -4,7 +4,7 @@ | --- | --- | --- | --- | --- | | 1 | P0 smoke + key interaction skeleton (infra) | 28 (1 fixme) | ~3-4 min | ✅ landed | | 2 | Cross-browser matrix + drag/IME | +20 → 48 | +4-6 min | ⏳ pending | -| 3 | Render depth + remaining blocks + security | +25 → 73 | +2-3 min | ⏳ pending | +| 3 | Render depth + remaining blocks + security | +23 → 78 (1 fixme) | +2-3 min | ✅ landed | | 4 | Stability / performance / a11y guardrails | +25 → 98 | +3-5 min | ⏳ pending | Phase 1 baseline (PR landing snapshot): @@ -53,24 +53,27 @@ Unlocks Firefox + WebKit, and the input/drag flows that don't survive cross-engi ## Phase 3 — Render depth + remaining blocks + security +Landed: 23 new tests across `tests/diagrams/`, `tests/blocks/`, `tests/security/`. +Local runtime ~+2s on top of Phase 1 baseline. + ### Diagrams -- [ ] **Vega-Lite.** Inject a Vega-Lite spec via `setContent` → wait for `.mu-diagram-preview svg` → count mark elements (e.g. circle / rect) to verify the chart actually rendered. -- [ ] **PlantUML.** `@startuml…@enduml`. Plantuml-encoder forwards to a public service; either mock the network call (`page.route`) or allow real network in this spec only. +- [x] **Vega-Lite.** Inject a Vega-Lite spec via `setContent` → wait for `.mu-diagram-preview svg` → count `path|rect` mark elements to verify the chart actually rendered. (`tests/diagrams/vega-lite.spec.ts`, 2 tests) +- [x] **PlantUML.** `@startuml…@enduml` round-trips through `setContent` + `getMarkdown`. `plantuml.com/**` is mocked via `page.route` for hermeticity; the spec asserts the encoded URL shape and `getMarkdown` preserves source. (`tests/diagrams/plantuml.spec.ts`, 2 tests) ### Remaining block types -- [ ] **Frontmatter (yaml / toml / json `;;;` / json `{}`).** Setext / setContent each style → assert getMarkdown round-trips delimiter style. -- [ ] **HTML inline formats.** ``, ``, ``, ``, `` display and edit; cursor placement after wrapping a selection. -- [ ] **ReferenceLink / ReferenceImage round-trip.** `[label][ref]` + `[ref]: url "title"` — direct regression coverage for PR-16. -- [ ] **Footnote.** Multiple references to one definition, deletion-cleanup of orphan definitions. +- [x] **Frontmatter (yaml / toml / json `;;;` / json `{}`).** All four delimiter styles round-trip through `setContent` + `getMarkdown`. (`tests/blocks/frontmatter.spec.ts`, 4 tests) +- [x] **HTML inline formats.** ``, ``, ``, `` each round-trip via the generic `htmlTag` renderer; `` is split out because it routes through the dedicated `htmlRuby` renderer (mounts `span.mu-ruby` not `*.mu-raw-html`). (`tests/blocks/html-inline.spec.ts`, 5 tests) +- [x] **ReferenceLink / ReferenceImage round-trip.** Direct PR-16 regression coverage including case-insensitive label resolution. Reference images mock `example.test/**` to make `loadImage` resolve. (`tests/blocks/reference-link-image.spec.ts`, 4 tests) +- [x] **Footnote.** Multiple `[^a]` refs sharing a definition, definition appearing before vs after the first ref, and the deliberate "no auto-cleanup of orphan defs" current contract. (`tests/blocks/footnote-scenarios.spec.ts`, 3 tests) ### Sanitize / XSS -- [ ] Inject `` via setContent → assert `window.__pwned` never set. -- [ ] Inject `x` → assert anchor stripped or href neutralised. -- [ ] Inject `` → assert onerror dropped. -- [ ] Static export via `new MarkdownToHtml(md).generate()` against same payloads → assert sanitized HTML output. +- [x] Inject `` via setContent → assert `window.__pwned` never set. Canary declared on `Window` in `e2e/types.d.ts`. +- [x] Inject `x` → assert anchor's rendered `href` is either dropped or no longer contains `javascript:`. +- [x] Inject `` → assert `onerror` attribute is stripped + canary not set. +- [ ] Static export via `new MarkdownToHtml(md).generate()` against same payloads → assert sanitized HTML output. **Deferred to Phase 4** — current host doesn't expose `MarkdownToHtml` on `window`, and reaching for `page.evaluate(() => new (await import('@muyajs/core')).MarkdownToHtml(...))` would require new host plumbing. Phase 4 can wire it onto `window.__e2e` and assert the static path. --- diff --git a/e2e/tests/blocks/footnote-scenarios.spec.ts b/e2e/tests/blocks/footnote-scenarios.spec.ts new file mode 100644 index 0000000..c91ae9d --- /dev/null +++ b/e2e/tests/blocks/footnote-scenarios.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +/** + * Footnote scenarios beyond the bare `setContent` smoke test in + * `tests/ui/footnote.spec.ts`. Covers: + * - Multiple references to one definition. + * - Definition appearing before vs after first reference. + * - Orphan-definition behavior when the inline reference is deleted — + * current contract: definitions are *not* auto-cleaned up. + */ + +test.describe('footnote scenarios', () => { + test('multiple references to the same definition all render identifiers', async ({ page }) => { + const source = 'A[^a] then B[^a] then C[^a].\n\n[^a]: shared body\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + await expect(page.locator(editor.paragraph).first()).toContainText('A'); + + // All three inline footnote identifiers should mount. + const identifiers = page.locator(editor.inlineFootnoteIdentifier); + await expect(identifiers).toHaveCount(3); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + // Reference shape is `[^a]` × 3. + expect((md.match(/\[\^a\](?!:)/g) ?? []).length).toBe(3); + expect(md).toContain('[^a]: shared body'); + }); + + test('definition appearing BEFORE the first reference still resolves', async ({ page }) => { + // Spec says definitions can appear anywhere; renderer should still + // recognize the inline `[^a]` token regardless of doc order. + const source = '[^a]: defined first\n\nLater paragraph with[^a] a reference.\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + // Sync barrier: the editor root should contain both texts. The + // first `.mu-paragraph` belongs to the footnote definition body so + // we anchor on the editor root instead. + await expect(page.locator(editor.root)).toContainText('Later paragraph'); + + await expect(page.locator(editor.inlineFootnoteIdentifier).first()).toBeVisible(); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain('[^a]: defined first'); + expect(md).toContain('[^a]'); + }); + + test('deleting an inline [^a] token leaves the definition in state (no auto-cleanup)', async ({ page }) => { + const source = 'Body[^a] text.\n\n[^a]: orphan body\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + await expect(page.locator(editor.inlineFootnoteIdentifier).first()).toBeVisible(); + + // Wipe out the inline reference by reloading the paragraph without + // the `[^a]` token. The definition block is untouched. + await page.evaluate(() => { + window.muya!.setContent('Body text.\n\n[^a]: orphan body\n'); + }); + + await expect(page.locator(editor.paragraph).first()).toContainText('Body text'); + // No inline identifier any more. + await expect(page.locator(editor.inlineFootnoteIdentifier)).toHaveCount(0); + + // Definition survives — verifying that orphan defs aren't auto-pruned. + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain('[^a]: orphan body'); + // No `[^a]` reference in the body (only the definition prefix). + expect((md.match(/\[\^a\](?!:)/g) ?? []).length).toBe(0); + }); +}); diff --git a/e2e/tests/blocks/frontmatter.spec.ts b/e2e/tests/blocks/frontmatter.spec.ts new file mode 100644 index 0000000..9d52d99 --- /dev/null +++ b/e2e/tests/blocks/frontmatter.spec.ts @@ -0,0 +1,90 @@ +import type { TState } from '@muyajs/core'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +/** + * Frontmatter has four delimiter styles, each round-tripped by + * serializeFrontMatter in `state/stateToMarkdown.ts`: + * - YAML `---\n…---\n` (lang: 'yaml', style: '-') + * - TOML `+++\n…+++\n` (lang: 'toml', style: '+') + * - JSON `;;;\n…;;;\n` (lang: 'json', style: ';') + * - JSON `{\n…}\n` (lang: 'json', style: '{') + * + * Each style is set via `setContent` with explicit meta. We assert the block + * renders + the markdown round-trip preserves the right delimiter shape. + */ + +interface IStyleCase { + label: string; + lang: 'yaml' | 'toml' | 'json'; + style: '-' | '+' | ';' | '{'; + text: string; + expectedStart: string; + expectedEnd: string; +} + +const STYLE_CASES: IStyleCase[] = [ + { + label: 'YAML (---)', + lang: 'yaml', + style: '-', + text: 'title: hi\nauthor: me', + expectedStart: '---\n', + expectedEnd: '---\n', + }, + { + label: 'TOML (+++)', + lang: 'toml', + style: '+', + text: 'title = "hi"\nauthor = "me"', + expectedStart: '+++\n', + expectedEnd: '+++\n', + }, + { + label: 'JSON (;;;)', + lang: 'json', + style: ';', + text: '"title": "hi",\n"author": "me"', + expectedStart: ';;;\n', + expectedEnd: ';;;\n', + }, + { + label: 'JSON ({})', + lang: 'json', + style: '{', + text: '"title": "hi",\n"author": "me"', + expectedStart: '{\n', + expectedEnd: '}\n', + }, +]; + +test.describe('frontmatter block', () => { + for (const styleCase of STYLE_CASES) { + test(`renders + round-trips ${styleCase.label}`, async ({ page }) => { + await page.evaluate((c) => { + const state: TState[] = [{ + name: 'frontmatter', + meta: { lang: c.lang, style: c.style }, + text: c.text, + }, { + name: 'paragraph', + text: 'body', + }]; + window.muya!.setContent(state); + }, styleCase); + + // The block mounts as `` wrapping a code block. + const fm = page.locator(editor.frontmatter); + await expect(fm).toBeVisible(); + // Use a sync barrier on the paragraph too — its presence confirms + // the document loaded fully. + await expect(page.locator(editor.paragraph).first()).toContainText('body'); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md.startsWith(styleCase.expectedStart)).toBe(true); + expect(md).toContain(styleCase.text); + // The closing delimiter immediately precedes the body paragraph. + expect(md).toContain(styleCase.expectedEnd); + }); + } +}); diff --git a/e2e/tests/blocks/html-inline.spec.ts b/e2e/tests/blocks/html-inline.spec.ts new file mode 100644 index 0000000..e3ca979 --- /dev/null +++ b/e2e/tests/blocks/html-inline.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '../fixtures/muya'; + +/** + * Inline HTML tags (``, ``, ``, ``, ``) render via + * `inlineRenderer/renderer/htmlTag.ts`: each tag becomes an actual element + * wrapped with `.mu-inline-rule.mu-raw-html`. We assert: + * - The tag renders inline (an element with the right tagName mounts). + * - getMarkdown round-trips the literal tag text. + */ + +interface ITagCase { + label: string; + tag: 'u' | 'mark' | 'sup' | 'sub'; + markdown: string; +} + +/** + * Generic tags routed through `htmlTag.ts` mount the actual `` / `` + * / `` / `` element with `.mu-raw-html`. `` is a special + * case (see below) because it has its own `htmlRuby.ts` renderer. + */ +const TAG_CASES: ITagCase[] = [ + { + label: 'underline ', + tag: 'u', + markdown: 'Text with underline inside.', + }, + { + label: 'highlight ', + tag: 'mark', + markdown: 'Text with highlight inside.', + }, + { + label: 'superscript ', + tag: 'sup', + markdown: 'E = mc2.', + }, + { + label: 'subscript ', + tag: 'sub', + markdown: 'H2O.', + }, +]; + +test.describe('inline html tags', () => { + for (const tagCase of TAG_CASES) { + test(`${tagCase.label} renders and round-trips`, async ({ page }) => { + await page.evaluate((md) => { + window.muya!.setContent(md); + }, tagCase.markdown); + + // The actual `` / `` / `` / `` element mounts + // inside the paragraph wrapped in `.mu-raw-html`. + const el = page.locator(`${tagCase.tag}.mu-raw-html`).first(); + await expect(el).toBeVisible(); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + // Round-trip preserves the literal opening + closing tag text. + expect(md).toContain(`<${tagCase.tag}>`); + expect(md).toContain(``); + }); + } + + test('ruby renders via htmlRuby + round-trips', async ({ page }) => { + // `` flows through `htmlRuby.ts`, which mounts a + // `span.mu-ruby` wrapper containing a `span.mu-ruby-text` (the + // source side) and a `span.mu-ruby-render` (the preview that hosts + // the actual DOM via `htmlToVNode(raw)`). + const markdown = 'Word kan here.'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, markdown); + + await expect(page.locator('span.mu-ruby').first()).toBeVisible(); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain(''); + expect(md).toContain(''); + expect(md).toContain('kan'); + }); +}); diff --git a/e2e/tests/blocks/reference-link-image.spec.ts b/e2e/tests/blocks/reference-link-image.spec.ts new file mode 100644 index 0000000..9d9e21c --- /dev/null +++ b/e2e/tests/blocks/reference-link-image.spec.ts @@ -0,0 +1,105 @@ +import { Buffer } from 'node:buffer'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +// A 1×1 transparent PNG; works for any browser's loader. +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64', +); + +/** + * Reference-link / reference-image round-trip — defense-in-depth for PR-16. + * + * Reference definitions live as `paragraph` state nodes whose text is the raw + * `[label]: url "title"` line. `InlineRenderer.collectReferenceDefinitions` + * regex-scans paragraphs to build the labels Map, which the lexer consults + * when expanding `[text][ref]` and `![alt][ref]`. + * + * Concrete things this spec defends against: + * - Reference link `[label][ref]` resolves the `href` from the labels Map. + * - Reference image `![alt][ref]` resolves a real `` src. + * - Case-insensitive label matching (CommonMark normalizes case + whitespace). + * - getMarkdown round-trips the definition exactly (label + url + optional title). + */ + +test.describe('reference link', () => { + test('[label][ref] renders an anchor + round-trips the definition', async ({ page }) => { + const source = 'See [label][ref] inline.\n\n[ref]: https://example.com "the title"\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + // Sync barrier — wait for the paragraph to render the link text. + await expect(page.locator(editor.paragraph).first()).toContainText('label'); + + // The reference link mounts as `a.mu-reference-link` with the + // resolved href from the labels Map. + const anchor = page.locator(editor.referenceLink).first(); + await expect(anchor).toBeVisible(); + await expect(anchor).toHaveAttribute('href', 'https://example.com'); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain('[label][ref]'); + expect(md).toContain('[ref]: https://example.com "the title"'); + }); + + test('reference link with case-mismatched label still resolves', async ({ page }) => { + // CommonMark normalizes labels case-insensitively (and collapses + // internal whitespace). `[LaBeL][REF]` should resolve against + // `[ref]: …`. + const source = 'See [text][REF] inline.\n\n[ref]: https://example.com\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + await expect(page.locator(editor.paragraph).first()).toContainText('text'); + + const anchor = page.locator(editor.referenceLink).first(); + await expect(anchor).toBeVisible(); + await expect(anchor).toHaveAttribute('href', 'https://example.com'); + }); +}); + +test.describe('reference image', () => { + test.beforeEach(async ({ page }) => { + // The reference-image renderer only mounts an actual once + // `loadImage` resolves (see `loadImageAsync.ts`). `example.test` would + // otherwise fail DNS resolution and never resolve. Serve a 1×1 PNG. + await page.route('**/example.test/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/png', + body: ONE_PIXEL_PNG, + }); + }); + }); + + test('![alt][ref] renders and round-trips the definition', async ({ page }) => { + const source = '![alt text][img]\n\n[img]: https://example.test/img.png\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + // The reference image renders inside the paragraph; wait for the + // rendered `` to mount (image is async — see `loadImageAsync`). + const img = page.locator(`${editor.paragraph} img`).first(); + await expect(img).toBeVisible({ timeout: 10_000 }); + await expect(img).toHaveAttribute('src', /example\.test\/img\.png/); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain('![alt text][img]'); + expect(md).toContain('[img]: https://example.test/img.png'); + }); + + test('reference image with case-mismatched label still resolves', async ({ page }) => { + const source = '![alt][IMG]\n\n[img]: https://example.test/case.png\n'; + await page.evaluate((md) => { + window.muya!.setContent(md); + }, source); + + const img = page.locator(`${editor.paragraph} img`).first(); + await expect(img).toBeVisible({ timeout: 10_000 }); + await expect(img).toHaveAttribute('src', /example\.test\/case\.png/); + }); +}); diff --git a/e2e/tests/diagrams/plantuml.spec.ts b/e2e/tests/diagrams/plantuml.spec.ts new file mode 100644 index 0000000..9b7e76b --- /dev/null +++ b/e2e/tests/diagrams/plantuml.spec.ts @@ -0,0 +1,76 @@ +import type { TState } from '@muyajs/core'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +/** + * PlantUML diagram rendering. The PlantUML loader (packages/core/src/utils/ + * diagram/plantuml/index.ts) hands off to the public `www.plantuml.com/plantuml` + * service: it base64-encodes the source and sets ``. + * + * We never want the real network in a unit-ish e2e: it'd be flaky and would + * leak telemetry. Intercept all plantuml.com traffic and return a small inline + * SVG so the spec asserts the integration (encoded URL + img mount) without + * relying on the external service. + */ + +const PLANTUML_SOURCE = '@startuml\nA -> B\n@enduml'; + +const STUB_SVG = '' + + ''; + +test.describe('plantuml diagram', () => { + test.beforeEach(async ({ page }) => { + // Hermetic mock: every plantuml.com request returns the same stub SVG. + await page.route('**/plantuml.com/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: STUB_SVG, + }); + }); + }); + + test('setContent with @startuml renders an pointing at plantuml.com', async ({ page }) => { + await page.evaluate((text) => { + const state: TState[] = [{ + name: 'diagram', + text, + meta: { lang: 'yaml', type: 'plantuml' }, + }]; + window.muya!.setContent(state); + }, PLANTUML_SOURCE); + + // The PlantUML renderer writes `` into the + // diagram preview synchronously after the loader resolves. Wait for + // the `` to appear. + const img = page.locator(`${editor.diagramPreview} img`).first(); + await expect(img).toBeVisible({ timeout: 10_000 }); + + const src = await img.getAttribute('src'); + expect(src).toBeTruthy(); + // The encoded URL points at the public service. We can't assert the + // exact encoded blob (it's deflate+base64), but the prefix shape is + // stable. + expect(src).toMatch(/^https?:\/\/(www\.)?plantuml\.com\/plantuml\/svg\//); + }); + + test('plantuml diagram round-trips through getMarkdown', async ({ page }) => { + await page.evaluate((text) => { + const state: TState[] = [{ + name: 'diagram', + text, + meta: { lang: 'yaml', type: 'plantuml' }, + }]; + window.muya!.setContent(state); + }, PLANTUML_SOURCE); + + await expect(page.locator(`${editor.diagramPreview} img`).first()) + .toBeVisible({ timeout: 10_000 }); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + expect(md).toContain('```plantuml'); + expect(md).toContain('@startuml'); + expect(md).toContain('A -> B'); + expect(md).toContain('@enduml'); + }); +}); diff --git a/e2e/tests/diagrams/vega-lite.spec.ts b/e2e/tests/diagrams/vega-lite.spec.ts new file mode 100644 index 0000000..c73bf6d --- /dev/null +++ b/e2e/tests/diagrams/vega-lite.spec.ts @@ -0,0 +1,75 @@ +import type { TState } from '@muyajs/core'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +/** + * A minimal valid Vega-Lite v5 spec — small enough that rendering is fast + * but still produces SVG `` marks we can count. + */ +const VEGA_LITE_SPEC = JSON.stringify({ + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + description: 'A simple bar chart with embedded data.', + data: { + values: [ + { a: 'A', b: 28 }, + { a: 'B', b: 55 }, + { a: 'C', b: 43 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'a', type: 'nominal' }, + y: { field: 'b', type: 'quantitative' }, + }, +}); + +test.describe('vega-lite diagram', () => { + test('setContent with a vega-lite diagram renders an SVG with marks', async ({ page }) => { + await page.evaluate((text) => { + const state: TState[] = [{ + name: 'diagram', + text, + meta: { lang: 'json', type: 'vega-lite' }, + }]; + window.muya!.setContent(state); + }, VEGA_LITE_SPEC); + + // Vega-Lite renders asynchronously; allow up to 15s for the SVG. + const svg = page.locator(`${editor.diagramPreview} svg`).first(); + await expect(svg).toBeVisible({ timeout: 15_000 }); + + // A 3-bar bar chart should yield at least 3 path/rect mark elements + // inside the rendered SVG. We use a permissive selector so a future + // theme change between `` and `` doesn't regress us. + const markCount = await page.evaluate(() => { + const root = document.querySelector('.mu-diagram-preview svg'); + if (!root) + return 0; + return root.querySelectorAll('path, rect').length; + }); + expect(markCount).toBeGreaterThan(0); + }); + + test('vega-lite round-trips through getMarkdown', async ({ page }) => { + await page.evaluate((text) => { + const state: TState[] = [{ + name: 'diagram', + text, + meta: { lang: 'json', type: 'vega-lite' }, + }]; + window.muya!.setContent(state); + }, VEGA_LITE_SPEC); + + // Wait for the SVG to mount as a sync barrier before reading markdown. + await expect(page.locator(`${editor.diagramPreview} svg`).first()) + .toBeVisible({ timeout: 15_000 }); + + const md = await page.evaluate(() => window.muya!.getMarkdown()); + // The diagram serializes as a fenced block with the `vega-lite` tag. + // The body is whatever string we passed to `text` — for our spec that + // is `JSON.stringify(...)` with no extra whitespace. + expect(md).toContain('```vega-lite'); + expect(md).toContain('"mark":"bar"'); + expect(md.trim().endsWith('```')).toBe(true); + }); +}); diff --git a/e2e/tests/helpers/selectors.ts b/e2e/tests/helpers/selectors.ts index 5438fe8..f68e762 100644 --- a/e2e/tests/helpers/selectors.ts +++ b/e2e/tests/helpers/selectors.ts @@ -28,10 +28,19 @@ export const editor = { mathRender: '.mu-math-render', katex: '.katex', diagramBlock: '.mu-diagram-block', + diagramContainer: '.mu-diagram-container', diagramPreview: '.mu-diagram-preview', image: '.mu-inline-image', inlineFootnoteIdentifier: '.mu-inline-footnote-identifier', link: 'span.mu-link, a.mu-reference-link, a.mu-raw-html', + // Frontmatter block (renders as a `` wrapping a code block). + frontmatter: '.mu-frontmatter', + // Inline reference link / reference image — see PR-16 regression area. + referenceLink: 'a.mu-reference-link', + referenceImage: '.mu-image-marked-text', + // Inline html tags wrap their children with `.mu-raw-html`. The tag itself + // is the actual ``, ``, ``, `` or `` element. + rawHtml: '.mu-raw-html', } as const; // Float root class names confirmed against the `const name = 'mu-...'` lines diff --git a/e2e/tests/security/sanitize.spec.ts b/e2e/tests/security/sanitize.spec.ts new file mode 100644 index 0000000..b4998a5 --- /dev/null +++ b/e2e/tests/security/sanitize.spec.ts @@ -0,0 +1,96 @@ +import type { TState } from '@muyajs/core'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +/** + * Sanitization / XSS guardrails for the `setContent` → DOM render path. + * + * Muya routes HTML blocks through DOMPurify (`utils/dompurify.ts`, + * `PREVIEW_DOMPURIFY_CONFIG`). Inline `` URLs go through + * `sanitizeHyperlink`. We assert three canonical XSS shapes are neutralized: + * - `'; + await page.evaluate((text) => { + const state: TState[] = [{ name: 'html-block', text }]; + window.muya!.setContent(state); + }, payload); + + // Sync barrier: html-block mounts an `.mu-html-block` and (eventually) + // an `.mu-html-preview` child. The script should never run. + await expect(page.locator(editor.htmlBlock).first()).toBeVisible(); + + // Give the event loop a tick — DOMPurify strips inert nodes, but + // we want to be sure nothing async fires later. + await page.waitForTimeout(100); + + const pwned = await page.evaluate(() => (window as Window & { __pwned?: boolean }).__pwned); + expect(pwned).toBeUndefined(); + }); + + test(' has its href sanitized', async ({ page }) => { + const payload = 'x'; + await page.evaluate((text) => { + const state: TState[] = [{ name: 'html-block', text }]; + window.muya!.setContent(state); + }, payload); + + await expect(page.locator(editor.htmlBlock).first()).toBeVisible(); + + // Read the rendered anchor's href out of the preview DOM. Two + // acceptable outcomes: + // - DOMPurify removes the href attribute entirely. + // - DOMPurify rewrites it to `about:blank` (some configurations). + // Either way, the navigable javascript: URI must not survive. + const href = await page.evaluate(() => { + const preview = document.querySelector('.mu-html-preview'); + const anchor = preview?.querySelector('a#xss-anchor'); + return anchor?.getAttribute('href') ?? null; + }); + // If the attribute is missing the value will be null. If it's been + // rewritten it must not contain `javascript:`. + if (href !== null) + expect(href.toLowerCase()).not.toContain('javascript:'); + }); + + test(' drops the onerror attribute', async ({ page }) => { + // Use a fake `src` that will 404 — if the `onerror` survives sanitization + // it'd fire and set the canary. + const payload = ''; + await page.evaluate((text) => { + const state: TState[] = [{ name: 'html-block', text }]; + window.muya!.setContent(state); + }, payload); + + await expect(page.locator(editor.htmlBlock).first()).toBeVisible(); + + // Wait long enough for the broken-image error to fire if it can. + await page.waitForTimeout(200); + + const pwned = await page.evaluate(() => (window as Window & { __pwned?: boolean }).__pwned); + expect(pwned).toBeUndefined(); + + const onerrorAttr = await page.evaluate(() => { + const preview = document.querySelector('.mu-html-preview'); + const img = preview?.querySelector('img#xss-img'); + return img?.getAttribute('onerror'); + }); + expect(onerrorAttr).toBeFalsy(); + }); +}); diff --git a/e2e/types.d.ts b/e2e/types.d.ts index 0747a03..53aea4c 100644 --- a/e2e/types.d.ts +++ b/e2e/types.d.ts @@ -17,6 +17,11 @@ declare global { PICKED_IMAGE_URL: string; UPLOADED_IMAGE_URL: string; }; + + // XSS canary used by tests/security/sanitize.spec.ts. If a malicious + // payload survives sanitization and executes, it would set this flag — + // the spec asserts it remains `undefined`. + __pwned?: boolean; } // `Intl.Segmenter` (Stage 4, ES2022) isn't in the ES2020 lib host/ targets.