@nfcard/validation 0.1.0 → 0.1.1

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/src/index.test.ts CHANGED
@@ -1,634 +1,143 @@
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
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createOrderSchema,
4
+ socialOverridesSchema,
5
+ validateConfiguration,
6
+ validateDigitalConfig,
7
+ } from './index.js';
8
+
9
+ // Focused suite for the 0.1.1 backport (FOXHOLE-595 input-side hardening on the
10
+ // published 0.1.0 base). The 0.1.x line deliberately predates the 0.2.x
11
+ // configurator-v4 / designType changes, so this only covers the socialOverrides
12
+ // change plus smoke checks that the 0.1.0 schema graph still builds + parses.
13
+
14
+ const validUserData = {
15
+ firstName: 'János',
16
+ lastName: 'Kovács',
17
+ email: 'a@b.co',
18
+ phone: '+36 20 123 4567',
19
+ phoneCountryCode: 'HU',
20
+ position: 'CEO',
21
+ location: 'Budapest',
22
+ company: 'Foxhole',
23
+ website: 'www.foxhome.hu',
24
+ bio: 'short bio',
25
+ };
26
+
27
+ const validCardElements = {
28
+ showName: true,
29
+ showPosition: false,
30
+ showEmail: true,
31
+ showPhone: false,
32
+ showCompany: false,
33
+ showLogo: false,
34
+ showQr: false,
35
+ showPhoto: false,
36
+ };
37
+
38
+ const validPhysicalConfig = {
39
+ material: 'plastic' as const,
40
+ comboId: 'A1' as const,
41
+ frontElements: validCardElements,
42
+ backElements: validCardElements,
43
+ };
44
+
45
+ const validDigitalConfig = {
46
+ templateId: 'minimal' as const,
47
+ accentColor: '#F47B20',
48
+ fontKey: 'inter',
49
+ showAvatar: true,
50
+ };
51
+
52
+ // ── socialOverridesSchema (FOXHOLE-595 input side) ──────────────
53
+
54
+ describe('socialOverridesSchema (FOXHOLE-595 input side)', () => {
55
+ it('sanitizes every present URL field to safe https (or drops unsafe)', () => {
56
+ const out = socialOverridesSchema.parse({
57
+ facebook: 'https://facebook.com/foxhome',
58
+ linkedin: 'javascript:alert(1)', // unsafe scheme → ''
59
+ instagram: 'instagram.com/foxhome', // bare host → https
60
+ x: 'http://x.com/foxhome', // http upgraded → https
61
+ customUrl: '<script>x</script>https://foo.bar', // tags stripped
62
+ customUrlLabel: '<b>My link</b>',
63
+ });
64
+ expect(out.facebook).toBe('https://facebook.com/foxhome');
65
+ expect(out.linkedin).toBe('');
66
+ expect(out.instagram).toBe('https://instagram.com/foxhome');
67
+ expect(out.x?.startsWith('https://')).toBe(true);
68
+ expect(out.customUrl).not.toMatch(/script/i);
69
+ expect(out.customUrlLabel).toBe('My link');
70
+ });
71
+
72
+ it('preserves SPARSE semantics — omitted keys stay omitted (no merge clobber)', () => {
73
+ const out = socialOverridesSchema.parse({ facebook: 'https://fb.com/x' });
74
+ expect(out.facebook).toBe('https://fb.com/x');
75
+ expect('linkedin' in out).toBe(false);
76
+ expect('x' in out).toBe(false);
77
+ expect('customUrl' in out).toBe(false);
78
+ expect(Object.keys(out)).toEqual(['facebook']);
79
+ });
80
+
81
+ it('includes x for parity with the SocialLinks type + renderer', () => {
82
+ const out = socialOverridesSchema.parse({ x: 'x.com/foo' });
83
+ expect(out.x).toBe('https://x.com/foo');
84
+ });
85
+
86
+ it.each([
87
+ 'javascript:alert(1)',
88
+ 'data:text/html,<script>alert(1)</script>',
89
+ 'vbscript:msgbox(1)',
90
+ 'file:///etc/passwd',
91
+ ])('coerces a dangerous override to empty rather than 400ing: %s', (bad) => {
92
+ const r = socialOverridesSchema.safeParse({ customUrl: bad });
93
+ expect(r.success).toBe(true);
94
+ if (r.success) expect(r.data.customUrl).toBe('');
95
+ });
96
+
97
+ it('accepts an empty object (every field optional)', () => {
98
+ const r = socialOverridesSchema.safeParse({});
99
+ expect(r.success).toBe(true);
100
+ if (r.success) expect(Object.keys(r.data)).toEqual([]);
101
+ });
102
+ });
103
+
104
+ // ── digitalConfig wiring ────────────────────────────────────────
105
+
106
+ describe('digitalConfigSchema — socialOverrides hardening (FOXHOLE-595)', () => {
107
+ it('sanitizes socialOverrides on the full config', () => {
108
+ const r = validateDigitalConfig({
109
+ ...validDigitalConfig,
110
+ socialOverrides: { linkedin: 'javascript:alert(1)', facebook: 'fb.com/x' },
111
+ });
112
+ expect(r.success).toBe(true);
113
+ if (r.success) {
114
+ expect(r.data.socialOverrides?.linkedin).toBe('');
115
+ expect(r.data.socialOverrides?.facebook).toBe('https://fb.com/x');
116
+ expect('instagram' in (r.data.socialOverrides ?? {})).toBe(false);
117
+ }
118
+ });
119
+ });
120
+
121
+ // ── smoke: the 0.1.0 schema graph still builds + parses ─────────
122
+
123
+ describe('0.1.0 schema graph smoke', () => {
124
+ it('validateConfiguration accepts a complete configuration', () => {
125
+ const r = validateConfiguration({
126
+ sessionId: '550e8400-e29b-41d4-a716-446655440000',
127
+ userData: validUserData,
128
+ physicalConfig: validPhysicalConfig,
129
+ digitalConfig: validDigitalConfig,
130
+ quantity: 1,
131
+ });
132
+ expect(r.success).toBe(true);
133
+ });
134
+
135
+ it('createOrderSchema accepts {configurationId} WITHOUT designType (0.1.x line)', () => {
136
+ // Guards that this really is the 0.1.x backport, not the 0.2.x line where
137
+ // designType is required on createOrderSchema.
138
+ const r = createOrderSchema.safeParse({
139
+ configurationId: '550e8400-e29b-41d4-a716-446655440000',
140
+ });
141
+ expect(r.success).toBe(true);
142
+ });
143
+ });