@transferwise/components 0.0.0-experimental-7ad3353 → 0.0.0-experimental-f5ce19a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/build/ExpressiveMoneyInput/AmountInput/AmountInput.js +2 -2
  2. package/build/ExpressiveMoneyInput/AmountInput/AmountInput.js.map +1 -1
  3. package/build/ExpressiveMoneyInput/AmountInput/AmountInput.mjs +1 -1
  4. package/build/ExpressiveMoneyInput/AmountInput/AmountInput.mjs.map +1 -1
  5. package/build/ExpressiveMoneyInput/AnimatedNumber/AnimatedNumber.js +4 -4
  6. package/build/ExpressiveMoneyInput/AnimatedNumber/AnimatedNumber.js.map +1 -1
  7. package/build/ExpressiveMoneyInput/AnimatedNumber/AnimatedNumber.mjs +1 -1
  8. package/build/ExpressiveMoneyInput/AnimatedNumber/AnimatedNumber.mjs.map +1 -1
  9. package/build/ExpressiveMoneyInput/Chevron/Chevron.js +2 -2
  10. package/build/ExpressiveMoneyInput/Chevron/Chevron.js.map +1 -1
  11. package/build/ExpressiveMoneyInput/Chevron/Chevron.mjs +1 -1
  12. package/build/ExpressiveMoneyInput/Chevron/Chevron.mjs.map +1 -1
  13. package/build/ExpressiveMoneyInput/ExpressiveMoneyInput.js +3 -3
  14. package/build/ExpressiveMoneyInput/ExpressiveMoneyInput.js.map +1 -1
  15. package/build/ExpressiveMoneyInput/ExpressiveMoneyInput.mjs +1 -1
  16. package/build/ExpressiveMoneyInput/ExpressiveMoneyInput.mjs.map +1 -1
  17. package/build/Markup/Markup.js +137 -0
  18. package/build/Markup/Markup.js.map +1 -0
  19. package/build/Markup/Markup.mjs +132 -0
  20. package/build/Markup/Markup.mjs.map +1 -0
  21. package/build/Markup/constants.js +36 -0
  22. package/build/Markup/constants.js.map +1 -0
  23. package/build/Markup/constants.mjs +29 -0
  24. package/build/Markup/constants.mjs.map +1 -0
  25. package/build/Markup/utils/parseMarkup.js +236 -0
  26. package/build/Markup/utils/parseMarkup.js.map +1 -0
  27. package/build/Markup/utils/parseMarkup.mjs +231 -0
  28. package/build/Markup/utils/parseMarkup.mjs.map +1 -0
  29. package/build/Markup/utils/sanitise.js +114 -0
  30. package/build/Markup/utils/sanitise.js.map +1 -0
  31. package/build/Markup/utils/sanitise.mjs +111 -0
  32. package/build/Markup/utils/sanitise.mjs.map +1 -0
  33. package/build/index.js +2 -0
  34. package/build/index.js.map +1 -1
  35. package/build/index.mjs +1 -0
  36. package/build/index.mjs.map +1 -1
  37. package/build/main.css +35 -0
  38. package/build/styles/Markup/Markup.css +28 -0
  39. package/build/styles/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
  40. package/build/styles/main.css +35 -0
  41. package/build/types/Body/Body.d.ts +2 -2
  42. package/build/types/ExpressiveMoneyInput/ExpressiveMoneyInput.d.ts +1 -1
  43. package/build/types/IconButton/IconButton.d.ts +1 -1
  44. package/build/types/Markup/Markup.d.ts +18 -0
  45. package/build/types/Markup/Markup.d.ts.map +1 -0
  46. package/build/types/Markup/Markup.types.d.ts +77 -0
  47. package/build/types/Markup/Markup.types.d.ts.map +1 -0
  48. package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts +12 -0
  49. package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts.map +1 -0
  50. package/build/types/Markup/constants.d.ts +20 -0
  51. package/build/types/Markup/constants.d.ts.map +1 -0
  52. package/build/types/Markup/index.d.ts +3 -0
  53. package/build/types/Markup/index.d.ts.map +1 -0
  54. package/build/types/Markup/utils/parseMarkup.d.ts +8 -0
  55. package/build/types/Markup/utils/parseMarkup.d.ts.map +1 -0
  56. package/build/types/Markup/utils/sanitise.d.ts +4 -0
  57. package/build/types/Markup/utils/sanitise.d.ts.map +1 -0
  58. package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
  59. package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
  60. package/build/types/Title/Title.d.ts +2 -2
  61. package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
  62. package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
  63. package/build/types/index.d.ts +2 -0
  64. package/build/types/index.d.ts.map +1 -1
  65. package/package.json +3 -3
  66. package/src/ExpressiveMoneyInput/AmountInput/AmountInput.tsx +1 -1
  67. package/src/ExpressiveMoneyInput/AnimatedNumber/AnimatedNumber.tsx +1 -1
  68. package/src/ExpressiveMoneyInput/Chevron/Chevron.tsx +1 -1
  69. package/src/ExpressiveMoneyInput/ExpressiveMoneyInput.tsx +2 -2
  70. package/src/Markup/Markup.css +28 -0
  71. package/src/Markup/Markup.injection.test.tsx +163 -0
  72. package/src/Markup/Markup.less +29 -0
  73. package/src/Markup/Markup.test.tsx +463 -0
  74. package/src/Markup/Markup.tsx +169 -0
  75. package/src/Markup/Markup.types.ts +62 -0
  76. package/src/Markup/_stories/Markup.docs.mdx +75 -0
  77. package/src/Markup/_stories/Markup.security.docs.mdx +343 -0
  78. package/src/Markup/_stories/Markup.story.tsx +266 -0
  79. package/src/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
  80. package/src/Markup/_stories/Vulnerability/Vulnerability.tsx +30 -0
  81. package/src/Markup/constants.ts +41 -0
  82. package/src/Markup/index.ts +2 -0
  83. package/src/Markup/utils/parseMarkup.test.ts +706 -0
  84. package/src/Markup/utils/parseMarkup.ts +234 -0
  85. package/src/Markup/utils/sanitise.test.ts +497 -0
  86. package/src/Markup/utils/sanitise.ts +133 -0
  87. package/src/index.ts +2 -0
  88. package/src/main.css +35 -0
  89. package/src/main.less +1 -0
@@ -0,0 +1,163 @@
1
+ import React from 'react';
2
+
3
+ import { render } from '../test-utils';
4
+
5
+ import { Markup } from './Markup';
6
+
7
+ /**
8
+ * Proof of Concept: Markup Injection via Attacker-Controlled Data
9
+ *
10
+ * These tests demonstrate Laszlo's concern: when untrusted data (e.g. a payee name,
11
+ * company name, or any string from an external source) is interpolated into a markup
12
+ * template string, the attacker can break out of their structural context and inject
13
+ * arbitrary allowed markup — links, emphasis, etc.
14
+ *
15
+ * This is NOT about XSS (script execution is blocked). It's about content spoofing
16
+ * and phishing within trusted UI.
17
+ */
18
+ describe('Markup injection via attacker-controlled data', () => {
19
+ describe('the vulnerability: structural injection', () => {
20
+ it('default allowedHrefs blocks injected external links', () => {
21
+ const attackerControlledName =
22
+ 'Acme Corp</strong>' +
23
+ '<link href="https://evil.example.com">' +
24
+ '<important>ACTION REQUIRED: Verify your identity</important>' +
25
+ '</link>' +
26
+ '<strong>';
27
+
28
+ const template = `Transfer <strong>£35.00</strong> to <strong>${attackerControlledName}</strong>`;
29
+
30
+ const { container } = render(<Markup>{template}</Markup>);
31
+
32
+ // The default allowlist blocks external domains — the link is NOT rendered
33
+ const injectedLink = container.querySelector('a[href="https://evil.example.com"]');
34
+ expect(injectedLink).not.toBeInTheDocument();
35
+
36
+ // But the structural injection still works (emphasis renders)
37
+ const injectedEmphasis = container.querySelector('.wds-markup-emphasis--important');
38
+ expect(injectedEmphasis).toBeInTheDocument();
39
+ expect(injectedEmphasis).toHaveTextContent('ACTION REQUIRED: Verify your identity');
40
+ });
41
+
42
+ it('injected external links render when allowLinks="all"', () => {
43
+ const attackerControlledName =
44
+ 'Acme Corp</strong>' +
45
+ '<link href="https://evil.example.com">' +
46
+ '<important>ACTION REQUIRED: Verify your identity</important>' +
47
+ '</link>' +
48
+ '<strong>';
49
+
50
+ const template = `Transfer <strong>£35.00</strong> to <strong>${attackerControlledName}</strong>`;
51
+
52
+ const { container } = render(<Markup allowLinks="all">{template}</Markup>);
53
+
54
+ // With allowLinks="all", the link renders — demonstrating the structural injection
55
+ const injectedLink = container.querySelector('a[href="https://evil.example.com"]');
56
+ expect(injectedLink).toBeInTheDocument();
57
+ });
58
+
59
+ it('default allowedHrefs blocks phishing links in paragraph injection', () => {
60
+ const attackerNote =
61
+ 'Thanks!</p>' +
62
+ '<p><important>Your account has been compromised. ' +
63
+ 'Contact support at </important>' +
64
+ '<link href="https://phishing.example.com">this secure link</link></p>' +
65
+ '<p>';
66
+
67
+ const template = `<p>Note from sender: ${attackerNote}</p>`;
68
+
69
+ const { container } = render(<Markup>{template}</Markup>);
70
+
71
+ const phishingLink = container.querySelector('a[href="https://phishing.example.com"]');
72
+ expect(phishingLink).not.toBeInTheDocument();
73
+ });
74
+
75
+ it('attacker can inject misleading accessibility labels', () => {
76
+ const attackerValue =
77
+ 'normal text</strong>' +
78
+ '<strong accessibilityLabel="Security alert: click to verify">' +
79
+ '<link href="https://evil.example.com">here</link>' +
80
+ '</strong>' +
81
+ '<strong>';
82
+
83
+ const template = `Your balance: <strong>${attackerValue}</strong>`;
84
+
85
+ const { container } = render(<Markup>{template}</Markup>);
86
+
87
+ const misleadingElement = container.querySelector(
88
+ '[aria-label="Security alert: click to verify"]',
89
+ );
90
+ expect(misleadingElement).toBeInTheDocument();
91
+ });
92
+ });
93
+
94
+ describe('what IS blocked (XSS / script execution)', () => {
95
+ it('cannot inject arbitrary HTML elements', () => {
96
+ const xssAttempt = '<img src=x onerror=alert(1)>';
97
+ const template = `Hello <strong>${xssAttempt}</strong>`;
98
+
99
+ const { container } = render(<Markup>{template}</Markup>);
100
+
101
+ // <img> is not in the allow-list, so it's rendered as text
102
+ expect(container.querySelector('img')).not.toBeInTheDocument();
103
+ expect(container).toHaveTextContent(/<img src=x onerror=alert\(1\)>/);
104
+ });
105
+
106
+ it('cannot inject script tags', () => {
107
+ const xssAttempt = '<script>alert("xss")</script>';
108
+ const template = `Hello <strong>${xssAttempt}</strong>`;
109
+
110
+ const { container } = render(<Markup>{template}</Markup>);
111
+
112
+ expect(container.querySelector('script')).not.toBeInTheDocument();
113
+ });
114
+
115
+ it('blocks javascript: URLs in links', () => {
116
+ // Even if attacker injects a link, javascript: URLs are blocked
117
+ const attack = 'click</strong><link href="javascript:alert(1)">me</link><strong>';
118
+ const template = `<strong>${attack}</strong>`;
119
+
120
+ const { container } = render(<Markup>{template}</Markup>);
121
+
122
+ // The link renders but without the dangerous href (falls back to Fragment)
123
+ expect(container.querySelector('a[href*="javascript"]')).not.toBeInTheDocument();
124
+ });
125
+ });
126
+
127
+ describe('the general nature of this problem', () => {
128
+ it('is identical to HTML injection via innerHTML', () => {
129
+ // This is the same class of bug — mixing code and data in a string
130
+ const attackerName = '<b>URGENT</b>';
131
+ const div = document.createElement('div');
132
+ div.innerHTML = `Hello ${attackerName}`;
133
+
134
+ // The attacker's data became structure
135
+ expect(div.querySelector('b')).not.toBeNull();
136
+ expect(div.querySelector('b')!).toHaveTextContent('URGENT');
137
+ });
138
+
139
+ it('react JSX prevents this by design', () => {
140
+ // In contrast, React treats variables as pure data — never as structure
141
+ const attackerName = '<strong>URGENT</strong>';
142
+
143
+ const { container } = render(<span>Hello {attackerName}</span>);
144
+
145
+ // React escapes the string — it renders as visible text, not as a <strong> tag
146
+ expect(container.querySelector('strong')).not.toBeInTheDocument();
147
+ expect(container).toHaveTextContent(/<strong>URGENT<\/strong>/);
148
+ });
149
+
150
+ it('demonstrates why Markup reintroduces the problem React solved', () => {
151
+ // The same attacker string that React handles safely...
152
+ const attackerName = '<strong>URGENT: Call +44 123 456 789 NOW</strong>';
153
+
154
+ // ...becomes dangerous when passed through Markup's string parser
155
+ const { container } = render(<Markup>{`Hello ${attackerName}`}</Markup>);
156
+
157
+ // The attacker's data has been promoted to structure
158
+ const injected = container.querySelector('strong');
159
+ expect(injected).toBeInTheDocument();
160
+ expect(injected).toHaveTextContent('URGENT: Call +44 123 456 789 NOW');
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,29 @@
1
+ .wds-markup-emphasis {
2
+ font-weight: var(--font-weight-semi-bold);
3
+ font-style: normal;
4
+ }
5
+
6
+ .wds-markup-emphasis--important {
7
+ color: var(--color-content-primary);
8
+ }
9
+
10
+ .wds-markup-emphasis--positive {
11
+ color: var(--color-content-positive);
12
+ }
13
+
14
+ .wds-markup-emphasis--negative {
15
+ color: var(--color-content-negative);
16
+ }
17
+
18
+ .wds-markup-strong {
19
+ font-weight: var(--font-weight-semi-bold);
20
+ }
21
+
22
+ /// @see https://www.webaxe.org/strikethrough-html-accessibility/
23
+ .wds-markup-strikethrough {
24
+ text-decoration: line-through;
25
+ }
26
+
27
+ .wds-markup-paragraph {
28
+ margin: 0;
29
+ }
@@ -0,0 +1,463 @@
1
+ import React from 'react';
2
+
3
+ import { render, screen, userEvent } from '../test-utils';
4
+
5
+ import { Markup } from './Markup';
6
+
7
+ describe('Markup', () => {
8
+ it('renders plain text', () => {
9
+ render(<Markup>hello world</Markup>);
10
+ expect(screen.getByText('hello world')).toBeInTheDocument();
11
+ });
12
+
13
+ it('returns null for empty children', () => {
14
+ const { container } = render(<Markup />);
15
+ expect(container).toBeEmptyDOMElement();
16
+ });
17
+
18
+ describe('wrapper element', () => {
19
+ it('renders in a span wrapper for inline content', () => {
20
+ render(<Markup>content</Markup>);
21
+ const element = screen.getByText('content');
22
+ expect(element.tagName).toBe('SPAN');
23
+ });
24
+
25
+ it('renders in a div wrapper when paragraph nodes are present', () => {
26
+ const { container } = render(<Markup>{'<p>block content</p>'}</Markup>);
27
+ const wrapper = container.firstChild as HTMLElement;
28
+ expect(wrapper.tagName).toBe('DIV');
29
+ });
30
+
31
+ it('renders in a div wrapper when nested paragraph nodes are present', () => {
32
+ const { container } = render(
33
+ <Markup>{'<important><p>nested paragraph</p></important>'}</Markup>,
34
+ );
35
+ const wrapper = container.firstChild as HTMLElement;
36
+ expect(wrapper.tagName).toBe('DIV');
37
+ });
38
+
39
+ it('uses span when as="span" even with paragraphs', () => {
40
+ const { container } = render(<Markup as="span">{'<p>forced span</p>'}</Markup>);
41
+ const wrapper = container.firstChild as HTMLElement;
42
+ expect(wrapper.tagName).toBe('SPAN');
43
+ });
44
+
45
+ it('uses div when as="div" even without paragraphs', () => {
46
+ const { container } = render(<Markup as="div">inline but div</Markup>);
47
+ const wrapper = container.firstChild as HTMLElement;
48
+ expect(wrapper.tagName).toBe('DIV');
49
+ });
50
+
51
+ it('accepts any HTML element via as prop', () => {
52
+ const { container } = render(<Markup as="section">{'<p>in a section</p>'}</Markup>);
53
+ const wrapper = container.firstChild as HTMLElement;
54
+ expect(wrapper.tagName).toBe('SECTION');
55
+ });
56
+
57
+ it('accepts a React component via as prop', () => {
58
+ const Card = ({
59
+ className,
60
+ children,
61
+ }: {
62
+ className?: string;
63
+ children?: React.ReactNode;
64
+ }) => (
65
+ <article data-testid="card" className={className}>
66
+ {children}
67
+ </article>
68
+ );
69
+ const { container } = render(
70
+ <Markup as={Card} className="custom">
71
+ {'<important>inside component</important>'}
72
+ </Markup>,
73
+ );
74
+ expect(screen.getByTestId('card')).toHaveClass('custom');
75
+ expect(container.querySelector('em')).toHaveTextContent('inside component');
76
+ });
77
+
78
+ it('passes data-testid to the wrapper element', () => {
79
+ render(<Markup data-testid="markup-wrapper">content</Markup>);
80
+ expect(screen.getByTestId('markup-wrapper')).toBeInTheDocument();
81
+ });
82
+
83
+ it('applies className to wrapper regardless of element type', () => {
84
+ render(<Markup className="custom">content</Markup>);
85
+ expect(screen.getByText('content')).toHaveClass('custom');
86
+ });
87
+
88
+ it('applies className to div wrapper with paragraphs', () => {
89
+ const { container } = render(<Markup className="block-wrapper">{'<p>paragraph</p>'}</Markup>);
90
+ const wrapper = container.firstChild as HTMLElement;
91
+ expect(wrapper).toHaveClass('block-wrapper');
92
+ expect(wrapper.tagName).toBe('DIV');
93
+ });
94
+ });
95
+
96
+ describe('emphasis', () => {
97
+ it('renders <important> with correct class', () => {
98
+ render(<Markup>{'<important>critical</important>'}</Markup>);
99
+ const element = screen.getByText('critical');
100
+ expect(element.tagName).toBe('EM');
101
+ expect(element).toHaveClass('wds-markup-emphasis', 'wds-markup-emphasis--important');
102
+ });
103
+
104
+ it('renders <positive> with correct class', () => {
105
+ render(<Markup>{'<positive>good</positive>'}</Markup>);
106
+ expect(screen.getByText('good')).toHaveClass('wds-markup-emphasis--positive');
107
+ });
108
+
109
+ it('renders <negative> with correct class', () => {
110
+ render(<Markup>{'<negative>bad</negative>'}</Markup>);
111
+ expect(screen.getByText('bad')).toHaveClass('wds-markup-emphasis--negative');
112
+ });
113
+
114
+ it('treats <warning> as plain text (unsupported tag)', () => {
115
+ render(<Markup>{'<warning>careful</warning>'}</Markup>);
116
+ expect(screen.getByText(/<warning>/)).toBeInTheDocument();
117
+ });
118
+ });
119
+
120
+ describe('links', () => {
121
+ it('renders a link with href', () => {
122
+ render(<Markup>{'<link href="/settings">settings</link>'}</Markup>);
123
+ const link = screen.getByRole('link', { name: 'settings' });
124
+ expect(link).toHaveAttribute('href', '/settings');
125
+ });
126
+
127
+ it('renders a link with target', () => {
128
+ render(<Markup>{'<link href="https://wise.com/help" target="_blank">visit</link>'}</Markup>);
129
+ const link = screen.getByRole('link', { name: /visit/ });
130
+ expect(link).toHaveAttribute('target', '_blank');
131
+ });
132
+
133
+ it('renders a button-mode link with action', async () => {
134
+ const handleSave = jest.fn();
135
+ render(<Markup actions={{ save: handleSave }}>{'<link action="save">Save</link>'}</Markup>);
136
+ const button = screen.getByRole('button', { name: 'Save' });
137
+ await userEvent.click(button);
138
+ expect(handleSave).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it('renders as plain text when action handler is missing', () => {
142
+ render(<Markup>{'<link action="missing">text</link>'}</Markup>);
143
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
144
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
145
+ expect(screen.getByText('text')).toBeInTheDocument();
146
+ });
147
+
148
+ it('renders as plain text when href is unsafe', () => {
149
+ render(<Markup>{'<link href="javascript:alert(1)">evil</link>'}</Markup>);
150
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
151
+ expect(screen.getByText('evil')).toBeInTheDocument();
152
+ });
153
+ });
154
+
155
+ describe('nesting', () => {
156
+ it('renders emphasis inside link', () => {
157
+ render(<Markup>{'<link href="/x"><important>bold link</important></link>'}</Markup>);
158
+ const link = screen.getByRole('link');
159
+ const emphasis = link.querySelector('em');
160
+ expect(emphasis).toHaveClass('wds-markup-emphasis--important');
161
+ expect(emphasis).toHaveTextContent('bold link');
162
+ });
163
+
164
+ it('renders link inside emphasis', () => {
165
+ render(<Markup>{'<important>Read the <link href="/terms">terms</link></important>'}</Markup>);
166
+ const emphasis = screen.getByText(/Read the/);
167
+ expect(emphasis.tagName).toBe('EM');
168
+ const link = screen.getByRole('link', { name: 'terms' });
169
+ expect(emphasis).toContainElement(link);
170
+ });
171
+ });
172
+
173
+ describe('strong', () => {
174
+ it('renders <strong> as a strong element', () => {
175
+ render(<Markup>{'<strong>bold</strong>'}</Markup>);
176
+ const element = screen.getByText('bold');
177
+ expect(element.tagName).toBe('STRONG');
178
+ });
179
+ });
180
+
181
+ describe('strikethrough', () => {
182
+ it('renders <strikethrough> as a del element', () => {
183
+ render(<Markup>{'<strikethrough>removed</strikethrough>'}</Markup>);
184
+ const element = screen.getByText('removed');
185
+ expect(element.tagName).toBe('DEL');
186
+ });
187
+ });
188
+
189
+ describe('paragraph', () => {
190
+ it('renders <paragraph> as a p element', () => {
191
+ render(<Markup>{'<paragraph>A paragraph.</paragraph>'}</Markup>);
192
+ const element = screen.getByText('A paragraph.');
193
+ expect(element.tagName).toBe('P');
194
+ });
195
+
196
+ it('renders <p> as a p element', () => {
197
+ render(<Markup>{'<p>Short form.</p>'}</Markup>);
198
+ const element = screen.getByText('Short form.');
199
+ expect(element.tagName).toBe('P');
200
+ });
201
+ });
202
+
203
+ describe('newlines', () => {
204
+ it('renders newlines as br elements', () => {
205
+ const { container } = render(<Markup>{'line one\nline two'}</Markup>);
206
+ expect(container.querySelector('br')).toBeInTheDocument();
207
+ });
208
+ });
209
+
210
+ describe('non-string children (pass-through)', () => {
211
+ it('passes through JSX elements unchanged', () => {
212
+ render(
213
+ <Markup>
214
+ <button type="button">Click me</button>
215
+ </Markup>,
216
+ );
217
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
218
+ });
219
+
220
+ it('passes through a React component', () => {
221
+ const Custom = () => <span data-testid="custom">custom component</span>;
222
+ render(
223
+ <Markup>
224
+ <Custom />
225
+ </Markup>,
226
+ );
227
+ expect(screen.getByTestId('custom')).toBeInTheDocument();
228
+ });
229
+
230
+ it('applies className when passing through JSX', () => {
231
+ const { container } = render(
232
+ <Markup className="wrapper">
233
+ <em>styled</em>
234
+ </Markup>,
235
+ );
236
+ expect(container.firstChild).toHaveClass('wrapper');
237
+ });
238
+
239
+ it('returns null for undefined children', () => {
240
+ const { container } = render(<Markup>{undefined}</Markup>);
241
+ expect(container).toBeEmptyDOMElement();
242
+ });
243
+
244
+ it('returns null for null children', () => {
245
+ const { container } = render(<Markup>{null}</Markup>);
246
+ expect(container).toBeEmptyDOMElement();
247
+ });
248
+
249
+ it('passes through numbers as-is', () => {
250
+ render(<Markup>{42}</Markup>);
251
+ expect(screen.getByText('42')).toBeInTheDocument();
252
+ });
253
+
254
+ it('passes through fragment with mixed content', () => {
255
+ render(
256
+ <Markup>
257
+ <>
258
+ <strong>bold</strong> and text
259
+ </>
260
+ </Markup>,
261
+ );
262
+ expect(screen.getByText('bold')).toBeInTheDocument();
263
+ expect(screen.getByText(/and text/)).toBeInTheDocument();
264
+ });
265
+ });
266
+
267
+ describe('accessibilityLabel', () => {
268
+ it('applies aria-label to emphasis tags', () => {
269
+ render(
270
+ <Markup>{'<important accessibilityLabel="Important notice">critical</important>'}</Markup>,
271
+ );
272
+ const element = screen.getByText('critical');
273
+ expect(element).toHaveAttribute('aria-label', 'Important notice');
274
+ });
275
+
276
+ it('applies aria-label to strong tags', () => {
277
+ render(<Markup>{'<strong accessibilityLabel="Highlighted">bold</strong>'}</Markup>);
278
+ expect(screen.getByText('bold')).toHaveAttribute('aria-label', 'Highlighted');
279
+ });
280
+
281
+ it('applies aria-label to strikethrough tags', () => {
282
+ render(
283
+ <Markup>
284
+ {'<strikethrough accessibilityLabel="No longer valid">old price</strikethrough>'}
285
+ </Markup>,
286
+ );
287
+ expect(screen.getByText('old price')).toHaveAttribute('aria-label', 'No longer valid');
288
+ });
289
+
290
+ it('applies aria-label to link tags with href', () => {
291
+ render(
292
+ <Markup>
293
+ {'<link href="/settings" accessibilityLabel="Go to account settings">settings</link>'}
294
+ </Markup>,
295
+ );
296
+ const link = screen.getByRole('link', { name: 'Go to account settings' });
297
+ expect(link).toBeInTheDocument();
298
+ });
299
+
300
+ it('applies aria-label to button-mode link tags', () => {
301
+ const handler = jest.fn();
302
+ render(
303
+ <Markup actions={{ save: handler }}>
304
+ {'<link action="save" accessibilityLabel="Save your changes">Save</link>'}
305
+ </Markup>,
306
+ );
307
+ expect(screen.getByRole('button', { name: 'Save your changes' })).toBeInTheDocument();
308
+ });
309
+
310
+ it('does not apply aria-label to paragraph tags', () => {
311
+ render(<Markup>{'<paragraph accessibilityLabel="ignored">content</paragraph>'}</Markup>);
312
+ const element = screen.getByText('content');
313
+ expect(element.tagName).toBe('P');
314
+ expect(element).not.toHaveAttribute('aria-label');
315
+ });
316
+
317
+ it('does not render aria-label attribute when not provided', () => {
318
+ render(<Markup>{'<strong>plain bold</strong>'}</Markup>);
319
+ expect(screen.getByText('plain bold')).not.toHaveAttribute('aria-label');
320
+ });
321
+ });
322
+
323
+ describe('security', () => {
324
+ it('does not render script tags', () => {
325
+ const { container } = render(<Markup>{'<script>alert("xss")</script>'}</Markup>);
326
+ expect(container.querySelector('script')).not.toBeInTheDocument();
327
+ expect(container).toHaveTextContent('<script>alert("xss")</script>');
328
+ });
329
+ });
330
+
331
+ describe('data prop (placeholder substitution)', () => {
332
+ it('substitutes a single placeholder', () => {
333
+ render(<Markup data={{ name: 'John' }}>{'Hello, {{name}}!'}</Markup>);
334
+ expect(screen.getByText('Hello, John!')).toBeInTheDocument();
335
+ });
336
+
337
+ it('substitutes multiple placeholders', () => {
338
+ render(
339
+ <Markup data={{ amount: '£35.00', recipient: 'Alice' }}>
340
+ {'You sent {{amount}} to {{recipient}}'}
341
+ </Markup>,
342
+ );
343
+ expect(screen.getByText('You sent £35.00 to Alice')).toBeInTheDocument();
344
+ });
345
+
346
+ it('leaves unmatched placeholders as literal text', () => {
347
+ render(<Markup data={{ name: 'John' }}>{'Hello, {{name}}! Your code is {{code}}'}</Markup>);
348
+ expect(screen.getByText('Hello, John! Your code is {{code}}')).toBeInTheDocument();
349
+ });
350
+
351
+ it('converts numeric values to strings', () => {
352
+ render(<Markup data={{ count: 42 }}>{'You have {{count}} items'}</Markup>);
353
+ expect(screen.getByText('You have 42 items')).toBeInTheDocument();
354
+ });
355
+
356
+ it('substitutes placeholders inside nested markup', () => {
357
+ render(<Markup data={{ price: '£35.00' }}>{'Total: <strong>{{price}}</strong>'}</Markup>);
358
+ const strong = screen.getByText('£35.00');
359
+ expect(strong.tagName).toBe('STRONG');
360
+ });
361
+
362
+ it('renders without data prop (placeholders remain literal)', () => {
363
+ render(<Markup>{'Hello, {{name}}!'}</Markup>);
364
+ expect(screen.getByText('Hello, {{name}}!')).toBeInTheDocument();
365
+ });
366
+
367
+ it('does not parse markup tags inside data values', () => {
368
+ const { container } = render(
369
+ <Markup data={{ name: '<link href="https://evil.com">click</link>' }}>
370
+ {'Hello, {{name}}!'}
371
+ </Markup>,
372
+ );
373
+ expect(container.querySelector('a')).not.toBeInTheDocument();
374
+ expect(container).toHaveTextContent('Hello, <link href="https://evil.com">click</link>!');
375
+ });
376
+
377
+ it('does not parse emphasis tags inside data values', () => {
378
+ const { container } = render(
379
+ <Markup data={{ value: '<strong>injected</strong>' }}>{'Result: {{value}}'}</Markup>,
380
+ );
381
+ expect(container.querySelector('strong')).not.toBeInTheDocument();
382
+ expect(container).toHaveTextContent('Result: <strong>injected</strong>');
383
+ });
384
+
385
+ it('does not allow data values to break out of parent tags', () => {
386
+ const { container } = render(
387
+ <Markup data={{ price: '</strong><link href="https://evil.com">phish</link><strong>' }}>
388
+ {'Price: <strong>{{price}}</strong>'}
389
+ </Markup>,
390
+ );
391
+ expect(container.querySelector('a')).not.toBeInTheDocument();
392
+ const strong = container.querySelector('strong');
393
+ expect(strong).toHaveTextContent(
394
+ '</strong><link href="https://evil.com">phish</link><strong>',
395
+ );
396
+ });
397
+
398
+ it('substitutes the same placeholder used multiple times', () => {
399
+ render(
400
+ <Markup data={{ item: 'widget' }}>{'Buy a {{item}}! Everyone loves a {{item}}.'}</Markup>,
401
+ );
402
+ expect(screen.getByText('Buy a widget! Everyone loves a widget.')).toBeInTheDocument();
403
+ });
404
+ });
405
+
406
+ describe('allowLinks', () => {
407
+ it('renders root-relative links by default (internal mode)', () => {
408
+ render(<Markup>{'<link href="/settings">settings</link>'}</Markup>);
409
+ expect(screen.getByRole('link', { name: /settings/ })).toHaveAttribute('href', '/settings');
410
+ });
411
+
412
+ it('renders wise.com links by default (internal mode)', () => {
413
+ render(<Markup>{'<link href="https://wise.com/help">help</link>'}</Markup>);
414
+ expect(screen.getByRole('link', { name: /help/ })).toHaveAttribute(
415
+ 'href',
416
+ 'https://wise.com/help',
417
+ );
418
+ });
419
+
420
+ it('blocks external domains by default (internal mode)', () => {
421
+ render(<Markup>{'<link href="https://example.com">external</link>'}</Markup>);
422
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
423
+ expect(screen.getByText('external')).toBeInTheDocument();
424
+ });
425
+
426
+ it('allows external domains when set to "all"', () => {
427
+ render(
428
+ <Markup allowLinks="all">{'<link href="https://example.com">external</link>'}</Markup>,
429
+ );
430
+ expect(screen.getByRole('link', { name: /external/ })).toHaveAttribute(
431
+ 'href',
432
+ 'https://example.com',
433
+ );
434
+ });
435
+
436
+ it('blocks all links when set to "none"', () => {
437
+ render(<Markup allowLinks="none">{'<link href="/settings">settings</link>'}</Markup>);
438
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
439
+ expect(screen.getByText('settings')).toBeInTheDocument();
440
+ });
441
+
442
+ it('does not bypass base sanitisation even in "all" mode', () => {
443
+ render(<Markup allowLinks="all">{'<link href="javascript:alert(1)">xss</link>'}</Markup>);
444
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
445
+ });
446
+
447
+ it('does not affect action-based links in "none" mode', () => {
448
+ const handleClick = jest.fn();
449
+ render(
450
+ <Markup allowLinks="none" actions={{ save: handleClick }}>
451
+ {'<link action="save">save</link>'}
452
+ </Markup>,
453
+ );
454
+ expect(screen.getByRole('button', { name: /save/ })).toBeInTheDocument();
455
+ });
456
+
457
+ it('blocks root-relative paths that resolve to a different origin via URL parser', () => {
458
+ render(<Markup>{'<link href="/\\evil.com">sneaky</link>'}</Markup>);
459
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
460
+ expect(screen.getByText('sneaky')).toBeInTheDocument();
461
+ });
462
+ });
463
+ });