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.
@@ -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>' +