import { describe, test, expect } from 'vitest'; import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions, injectAttrs, buildSeoMeta, RESPONSIVE_CSS_MINIFIED, VISIBILITY_CSS_MINIFIED, } from './html-export'; import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext'; /** * C2: buildDataAttrs (internal to html-export.ts) previously interpolated * `props.animation` / `props.animationDelay` directly into * `data-animation="${...}"` / `data-animation-delay="${...}"` with no * escaping. Runs for EVERY node (via renderNode -> buildDataAttrs -> * injectAttrs) and is reachable via AI `update_props` + deserialized saved * state. A malicious value like `x">` breaks * out of the attribute and injects a live element. */ describe('buildDataAttrs escapes animation/animationDelay (C2)', () => { const makeState = (props: Record) => JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {}, ...props }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, }, }); test('malicious animation value is escaped -- no raw breakout, no injected ', () => { const payload = 'x">'; const state = makeState({ animation: payload }); const { html } = exportBodyHtml(state); expect(html).not.toContain(''); expect(html).not.toMatch(/data-animation="x">/); // The attribute value must be escaped, so no raw `"` or `<` survives // inside the data-animation attribute region. expect(html).toContain('data-animation="x"><img src=y onerror=alert(1)>"'); }); test('malicious animationDelay value is escaped -- no raw breakout', () => { const payload = 'x">'; const state = makeState({ animation: 'fade-in', animationDelay: payload }); const { html } = exportBodyHtml(state); expect(html).not.toContain(''); expect(html).toContain('data-animation-delay="x"><script>alert(1)</script>"'); }); test('normal animation value still emits data-animation="fade-in" unchanged', () => { const state = makeState({ animation: 'fade-in' }); const { html } = exportBodyHtml(state); expect(html).toContain('data-animation="fade-in"'); }); test('normal animationDelay value still emits unchanged', () => { const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' }); const { html } = exportBodyHtml(state); expect(html).toContain('data-animation-delay="0.5s"'); }); }); /** * Adversarial re-review of C1: the `typeName === 'Container' || typeName === * 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in * the component resolver, e.g. legacy/tampered saved state) interpolated * `props.tag` raw into `<${tag}` with no validation -- the same breakout * class as the fixed Container.toHtml bug, reachable via deserialized saved * state. */ describe('renderNode div-fallback allowlists props.tag', () => { const makeUnresolvedDivState = (tag: unknown) => JSON.stringify({ ROOT: { type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch isCanvas: true, props: { tag, style: {} }, displayName: 'div', custom: {}, hidden: false, nodes: [], linkedNodes: {}, }, }); test('a malicious tag value falls back to div -- no injected ', () => { const state = makeUnresolvedDivState('div> { const state = makeUnresolvedDivState('section'); const { html } = exportBodyHtml(state); expect(html).toContain(''); }); }); /** * PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS * variables emitted into `wrapInDocument`'s (exercised via * `exportToHtml`, since `exportBodyHtml` intentionally stays body-only -- * contract §7). These assert the exact "only emit when set" / sanitization * / ordering rules the backend implementer's `generateCompiledHTML` must * mirror byte-for-byte for editor Preview === published output. */ describe('PKG-H: SEO/meta + favicon + design-token emission', () => { const EMPTY_ROOT_STATE = JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, }, }); function exportWith(options: ExportOptions): string { return exportToHtml(EMPTY_ROOT_STATE, options).html; } describe('back-compat: no seo, no favicon, no design passed', () => { const html = exportWith({ title: 'Legacy Page' }); test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => { expect(html).toContain('Legacy Page'); expect(html).not.toContain('name="robots"'); expect(html).not.toContain('name="description"'); expect(html).not.toContain('property="og:description"'); expect(html).not.toContain('property="og:image"'); expect(html).not.toContain('rel="icon"'); // og:title still always emits (falls back to title) -- og:type is a // fixed tag too -- neither depends on seo being set. expect(html).toContain(''); expect(html).toContain(''); // twitter:card only emits when ogImage or description is set -- neither // is here, so it must be absent. expect(html).not.toContain('name="twitter:card"'); }); test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => { expect(html).toContain(':root{--wsb-primary:'); expect(html).toContain('--wsb-button-radius:'); expect(html).toContain('body{font-family:var(--wsb-body-font)'); expect(html).toContain('a:focus-visible'); expect(html).toContain('prefers-reduced-motion:reduce'); }); }); describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => { test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => { const html = exportWith({ title: 'Page', description: 'A great page.' }); expect(html).toContain(''); expect(html).toContain(''); expect(html).toContain(''); }); test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => { const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' }); expect(html).toContain(''); expect(html).toContain(''); expect(html).not.toContain('name="description"'); }); test('explicit twitterCard overrides the derived default', () => { const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' }); expect(html).toContain(''); }); test('ogTitle set -> og:title uses it instead of the page title', () => { const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' }); expect(html).toContain(''); expect(html).not.toContain(''); }); test('noindex true -> robots meta appears; false/absent -> it does not', () => { expect(exportWith({ title: 'Page', noindex: true })).toContain(''); expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"'); expect(exportWith({ title: 'Page' })).not.toContain('name="robots"'); }); test('favicon set -> icon link appears with the exact URL', () => { const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' }); expect(html).toContain(''); }); test('favicon absent -> no icon link', () => { expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"'); }); }); describe('sanitization neutralizes malicious values', () => { test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => { const html = exportWith({ title: 'Page', ogImage: 'javascript:alert(1)', favicon: 'javascript:alert(1)', description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty }); expect(html).not.toContain('javascript:alert'); expect(html).not.toContain('property="og:image"'); expect(html).not.toContain('rel="icon"'); // og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'. expect(html).toContain(''); }); test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => { const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' }); expect(html).toContain(''); }); test('an attribute-breakout metaDescription is escaped, not left raw', () => { const payload = '">'; const html = exportWith({ title: 'Page', description: payload }); expect(html).not.toContain(''); expect(html).toContain('"><script>alert(1)</script>'); }); test('a malicious design-token value cannot break out of the :root{} block', () => { const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '' }; const html = exportWith({ title: 'Page', design }); expect(html).not.toContain(''); expect(html).not.toContain('