@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.d.ts +3519 -111
- package/dist/index.js +0 -0
- package/package.json +2 -2
- package/src/index.test.ts +255 -0
- package/src/index.ts +0 -0
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.
|
|
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.
|
|
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
|