@nfcard/validation 0.5.0 → 0.8.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 CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.4.0"
20
+ "@nfcard/types": "0.7.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
package/src/index.test.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
+ cardDesignSchema,
3
4
  cardElementsSchema,
4
5
  comboIdSchema,
5
6
  configurationCreateSchema,
6
7
  contactExchangeSchema,
7
8
  createOrderSchema,
9
+ ctaButtonsSchema,
8
10
  digitalConfigSchema,
9
11
  hubConfigSchema,
10
12
  materialSchema,
13
+ MAX_CTA_BUTTONS,
11
14
  physicalConfigSchema,
12
15
  profileCreateSchema,
13
16
  profileVisibilitySchema,
@@ -15,6 +18,7 @@ import {
15
18
  sanitizeText,
16
19
  signupWithProfileSchema,
17
20
  socialLinksSchema,
21
+ socialOverridesSchema,
18
22
  userDataSchema,
19
23
  validateConfiguration,
20
24
  validateDigitalConfig,
@@ -208,6 +212,151 @@ describe('socialLinksSchema', () => {
208
212
  });
209
213
  });
210
214
 
215
+ // ── socialOverridesSchema (FOXHOLE-595 input side) ──────────────
216
+
217
+ describe('socialOverridesSchema (FOXHOLE-595 input side)', () => {
218
+ it('sanitizes every present URL field to safe https (or drops unsafe)', () => {
219
+ const out = socialOverridesSchema.parse({
220
+ facebook: 'https://facebook.com/foxhome',
221
+ linkedin: 'javascript:alert(1)', // unsafe scheme → ''
222
+ instagram: 'instagram.com/foxhome', // bare host → https
223
+ x: 'http://x.com/foxhome', // http upgraded → https
224
+ customUrl: '<script>x</script>https://foo.bar', // tags stripped
225
+ customUrlLabel: '<b>My link</b>',
226
+ });
227
+ expect(out.facebook).toBe('https://facebook.com/foxhome');
228
+ expect(out.linkedin).toBe('');
229
+ expect(out.instagram).toBe('https://instagram.com/foxhome');
230
+ expect(out.x?.startsWith('https://')).toBe(true);
231
+ expect(out.customUrl).not.toMatch(/script/i);
232
+ expect(out.customUrlLabel).toBe('My link');
233
+ });
234
+
235
+ it('preserves SPARSE semantics — omitted keys stay omitted (no merge clobber)', () => {
236
+ // The renderer merges { ...baseSocialLinks, ...socialOverrides }; an
237
+ // omitted override key must NOT appear as '' or it would wipe the
238
+ // inherited link. See socialOverridesSchema comment.
239
+ const out = socialOverridesSchema.parse({ facebook: 'https://fb.com/x' });
240
+ expect(out.facebook).toBe('https://fb.com/x');
241
+ expect('linkedin' in out).toBe(false);
242
+ expect('x' in out).toBe(false);
243
+ expect('customUrl' in out).toBe(false);
244
+ expect('customUrlLabel' in out).toBe(false);
245
+ expect(Object.keys(out)).toEqual(['facebook']);
246
+ });
247
+
248
+ it('includes x for parity with the SocialLinks type + renderer', () => {
249
+ const out = socialOverridesSchema.parse({ x: 'x.com/foo' });
250
+ expect(out.x).toBe('https://x.com/foo');
251
+ });
252
+
253
+ it.each([
254
+ 'javascript:alert(1)',
255
+ 'JaVaScRiPt:alert(1)',
256
+ 'data:text/html,<script>alert(1)</script>',
257
+ 'vbscript:msgbox(1)',
258
+ 'file:///etc/passwd',
259
+ 'mailto:a@b.c',
260
+ ])('coerces a dangerous override to empty rather than 400ing: %s', (bad) => {
261
+ const r = socialOverridesSchema.safeParse({ customUrl: bad });
262
+ expect(r.success).toBe(true);
263
+ if (r.success) expect(r.data.customUrl).toBe('');
264
+ });
265
+
266
+ it('accepts an empty object (every field optional)', () => {
267
+ const r = socialOverridesSchema.safeParse({});
268
+ expect(r.success).toBe(true);
269
+ if (r.success) expect(Object.keys(r.data)).toEqual([]);
270
+ });
271
+ });
272
+
273
+ // ── ctaButtonsSchema (NFCARD-211 — Pro CTA buttons) ─────────────
274
+
275
+ describe('ctaButtonsSchema (NFCARD-211 — Pro CTA buttons)', () => {
276
+ it('keeps a valid button and upgrades http → https', () => {
277
+ const out = ctaButtonsSchema.parse([
278
+ { label: 'Book a call', url: 'http://calendly.com/foxhome' },
279
+ ]);
280
+ expect(out).toEqual([
281
+ { label: 'Book a call', url: 'https://calendly.com/foxhome' },
282
+ ]);
283
+ });
284
+
285
+ it('prefixes https onto a bare-host url', () => {
286
+ const [btn] = ctaButtonsSchema.parse([
287
+ { label: 'Shop', url: 'foxhome.hu/shop' },
288
+ ]);
289
+ expect(btn.url).toBe('https://foxhome.hu/shop');
290
+ });
291
+
292
+ it.each([
293
+ 'javascript:alert(1)',
294
+ 'JaVaScRiPt:alert(1)',
295
+ 'data:text/html,<script>alert(1)</script>',
296
+ 'vbscript:msgbox(1)',
297
+ 'file:///etc/passwd',
298
+ 'mailto:a@b.c',
299
+ ])('drops a button whose url is a dangerous/non-http scheme: %s', (bad) => {
300
+ // url sanitizes to '' → the button is incomplete → filtered out.
301
+ expect(ctaButtonsSchema.parse([{ label: 'Click me', url: bad }])).toEqual([]);
302
+ });
303
+
304
+ it('strips HTML tags from the label and caps its length at 40', () => {
305
+ const [btn] = ctaButtonsSchema.parse([
306
+ { label: '<b>Pay now</b>', url: 'https://pay.example.com' },
307
+ ]);
308
+ expect(btn.label).toBe('Pay now');
309
+ const long = ctaButtonsSchema.parse([
310
+ { label: 'x'.repeat(200), url: 'https://e.com' },
311
+ ]);
312
+ expect(long[0].label.length).toBe(40);
313
+ });
314
+
315
+ it('drops incomplete buttons (missing label or url)', () => {
316
+ const out = ctaButtonsSchema.parse([
317
+ { label: '', url: 'https://valid.example.com' },
318
+ { label: 'No link', url: '' },
319
+ { label: 'Good', url: 'https://ok.example.com' },
320
+ ]);
321
+ // URL.href normalises a bare origin with a trailing slash.
322
+ expect(out).toEqual([{ label: 'Good', url: 'https://ok.example.com/' }]);
323
+ });
324
+
325
+ it(`caps the list at MAX_CTA_BUTTONS (${MAX_CTA_BUTTONS})`, () => {
326
+ const many = Array.from({ length: MAX_CTA_BUTTONS + 4 }, (_, i) => ({
327
+ label: `Btn ${i}`,
328
+ url: `https://e${i}.example.com`,
329
+ }));
330
+ expect(ctaButtonsSchema.parse(many)).toHaveLength(MAX_CTA_BUTTONS);
331
+ });
332
+
333
+ it('rejects an abusive payload over the hard array cap', () => {
334
+ const huge = Array.from({ length: 51 }, () => ({
335
+ label: 'x',
336
+ url: 'https://e.example.com',
337
+ }));
338
+ expect(ctaButtonsSchema.safeParse(huge).success).toBe(false);
339
+ });
340
+
341
+ it('flows through digitalConfigSchema — unsafe buttons dropped on save', () => {
342
+ const r = digitalConfigSchema.safeParse({
343
+ templateId: 'card',
344
+ accentColor: '#1A6B52',
345
+ fontKey: 'inter',
346
+ showAvatar: true,
347
+ ctaButtons: [
348
+ { label: 'Evil', url: 'javascript:alert(1)' },
349
+ { label: 'Book', url: 'calendly.com/x' },
350
+ ],
351
+ });
352
+ expect(r.success).toBe(true);
353
+ if (r.success)
354
+ expect(r.data.ctaButtons).toEqual([
355
+ { label: 'Book', url: 'https://calendly.com/x' },
356
+ ]);
357
+ });
358
+ });
359
+
211
360
  // ── userDataSchema ──────────────────────────────────────────────
212
361
 
213
362
  describe('userDataSchema / validateUserData', () => {
@@ -378,6 +527,23 @@ describe('digitalConfigSchema / validateDigitalConfig', () => {
378
527
  const r = validateDigitalConfig({ ...validDigitalConfig, fontKey: '' });
379
528
  expect(r.success).toBe(false);
380
529
  });
530
+
531
+ it('sanitizes socialOverrides on the full config (FOXHOLE-595 input side)', () => {
532
+ const r = validateDigitalConfig({
533
+ ...validDigitalConfig,
534
+ socialOverrides: {
535
+ linkedin: 'javascript:alert(1)', // unsafe → ''
536
+ facebook: 'fb.com/x', // bare host → https
537
+ },
538
+ });
539
+ expect(r.success).toBe(true);
540
+ if (r.success) {
541
+ expect(r.data.socialOverrides?.linkedin).toBe('');
542
+ expect(r.data.socialOverrides?.facebook).toBe('https://fb.com/x');
543
+ // omitted override keys stay absent — no merge clobber downstream
544
+ expect('instagram' in (r.data.socialOverrides ?? {})).toBe(false);
545
+ }
546
+ });
381
547
  });
382
548
 
383
549
  // ── configurationCreateSchema ───────────────────────────────────
@@ -685,3 +851,92 @@ describe('hubConfigSchema', () => {
685
851
  expect(hubConfigSchema.safeParse({ avatarUrl: '' }).success).toBe(true);
686
852
  });
687
853
  });
854
+
855
+ // ── cardDesign (cardgen interop, FOXHOLE-810) ───────────────────
856
+
857
+ const validCardDesign = {
858
+ material: '3dprint_standard' as const,
859
+ designType: 'custom' as const,
860
+ thicknessMm: 1.8,
861
+ pattern: {
862
+ family: 'spiral' as const,
863
+ variant: 'pulse' as const,
864
+ density: 0.55,
865
+ holeMinMm: 0.6,
866
+ holeMaxMm: 2.0,
867
+ intensity: 0.5,
868
+ cellShape: 0 as const,
869
+ cellRotationDeg: 0,
870
+ chipShape: 0 as const,
871
+ chipRotationDeg: 0,
872
+ },
873
+ layout: { chipAnchor: { col: 0 as const, row: 1 as const } },
874
+ filaments: { background: '#F2F1ED', text: '#1A1A1A', accent: '#3FA34D' },
875
+ };
876
+
877
+ const valid3dPhysicalConfig = {
878
+ material: '3dprint_standard' as const,
879
+ colour: '#F2F1ED',
880
+ comboId: 'A1' as const,
881
+ frontElements: validCardElements,
882
+ backElements: validCardElements,
883
+ };
884
+
885
+ describe('cardDesignSchema', () => {
886
+ it('accepts a valid 3D card design', () => {
887
+ expect(cardDesignSchema.safeParse(validCardDesign).success).toBe(true);
888
+ });
889
+
890
+ it('accepts a solid card (pattern: null)', () => {
891
+ expect(
892
+ cardDesignSchema.safeParse({ ...validCardDesign, pattern: null }).success,
893
+ ).toBe(true);
894
+ });
895
+
896
+ it('rejects plastic (cardgen has no FDM mesh for PVC)', () => {
897
+ expect(
898
+ cardDesignSchema.safeParse({ ...validCardDesign, material: 'plastic' }).success,
899
+ ).toBe(false);
900
+ });
901
+
902
+ it('rejects holeMin > holeMax', () => {
903
+ const bad = { ...validCardDesign, pattern: { ...validCardDesign.pattern, holeMinMm: 3, holeMaxMm: 1 } };
904
+ expect(cardDesignSchema.safeParse(bad).success).toBe(false);
905
+ });
906
+
907
+ it('rejects an unknown pattern family', () => {
908
+ const bad = { ...validCardDesign, pattern: { ...validCardDesign.pattern, family: 'mosaic' } };
909
+ expect(cardDesignSchema.safeParse(bad).success).toBe(false);
910
+ });
911
+
912
+ it('rejects a chip anchor outside 0..2', () => {
913
+ const bad = { ...validCardDesign, layout: { chipAnchor: { col: 3, row: 1 } } };
914
+ expect(cardDesignSchema.safeParse(bad).success).toBe(false);
915
+ });
916
+
917
+ it('rejects a non-hex filament', () => {
918
+ const bad = { ...validCardDesign, filaments: { ...validCardDesign.filaments, accent: 'green' } };
919
+ expect(cardDesignSchema.safeParse(bad).success).toBe(false);
920
+ });
921
+ });
922
+
923
+ describe('physicalConfig.cardDesign (nested, not stripped)', () => {
924
+ it('accepts a 3D physicalConfig carrying a matching cardDesign', () => {
925
+ const res = physicalConfigSchema.safeParse({ ...valid3dPhysicalConfig, cardDesign: validCardDesign });
926
+ expect(res.success).toBe(true);
927
+ // and it survives (not stripped):
928
+ if (res.success) expect(res.data.cardDesign?.material).toBe('3dprint_standard');
929
+ });
930
+
931
+ it('rejects a cardDesign whose material disagrees with the card', () => {
932
+ const res = physicalConfigSchema.safeParse({
933
+ ...valid3dPhysicalConfig,
934
+ cardDesign: { ...validCardDesign, material: 'metal' },
935
+ });
936
+ expect(res.success).toBe(false);
937
+ });
938
+
939
+ it('still accepts a legacy physicalConfig with no cardDesign', () => {
940
+ expect(physicalConfigSchema.safeParse(validPhysicalConfig).success).toBe(true);
941
+ });
942
+ });
package/src/index.ts CHANGED
Binary file