@learncard/render-method-plugin 3.0.4 → 3.0.6

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,652 @@
1
+ import type { UnsignedVC, VC, TemplateRenderMethod } from '@learncard/types';
2
+
3
+ import {
4
+ attachRenderMethod,
5
+ buildTemplateRenderMethod,
6
+ DEFAULT_TEMPLATE_ID,
7
+ getRenderMethodPlugin,
8
+ } from '../plugin';
9
+ import {
10
+ buildRenderData,
11
+ findRenderMethod,
12
+ findRenderMethods,
13
+ findTemplateRenderMethod,
14
+ findTemplateRenderMethods,
15
+ getRenderMethods,
16
+ getSvgMustacheRenderMethod,
17
+ isSvgMustacheRenderMethod,
18
+ isTemplateRenderMethod,
19
+ } from '../read';
20
+ import { AttachRenderMethodConfig, RENDER_METHOD_CONTEXT } from '../types';
21
+
22
+ const minimalVc: UnsignedVC = {
23
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
24
+ type: ['VerifiableCredential'],
25
+ issuer: 'did:example:issuer',
26
+ validFrom: '2024-01-01T00:00:00Z',
27
+ credentialSubject: { id: 'did:example:subject' },
28
+ } as UnsignedVC;
29
+
30
+ describe('buildTemplateRenderMethod', () => {
31
+ it('builds a TemplateRenderMethod from a templateId', () => {
32
+ const rm = buildTemplateRenderMethod({ templateId: 'https://example.com/t.svg' });
33
+
34
+ expect(rm.type).toBe('TemplateRenderMethod');
35
+ expect(rm.renderSuite).toBe('svg-mustache');
36
+ expect(rm.template).toBe('https://example.com/t.svg');
37
+ expect(rm.outputPreference).toEqual({ mediaType: 'image/svg+xml' });
38
+ expect((rm as TemplateRenderMethod).renderProperty).toBeUndefined();
39
+ });
40
+
41
+ it('builds a TemplateRenderMethod from a templateValue as a URL-encoded data URI', () => {
42
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>{{name}}</text></svg>';
43
+ const rm = buildTemplateRenderMethod({ templateValue: svg });
44
+
45
+ expect(rm.template.startsWith('data:image/svg+xml,')).toBe(true);
46
+ const payload = rm.template.slice('data:image/svg+xml,'.length);
47
+ expect(decodeURIComponent(payload)).toBe(svg);
48
+ });
49
+
50
+ it('round-trips multi-byte and special characters in templateValue', () => {
51
+ const svg = '<svg>{{ name }}<text>åé→#?&</text></svg>';
52
+ const rm = buildTemplateRenderMethod({ templateValue: svg });
53
+
54
+ const payload = rm.template.slice('data:image/svg+xml,'.length);
55
+ expect(decodeURIComponent(payload)).toBe(svg);
56
+ });
57
+
58
+ it('includes renderProperty only when explicitly provided', () => {
59
+ const withProps = buildTemplateRenderMethod({
60
+ templateId: 'https://example.com/t.svg',
61
+ renderProperty: ['/credentialSubject/name'],
62
+ });
63
+ const withoutProps = buildTemplateRenderMethod({
64
+ templateId: 'https://example.com/t.svg',
65
+ });
66
+
67
+ expect(withProps.renderProperty).toEqual(['/credentialSubject/name']);
68
+ expect(withoutProps.renderProperty).toBeUndefined();
69
+ });
70
+
71
+ it('accepts http and https schemes for templateId', () => {
72
+ expect(
73
+ buildTemplateRenderMethod({ templateId: 'http://example.com/t.svg' }).template
74
+ ).toBe('http://example.com/t.svg');
75
+ expect(
76
+ buildTemplateRenderMethod({ templateId: 'https://example.com/t.svg' }).template
77
+ ).toBe('https://example.com/t.svg');
78
+ });
79
+
80
+ it('rejects non-http(s) templateId schemes', () => {
81
+ expect(() =>
82
+ buildTemplateRenderMethod({
83
+ templateId: 'javascript:alert(1)',
84
+ } as AttachRenderMethodConfig)
85
+ ).toThrow(/must be an http\(s\) URL/i);
86
+
87
+ expect(() =>
88
+ buildTemplateRenderMethod({
89
+ templateId: 'file:///etc/passwd',
90
+ } as AttachRenderMethodConfig)
91
+ ).toThrow(/must be an http\(s\) URL/i);
92
+
93
+ expect(() =>
94
+ buildTemplateRenderMethod({ templateId: 'data:text/html,evil' } as AttachRenderMethodConfig)
95
+ ).toThrow(/must be an http\(s\) URL/i);
96
+ });
97
+
98
+ it('rejects empty or whitespace-only templateId', () => {
99
+ expect(() =>
100
+ buildTemplateRenderMethod({ templateId: '' } as AttachRenderMethodConfig)
101
+ ).toThrow(/must be an http\(s\) URL/i);
102
+ expect(() =>
103
+ buildTemplateRenderMethod({ templateId: ' ' } as AttachRenderMethodConfig)
104
+ ).toThrow(/must be an http\(s\) URL/i);
105
+ });
106
+
107
+ it('rejects empty templateValue', () => {
108
+ expect(() =>
109
+ buildTemplateRenderMethod({ templateValue: '' } as AttachRenderMethodConfig)
110
+ ).toThrow(/must be a non-empty SVG/i);
111
+ expect(() =>
112
+ buildTemplateRenderMethod({ templateValue: ' \n ' } as AttachRenderMethodConfig)
113
+ ).toThrow(/must be a non-empty SVG/i);
114
+ });
115
+
116
+ it('rejects a config with neither templateId nor templateValue', () => {
117
+ expect(() =>
118
+ buildTemplateRenderMethod({} as AttachRenderMethodConfig)
119
+ ).toThrow(/must provide either `templateId` or `templateValue`/i);
120
+ });
121
+ });
122
+
123
+ describe('attachRenderMethod (opt-in)', () => {
124
+ it('returns the VC unchanged when no config is provided', () => {
125
+ const result = attachRenderMethod(minimalVc);
126
+
127
+ expect(result).toBe(minimalVc);
128
+ expect(result.renderMethod).toBeUndefined();
129
+ expect(result['@context']).not.toContain(RENDER_METHOD_CONTEXT);
130
+ });
131
+
132
+ it('attaches a renderMethod and the render-method JSON-LD context when config is provided', () => {
133
+ const result = attachRenderMethod(minimalVc, {
134
+ templateId: 'https://example.com/t.svg',
135
+ });
136
+
137
+ expect(result).not.toBe(minimalVc);
138
+ expect(Array.isArray(result.renderMethod)).toBe(false);
139
+ expect((result.renderMethod as TemplateRenderMethod).template).toBe(
140
+ 'https://example.com/t.svg'
141
+ );
142
+ expect(result['@context']).toContain(RENDER_METHOD_CONTEXT);
143
+ });
144
+
145
+ it('normalizes a string @context into an array before appending', () => {
146
+ const vcWithStringContext = {
147
+ ...minimalVc,
148
+ '@context': 'https://www.w3.org/ns/credentials/v2',
149
+ } as unknown as UnsignedVC;
150
+
151
+ const result = attachRenderMethod(vcWithStringContext, {
152
+ templateId: 'https://example.com/t.svg',
153
+ });
154
+
155
+ expect(Array.isArray(result['@context'])).toBe(true);
156
+ expect(result['@context']).toContain('https://www.w3.org/ns/credentials/v2');
157
+ expect(result['@context']).toContain(RENDER_METHOD_CONTEXT);
158
+ });
159
+
160
+ it('is idempotent on @context: does not duplicate the render-method context', () => {
161
+ const once = attachRenderMethod(minimalVc, {
162
+ templateId: 'https://example.com/t.svg',
163
+ });
164
+ const twice = attachRenderMethod(once, {
165
+ templateId: 'https://example.com/t2.svg',
166
+ });
167
+
168
+ const occurrences = (twice['@context'] as string[]).filter(
169
+ c => c === RENDER_METHOD_CONTEXT
170
+ );
171
+ expect(occurrences.length).toBe(1);
172
+ });
173
+
174
+ it('wraps a pre-existing single renderMethod object into an array', () => {
175
+ const existing: TemplateRenderMethod = {
176
+ type: 'TemplateRenderMethod',
177
+ renderSuite: 'svg-mustache',
178
+ template: 'https://example.com/existing.svg',
179
+ outputPreference: { mediaType: 'image/svg+xml' },
180
+ };
181
+ const vcWithExisting = { ...minimalVc, renderMethod: existing } as UnsignedVC;
182
+
183
+ const result = attachRenderMethod(vcWithExisting, {
184
+ templateId: 'https://example.com/new.svg',
185
+ });
186
+
187
+ const renderMethods = result.renderMethod as TemplateRenderMethod[];
188
+ expect(Array.isArray(renderMethods)).toBe(true);
189
+ expect(renderMethods).toHaveLength(2);
190
+ expect(renderMethods[0].template).toBe('https://example.com/existing.svg');
191
+ expect(renderMethods[1].template).toBe('https://example.com/new.svg');
192
+ });
193
+
194
+ it('appends to a pre-existing renderMethod array', () => {
195
+ const existing: TemplateRenderMethod[] = [
196
+ {
197
+ type: 'TemplateRenderMethod',
198
+ renderSuite: 'svg-mustache',
199
+ template: 'https://example.com/a.svg',
200
+ outputPreference: { mediaType: 'image/svg+xml' },
201
+ },
202
+ {
203
+ type: 'TemplateRenderMethod',
204
+ renderSuite: 'svg-mustache',
205
+ template: 'https://example.com/b.svg',
206
+ outputPreference: { mediaType: 'image/svg+xml' },
207
+ },
208
+ ];
209
+ const vcWithArray = { ...minimalVc, renderMethod: existing } as UnsignedVC;
210
+
211
+ const result = attachRenderMethod(vcWithArray, {
212
+ templateId: 'https://example.com/c.svg',
213
+ });
214
+
215
+ const renderMethods = result.renderMethod as TemplateRenderMethod[];
216
+ expect(renderMethods).toHaveLength(3);
217
+ expect(renderMethods[2].template).toBe('https://example.com/c.svg');
218
+ });
219
+
220
+ it('propagates buildTemplateRenderMethod throws on invalid templateId', () => {
221
+ expect(() =>
222
+ attachRenderMethod(minimalVc, {
223
+ templateId: 'javascript:bad',
224
+ } as AttachRenderMethodConfig)
225
+ ).toThrow(/must be an http\(s\) URL/i);
226
+ });
227
+
228
+ it('does not mutate the input VC when attaching', () => {
229
+ const originalContext = [...(minimalVc['@context'] as string[])];
230
+ const originalRenderMethod = minimalVc.renderMethod;
231
+
232
+ attachRenderMethod(minimalVc, { templateId: 'https://example.com/t.svg' });
233
+
234
+ expect(minimalVc['@context']).toEqual(originalContext);
235
+ expect(minimalVc.renderMethod).toBe(originalRenderMethod);
236
+ });
237
+ });
238
+
239
+ describe('getRenderMethodPlugin', () => {
240
+ it('returns a plugin descriptor with the expected shape', () => {
241
+ const plugin = getRenderMethodPlugin({} as never);
242
+
243
+ expect(plugin.name).toBe('Render Method');
244
+ expect(plugin.displayName).toBe('Render Method Plugin');
245
+ expect(typeof plugin.methods.attachRenderMethod).toBe('function');
246
+ expect(typeof plugin.methods.buildTemplateRenderMethod).toBe('function');
247
+ });
248
+
249
+ it('exposes attachRenderMethod with opt-in semantics via the plugin', () => {
250
+ const plugin = getRenderMethodPlugin({} as never);
251
+
252
+ const unchanged = plugin.methods.attachRenderMethod({} as never, minimalVc);
253
+ expect(unchanged).toBe(minimalVc);
254
+
255
+ const attached = plugin.methods.attachRenderMethod({} as never, minimalVc, {
256
+ templateId: DEFAULT_TEMPLATE_ID,
257
+ });
258
+ expect(attached).not.toBe(minimalVc);
259
+ expect(attached.renderMethod).toBeDefined();
260
+ });
261
+
262
+ it('exposes buildTemplateRenderMethod via the plugin', () => {
263
+ const plugin = getRenderMethodPlugin({} as never);
264
+
265
+ const rm = plugin.methods.buildTemplateRenderMethod({} as never, {
266
+ templateId: 'https://example.com/t.svg',
267
+ });
268
+ expect(rm.type).toBe('TemplateRenderMethod');
269
+ expect(rm.template).toBe('https://example.com/t.svg');
270
+ });
271
+ });
272
+
273
+ describe('DEFAULT_TEMPLATE_ID', () => {
274
+ it('points to the LearnCard template host over https', () => {
275
+ expect(DEFAULT_TEMPLATE_ID.startsWith('https://templates.learncard.com/')).toBe(true);
276
+ });
277
+
278
+ it('is accepted as a valid templateId', () => {
279
+ const rm = buildTemplateRenderMethod({ templateId: DEFAULT_TEMPLATE_ID });
280
+ expect(rm.template).toBe(DEFAULT_TEMPLATE_ID);
281
+ });
282
+ });
283
+
284
+ const validSvgMustacheRm: TemplateRenderMethod = {
285
+ type: 'TemplateRenderMethod',
286
+ renderSuite: 'svg-mustache',
287
+ template: 'https://example.com/t.svg',
288
+ outputPreference: { mediaType: 'image/svg+xml' },
289
+ };
290
+
291
+ describe('type guards', () => {
292
+ it('isTemplateRenderMethod accepts a valid TemplateRenderMethod', () => {
293
+ expect(isTemplateRenderMethod(validSvgMustacheRm)).toBe(true);
294
+ });
295
+
296
+ it('isTemplateRenderMethod rejects shapes missing required fields', () => {
297
+ expect(isTemplateRenderMethod({})).toBe(false);
298
+ expect(isTemplateRenderMethod({ type: 'TemplateRenderMethod' })).toBe(false);
299
+ expect(isTemplateRenderMethod(null)).toBe(false);
300
+ expect(isTemplateRenderMethod('not-an-object')).toBe(false);
301
+ });
302
+
303
+ it('isSvgMustacheRenderMethod accepts only svg-mustache TemplateRenderMethods', () => {
304
+ expect(isSvgMustacheRenderMethod(validSvgMustacheRm)).toBe(true);
305
+ expect(
306
+ isSvgMustacheRenderMethod({ ...validSvgMustacheRm, renderSuite: 'html-mustache' })
307
+ ).toBe(false);
308
+ expect(
309
+ isSvgMustacheRenderMethod({ ...validSvgMustacheRm, type: 'OtherRenderMethod' })
310
+ ).toBe(false);
311
+ expect(isSvgMustacheRenderMethod(null)).toBe(false);
312
+ });
313
+ });
314
+
315
+ describe('getRenderMethods', () => {
316
+ it('returns [] when the VC has no renderMethod', () => {
317
+ const vc = { '@context': [], type: ['VerifiableCredential'] } as unknown as VC;
318
+ expect(getRenderMethods(vc)).toEqual([]);
319
+ });
320
+
321
+ it('normalizes a single-object renderMethod to a one-element array', () => {
322
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
323
+ expect(getRenderMethods(vc)).toEqual([validSvgMustacheRm]);
324
+ });
325
+
326
+ it('returns the renderMethod array as-is for array-form VCs', () => {
327
+ const rmArr = [validSvgMustacheRm, { ...validSvgMustacheRm, template: 'https://b.svg' }];
328
+ const vc = { renderMethod: rmArr } as unknown as VC;
329
+ expect(getRenderMethods(vc)).toEqual(rmArr);
330
+ });
331
+
332
+ it('unwraps CertifiedBoostCredential to read renderMethod from inner boostCredential', () => {
333
+ const boostInner = { renderMethod: validSvgMustacheRm };
334
+ const wrapped = {
335
+ type: ['VerifiableCredential', 'CertifiedBoostCredential'],
336
+ boostCredential: boostInner,
337
+ } as unknown as VC;
338
+ expect(getRenderMethods(wrapped)).toEqual([validSvgMustacheRm]);
339
+ });
340
+
341
+ it('does NOT validate entries — passes through raw shapes', () => {
342
+ const junk = { type: 'UnknownRenderMethod', foo: 'bar' };
343
+ const vc = { renderMethod: junk } as unknown as VC;
344
+ expect(getRenderMethods(vc)).toEqual([junk]);
345
+ });
346
+ });
347
+
348
+ describe('findRenderMethod / findRenderMethods', () => {
349
+ it('findRenderMethod returns the first match by predicate', () => {
350
+ const other = { type: 'OtherRenderMethod', renderSuite: 'png' };
351
+ const vc = {
352
+ renderMethod: [other, validSvgMustacheRm],
353
+ } as unknown as VC;
354
+ expect(findRenderMethod(vc, isSvgMustacheRenderMethod)).toEqual(validSvgMustacheRm);
355
+ });
356
+
357
+ it('findRenderMethod returns null when no entry matches', () => {
358
+ const vc = {
359
+ renderMethod: [{ type: 'OtherRenderMethod', renderSuite: 'png' }],
360
+ } as unknown as VC;
361
+ expect(findRenderMethod(vc, isSvgMustacheRenderMethod)).toBeNull();
362
+ });
363
+
364
+ it('findRenderMethods returns every match', () => {
365
+ const a = { ...validSvgMustacheRm, template: 'https://a.svg' };
366
+ const b = { ...validSvgMustacheRm, template: 'https://b.svg' };
367
+ const other = { type: 'OtherRenderMethod', renderSuite: 'png' };
368
+ const vc = { renderMethod: [a, other, b] } as unknown as VC;
369
+ expect(findRenderMethods(vc, isSvgMustacheRenderMethod)).toEqual([a, b]);
370
+ });
371
+
372
+ it('supports custom predicates for future render suites', () => {
373
+ const htmlMustache = {
374
+ type: 'TemplateRenderMethod',
375
+ renderSuite: 'html-mustache',
376
+ template: 'https://example.com/t.html',
377
+ };
378
+ const vc = { renderMethod: [validSvgMustacheRm, htmlMustache] } as unknown as VC;
379
+
380
+ const isHtmlMustache = (rm: unknown): rm is TemplateRenderMethod =>
381
+ typeof rm === 'object' &&
382
+ rm !== null &&
383
+ (rm as { renderSuite?: unknown }).renderSuite === 'html-mustache';
384
+
385
+ expect(findRenderMethod(vc, isHtmlMustache)).toEqual(htmlMustache);
386
+ });
387
+ });
388
+
389
+ describe('getSvgMustacheRenderMethod', () => {
390
+ it('returns the first svg-mustache entry', () => {
391
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
392
+ expect(getSvgMustacheRenderMethod(vc)).toEqual(validSvgMustacheRm);
393
+ });
394
+
395
+ it('returns null when no svg-mustache entry exists', () => {
396
+ const vc = {
397
+ renderMethod: [{ type: 'OtherRenderMethod', renderSuite: 'png' }],
398
+ } as unknown as VC;
399
+ expect(getSvgMustacheRenderMethod(vc)).toBeNull();
400
+ });
401
+
402
+ it('returns null when renderMethod is missing entirely', () => {
403
+ expect(getSvgMustacheRenderMethod({} as VC)).toBeNull();
404
+ });
405
+
406
+ it('skips invalid entries and finds the first VALID svg-mustache entry', () => {
407
+ const invalid = { type: 'TemplateRenderMethod', renderSuite: 'svg-mustache' };
408
+ const vc = { renderMethod: [invalid, validSvgMustacheRm] } as unknown as VC;
409
+ expect(getSvgMustacheRenderMethod(vc)).toEqual(validSvgMustacheRm);
410
+ });
411
+
412
+ it('unwraps CertifiedBoostCredential', () => {
413
+ const wrapped = {
414
+ type: ['VerifiableCredential', 'CertifiedBoostCredential'],
415
+ boostCredential: { renderMethod: validSvgMustacheRm },
416
+ } as unknown as VC;
417
+ expect(getSvgMustacheRenderMethod(wrapped)).toEqual(validSvgMustacheRm);
418
+ });
419
+ });
420
+
421
+ describe('buildRenderData', () => {
422
+ const vc = {
423
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
424
+ type: ['VerifiableCredential'],
425
+ issuer: { id: 'did:example:issuer', name: 'Example College' },
426
+ validFrom: '2026-01-15T00:00:00Z',
427
+ credentialSubject: { id: 'did:example:subject', name: 'Ada Lovelace' },
428
+ } as unknown as VC;
429
+
430
+ it('spreads credential fields at the top level', () => {
431
+ const data = buildRenderData(vc);
432
+ expect(data.issuer).toEqual(vc.issuer);
433
+ expect(data.credentialSubject).toEqual(vc.credentialSubject);
434
+ });
435
+
436
+ it('adds vc and credential aliases pointing at the credential', () => {
437
+ const data = buildRenderData(vc);
438
+ expect(data.vc).toBe(data.credential);
439
+ });
440
+
441
+ it('wraps a single credentialSubject in credentialSubjects array', () => {
442
+ const data = buildRenderData(vc);
443
+ expect(Array.isArray(data.credentialSubjects)).toBe(true);
444
+ expect((data.credentialSubjects as unknown[])).toHaveLength(1);
445
+ });
446
+
447
+ it('keeps credentialSubjects as-is when already an array', () => {
448
+ const multi = {
449
+ ...vc,
450
+ credentialSubject: [{ name: 'A' }, { name: 'B' }],
451
+ } as unknown as VC;
452
+ const data = buildRenderData(multi);
453
+ expect((data.credentialSubjects as unknown[])).toHaveLength(2);
454
+ });
455
+
456
+ it('overlays renderProperty (RFC 6901 pointers) at their pointer paths', () => {
457
+ const data = buildRenderData(vc, ['/issuer/name', '/credentialSubject/name']);
458
+ expect((data.issuer as { name: string }).name).toBe('Example College');
459
+ expect((data.credentialSubject as { name: string }).name).toBe('Ada Lovelace');
460
+ });
461
+
462
+ it('handles RFC 6901 escapes (~1 -> /, ~0 -> ~)', () => {
463
+ const escaped = {
464
+ ...vc,
465
+ credentialSubject: {
466
+ 'a/b': 'slash-value',
467
+ 'a~b': 'tilde-value',
468
+ },
469
+ } as unknown as VC;
470
+ const data = buildRenderData(escaped, [
471
+ '/credentialSubject/a~1b',
472
+ '/credentialSubject/a~0b',
473
+ ]);
474
+ expect((data.credentialSubject as Record<string, string>)['a/b']).toBe('slash-value');
475
+ expect((data.credentialSubject as Record<string, string>)['a~b']).toBe('tilde-value');
476
+ });
477
+
478
+ it('skips pointers that resolve to undefined', () => {
479
+ const data = buildRenderData(vc, ['/credentialSubject/missing']);
480
+ expect((data.credentialSubject as Record<string, unknown>).missing).toBeUndefined();
481
+ });
482
+
483
+ it('does NOT unwrap CertifiedBoostCredential (caller chooses the layer)', () => {
484
+ const wrapped = {
485
+ type: ['VerifiableCredential', 'CertifiedBoostCredential'],
486
+ issuer: { name: 'Wrapper Issuer' },
487
+ boostCredential: { issuer: { name: 'Inner Issuer' } },
488
+ } as unknown as VC;
489
+ const data = buildRenderData(wrapped);
490
+ expect((data.issuer as { name: string }).name).toBe('Wrapper Issuer');
491
+ });
492
+ });
493
+
494
+ describe('buildRenderData — prototype-pollution defense', () => {
495
+ const proto = Object.prototype as Record<string, unknown>;
496
+
497
+ afterEach(() => {
498
+ delete proto.polluted;
499
+ delete proto.polluted2;
500
+ delete proto.polluted3;
501
+ });
502
+
503
+ it('rejects /__proto__/<key> pointers (does not pollute Object.prototype)', () => {
504
+ const maliciousVc = {
505
+ __proto__: { polluted: 'pwned' },
506
+ credentialSubject: { id: 'did:example:s' },
507
+ } as unknown as VC;
508
+
509
+ buildRenderData(maliciousVc, ['/__proto__/polluted']);
510
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
511
+ expect(proto.polluted).toBeUndefined();
512
+ });
513
+
514
+ it('rejects /constructor/prototype/<key> pointers', () => {
515
+ buildRenderData({ credentialSubject: {} } as unknown as VC, [
516
+ '/constructor/prototype/polluted2',
517
+ ]);
518
+ expect(({} as Record<string, unknown>).polluted2).toBeUndefined();
519
+ expect(proto.polluted2).toBeUndefined();
520
+ });
521
+
522
+ it('rejects pointers containing /prototype/ segments', () => {
523
+ buildRenderData({ credentialSubject: {} } as unknown as VC, [
524
+ '/credentialSubject/prototype/polluted3',
525
+ ]);
526
+ expect(({} as Record<string, unknown>).polluted3).toBeUndefined();
527
+ expect(proto.polluted3).toBeUndefined();
528
+ });
529
+
530
+ it('still processes safe pointers in the same call as a rejected one', () => {
531
+ const vc = {
532
+ credentialSubject: { id: 'did:example:s', name: 'Ada' },
533
+ } as unknown as VC;
534
+ const data = buildRenderData(vc, [
535
+ '/__proto__/polluted',
536
+ '/credentialSubject/name',
537
+ ]);
538
+ expect((data.credentialSubject as { name: string }).name).toBe('Ada');
539
+ expect(proto.polluted).toBeUndefined();
540
+ });
541
+ });
542
+
543
+ describe('findTemplateRenderMethod / findTemplateRenderMethods (string-based sugar)', () => {
544
+ const htmlMustacheRm: TemplateRenderMethod = {
545
+ type: 'TemplateRenderMethod',
546
+ renderSuite: 'html-mustache',
547
+ template: 'https://example.com/t.html',
548
+ };
549
+
550
+ it('finds by a single suite string', () => {
551
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
552
+ expect(findTemplateRenderMethod(vc, 'svg-mustache')).toEqual(validSvgMustacheRm);
553
+ });
554
+
555
+ it('returns null when no entry matches the single suite', () => {
556
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
557
+ expect(findTemplateRenderMethod(vc, 'html-mustache')).toBeNull();
558
+ });
559
+
560
+ it('finds the first match across an array of suites (capability negotiation)', () => {
561
+ const vc = {
562
+ renderMethod: [
563
+ { type: 'OtherRenderMethod', renderSuite: 'png' },
564
+ htmlMustacheRm,
565
+ validSvgMustacheRm,
566
+ ],
567
+ } as unknown as VC;
568
+ expect(
569
+ findTemplateRenderMethod(vc, ['svg-mustache', 'html-mustache'])
570
+ ).toEqual(htmlMustacheRm);
571
+ });
572
+
573
+ it('returns null when none of the listed suites match', () => {
574
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
575
+ expect(findTemplateRenderMethod(vc, ['html-mustache', 'pdf'])).toBeNull();
576
+ });
577
+
578
+ it('findTemplateRenderMethods returns every entry matching a single suite', () => {
579
+ const second = { ...validSvgMustacheRm, template: 'https://b.svg' };
580
+ const vc = { renderMethod: [validSvgMustacheRm, second] } as unknown as VC;
581
+ expect(findTemplateRenderMethods(vc, 'svg-mustache')).toEqual([
582
+ validSvgMustacheRm,
583
+ second,
584
+ ]);
585
+ });
586
+
587
+ it('findTemplateRenderMethods returns every entry matching any of an array of suites', () => {
588
+ const vc = {
589
+ renderMethod: [validSvgMustacheRm, htmlMustacheRm],
590
+ } as unknown as VC;
591
+ expect(
592
+ findTemplateRenderMethods(vc, ['svg-mustache', 'html-mustache'])
593
+ ).toHaveLength(2);
594
+ });
595
+
596
+ it('returns the Zod-parsed shape (unknown keys stripped)', () => {
597
+ const withExtra = { ...validSvgMustacheRm, extraField: 'should-strip' };
598
+ const vc = { renderMethod: withExtra } as unknown as VC;
599
+ const found = findTemplateRenderMethod(vc, 'svg-mustache');
600
+ expect(found).toBeDefined();
601
+ expect((found as Record<string, unknown>).extraField).toBeUndefined();
602
+ });
603
+
604
+ it('unwraps CertifiedBoostCredential', () => {
605
+ const wrapped = {
606
+ type: ['VerifiableCredential', 'CertifiedBoostCredential'],
607
+ boostCredential: { renderMethod: htmlMustacheRm },
608
+ } as unknown as VC;
609
+ expect(findTemplateRenderMethod(wrapped, 'html-mustache')).toEqual(htmlMustacheRm);
610
+ });
611
+ });
612
+
613
+ describe('plugin read-side methods', () => {
614
+ it('exposes the read-side API via wallet.invoke surface', () => {
615
+ const plugin = getRenderMethodPlugin({} as never);
616
+ expect(typeof plugin.methods.getRenderMethods).toBe('function');
617
+ expect(typeof plugin.methods.findRenderMethod).toBe('function');
618
+ expect(typeof plugin.methods.findRenderMethods).toBe('function');
619
+ expect(typeof plugin.methods.findTemplateRenderMethod).toBe('function');
620
+ expect(typeof plugin.methods.findTemplateRenderMethods).toBe('function');
621
+ expect(typeof plugin.methods.getSvgMustacheRenderMethod).toBe('function');
622
+ expect(typeof plugin.methods.buildRenderData).toBe('function');
623
+ });
624
+
625
+ it('plugin.methods.findRenderMethod accepts a predicate and narrows', () => {
626
+ const plugin = getRenderMethodPlugin({} as never);
627
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
628
+ const found = plugin.methods.findRenderMethod(
629
+ {} as never,
630
+ vc,
631
+ isSvgMustacheRenderMethod
632
+ );
633
+ expect(found).toEqual(validSvgMustacheRm);
634
+ });
635
+
636
+ it('plugin.methods.findTemplateRenderMethod accepts a single suite string', () => {
637
+ const plugin = getRenderMethodPlugin({} as never);
638
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
639
+ const found = plugin.methods.findTemplateRenderMethod({} as never, vc, 'svg-mustache');
640
+ expect(found).toEqual(validSvgMustacheRm);
641
+ });
642
+
643
+ it('plugin.methods.findTemplateRenderMethod accepts an array of suites', () => {
644
+ const plugin = getRenderMethodPlugin({} as never);
645
+ const vc = { renderMethod: validSvgMustacheRm } as unknown as VC;
646
+ const found = plugin.methods.findTemplateRenderMethod({} as never, vc, [
647
+ 'html-mustache',
648
+ 'svg-mustache',
649
+ ]);
650
+ expect(found).toEqual(validSvgMustacheRm);
651
+ });
652
+ });