- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
text-transform (none/uppercase/lowercase/capitalize), italic + underline
toggles (fontStyle/textDecoration), and a custom NumericUnitInput
font-size alongside the existing preset row. All write to component
`style`; no toHtml changes needed (style already flows through
cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
existing `target` prop ('_self'/'_blank' -- toHtml already emitted
rel="noopener noreferrer" for _blank). New Hover State section
(hoverBg/hoverColor via ColorPickerField) renders a scoped
`<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
two buttons on one page don't collide; both values sanitized through
cssValue against <style>-element breakout. Editor canvas gets a live
hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
approach), since there's no way to preview a CSS :hover rule directly
on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
Text only)/border/box-shadow/opacity style defaults + AnimationControl/
VisibilityControl-backed animation/animationDelay/hideOnDesktop/
hideOnTablet/hideOnMobile props, each panel gaining collapsible
Spacing / Border & Effects / Animation & Visibility sections. Button
keeps its existing padding-shorthand preset row rather than adding a
redundant per-side padding control; only margin got the new
per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
emission (line-height/letter-spacing/text-transform/font-style/
text-decoration/custom font-size), button target+rel, scoped hover
style emission + two adversarial style-breakout cases (</style><script>
and rule-injection via `;}selector{`), and craft.props assertions for
every new prop on all three components. 674 tests green, tsc + vite
build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
5.2 KiB
TypeScript
121 lines
5.2 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import { ButtonLink } from './ButtonLink';
|
|
|
|
const toHtml = (ButtonLink as any).toHtml;
|
|
|
|
describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => {
|
|
test('a javascript: URL is neutralized', () => {
|
|
const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, '');
|
|
expect(html).not.toContain('javascript:alert');
|
|
});
|
|
|
|
test('a quote-breakout href does not escape the href attribute', () => {
|
|
const malicious = '"><script>alert(1)</script>';
|
|
const { html } = toHtml({ href: malicious, text: 'Click' }, '');
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
});
|
|
|
|
test('a normal href still renders correctly', () => {
|
|
const { html } = toHtml({ href: 'https://example.com', text: 'Click' }, '');
|
|
expect(html).toContain('href="https://example.com"');
|
|
});
|
|
});
|
|
|
|
describe('ButtonLink.toHtml target (boolean-gated, not raw interpolation)', () => {
|
|
test('an attribute-breakout value for target does not reach the output raw', () => {
|
|
const malicious = '_blank" onmouseover="alert(1)' as any;
|
|
const { html } = toHtml({ href: '#', text: 'x', target: malicious }, '');
|
|
expect(html).not.toContain('onmouseover');
|
|
});
|
|
|
|
test('target="_blank" still adds rel=noopener noreferrer', () => {
|
|
const { html } = toHtml({ href: '#', text: 'x', target: '_blank' }, '');
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
});
|
|
|
|
describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () => {
|
|
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
|
|
const { html } = toHtml({ href: '#', text: '</a><img src=x onerror=alert(1)>' }, '');
|
|
expect(html).not.toContain('<img');
|
|
expect(html).toContain('<img');
|
|
});
|
|
|
|
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
|
|
const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
|
|
expect(html).toContain('Tom & Jerry');
|
|
});
|
|
|
|
test('a normal text value still renders unchanged', () => {
|
|
const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
|
|
expect(html).toContain('>Click Me</a>');
|
|
});
|
|
});
|
|
|
|
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
|
|
test('no hover props -- no <style> block, no class added', () => {
|
|
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
|
|
expect(html).not.toContain('<style>');
|
|
expect(html).not.toContain('class=');
|
|
});
|
|
|
|
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
|
|
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
|
|
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
|
|
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
|
|
});
|
|
|
|
test('two different node ids produce different scope classes (no collision)', () => {
|
|
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
|
|
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
|
|
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
|
|
expect(scopeOf(a)).toBeTruthy();
|
|
expect(scopeOf(a)).not.toBe(scopeOf(b));
|
|
});
|
|
|
|
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
|
|
const malicious = '</style><script>alert(1)</script>';
|
|
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
|
|
expect(html).not.toContain('</style><script>');
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
});
|
|
|
|
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
|
|
const malicious = 'red;}body{background:red';
|
|
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
|
|
expect(html).not.toContain('}body{');
|
|
expect(html).not.toContain(';}');
|
|
// The whole export is still exactly one <style> element -- no new rule
|
|
// or element was opened by the malicious value.
|
|
expect((html.match(/<style>/g) || []).length).toBe(1);
|
|
expect((html.match(/<\/style>/g) || []).length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
|
|
test('target defaults to _self, hoverBg/hoverColor blank', () => {
|
|
const props = (ButtonLink as any).craft.props;
|
|
expect(props.target).toBe('_self');
|
|
expect(props.hoverBg).toBe('');
|
|
expect(props.hoverColor).toBe('');
|
|
});
|
|
|
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
|
const props = (ButtonLink as any).craft.props;
|
|
expect(props.animation).toBe('');
|
|
expect(props.animationDelay).toBe('0');
|
|
expect(props.hideOnDesktop).toBe(false);
|
|
expect(props.hideOnTablet).toBe(false);
|
|
expect(props.hideOnMobile).toBe(false);
|
|
});
|
|
|
|
test('style carries blank/default box-model keys', () => {
|
|
const style = (ButtonLink as any).craft.props.style;
|
|
expect(style).toHaveProperty('marginTop');
|
|
expect(style.border).toBe('none');
|
|
expect(style.boxShadow).toBe('none');
|
|
expect(style.opacity).toBe('1');
|
|
});
|
|
});
|