-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtemplate-engine.test.ts
More file actions
140 lines (115 loc) · 5.37 KB
/
Copy pathtemplate-engine.test.ts
File metadata and controls
140 lines (115 loc) · 5.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect } from 'vitest';
import { renderTemplate, requireVars, htmlToText } from './template-engine.js';
describe('template-engine', () => {
describe('renderTemplate', () => {
it('substitutes dotted paths', () => {
expect(renderTemplate('Hi {{user.name}}', { user: { name: 'Alice' } }))
.toBe('Hi Alice');
});
it('escapes HTML by default', () => {
expect(renderTemplate('<p>{{x}}</p>', { x: '<script>alert(1)</script>' }))
.toBe('<p><script>alert(1)</script></p>');
});
it('does not escape with triple braces', () => {
expect(renderTemplate('<a href="{{{url}}}">go</a>', { url: 'https://x.com/?a=1&b=2' }))
.toBe('<a href="https://x.com/?a=1&b=2">go</a>');
});
it('renders missing variables as empty strings', () => {
expect(renderTemplate('a={{a}} b={{b}}', { a: 'A' })).toBe('a=A b=');
});
it('handles deeply nested paths', () => {
expect(renderTemplate('{{a.b.c.d}}', { a: { b: { c: { d: 'deep' } } } }))
.toBe('deep');
});
it('stringifies non-string scalars', () => {
expect(renderTemplate('count={{n}}', { n: 42 })).toBe('count=42');
expect(renderTemplate('flag={{f}}', { f: true })).toBe('flag=true');
});
it('escapes all standard HTML entities', () => {
expect(renderTemplate('{{s}}', { s: `&<>"'` })).toBe('&<>"'');
});
// ADR-0053 Phase 2: formatter holes reuse the shared formula whitelist.
describe('formatter holes', () => {
it('applies currency / number formatters', () => {
expect(renderTemplate('{{ amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50');
expect(renderTemplate('{{ n | number:2 }}', { n: 1000 })).toBe('1,000.00');
});
it('renders datetime in the supplied reference timezone', () => {
// 2026-06-02T01:30Z → 2026-06-01 in America/New_York.
const data = { ts: '2026-06-02T01:30:00Z' };
const ny = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'America/New_York' });
expect(ny).toContain('6/1/26');
const utc = renderTemplate('{{ ts | datetime }}', data, { timeZone: 'UTC' });
expect(utc).toContain('6/2/26');
});
it('still HTML-escapes formatted output unless triple-braced', () => {
// A formatter can yield characters needing escaping; default escapes.
expect(renderTemplate('{{ s | upper }}', { s: 'a&b' })).toBe('A&B');
expect(renderTemplate('{{{ s | upper }}}', { s: 'a&b' })).toBe('A&B');
});
it('falls back to the raw value for an unknown formatter (no throw)', () => {
expect(renderTemplate('{{ x | bogus }}', { x: 'hi' })).toBe('hi');
});
it('renders a missing formatted value as empty (never "undefined")', () => {
expect(renderTemplate('{{ missing | datetime }}', {})).toBe('');
});
});
});
describe('requireVars', () => {
it('passes when all present', () => {
expect(() => requireVars({ a: 1, b: 'x' }, ['a', 'b'])).not.toThrow();
});
it('throws MISSING_VARIABLES listing the gaps', () => {
expect(() => requireVars({ a: 1 }, ['a', 'b', 'c']))
.toThrow('MISSING_VARIABLES: b, c');
});
it('supports dotted paths', () => {
expect(() => requireVars({ user: { name: 'a' } }, ['user.name'])).not.toThrow();
expect(() => requireVars({ user: {} }, ['user.name']))
.toThrow('MISSING_VARIABLES: user.name');
});
});
describe('htmlToText', () => {
it('strips tags and collapses whitespace', () => {
expect(htmlToText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('converts <br> to newlines', () => {
expect(htmlToText('a<br>b<br/>c')).toBe('a\nb\nc');
});
it('handles common entities', () => {
expect(htmlToText('<p>1 < 2 && 3 > 2</p>')).toBe('1 < 2 && 3 > 2');
});
it('collapses 3+ newlines to 2', () => {
expect(htmlToText('<p>a</p><p>b</p>')).toBe('a\nb');
});
describe('adversarial sanitization', () => {
it('does not double-unescape entities', () => {
// &lt; must decode ONCE to the literal text "<", never to "<".
const out = htmlToText('&lt;script&gt;');
expect(out).toBe('<script>');
expect(out).not.toContain('<');
expect(out).not.toContain('>');
});
it('decodes single-escaped entities exactly once', () => {
// Sanity counterpart: single-escaped sequences still decode normally.
expect(htmlToText('a && b')).toBe('a && b');
});
it('strips overlapping/nested tags so no tag survives', () => {
const out = htmlToText('<scr<script>ipt>alert(1)</script>');
expect(out).not.toContain('<');
expect(out.toLowerCase()).not.toContain('<script');
});
it('strips tags that re-form after a single pass', () => {
const out = htmlToText('<<script>script>alert(1)<</p>/p>');
expect(out).not.toContain('<');
expect(out.toLowerCase()).not.toContain('<script');
});
it('handles deeply nested entities without producing a live tag', () => {
const out = htmlToText('&amp;lt;img src=x onerror=alert(1)&amp;gt;');
expect(out).not.toContain('<');
expect(out).not.toContain('>');
});
});
});
});