@nfcard/validation 0.1.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/dist/index.js ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@nfcard/validation",
3
+ "version": "0.1.0",
4
+ "description": "Shared Zod validation schemas for the NFCard product family.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "zod": "^3.24.0",
20
+ "@nfcard/types": "0.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "tsup": "^8.0.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://bitbucket.org/fox-hole/nfcard-shared.git",
32
+ "directory": "validation"
33
+ },
34
+ "scripts": {
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "build": "tsup src/index.ts --format esm --dts --clean"
38
+ }
39
+ }
@@ -0,0 +1,634 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ cardElementsSchema,
4
+ comboIdSchema,
5
+ configurationCreateSchema,
6
+ contactExchangeSchema,
7
+ createOrderSchema,
8
+ digitalConfigSchema,
9
+ hubConfigSchema,
10
+ materialSchema,
11
+ physicalConfigSchema,
12
+ profileCreateSchema,
13
+ profileVisibilitySchema,
14
+ sanitizeHttpUrl,
15
+ sanitizeText,
16
+ signupWithProfileSchema,
17
+ socialLinksSchema,
18
+ userDataSchema,
19
+ validateConfiguration,
20
+ validateDigitalConfig,
21
+ validatePhysicalConfig,
22
+ validateUserData,
23
+ webAddressSchema,
24
+ } from './index.js';
25
+
26
+ // ── helpers ─────────────────────────────────────────────────────
27
+
28
+ const validUserData = {
29
+ firstName: 'János',
30
+ lastName: 'Kovács',
31
+ email: 'a@b.co',
32
+ phone: '+36 20 123 4567',
33
+ phoneCountryCode: 'HU',
34
+ position: 'CEO',
35
+ location: 'Budapest',
36
+ company: 'Foxhole',
37
+ website: 'www.foxhome.hu',
38
+ bio: 'short bio',
39
+ };
40
+
41
+ const validCardElements = {
42
+ showName: true,
43
+ showPosition: false,
44
+ showEmail: true,
45
+ showPhone: false,
46
+ showCompany: false,
47
+ showLogo: false,
48
+ showQr: false,
49
+ showPhoto: false,
50
+ };
51
+
52
+ const validPhysicalConfig = {
53
+ material: 'plastic' as const,
54
+ comboId: 'A1' as const,
55
+ frontElements: validCardElements,
56
+ backElements: validCardElements,
57
+ };
58
+
59
+ const validDigitalConfig = {
60
+ templateId: 'minimal' as const,
61
+ accentColor: '#F47B20',
62
+ fontKey: 'inter',
63
+ showAvatar: true,
64
+ };
65
+
66
+ // ── sanitizeText ────────────────────────────────────────────────
67
+
68
+ describe('sanitizeText', () => {
69
+ it('strips HTML / script tags (keeps non-tag inner text — verified)', () => {
70
+ // The regex strips `<…>` markup but does not blacklist tag *contents*
71
+ // because this output is plain-text only, never re-injected as HTML.
72
+ expect(sanitizeText('<b>bold</b> text')).toBe('bold text');
73
+ expect(sanitizeText('<img src=x onerror=alert(1)>')).toBe('');
74
+ // Script-tag contents come through as inert text — the angle brackets
75
+ // (the only attack vector for HTML injection) are gone.
76
+ expect(sanitizeText('<script>alert(1)</script>hello')).toBe(
77
+ 'alert(1)hello',
78
+ );
79
+ expect(sanitizeText('<script>alert(1)</script>hello')).not.toMatch(/[<>]/);
80
+ });
81
+
82
+ it('strips control characters', () => {
83
+ expect(sanitizeText('hello\x00world')).toBe('helloworld');
84
+ expect(sanitizeText('a\nb\tc')).toBe('abc');
85
+ });
86
+
87
+ it('caps length at the default 50', () => {
88
+ const long = 'a'.repeat(200);
89
+ expect(sanitizeText(long).length).toBe(50);
90
+ });
91
+
92
+ it('honours a custom max', () => {
93
+ expect(sanitizeText('a'.repeat(100), 10).length).toBe(10);
94
+ });
95
+
96
+ it('returns empty for non-string input', () => {
97
+ expect(sanitizeText(null)).toBe('');
98
+ expect(sanitizeText(undefined)).toBe('');
99
+ expect(sanitizeText(42)).toBe('');
100
+ expect(sanitizeText({})).toBe('');
101
+ });
102
+
103
+ it('trims whitespace', () => {
104
+ expect(sanitizeText(' hello ')).toBe('hello');
105
+ });
106
+ });
107
+
108
+ // ── sanitizeHttpUrl ─────────────────────────────────────────────
109
+
110
+ describe('sanitizeHttpUrl', () => {
111
+ it('accepts a plain https URL and keeps it', () => {
112
+ expect(sanitizeHttpUrl('https://foxhome.hu/')).toBe('https://foxhome.hu/');
113
+ });
114
+
115
+ it('upgrades http to https', () => {
116
+ expect(sanitizeHttpUrl('http://foxhome.hu/')).toBe('https://foxhome.hu/');
117
+ });
118
+
119
+ it('prefixes https:// onto a bare hostname', () => {
120
+ expect(sanitizeHttpUrl('foxhome.hu/x')).toBe('https://foxhome.hu/x');
121
+ });
122
+
123
+ it.each([
124
+ 'javascript:alert(1)',
125
+ 'JaVaScRiPt:alert(1)',
126
+ 'data:text/html,<script>alert(1)</script>',
127
+ 'vbscript:msgbox(1)',
128
+ 'file:///etc/passwd',
129
+ 'ftp://foxhome.hu',
130
+ 'mailto:a@b.c',
131
+ ])('rejects dangerous / non-http scheme: %s', (raw) => {
132
+ expect(sanitizeHttpUrl(raw)).toBe('');
133
+ });
134
+
135
+ it.each([
136
+ '<script>alert(1)</script>',
137
+ 'https://x.com" onclick="alert(1)',
138
+ "https://x.com' onmouseover='alert(1)",
139
+ 'https://x.com`x`',
140
+ 'https://x.com\\..\\..',
141
+ ])('strips HTML/quote/control chars before parsing: %s', (raw) => {
142
+ const out = sanitizeHttpUrl(raw);
143
+ // Either rejected outright or sanitized — never contains the
144
+ // dangerous chars.
145
+ expect(out).not.toMatch(/[<>"'`\\]/);
146
+ });
147
+
148
+ it('rejects values over 2048 chars', () => {
149
+ const long = 'https://foxhome.hu/' + 'a'.repeat(2050);
150
+ expect(sanitizeHttpUrl(long)).toBe('');
151
+ });
152
+
153
+ it('rejects a hostname without a dot', () => {
154
+ expect(sanitizeHttpUrl('https://localhost/')).toBe('');
155
+ });
156
+
157
+ it('rejects non-string input', () => {
158
+ expect(sanitizeHttpUrl(null)).toBe('');
159
+ expect(sanitizeHttpUrl(undefined)).toBe('');
160
+ expect(sanitizeHttpUrl(42)).toBe('');
161
+ });
162
+
163
+ it('rejects an unparseable value', () => {
164
+ expect(sanitizeHttpUrl('::::')).toBe('');
165
+ });
166
+ });
167
+
168
+ // ── webAddressSchema ────────────────────────────────────────────
169
+
170
+ describe('webAddressSchema', () => {
171
+ it.each(['', 'foxhome.hu', 'www.foxhome.hu', 'https://foxhome.hu'])(
172
+ 'accepts: %s',
173
+ (v) => {
174
+ expect(webAddressSchema.safeParse(v).success).toBe(true);
175
+ },
176
+ );
177
+
178
+ it.each(['javascript:alert(1)', 'mailto:a@b.c', 'not a url'])(
179
+ 'rejects: %s',
180
+ (v) => {
181
+ expect(webAddressSchema.safeParse(v).success).toBe(false);
182
+ },
183
+ );
184
+ });
185
+
186
+ // ── socialLinksSchema ───────────────────────────────────────────
187
+
188
+ describe('socialLinksSchema', () => {
189
+ it('sanitizes every link field to a safe https URL or empty', () => {
190
+ const out = socialLinksSchema.parse({
191
+ facebook: 'https://facebook.com/foxhome',
192
+ linkedin: 'javascript:alert(1)',
193
+ instagram: '',
194
+ x: 'http://x.com/foxhome', // upgraded
195
+ youtube: 'youtube.com/@channel',
196
+ tiktok: undefined,
197
+ customUrl: '<script>x</script>https://foo.bar',
198
+ customUrlLabel: '<b>My link</b>',
199
+ });
200
+ expect(out.facebook).toBe('https://facebook.com/foxhome');
201
+ expect(out.linkedin).toBe('');
202
+ expect(out.instagram).toBe('');
203
+ expect(out.x.startsWith('https://')).toBe(true);
204
+ expect(out.youtube).toBe('https://youtube.com/@channel');
205
+ expect(out.tiktok).toBe('');
206
+ expect(out.customUrl).not.toMatch(/script/i);
207
+ expect(out.customUrlLabel).toBe('My link'); // tags stripped, kept
208
+ });
209
+ });
210
+
211
+ // ── userDataSchema ──────────────────────────────────────────────
212
+
213
+ describe('userDataSchema / validateUserData', () => {
214
+ it('accepts a well-formed payload', () => {
215
+ const r = validateUserData(validUserData);
216
+ expect(r.success).toBe(true);
217
+ });
218
+
219
+ it('requires email', () => {
220
+ const r = validateUserData({ ...validUserData, email: 'not-an-email' });
221
+ expect(r.success).toBe(false);
222
+ if (!r.success) expect(r.errors.email).toBeDefined();
223
+ });
224
+
225
+ it('rejects an invalid phone format', () => {
226
+ const r = validateUserData({ ...validUserData, phone: 'abcdef' });
227
+ expect(r.success).toBe(false);
228
+ });
229
+
230
+ it('rejects an oversized firstName', () => {
231
+ const r = validateUserData({
232
+ ...validUserData,
233
+ firstName: 'a'.repeat(101),
234
+ });
235
+ expect(r.success).toBe(false);
236
+ });
237
+
238
+ it('accepts empty optional fields', () => {
239
+ const r = validateUserData({
240
+ email: 'a@b.co',
241
+ firstName: '',
242
+ lastName: '',
243
+ phone: '',
244
+ });
245
+ expect(r.success).toBe(true);
246
+ });
247
+
248
+ it('rejects a website with javascript: scheme', () => {
249
+ const r = validateUserData({
250
+ ...validUserData,
251
+ website: 'javascript:alert(1)',
252
+ });
253
+ expect(r.success).toBe(false);
254
+ });
255
+ });
256
+
257
+ // ── materialSchema ──────────────────────────────────────────────
258
+
259
+ describe('materialSchema', () => {
260
+ // Mirrors the Material union in @nfcard/types. `wood` was renamed to
261
+ // `bamboo` in FOXHOLE-685 / launch tiers Phase A — the schema must
262
+ // not accept the legacy value any more.
263
+ it.each(['plastic', '3dprint_standard', 'metal', 'bamboo'])(
264
+ 'accepts %s',
265
+ (m) => {
266
+ expect(materialSchema.safeParse(m).success).toBe(true);
267
+ },
268
+ );
269
+
270
+ it.each(['wood', 'aluminium', '', 'BAMBOO'])('rejects %s', (m) => {
271
+ expect(materialSchema.safeParse(m).success).toBe(false);
272
+ });
273
+ });
274
+
275
+ // ── comboIdSchema ───────────────────────────────────────────────
276
+
277
+ describe('comboIdSchema', () => {
278
+ it.each(['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'D1', 'D2', 'E1', 'E2'])(
279
+ 'accepts %s',
280
+ (id) => {
281
+ expect(comboIdSchema.safeParse(id).success).toBe(true);
282
+ },
283
+ );
284
+
285
+ it('rejects unknown ids', () => {
286
+ expect(comboIdSchema.safeParse('Z9').success).toBe(false);
287
+ expect(comboIdSchema.safeParse('').success).toBe(false);
288
+ });
289
+ });
290
+
291
+ // ── cardElementsSchema ──────────────────────────────────────────
292
+
293
+ describe('cardElementsSchema', () => {
294
+ it('requires every flag to be a boolean', () => {
295
+ expect(cardElementsSchema.safeParse(validCardElements).success).toBe(true);
296
+ expect(
297
+ cardElementsSchema.safeParse({ ...validCardElements, showName: 'yes' })
298
+ .success,
299
+ ).toBe(false);
300
+ });
301
+ });
302
+
303
+ // ── physicalConfigSchema ────────────────────────────────────────
304
+
305
+ describe('physicalConfigSchema / validatePhysicalConfig', () => {
306
+ it('accepts a plastic config without a colour', () => {
307
+ expect(validatePhysicalConfig(validPhysicalConfig).success).toBe(true);
308
+ });
309
+
310
+ it('requires a colour for non-plastic materials', () => {
311
+ const r = validatePhysicalConfig({
312
+ ...validPhysicalConfig,
313
+ material: '3dprint_standard',
314
+ });
315
+ expect(r.success).toBe(false);
316
+ if (!r.success) expect(r.errors['colour']).toBeDefined();
317
+ });
318
+
319
+ it('accepts a non-plastic config with a valid hex colour', () => {
320
+ const r = validatePhysicalConfig({
321
+ ...validPhysicalConfig,
322
+ material: '3dprint_standard',
323
+ colour: '#F47B20',
324
+ });
325
+ expect(r.success).toBe(true);
326
+ });
327
+
328
+ it('rejects a malformed hex colour', () => {
329
+ const r = validatePhysicalConfig({
330
+ ...validPhysicalConfig,
331
+ material: 'metal',
332
+ colour: 'red',
333
+ });
334
+ expect(r.success).toBe(false);
335
+ });
336
+
337
+ it('rejects a malformed qrTargetUrl', () => {
338
+ const r = validatePhysicalConfig({
339
+ ...validPhysicalConfig,
340
+ qrTargetUrl: 'not-a-url',
341
+ });
342
+ expect(r.success).toBe(false);
343
+ });
344
+ });
345
+
346
+ // ── digitalConfigSchema ─────────────────────────────────────────
347
+
348
+ describe('digitalConfigSchema / validateDigitalConfig', () => {
349
+ it('accepts the minimal happy path', () => {
350
+ expect(validateDigitalConfig(validDigitalConfig).success).toBe(true);
351
+ });
352
+
353
+ it('rejects a malformed accentColor', () => {
354
+ const r = validateDigitalConfig({
355
+ ...validDigitalConfig,
356
+ accentColor: 'orange',
357
+ });
358
+ expect(r.success).toBe(false);
359
+ });
360
+
361
+ it('rejects an unknown templateId', () => {
362
+ const r = validateDigitalConfig({
363
+ ...validDigitalConfig,
364
+ templateId: 'futuristic',
365
+ });
366
+ expect(r.success).toBe(false);
367
+ });
368
+
369
+ it('rejects an empty fontKey', () => {
370
+ const r = validateDigitalConfig({ ...validDigitalConfig, fontKey: '' });
371
+ expect(r.success).toBe(false);
372
+ });
373
+ });
374
+
375
+ // ── configurationCreateSchema ───────────────────────────────────
376
+
377
+ describe('validateConfiguration', () => {
378
+ it('accepts a complete configuration', () => {
379
+ const r = validateConfiguration({
380
+ sessionId: '550e8400-e29b-41d4-a716-446655440000',
381
+ userData: validUserData,
382
+ physicalConfig: validPhysicalConfig,
383
+ digitalConfig: validDigitalConfig,
384
+ quantity: 5,
385
+ });
386
+ expect(r.success).toBe(true);
387
+ });
388
+
389
+ it('defaults quantity to 1 when omitted', () => {
390
+ const r = configurationCreateSchema.safeParse({
391
+ sessionId: '550e8400-e29b-41d4-a716-446655440000',
392
+ userData: validUserData,
393
+ physicalConfig: validPhysicalConfig,
394
+ digitalConfig: validDigitalConfig,
395
+ });
396
+ expect(r.success).toBe(true);
397
+ if (r.success) expect(r.data.quantity).toBe(1);
398
+ });
399
+
400
+ it('rejects a non-uuid sessionId', () => {
401
+ const r = validateConfiguration({
402
+ sessionId: 'not-a-uuid',
403
+ userData: validUserData,
404
+ physicalConfig: validPhysicalConfig,
405
+ digitalConfig: validDigitalConfig,
406
+ });
407
+ expect(r.success).toBe(false);
408
+ });
409
+
410
+ it('rejects quantity above 1000', () => {
411
+ const r = validateConfiguration({
412
+ sessionId: '550e8400-e29b-41d4-a716-446655440000',
413
+ userData: validUserData,
414
+ physicalConfig: validPhysicalConfig,
415
+ digitalConfig: validDigitalConfig,
416
+ quantity: 1001,
417
+ });
418
+ expect(r.success).toBe(false);
419
+ });
420
+ });
421
+
422
+ // ── profileCreateSchema ─────────────────────────────────────────
423
+
424
+ describe('profileCreateSchema', () => {
425
+ it('accepts a minimal profile', () => {
426
+ const r = profileCreateSchema.safeParse({
427
+ label: 'Personal',
428
+ userData: { ...validUserData, email: '' }, // email optional on profiles
429
+ digitalConfig: validDigitalConfig,
430
+ });
431
+ expect(r.success).toBe(true);
432
+ });
433
+
434
+ it('rejects an invalid accessPin (letters)', () => {
435
+ const r = profileCreateSchema.safeParse({
436
+ label: 'Personal',
437
+ userData: { ...validUserData, email: '' },
438
+ digitalConfig: validDigitalConfig,
439
+ accessPin: 'abcd',
440
+ });
441
+ expect(r.success).toBe(false);
442
+ });
443
+
444
+ it('rejects too-short accessPin', () => {
445
+ const r = profileCreateSchema.safeParse({
446
+ label: 'Personal',
447
+ userData: { ...validUserData, email: '' },
448
+ digitalConfig: validDigitalConfig,
449
+ accessPin: '12',
450
+ });
451
+ expect(r.success).toBe(false);
452
+ });
453
+
454
+ it('rejects missing label', () => {
455
+ const r = profileCreateSchema.safeParse({
456
+ userData: { ...validUserData, email: '' },
457
+ digitalConfig: validDigitalConfig,
458
+ });
459
+ expect(r.success).toBe(false);
460
+ });
461
+ });
462
+
463
+ describe('profileVisibilitySchema', () => {
464
+ it.each(['public', 'private', 'link_only'])('accepts %s', (v) => {
465
+ expect(profileVisibilitySchema.safeParse(v).success).toBe(true);
466
+ });
467
+
468
+ it('does NOT accept pending_verification from user input', () => {
469
+ // FOXHOLE-684: server-managed state, never accepted from a request.
470
+ expect(profileVisibilitySchema.safeParse('pending_verification').success).toBe(
471
+ false,
472
+ );
473
+ });
474
+ });
475
+
476
+ // ── signupWithProfileSchema ─────────────────────────────────────
477
+
478
+ describe('signupWithProfileSchema', () => {
479
+ const baseSignup = {
480
+ email: 'a@b.co',
481
+ password: 'longenough',
482
+ acceptedTerms: true,
483
+ userData: { ...validUserData, email: '' },
484
+ digitalConfig: validDigitalConfig,
485
+ };
486
+
487
+ it('accepts a valid signup body', () => {
488
+ expect(signupWithProfileSchema.safeParse(baseSignup).success).toBe(true);
489
+ });
490
+
491
+ it('rejects acceptedTerms: false', () => {
492
+ const r = signupWithProfileSchema.safeParse({
493
+ ...baseSignup,
494
+ acceptedTerms: false,
495
+ });
496
+ expect(r.success).toBe(false);
497
+ });
498
+
499
+ it('rejects a too-short password', () => {
500
+ const r = signupWithProfileSchema.safeParse({
501
+ ...baseSignup,
502
+ password: 'short',
503
+ });
504
+ expect(r.success).toBe(false);
505
+ });
506
+
507
+ it('defaults preferredLanguage to hu', () => {
508
+ const r = signupWithProfileSchema.safeParse(baseSignup);
509
+ expect(r.success).toBe(true);
510
+ if (r.success) expect(r.data.preferredLanguage).toBe('hu');
511
+ });
512
+ });
513
+
514
+ // ── createOrderSchema ───────────────────────────────────────────
515
+
516
+ describe('createOrderSchema', () => {
517
+ it('accepts a uuid configurationId', () => {
518
+ expect(
519
+ createOrderSchema.safeParse({
520
+ configurationId: '550e8400-e29b-41d4-a716-446655440000',
521
+ }).success,
522
+ ).toBe(true);
523
+ });
524
+
525
+ it('rejects a non-uuid configurationId', () => {
526
+ expect(
527
+ createOrderSchema.safeParse({ configurationId: 'not-a-uuid' }).success,
528
+ ).toBe(false);
529
+ });
530
+
531
+ it('accepts an optional discountCode', () => {
532
+ const r = createOrderSchema.safeParse({
533
+ configurationId: '550e8400-e29b-41d4-a716-446655440000',
534
+ discountCode: 'SUMMER10',
535
+ });
536
+ expect(r.success).toBe(true);
537
+ });
538
+ });
539
+
540
+ // ── contactExchangeSchema ───────────────────────────────────────
541
+
542
+ describe('contactExchangeSchema', () => {
543
+ it('accepts name + email + phone', () => {
544
+ expect(
545
+ contactExchangeSchema.safeParse({
546
+ name: 'Anna',
547
+ email: 'a@b.co',
548
+ phone: '+36 20 123 4567',
549
+ }).success,
550
+ ).toBe(true);
551
+ });
552
+
553
+ it('requires name', () => {
554
+ expect(
555
+ contactExchangeSchema.safeParse({ email: 'a@b.co' }).success,
556
+ ).toBe(false);
557
+ });
558
+
559
+ it('accepts name only (email + phone optional)', () => {
560
+ expect(contactExchangeSchema.safeParse({ name: 'Anna' }).success).toBe(
561
+ true,
562
+ );
563
+ });
564
+
565
+ it('rejects a malformed email', () => {
566
+ expect(
567
+ contactExchangeSchema.safeParse({ name: 'Anna', email: 'not-an-email' })
568
+ .success,
569
+ ).toBe(false);
570
+ });
571
+
572
+ it('rejects an oversized notes field', () => {
573
+ expect(
574
+ contactExchangeSchema.safeParse({
575
+ name: 'Anna',
576
+ notes: 'a'.repeat(501),
577
+ }).success,
578
+ ).toBe(false);
579
+ });
580
+ });
581
+
582
+ // ── hubConfigSchema ─────────────────────────────────────────────
583
+
584
+ describe('hubConfigSchema', () => {
585
+ it('accepts an empty config (every field optional)', () => {
586
+ expect(hubConfigSchema.safeParse({}).success).toBe(true);
587
+ });
588
+
589
+ it('accepts a fully-populated config', () => {
590
+ expect(
591
+ hubConfigSchema.safeParse({
592
+ displayName: 'Foxhole',
593
+ subtitle: 'Cards',
594
+ avatarMode: 'monogram',
595
+ avatarUrl: 'https://cdn/x.png',
596
+ monogramText: 'FX',
597
+ accentColor: '#F47B20',
598
+ backgroundColor: '#FFFFFF',
599
+ textColor: '#000000',
600
+ tileBackgroundColor: '#EEEEEE',
601
+ tileBorderColor: '#CCCCCC',
602
+ fontKey: 'inter',
603
+ footerText: 'Powered by Foxhole',
604
+ }).success,
605
+ ).toBe(true);
606
+ });
607
+
608
+ it('rejects a malformed hex colour', () => {
609
+ expect(
610
+ hubConfigSchema.safeParse({ accentColor: 'orange' }).success,
611
+ ).toBe(false);
612
+ });
613
+
614
+ it.each([
615
+ 'javascript:alert(1)',
616
+ 'data:text/html,<script>alert(1)</script>',
617
+ 'vbscript:msgbox(1)',
618
+ 'file:///etc/passwd',
619
+ 'mailto:a@b.c',
620
+ 'ftp://foxhome.hu',
621
+ ])('rejects avatarUrl with non-http(s) scheme: %s', (avatarUrl) => {
622
+ expect(hubConfigSchema.safeParse({ avatarUrl }).success).toBe(false);
623
+ });
624
+
625
+ it('accepts a plain https avatarUrl', () => {
626
+ expect(
627
+ hubConfigSchema.safeParse({ avatarUrl: 'https://cdn/x.png' }).success,
628
+ ).toBe(true);
629
+ });
630
+
631
+ it('accepts an empty avatarUrl', () => {
632
+ expect(hubConfigSchema.safeParse({ avatarUrl: '' }).success).toBe(true);
633
+ });
634
+ });
package/src/index.ts ADDED
Binary file