spyne 0.25.0 → 0.26.0
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.
- package/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +1 -1
- package/src/spyne/channels/channel-payload-class.js +112 -17
- package/src/spyne/channels/channel.js +13 -0
- package/src/spyne/channels/spyne-channel-window.js +95 -3
- package/src/spyne/spyne-app.js +4 -2
- package/src/spyne/spyne-plugins.js +17 -0
- package/src/spyne/spyne.js +6 -1
- package/src/spyne/utils/channel-payload-filter.js +26 -22
- package/src/spyne/utils/safe-clone.js +128 -2
- package/src/spyne/utils/sanitize-data.js +180 -18
- package/src/spyne/utils/sanitize-html.js +9 -38
- package/src/spyne/utils/spyne-app-properties.js +30 -0
- package/src/spyne/views/dom-element-template.js +93 -0
- package/src/spyne/views/dom-element.js +2 -2
- package/src/tests/channels/channel-payload-filter.test.js +26 -0
- package/src/tests/channels/channel-window-custom-events.test.js +130 -0
- package/src/tests/utils/safe-clone.test.js +193 -0
- package/src/tests/utils/security.test.js +88 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { SpyneChannelWindow } from '../../spyne/channels/spyne-channel-window'
|
|
2
|
+
import { Subject } from 'rxjs'
|
|
3
|
+
const { expect } = require('chai')
|
|
4
|
+
|
|
5
|
+
const conformedEvent = (detail) => {
|
|
6
|
+
const event = new CustomEvent('my_test_event', { detail })
|
|
7
|
+
return {
|
|
8
|
+
action: 'CHANNEL_WINDOW_MY_TEST_EVENT_EVENT',
|
|
9
|
+
payload: event,
|
|
10
|
+
srcElement: undefined,
|
|
11
|
+
event
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('window channel customEvents config conforming', () => {
|
|
16
|
+
it('should conform a string entry', () => {
|
|
17
|
+
const entry = SpyneChannelWindow.conformCustomEventConfig('my_event')
|
|
18
|
+
expect(entry).to.deep.equal({ name: 'my_event', operator: undefined, value: undefined })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should conform an object entry with a buffer operator', () => {
|
|
22
|
+
const entry = SpyneChannelWindow.conformCustomEventConfig({ name: 'my_event', buffer: 400 })
|
|
23
|
+
expect(entry).to.deep.equal({ name: 'my_event', operator: 'buffer', value: 400 })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should conform debounce, throttle and count operators', () => {
|
|
27
|
+
expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e1', debounce: 250 }).operator).to.equal('debounce')
|
|
28
|
+
expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e2', throttle: 100 }).operator).to.equal('throttle')
|
|
29
|
+
expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e3', count: 50 }).operator).to.equal('count')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should pick the first operator when several are set', () => {
|
|
33
|
+
const entry = SpyneChannelWindow.conformCustomEventConfig({ name: 'my_event', debounce: 250, buffer: 400 })
|
|
34
|
+
expect(entry.operator).to.equal('buffer')
|
|
35
|
+
expect(entry.value).to.equal(400)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should skip invalid entries', () => {
|
|
39
|
+
expect(SpyneChannelWindow.conformCustomEventConfig(42)).to.equal(null)
|
|
40
|
+
expect(SpyneChannelWindow.conformCustomEventConfig({ buffer: 400 })).to.equal(null)
|
|
41
|
+
expect(SpyneChannelWindow.conformCustomEventConfig(null)).to.equal(null)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('window channel customEvents batching operators', () => {
|
|
46
|
+
it('should map a batch to one payload with detail as the details array', () => {
|
|
47
|
+
const batch = [conformedEvent({ id: 1 }), conformedEvent({ id: 2 }), conformedEvent({ id: 3 })]
|
|
48
|
+
const { action, payload, event } = SpyneChannelWindow.mapBatchedEvents(batch)
|
|
49
|
+
expect(action).to.equal('CHANNEL_WINDOW_MY_TEST_EVENT_EVENT')
|
|
50
|
+
expect(payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
51
|
+
expect(payload.isBatch).to.be.true
|
|
52
|
+
expect(payload.batchCount).to.equal(3)
|
|
53
|
+
expect(event).to.equal(batch[2].event)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should close a buffer batch after the quiet gap and emit once', (done) => {
|
|
57
|
+
const source$ = new Subject()
|
|
58
|
+
const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'buffer', 30)
|
|
59
|
+
const emissions = []
|
|
60
|
+
obs$.subscribe(p => emissions.push(p))
|
|
61
|
+
|
|
62
|
+
source$.next(conformedEvent({ id: 1 }))
|
|
63
|
+
source$.next(conformedEvent({ id: 2 }))
|
|
64
|
+
setTimeout(() => source$.next(conformedEvent({ id: 3 })), 10)
|
|
65
|
+
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
expect(emissions.length).to.equal(1)
|
|
68
|
+
expect(emissions[0].payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
69
|
+
done()
|
|
70
|
+
}, 100)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should emit separate buffer batches per burst', (done) => {
|
|
74
|
+
const source$ = new Subject()
|
|
75
|
+
const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'buffer', 20)
|
|
76
|
+
const emissions = []
|
|
77
|
+
obs$.subscribe(p => emissions.push(p))
|
|
78
|
+
|
|
79
|
+
source$.next(conformedEvent({ id: 1 }))
|
|
80
|
+
setTimeout(() => source$.next(conformedEvent({ id: 2 })), 60)
|
|
81
|
+
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
expect(emissions.length).to.equal(2)
|
|
84
|
+
expect(emissions[0].payload.batchCount).to.equal(1)
|
|
85
|
+
expect(emissions[1].payload.batchCount).to.equal(1)
|
|
86
|
+
done()
|
|
87
|
+
}, 140)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should batch every n events with the count operator', () => {
|
|
91
|
+
const source$ = new Subject()
|
|
92
|
+
const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'count', 2)
|
|
93
|
+
const emissions = []
|
|
94
|
+
obs$.subscribe(p => emissions.push(p))
|
|
95
|
+
|
|
96
|
+
source$.next(conformedEvent({ id: 1 }))
|
|
97
|
+
source$.next(conformedEvent({ id: 2 }))
|
|
98
|
+
source$.next(conformedEvent({ id: 3 }))
|
|
99
|
+
source$.next(conformedEvent({ id: 4 }))
|
|
100
|
+
source$.next(conformedEvent({ id: 5 }))
|
|
101
|
+
|
|
102
|
+
expect(emissions.length).to.equal(2)
|
|
103
|
+
expect(emissions[0].payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }])
|
|
104
|
+
expect(emissions[1].payload.detail).to.deep.equal([{ id: 3 }, { id: 4 }])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should pass only the latest event through debounce', (done) => {
|
|
108
|
+
const source$ = new Subject()
|
|
109
|
+
const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'debounce', 20)
|
|
110
|
+
const emissions = []
|
|
111
|
+
obs$.subscribe(p => emissions.push(p))
|
|
112
|
+
|
|
113
|
+
source$.next(conformedEvent({ id: 1 }))
|
|
114
|
+
source$.next(conformedEvent({ id: 2 }))
|
|
115
|
+
source$.next(conformedEvent({ id: 3 }))
|
|
116
|
+
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
expect(emissions.length).to.equal(1)
|
|
119
|
+
// debounced events keep the single-event payload shape (payload = CustomEvent)
|
|
120
|
+
expect(emissions[0].payload.detail).to.deep.equal({ id: 3 })
|
|
121
|
+
done()
|
|
122
|
+
}, 80)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should return the observable untouched when there is no operator', () => {
|
|
126
|
+
const source$ = new Subject()
|
|
127
|
+
const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, undefined, undefined)
|
|
128
|
+
expect(obs$).to.equal(source$)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { safeClone, safeCloneDeep, safeAugment, safeFilter, safeMap, safeReject } from '../../spyne/utils/safe-clone'
|
|
2
|
+
import { SpyneAppProperties } from '../../spyne/utils/spyne-app-properties'
|
|
3
|
+
import { ChannelPayload } from '../../spyne/channels/channel-payload-class'
|
|
4
|
+
|
|
5
|
+
const PROXY_NAME = 'safeCloneTestProxy'
|
|
6
|
+
|
|
7
|
+
// stub reviver: wraps the cloned target in a Proxy that answers a marker key
|
|
8
|
+
const stubReviver = (target, proxyProps) => new Proxy(target, {
|
|
9
|
+
get(t, key) {
|
|
10
|
+
if (key === '__test__revived') {
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
return t[key]
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const makeProxyMarkedObj = (extraProps = {}) => ({
|
|
18
|
+
__proxy__proxyName: PROXY_NAME,
|
|
19
|
+
__proxy__isProxy: true,
|
|
20
|
+
__proxy__props: {},
|
|
21
|
+
headline: 'A Test Headline',
|
|
22
|
+
...extraProps
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('safeClone proxy revival', () => {
|
|
26
|
+
before(() => {
|
|
27
|
+
SpyneAppProperties.registerProxyReviver(PROXY_NAME, stubReviver)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('safeClone (existing behavior)', () => {
|
|
31
|
+
it('revives a root-level proxy object', () => {
|
|
32
|
+
const revived = safeClone(makeProxyMarkedObj())
|
|
33
|
+
expect(revived.__test__revived).to.be.true
|
|
34
|
+
expect(revived.headline).to.equal('A Test Headline')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('deep-copies a plain object without mutating the source', () => {
|
|
38
|
+
const src = { a: { b: 1 } }
|
|
39
|
+
const copy = safeClone(src)
|
|
40
|
+
copy.a.b = 2
|
|
41
|
+
expect(src.a.b).to.equal(1)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('safeCloneDeep', () => {
|
|
46
|
+
it('revives a root-level proxy object', () => {
|
|
47
|
+
const revived = safeCloneDeep(makeProxyMarkedObj())
|
|
48
|
+
expect(revived.__test__revived).to.be.true
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('revives a proxy nested inside a plain wrapper', () => {
|
|
52
|
+
const wrapper = { pageData: { story: makeProxyMarkedObj() } }
|
|
53
|
+
const copy = safeCloneDeep(wrapper)
|
|
54
|
+
expect(copy.pageData.story.__test__revived).to.be.true
|
|
55
|
+
expect(copy.pageData.story.headline).to.equal('A Test Headline')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('revives proxies inside arrays', () => {
|
|
59
|
+
const arr = [makeProxyMarkedObj(), { plain: true }]
|
|
60
|
+
const copy = safeCloneDeep(arr)
|
|
61
|
+
expect(copy[0].__test__revived).to.be.true
|
|
62
|
+
expect(copy[1].plain).to.be.true
|
|
63
|
+
expect(copy).to.not.equal(arr)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('passes functions through by reference', () => {
|
|
67
|
+
const fn = () => 'curried'
|
|
68
|
+
const copy = safeCloneDeep({ curriedData: fn })
|
|
69
|
+
expect(copy.curriedData).to.equal(fn)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not infinitely recurse on cycles', () => {
|
|
73
|
+
const obj = { name: 'cyclical' }
|
|
74
|
+
obj.self = obj
|
|
75
|
+
const copy = safeCloneDeep(obj)
|
|
76
|
+
expect(copy.name).to.equal('cyclical')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns primitives as-is', () => {
|
|
80
|
+
expect(safeCloneDeep('str')).to.equal('str')
|
|
81
|
+
expect(safeCloneDeep(5)).to.equal(5)
|
|
82
|
+
expect(safeCloneDeep(null)).to.equal(null)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is reachable through safeClone(o, true)', () => {
|
|
86
|
+
const wrapper = { story: makeProxyMarkedObj() }
|
|
87
|
+
const copy = safeClone(wrapper, true)
|
|
88
|
+
expect(copy.story.__test__revived).to.be.true
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('safeFilter / safeReject / safeMap', () => {
|
|
93
|
+
const makeProxyMarkedArr = () => {
|
|
94
|
+
const arr = [
|
|
95
|
+
{ menu_label: 'a', headline: 'A' },
|
|
96
|
+
{ menu_label: 'b', headline: 'B' },
|
|
97
|
+
{ menu_label: 'c', headline: 'C' }
|
|
98
|
+
]
|
|
99
|
+
arr.__proxy__proxyName = PROXY_NAME
|
|
100
|
+
arr.__proxy__isProxy = true
|
|
101
|
+
arr.__proxy__props = {}
|
|
102
|
+
return arr
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('safeFilter keeps element identity and re-wraps the array', () => {
|
|
106
|
+
const src = makeProxyMarkedArr()
|
|
107
|
+
const result = safeFilter(src, (o) => o.menu_label !== 'b')
|
|
108
|
+
expect(result.length).to.equal(2)
|
|
109
|
+
expect(result[0]).to.equal(src[0]) // element reference preserved
|
|
110
|
+
expect(result.__test__revived).to.be.true // array-level identity re-wrapped
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('safeReject is the inverse of safeFilter', () => {
|
|
114
|
+
const src = makeProxyMarkedArr()
|
|
115
|
+
const result = safeReject(src, (o) => o.menu_label === 'b')
|
|
116
|
+
expect(result.length).to.equal(2)
|
|
117
|
+
expect(result.map(o => o.menu_label).join('')).to.equal('ac')
|
|
118
|
+
expect(result.__test__revived).to.be.true
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('safeMap gives callbacks writable copies of proxied elements', () => {
|
|
122
|
+
const src = [makeProxyMarkedObj()]
|
|
123
|
+
const result = safeMap(src, (el) => {
|
|
124
|
+
el.type = 'story'
|
|
125
|
+
return el
|
|
126
|
+
})
|
|
127
|
+
expect(result[0].type).to.equal('story')
|
|
128
|
+
expect(result[0].__test__revived).to.be.true // revived copy
|
|
129
|
+
expect(src[0].type).to.equal(undefined) // source untouched
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('safeMap passes plain elements by reference like native map', () => {
|
|
133
|
+
const plainEl = { plain: true }
|
|
134
|
+
const result = safeMap([plainEl], (el) => el)
|
|
135
|
+
expect(result[0]).to.equal(plainEl)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('all three degrade to native behavior on plain arrays', () => {
|
|
139
|
+
const plain = [1, 2, 3]
|
|
140
|
+
expect(safeFilter(plain, n => n > 1)).to.deep.equal([2, 3])
|
|
141
|
+
expect(safeReject(plain, n => n > 1)).to.deep.equal([1])
|
|
142
|
+
expect(safeMap(plain, n => n * 2)).to.deep.equal([2, 4, 6])
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('read-only payload facades (deepFreeze)', () => {
|
|
147
|
+
it('wraps proxy subtrees in a write-blocking facade instead of freezing', () => {
|
|
148
|
+
const proxyChild = makeProxyMarkedObj()
|
|
149
|
+
const payload = { plainKey: 1, story: proxyChild }
|
|
150
|
+
const frozen = ChannelPayload.deepFreeze(payload)
|
|
151
|
+
|
|
152
|
+
expect(Object.isFrozen(frozen)).to.be.true // plain shell frozen
|
|
153
|
+
expect(frozen.story).to.not.equal(proxyChild) // facade, not raw ref
|
|
154
|
+
expect(frozen.story.headline).to.equal('A Test Headline') // reads pass through
|
|
155
|
+
|
|
156
|
+
frozen.story.headline = 'MUTATED' // write silently blocked
|
|
157
|
+
expect(proxyChild.headline).to.equal('A Test Headline')
|
|
158
|
+
expect(frozen.story.headline).to.equal('A Test Headline')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reuses one facade per proxy across dispatches', () => {
|
|
162
|
+
const proxyChild = makeProxyMarkedObj()
|
|
163
|
+
const a = ChannelPayload.deepFreeze({ story: proxyChild })
|
|
164
|
+
const b = ChannelPayload.deepFreeze({ story: proxyChild })
|
|
165
|
+
expect(a.story).to.equal(b.story)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('facade data still revives to a writable copy via safeCloneDeep', () => {
|
|
169
|
+
const payload = ChannelPayload.deepFreeze({ story: makeProxyMarkedObj() })
|
|
170
|
+
const writable = safeCloneDeep(payload)
|
|
171
|
+
writable.story.type = 'story'
|
|
172
|
+
expect(writable.story.type).to.equal('story')
|
|
173
|
+
expect(writable.story.__test__revived).to.be.true
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('safeAugment', () => {
|
|
178
|
+
it('returns a writable revived copy carrying the extra props', () => {
|
|
179
|
+
const augmented = safeAugment(makeProxyMarkedObj(), { type: 'story', launchCode: 'hp3' })
|
|
180
|
+
expect(augmented.__test__revived).to.be.true
|
|
181
|
+
expect(augmented.type).to.equal('story')
|
|
182
|
+
expect(augmented.launchCode).to.equal('hp3')
|
|
183
|
+
expect(augmented.headline).to.equal('A Test Headline')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('augments plain objects without mutating the source', () => {
|
|
187
|
+
const src = { a: 1 }
|
|
188
|
+
const augmented = safeAugment(src, { b: 2 })
|
|
189
|
+
expect(augmented.b).to.equal(2)
|
|
190
|
+
expect(src.b).to.equal(undefined)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -268,6 +268,94 @@ describe('sanitization security', () => {
|
|
|
268
268
|
})
|
|
269
269
|
})
|
|
270
270
|
|
|
271
|
+
describe('anchor policy', () => {
|
|
272
|
+
it('should keep target in app mode and force noopener on _blank', () => {
|
|
273
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank">x</a>', { mode: 'app' })
|
|
274
|
+
|
|
275
|
+
expect(clean).to.include('target="_blank"')
|
|
276
|
+
expect(clean).to.include('noopener')
|
|
277
|
+
expect(clean).to.include('noreferrer')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should preserve existing rel values when forcing noopener', () => {
|
|
281
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank" rel="external">x</a>', { mode: 'richtext' })
|
|
282
|
+
|
|
283
|
+
expect(clean).to.include('external')
|
|
284
|
+
expect(clean).to.include('noopener')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('should keep named-window targets and force noopener on them', () => {
|
|
288
|
+
// target="blank" (no underscore) is a legal named browsing context
|
|
289
|
+
// and the most common real-world form; it carries the same opener
|
|
290
|
+
// risk as _blank and gets the same mitigation.
|
|
291
|
+
const clean = sanitizeData('<a href="https://rxjs.dev/api/index/function/fromEvent" target="blank">fromEvent</a>', { mode: 'app' })
|
|
292
|
+
|
|
293
|
+
expect(clean).to.include('target="blank"')
|
|
294
|
+
expect(clean).to.include('noopener')
|
|
295
|
+
expect(clean).to.include('noreferrer')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('should not add noopener to same-context targets', () => {
|
|
299
|
+
const clean = sanitizeData('<a href="https://example.com" target="_self">x</a>', { mode: 'app' })
|
|
300
|
+
|
|
301
|
+
expect(clean).to.include('target="_self"')
|
|
302
|
+
expect(clean).to.not.include('noopener')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('should remove target from anchors without an href', () => {
|
|
306
|
+
const clean = sanitizeData('<a target="_blank">x</a>', { mode: 'app' })
|
|
307
|
+
|
|
308
|
+
expect(clean).to.not.include('target=')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should strip target everywhere with allowTarget false', () => {
|
|
312
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank">x</a>', {
|
|
313
|
+
mode: 'richtext',
|
|
314
|
+
anchors: { allowTarget: false }
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(clean).to.not.include('target=')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should enforce allowedDomains on cross-origin hrefs', () => {
|
|
321
|
+
const opts = { mode: 'app', anchors: { allowedDomains: ['https://github.com'] } }
|
|
322
|
+
|
|
323
|
+
const allowed = sanitizeData('<a href="https://github.com/spynejs">x</a>', opts)
|
|
324
|
+
const blocked = sanitizeData('<a href="https://evil.example.com/page">x</a>', opts)
|
|
325
|
+
|
|
326
|
+
expect(allowed).to.include('href="https://github.com/spynejs"')
|
|
327
|
+
expect(blocked).to.not.include('href=')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('should always allow same-origin, relative, mailto and tel hrefs despite allowedDomains', () => {
|
|
331
|
+
const opts = { mode: 'app', anchors: { allowedDomains: ['https://github.com'] } }
|
|
332
|
+
|
|
333
|
+
expect(sanitizeData('<a href="/local/page">x</a>', opts)).to.include('href="/local/page"')
|
|
334
|
+
expect(sanitizeData('<a href="mailto:a@b.com">x</a>', opts)).to.include('href="mailto:a@b.com"')
|
|
335
|
+
expect(sanitizeData('<a href="tel:+15551234567">x</a>', opts)).to.include('href="tel:+15551234567"')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('should apply the anchor policy on the DomElement attribute channel', () => {
|
|
339
|
+
const el = new DomElement({
|
|
340
|
+
tagName: 'a',
|
|
341
|
+
attributes: { href: 'https://example.com', target: '_blank' }
|
|
342
|
+
}).render()
|
|
343
|
+
|
|
344
|
+
expect(el.getAttribute('target')).to.equal('_blank')
|
|
345
|
+
expect(el.getAttribute('rel')).to.include('noopener')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('should keep named-window targets with noopener on the DomElement attribute channel', () => {
|
|
349
|
+
const el = new DomElement({
|
|
350
|
+
tagName: 'a',
|
|
351
|
+
attributes: { href: 'https://example.com', target: 'blank' }
|
|
352
|
+
}).render()
|
|
353
|
+
|
|
354
|
+
expect(el.getAttribute('target')).to.equal('blank')
|
|
355
|
+
expect(el.getAttribute('rel')).to.include('noopener')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
271
359
|
describe('CMS wrappers and custom elements', () => {
|
|
272
360
|
const cmsMarkup = '<spyne-cms-item data-cms-id="post.title" data-cms-key="title">' +
|
|
273
361
|
'<spyne-cms-item-hitbox></spyne-cms-item-hitbox>' +
|