@nfcard/validation 0.24.0 → 0.25.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/dist/index.d.ts +68 -10
- package/dist/index.js +12 -7
- package/package.json +2 -2
- package/src/booking.test.ts +85 -85
- package/src/controlChars.test.ts +71 -0
- package/src/index.ts +0 -0
- package/src/profileLayout.test.ts +108 -7
- package/src/profileLayout.ts +43 -11
- package/src/tipJar.test.ts +77 -77
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _nfcard_types from '@nfcard/types';
|
|
2
|
-
import { CardAsset, CardElement, ElementTransform, ProfileLayout } from '@nfcard/types';
|
|
2
|
+
import { CardAsset, CardElement, ElementTransform, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -145,17 +145,17 @@ declare const textObjectStyleSchema: z.ZodObject<{
|
|
|
145
145
|
fontKey?: string | undefined;
|
|
146
146
|
italic?: boolean | undefined;
|
|
147
147
|
underline?: boolean | undefined;
|
|
148
|
-
color?: string | undefined;
|
|
149
|
-
sizeStep?: "sm" | "md" | "lg" | undefined;
|
|
150
148
|
align?: "start" | "center" | undefined;
|
|
149
|
+
sizeStep?: "sm" | "md" | "lg" | undefined;
|
|
150
|
+
color?: string | undefined;
|
|
151
151
|
}, {
|
|
152
152
|
weight?: "bold" | "normal" | undefined;
|
|
153
153
|
fontKey?: string | undefined;
|
|
154
154
|
italic?: boolean | undefined;
|
|
155
155
|
underline?: boolean | undefined;
|
|
156
|
-
color?: string | undefined;
|
|
157
|
-
sizeStep?: "sm" | "md" | "lg" | undefined;
|
|
158
156
|
align?: "start" | "center" | undefined;
|
|
157
|
+
sizeStep?: "sm" | "md" | "lg" | undefined;
|
|
158
|
+
color?: string | undefined;
|
|
159
159
|
}>;
|
|
160
160
|
declare const imageObjectStyleSchema: z.ZodObject<{
|
|
161
161
|
crop: z.ZodOptional<z.ZodObject<{
|
|
@@ -193,16 +193,74 @@ declare const imageObjectStyleSchema: z.ZodObject<{
|
|
|
193
193
|
borderColor?: string | undefined;
|
|
194
194
|
borderPx?: number | undefined;
|
|
195
195
|
}>;
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Contact rows and social chips: text minus `align` and `sizeStep`. Row
|
|
198
|
+
* alignment is the template's grid; and a row is a COMPOUND (label/value
|
|
199
|
+
* spans with their own px sizes), so no wrapper-level em can scale it
|
|
200
|
+
* without destroying the internal hierarchy — either would be a
|
|
201
|
+
* stored-but-dead field.
|
|
202
|
+
*/
|
|
203
|
+
declare const rowTextStyleSchema: z.ZodObject<Omit<{
|
|
204
|
+
color: z.ZodOptional<z.ZodString>;
|
|
205
|
+
sizeStep: z.ZodOptional<z.ZodEnum<["sm", "md", "lg"]>>;
|
|
206
|
+
align: z.ZodOptional<z.ZodEnum<["start", "center"]>>;
|
|
207
|
+
weight: z.ZodOptional<z.ZodEnum<["normal", "bold"]>>;
|
|
208
|
+
italic: z.ZodOptional<z.ZodBoolean>;
|
|
209
|
+
underline: z.ZodOptional<z.ZodBoolean>;
|
|
210
|
+
fontKey: z.ZodOptional<z.ZodString>;
|
|
211
|
+
}, "align" | "sizeStep">, "strict", z.ZodTypeAny, {
|
|
212
|
+
weight?: "bold" | "normal" | undefined;
|
|
213
|
+
fontKey?: string | undefined;
|
|
214
|
+
italic?: boolean | undefined;
|
|
215
|
+
underline?: boolean | undefined;
|
|
216
|
+
color?: string | undefined;
|
|
217
|
+
}, {
|
|
218
|
+
weight?: "bold" | "normal" | undefined;
|
|
219
|
+
fontKey?: string | undefined;
|
|
220
|
+
italic?: boolean | undefined;
|
|
221
|
+
underline?: boolean | undefined;
|
|
222
|
+
color?: string | undefined;
|
|
223
|
+
}>;
|
|
224
|
+
/** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
|
|
225
|
+
* DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
|
|
226
|
+
* in v1 — button geometry is the template's. `variant` is GONE (0.24.0
|
|
227
|
+
* accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
|
|
228
|
+
* — the toolbar exposes no variant control and none is planned — and a
|
|
229
|
+
* stored-but-unwritable field is dead weight on every future vintage. No
|
|
230
|
+
* deployed tag ever accepted one, so nothing stored carries it. */
|
|
231
|
+
declare const buttonObjectStyleSchema: z.ZodObject<Pick<{
|
|
232
|
+
color: z.ZodOptional<z.ZodString>;
|
|
233
|
+
sizeStep: z.ZodOptional<z.ZodEnum<["sm", "md", "lg"]>>;
|
|
234
|
+
align: z.ZodOptional<z.ZodEnum<["start", "center"]>>;
|
|
235
|
+
weight: z.ZodOptional<z.ZodEnum<["normal", "bold"]>>;
|
|
236
|
+
italic: z.ZodOptional<z.ZodBoolean>;
|
|
237
|
+
underline: z.ZodOptional<z.ZodBoolean>;
|
|
238
|
+
fontKey: z.ZodOptional<z.ZodString>;
|
|
239
|
+
}, "weight" | "fontKey" | "italic" | "color"> & {
|
|
197
240
|
bg: z.ZodOptional<z.ZodString>;
|
|
198
|
-
variant: z.ZodOptional<z.ZodEnum<["solid", "soft", "outline"]>>;
|
|
199
241
|
}, "strict", z.ZodTypeAny, {
|
|
200
|
-
|
|
242
|
+
weight?: "bold" | "normal" | undefined;
|
|
243
|
+
fontKey?: string | undefined;
|
|
244
|
+
italic?: boolean | undefined;
|
|
245
|
+
color?: string | undefined;
|
|
201
246
|
bg?: string | undefined;
|
|
202
247
|
}, {
|
|
203
|
-
|
|
248
|
+
weight?: "bold" | "normal" | undefined;
|
|
249
|
+
fontKey?: string | undefined;
|
|
250
|
+
italic?: boolean | undefined;
|
|
251
|
+
color?: string | undefined;
|
|
204
252
|
bg?: string | undefined;
|
|
205
253
|
}>;
|
|
254
|
+
/**
|
|
255
|
+
* Which style vocabulary each kind accepts — TOTAL over the kind vocabulary
|
|
256
|
+
* since the any-text wave, so "unknown kind" can only mean an id this
|
|
257
|
+
* schema's vintage cannot dispatch (already refused by `objectKindOf`).
|
|
258
|
+
*
|
|
259
|
+
* EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
|
|
260
|
+
* matrix from these shapes instead of hand-mirroring them, so a vocabulary
|
|
261
|
+
* change here reshapes the UI by construction.
|
|
262
|
+
*/
|
|
263
|
+
declare const STYLE_SCHEMA_FOR_KIND: Record<ProfileObjectKind, z.ZodObject<z.ZodRawShape>>;
|
|
206
264
|
declare const profileLayoutSchema: z.ZodEffects<z.ZodObject<{
|
|
207
265
|
v: z.ZodLiteral<1>;
|
|
208
266
|
sectionOrder: z.ZodOptional<z.ZodArray<z.ZodEnum<["identity", "bio", "contact", "social", "actions", "cta", "footer"]>, "many">>;
|
|
@@ -22665,4 +22723,4 @@ declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer
|
|
|
22665
22723
|
declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
|
|
22666
22724
|
declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
|
|
22667
22725
|
|
|
22668
|
-
export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
|
22726
|
+
export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
package/dist/index.js
CHANGED
|
@@ -484,13 +484,16 @@ var imageObjectStyleSchema = z.object({
|
|
|
484
484
|
borderPx: z.number().int().min(0).max(8).optional(),
|
|
485
485
|
shape: z.enum(["circle", "rounded", "square"]).optional()
|
|
486
486
|
}).strict();
|
|
487
|
-
var
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
})
|
|
487
|
+
var rowTextStyleSchema = textObjectStyleSchema.omit({
|
|
488
|
+
align: true,
|
|
489
|
+
sizeStep: true
|
|
490
|
+
});
|
|
491
|
+
var buttonObjectStyleSchema = textObjectStyleSchema.pick({ color: true, weight: true, italic: true, fontKey: true }).extend({ bg: z.string().regex(HEX_RE).optional() }).strict();
|
|
491
492
|
var STYLE_SCHEMA_FOR_KIND = {
|
|
492
493
|
text: textObjectStyleSchema,
|
|
493
494
|
freeText: textObjectStyleSchema,
|
|
495
|
+
contactRow: rowTextStyleSchema,
|
|
496
|
+
socialLink: rowTextStyleSchema,
|
|
494
497
|
image: imageObjectStyleSchema,
|
|
495
498
|
ctaButton: buttonObjectStyleSchema,
|
|
496
499
|
linkOut: buttonObjectStyleSchema,
|
|
@@ -562,7 +565,7 @@ var profileLayoutSchema = z.object({
|
|
|
562
565
|
ctx.addIssue({
|
|
563
566
|
code: z.ZodIssueCode.custom,
|
|
564
567
|
path: ["objects", key, "style"],
|
|
565
|
-
message: `kind '${kind}'
|
|
568
|
+
message: `kind '${kind}' has no style vocabulary in this build`
|
|
566
569
|
});
|
|
567
570
|
continue;
|
|
568
571
|
}
|
|
@@ -640,7 +643,7 @@ function isWebAddress(value) {
|
|
|
640
643
|
}
|
|
641
644
|
var webAddressSchema = z2.string().refine(isWebAddress, "invalidUrl").optional().or(z2.literal(""));
|
|
642
645
|
function stripUnsafeChars(s) {
|
|
643
|
-
return s.replace(/[
|
|
646
|
+
return s.replace(/[\x00-\x1f\x7f<>"'`\\]/g, "").trim();
|
|
644
647
|
}
|
|
645
648
|
function sanitizeHttpUrl(raw) {
|
|
646
649
|
if (typeof raw !== "string") return "";
|
|
@@ -660,7 +663,7 @@ function sanitizeHttpUrl(raw) {
|
|
|
660
663
|
}
|
|
661
664
|
function sanitizeText(raw, max = 50) {
|
|
662
665
|
if (typeof raw !== "string") return "";
|
|
663
|
-
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[
|
|
666
|
+
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[\x00-\x1f\x7f]/g, "");
|
|
664
667
|
return noTags.trim().slice(0, max);
|
|
665
668
|
}
|
|
666
669
|
var secureUrl = z2.string().max(2048).optional().or(z2.literal("")).transform((v) => sanitizeHttpUrl(v));
|
|
@@ -1186,6 +1189,7 @@ export {
|
|
|
1186
1189
|
QR_MATRIX_CELLS,
|
|
1187
1190
|
QR_MIN_SIZE_MM,
|
|
1188
1191
|
QR_MODULE_MIN_MM,
|
|
1192
|
+
STYLE_SCHEMA_FOR_KIND,
|
|
1189
1193
|
TEXT_LINE_FACTOR_EM,
|
|
1190
1194
|
TEXT_MIN_EM_MM,
|
|
1191
1195
|
adminCardCreateSchema,
|
|
@@ -1235,6 +1239,7 @@ export {
|
|
|
1235
1239
|
profileUserDataSchema,
|
|
1236
1240
|
profileVisibilitySchema,
|
|
1237
1241
|
qrModeSchema,
|
|
1242
|
+
rowTextStyleSchema,
|
|
1238
1243
|
safeProfileLayout,
|
|
1239
1244
|
sanitizeHttpUrl,
|
|
1240
1245
|
sanitizeText,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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.20.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/booking.test.ts
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { bookingSchema, digitalConfigSchema } from './index'
|
|
3
|
-
|
|
4
|
-
const baseConfig = {
|
|
5
|
-
templateId: 'classic' as const,
|
|
6
|
-
accentColor: '#123456',
|
|
7
|
-
fontKey: 'inter',
|
|
8
|
-
showAvatar: true,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('bookingSchema', () => {
|
|
12
|
-
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
|
|
14
|
-
provider: 'salonic',
|
|
15
|
-
handle: 'kiss-fodraszat',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
|
|
20
|
-
expect(
|
|
21
|
-
bookingSchema.parse({
|
|
22
|
-
provider: 'salonic',
|
|
23
|
-
handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
|
|
24
|
-
}),
|
|
25
|
-
).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('lowercases the handle', () => {
|
|
29
|
-
expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
|
|
30
|
-
provider: 'reservio',
|
|
31
|
-
handle: 'highbrow',
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
36
|
-
const out = bookingSchema.parse({
|
|
37
|
-
provider: 'salonic',
|
|
38
|
-
handle: 'kiss-fodraszat',
|
|
39
|
-
label: '<b>Foglalj</b> ' + 'x'.repeat(60),
|
|
40
|
-
})
|
|
41
|
-
expect(out?.provider).toBe('salonic')
|
|
42
|
-
expect(out?.label).not.toContain('<')
|
|
43
|
-
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
47
|
-
expect(bookingSchema.parse(undefined)).toBeUndefined()
|
|
48
|
-
expect(bookingSchema.parse(null)).toBeUndefined()
|
|
49
|
-
expect(bookingSchema.parse('nonsense')).toBeUndefined()
|
|
50
|
-
expect(bookingSchema.parse({})).toBeUndefined()
|
|
51
|
-
expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
|
|
52
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
|
|
53
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
|
|
54
|
-
expect(
|
|
55
|
-
bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
|
|
56
|
-
).toBeUndefined()
|
|
57
|
-
expect(
|
|
58
|
-
bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
|
|
59
|
-
).toBeUndefined()
|
|
60
|
-
expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
describe('digitalConfigSchema — booking field', () => {
|
|
65
|
-
it('round-trips a valid booking', () => {
|
|
66
|
-
const parsed = digitalConfigSchema.parse({
|
|
67
|
-
...baseConfig,
|
|
68
|
-
booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
|
|
69
|
-
})
|
|
70
|
-
expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('drops an invalid booking without failing the whole config save', () => {
|
|
74
|
-
const parsed = digitalConfigSchema.parse({
|
|
75
|
-
...baseConfig,
|
|
76
|
-
booking: { provider: 'salonic', handle: 'https://evil.com/x' },
|
|
77
|
-
})
|
|
78
|
-
expect(parsed.booking).toBeUndefined()
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('is optional — a config without a booking parses fine', () => {
|
|
82
|
-
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
83
|
-
expect(parsed.booking).toBeUndefined()
|
|
84
|
-
})
|
|
85
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bookingSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('bookingSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
|
|
14
|
+
provider: 'salonic',
|
|
15
|
+
handle: 'kiss-fodraszat',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
|
|
20
|
+
expect(
|
|
21
|
+
bookingSchema.parse({
|
|
22
|
+
provider: 'salonic',
|
|
23
|
+
handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
|
|
24
|
+
}),
|
|
25
|
+
).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('lowercases the handle', () => {
|
|
29
|
+
expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
|
|
30
|
+
provider: 'reservio',
|
|
31
|
+
handle: 'highbrow',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
36
|
+
const out = bookingSchema.parse({
|
|
37
|
+
provider: 'salonic',
|
|
38
|
+
handle: 'kiss-fodraszat',
|
|
39
|
+
label: '<b>Foglalj</b> ' + 'x'.repeat(60),
|
|
40
|
+
})
|
|
41
|
+
expect(out?.provider).toBe('salonic')
|
|
42
|
+
expect(out?.label).not.toContain('<')
|
|
43
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
47
|
+
expect(bookingSchema.parse(undefined)).toBeUndefined()
|
|
48
|
+
expect(bookingSchema.parse(null)).toBeUndefined()
|
|
49
|
+
expect(bookingSchema.parse('nonsense')).toBeUndefined()
|
|
50
|
+
expect(bookingSchema.parse({})).toBeUndefined()
|
|
51
|
+
expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
|
|
52
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
|
|
53
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
|
|
54
|
+
expect(
|
|
55
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
|
|
56
|
+
).toBeUndefined()
|
|
57
|
+
expect(
|
|
58
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
|
|
59
|
+
).toBeUndefined()
|
|
60
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('digitalConfigSchema — booking field', () => {
|
|
65
|
+
it('round-trips a valid booking', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('drops an invalid booking without failing the whole config save', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse({
|
|
75
|
+
...baseConfig,
|
|
76
|
+
booking: { provider: 'salonic', handle: 'https://evil.com/x' },
|
|
77
|
+
})
|
|
78
|
+
expect(parsed.booking).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is optional — a config without a booking parses fine', () => {
|
|
82
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
83
|
+
expect(parsed.booking).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { sanitizeHttpUrl, sanitizeText } from './index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The character classes in `stripUnsafeChars` and `sanitizeText` used to be
|
|
9
|
+
* written with LITERAL 0x00 / 0x1f / 0x7f bytes in the source.
|
|
10
|
+
*
|
|
11
|
+
* That was not a style problem. It made this file read as BINARY to ripgrep
|
|
12
|
+
* ("found \0 byte around offset 2873"), so `grep`/`Grep` silently skipped it
|
|
13
|
+
* and anyone searching for `sanitizeHttpUrl` concluded it did not exist. Worse,
|
|
14
|
+
* a Prettier run, an editor "strip control characters" save, or a
|
|
15
|
+
* `.gitattributes` filter would rewrite those classes without anyone noticing —
|
|
16
|
+
* and `sanitizeHttpUrl` is the SECOND-OPINION gate that `parseExternalUrl` in
|
|
17
|
+
* nfcard-api cross-checks itself against. The two disagreeing turns into a
|
|
18
|
+
* blanket refusal of every external URL: an outage, from a formatter.
|
|
19
|
+
*
|
|
20
|
+
* The bytes are now `\x00` / `\x1f` / `\x7f` escapes — same semantics, plain
|
|
21
|
+
* ASCII, greppable, formatter-proof. These tests keep it that way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SOURCE = fs.readFileSync(path.join(HERE, 'index.ts'));
|
|
26
|
+
|
|
27
|
+
describe('the source file stays plain ASCII', () => {
|
|
28
|
+
it('contains NO literal control bytes — that is what made it read as binary', () => {
|
|
29
|
+
const offenders: string[] = [];
|
|
30
|
+
for (let i = 0; i < SOURCE.length; i++) {
|
|
31
|
+
const byte = SOURCE[i];
|
|
32
|
+
const isControl = byte === 0 || byte < 9 || (byte > 13 && byte < 32) || byte === 127;
|
|
33
|
+
if (isControl) offenders.push(`0x${byte.toString(16)} @ ${i}`);
|
|
34
|
+
}
|
|
35
|
+
expect(offenders).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still declares the classes it always did, as escapes', () => {
|
|
39
|
+
const text = SOURCE.toString('utf8');
|
|
40
|
+
// ⚠️ Not a cosmetic assertion. `[ -<...]` — which is what these look like
|
|
41
|
+
// once the control bytes are eaten — is a RANGE over U+0020–U+003C that
|
|
42
|
+
// swallows `.` `/` `:` and every digit. That exact bug is on the record in
|
|
43
|
+
// this codebase; this pins the intended form so it cannot come back.
|
|
44
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f<>"\'`\\\\]/g');
|
|
45
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f]/g');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('the classes behave exactly as before the escape rewrite', () => {
|
|
50
|
+
const CONTROLS = ['\x00', '\x01', '\x1f', '\x7f'];
|
|
51
|
+
|
|
52
|
+
it.each(CONTROLS)('sanitizeHttpUrl strips %j out of a host', (ch) => {
|
|
53
|
+
expect(sanitizeHttpUrl(`https://exam${ch}ple.com/x`)).toBe('https://example.com/x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(CONTROLS)('sanitizeText strips %j', (ch) => {
|
|
57
|
+
expect(sanitizeText(`Ri${ch}csi`)).toBe('Ricsi');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('still strips the HTML-ish characters beside them', () => {
|
|
61
|
+
expect(sanitizeHttpUrl('https://example.com/<script>')).toBe('https://example.com/script');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does NOT strip the printable characters a URL needs', () => {
|
|
65
|
+
// The guard against re-introducing the U+0020–U+003C range bug: every one
|
|
66
|
+
// of these sits inside that range and must survive.
|
|
67
|
+
expect(sanitizeHttpUrl('https://example.com/a.b/c-1?d=2&e=3')).toBe(
|
|
68
|
+
'https://example.com/a.b/c-1?d=2&e=3',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/index.ts
CHANGED
|
Binary file
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* strings spelled out.
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, it } from 'vitest'
|
|
10
|
-
import { CANONICAL_OBJECT_ORDER, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
|
|
10
|
+
import { CANONICAL_OBJECT_ORDER, PROFILE_OBJECT_KINDS, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
|
|
11
11
|
import { MAX_CTA_BUTTONS, digitalConfigSchema } from './index'
|
|
12
|
-
import { profileLayoutSchema, safeProfileLayout } from './profileLayout'
|
|
12
|
+
import { STYLE_SCHEMA_FOR_KIND, profileLayoutSchema, safeProfileLayout } from './profileLayout'
|
|
13
13
|
|
|
14
14
|
const okLayout: ProfileLayout = {
|
|
15
15
|
v: 1,
|
|
@@ -21,8 +21,8 @@ const okLayout: ProfileLayout = {
|
|
|
21
21
|
objects: {
|
|
22
22
|
name: { style: { color: '#112233', sizeStep: 'lg', weight: 'bold' } },
|
|
23
23
|
avatar: { style: { crop: { x: 0.5, y: 0.25, zoom: 1.6 }, borderColor: '#ffffff', borderPx: 3 } },
|
|
24
|
-
'cta-0': { style: { bg: '#16a34a',
|
|
25
|
-
booking: { style: {
|
|
24
|
+
'cta-0': { style: { bg: '#16a34a', color: '#ffffff' } },
|
|
25
|
+
booking: { style: { weight: 'bold' } },
|
|
26
26
|
},
|
|
27
27
|
ownedObjects: [{ id: 'ft-abcd', kind: 'freeText', section: 'bio', text: 'Hello there' }],
|
|
28
28
|
}
|
|
@@ -69,10 +69,10 @@ describe('profileLayoutSchema — rejects abuse', () => {
|
|
|
69
69
|
expect(bad({ v: 1, objectOrder: { unknownSection: ['social-x'] } })).toBe(false)
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
it('objects: unknown ids,
|
|
72
|
+
it('objects: unknown ids, off-vocabulary props, injection-shaped values', () => {
|
|
73
73
|
expect(bad({ v: 1, objects: { 'not-a-thing': { style: {} } } })).toBe(false)
|
|
74
|
-
// contactRow is
|
|
75
|
-
//
|
|
74
|
+
// contactRow is TEXT-stylable since the any-text wave, but `bg` never
|
|
75
|
+
// joined its vocabulary — a row has no fill of its own.
|
|
76
76
|
expect(bad({ v: 1, objects: { 'contact-email': { style: { bg: '#112233' } } } })).toBe(false)
|
|
77
77
|
expect(bad({ v: 1, objects: { name: { style: { color: 'red;}{' } } } })).toBe(false)
|
|
78
78
|
expect(bad({ v: 1, objects: { name: { style: { color: '#12345' } } } })).toBe(false)
|
|
@@ -116,6 +116,107 @@ describe('profileLayoutSchema — rejects abuse', () => {
|
|
|
116
116
|
})
|
|
117
117
|
})
|
|
118
118
|
|
|
119
|
+
describe('the any-text wave: per-kind style vocabulary (kind × prop matrix)', () => {
|
|
120
|
+
const style = (id: string, s: Record<string, unknown>) =>
|
|
121
|
+
profileLayoutSchema.safeParse({ v: 1, objects: { [id]: { style: s } } }).success
|
|
122
|
+
|
|
123
|
+
it('rows (contactRow / socialLink) take the text vocabulary minus align/sizeStep', () => {
|
|
124
|
+
const full = {
|
|
125
|
+
color: '#112233',
|
|
126
|
+
weight: 'bold',
|
|
127
|
+
italic: true,
|
|
128
|
+
underline: true,
|
|
129
|
+
fontKey: 'inter',
|
|
130
|
+
}
|
|
131
|
+
expect(style('contact-email', full)).toBe(true)
|
|
132
|
+
expect(style('social-instagram', full)).toBe(true)
|
|
133
|
+
// Row alignment is the template's grid — align stays refused. And a row
|
|
134
|
+
// is a compound (label/value spans with own px sizes), so sizeStep has
|
|
135
|
+
// nothing honest to render — refused rather than stored dead.
|
|
136
|
+
expect(style('contact-email', { align: 'center' })).toBe(false)
|
|
137
|
+
expect(style('social-instagram', { align: 'start' })).toBe(false)
|
|
138
|
+
expect(style('contact-email', { sizeStep: 'sm' })).toBe(false)
|
|
139
|
+
expect(style('social-instagram', { sizeStep: 'lg' })).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('buttons take label styling; geometry props stay out', () => {
|
|
143
|
+
const label = { color: '#112233', weight: 'bold', italic: true, fontKey: 'inter' }
|
|
144
|
+
expect(style('cta-0', { ...label, bg: '#16a34a' })).toBe(true)
|
|
145
|
+
expect(style('booking', { ...label, bg: '#16a34a' })).toBe(true)
|
|
146
|
+
// No sizeStep (button geometry is the template's), no underline, no align.
|
|
147
|
+
expect(style('cta-0', { sizeStep: 'lg' })).toBe(false)
|
|
148
|
+
expect(style('cta-0', { underline: true })).toBe(false)
|
|
149
|
+
expect(style('booking', { align: 'center' })).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('🔴 variant is GONE for every button kind — no surface could ever write it', () => {
|
|
153
|
+
expect(style('act-share', { color: '#112233', bg: '#16a34a', weight: 'bold' })).toBe(true)
|
|
154
|
+
for (const id of ['cta-0', 'booking', 'act-share']) {
|
|
155
|
+
expect(style(id, { variant: 'solid' })).toBe(false)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('🔴 the vocabulary is TOTAL: every kind dispatches to a schema', () => {
|
|
160
|
+
// Driven off PROFILE_OBJECT_KINDS, not a hardcoded list — a NINTH kind
|
|
161
|
+
// added to types without a row here fails THIS line, not a production
|
|
162
|
+
// render. (tsc would catch it too, but tsc does not run on the publish
|
|
163
|
+
// path — validation 0.24.0 shipped while tsc was red.)
|
|
164
|
+
for (const kind of PROFILE_OBJECT_KINDS) {
|
|
165
|
+
expect(STYLE_SCHEMA_FOR_KIND[kind], `no style schema for kind '${kind}'`).toBeDefined()
|
|
166
|
+
}
|
|
167
|
+
// And through the real dispatch: one id per kind; `{}` is the least
|
|
168
|
+
// style. (The empty-style accept is also the additive guarantee: old
|
|
169
|
+
// documents keep parsing as vocabularies widen.)
|
|
170
|
+
const representative: Record<string, string> = {
|
|
171
|
+
text: 'name',
|
|
172
|
+
freeText: 'ft-abcd',
|
|
173
|
+
contactRow: 'contact-email',
|
|
174
|
+
socialLink: 'social-instagram',
|
|
175
|
+
image: 'avatar',
|
|
176
|
+
ctaButton: 'cta-0',
|
|
177
|
+
linkOut: 'booking',
|
|
178
|
+
action: 'act-share',
|
|
179
|
+
}
|
|
180
|
+
for (const kind of PROFILE_OBJECT_KINDS) {
|
|
181
|
+
const id = representative[kind]
|
|
182
|
+
expect(id, `no representative id for kind '${kind}' — extend this test`).toBeDefined()
|
|
183
|
+
expect(style(id, {})).toBe(true)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('🔴 a kind the table misses is a REFUSAL, never a throw', () => {
|
|
188
|
+
// The cross-package skew case: a types vintage that knows a kind this
|
|
189
|
+
// validation build does not. The guard must add an issue — an exception
|
|
190
|
+
// in superRefine ESCAPES safeParse (zod 3.25), and safeProfileLayout's
|
|
191
|
+
// "the public page never 500s over decoration" contract dies with it.
|
|
192
|
+
const stolen = STYLE_SCHEMA_FOR_KIND.action
|
|
193
|
+
delete (STYLE_SCHEMA_FOR_KIND as Partial<typeof STYLE_SCHEMA_FOR_KIND>).action
|
|
194
|
+
try {
|
|
195
|
+
const r = profileLayoutSchema.safeParse({
|
|
196
|
+
v: 1,
|
|
197
|
+
objects: { 'act-share': { style: { weight: 'bold' } } },
|
|
198
|
+
})
|
|
199
|
+
expect(r.success).toBe(false)
|
|
200
|
+
// A style-LESS override on the unknown kind keeps the old accept.
|
|
201
|
+
expect(
|
|
202
|
+
profileLayoutSchema.safeParse({ v: 1, objects: { 'act-share': {} } }).success,
|
|
203
|
+
).toBe(true)
|
|
204
|
+
} finally {
|
|
205
|
+
STYLE_SCHEMA_FOR_KIND.action = stolen
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('the distinct-font cap counts row and button fontKeys too', () => {
|
|
210
|
+
const fonts = Object.fromEntries(
|
|
211
|
+
['name', 'contact-email', 'social-instagram', 'cta-0', 'booking'].map((id, i) => [
|
|
212
|
+
id,
|
|
213
|
+
{ style: { fontKey: `font-${i}` } },
|
|
214
|
+
]),
|
|
215
|
+
)
|
|
216
|
+
expect(profileLayoutSchema.safeParse({ v: 1, objects: fonts }).success).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
119
220
|
describe('digitalConfigSchema integration', () => {
|
|
120
221
|
const baseConfig = {
|
|
121
222
|
templateId: 'classic',
|
package/src/profileLayout.ts
CHANGED
|
@@ -83,22 +83,47 @@ export const imageObjectStyleSchema = z
|
|
|
83
83
|
})
|
|
84
84
|
.strict()
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Contact rows and social chips: text minus `align` and `sizeStep`. Row
|
|
88
|
+
* alignment is the template's grid; and a row is a COMPOUND (label/value
|
|
89
|
+
* spans with their own px sizes), so no wrapper-level em can scale it
|
|
90
|
+
* without destroying the internal hierarchy — either would be a
|
|
91
|
+
* stored-but-dead field.
|
|
92
|
+
*/
|
|
93
|
+
export const rowTextStyleSchema = textObjectStyleSchema.omit({
|
|
94
|
+
align: true,
|
|
95
|
+
sizeStep: true,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
|
|
99
|
+
* DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
|
|
100
|
+
* in v1 — button geometry is the template's. `variant` is GONE (0.24.0
|
|
101
|
+
* accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
|
|
102
|
+
* — the toolbar exposes no variant control and none is planned — and a
|
|
103
|
+
* stored-but-unwritable field is dead weight on every future vintage. No
|
|
104
|
+
* deployed tag ever accepted one, so nothing stored carries it. */
|
|
105
|
+
export const buttonObjectStyleSchema = textObjectStyleSchema
|
|
106
|
+
.pick({ color: true, weight: true, italic: true, fontKey: true })
|
|
107
|
+
.extend({ bg: z.string().regex(HEX_RE).optional() })
|
|
91
108
|
.strict()
|
|
92
109
|
|
|
93
110
|
/**
|
|
94
|
-
* Which style vocabulary each kind accepts
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
111
|
+
* Which style vocabulary each kind accepts — TOTAL over the kind vocabulary
|
|
112
|
+
* since the any-text wave, so "unknown kind" can only mean an id this
|
|
113
|
+
* schema's vintage cannot dispatch (already refused by `objectKindOf`).
|
|
114
|
+
*
|
|
115
|
+
* EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
|
|
116
|
+
* matrix from these shapes instead of hand-mirroring them, so a vocabulary
|
|
117
|
+
* change here reshapes the UI by construction.
|
|
98
118
|
*/
|
|
99
|
-
const STYLE_SCHEMA_FOR_KIND:
|
|
119
|
+
export const STYLE_SCHEMA_FOR_KIND: Record<
|
|
120
|
+
ProfileObjectKind,
|
|
121
|
+
z.ZodObject<z.ZodRawShape>
|
|
122
|
+
> = {
|
|
100
123
|
text: textObjectStyleSchema,
|
|
101
124
|
freeText: textObjectStyleSchema,
|
|
125
|
+
contactRow: rowTextStyleSchema,
|
|
126
|
+
socialLink: rowTextStyleSchema,
|
|
102
127
|
image: imageObjectStyleSchema,
|
|
103
128
|
ctaButton: buttonObjectStyleSchema,
|
|
104
129
|
linkOut: buttonObjectStyleSchema,
|
|
@@ -191,11 +216,18 @@ export const profileLayoutSchema = z
|
|
|
191
216
|
}
|
|
192
217
|
const style = overrideParsed.data.style
|
|
193
218
|
if (style === undefined) continue
|
|
219
|
+
// The table is TOTAL by type, but tsc does not run on the publish
|
|
220
|
+
// path and `objectKindOf` lives in @nfcard/types behind a ^range —
|
|
221
|
+
// a types vintage that knows a NINTH kind can resolve under a
|
|
222
|
+
// validation build whose table stops at eight. Without this guard
|
|
223
|
+
// that skew is a TypeError that ESCAPES safeParse (verified against
|
|
224
|
+
// zod 3.25), turning `safeProfileLayout`'s promised null into a 500
|
|
225
|
+
// on the public render. Refusal, never a throw.
|
|
194
226
|
if (!styleSchema) {
|
|
195
227
|
ctx.addIssue({
|
|
196
228
|
code: z.ZodIssueCode.custom,
|
|
197
229
|
path: ['objects', key, 'style'],
|
|
198
|
-
message: `kind '${kind}'
|
|
230
|
+
message: `kind '${kind}' has no style vocabulary in this build`,
|
|
199
231
|
})
|
|
200
232
|
continue
|
|
201
233
|
}
|
package/src/tipJar.test.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
-
|
|
4
|
-
const baseConfig = {
|
|
5
|
-
templateId: 'classic' as const,
|
|
6
|
-
accentColor: '#123456',
|
|
7
|
-
fontKey: 'inter',
|
|
8
|
-
showAvatar: true,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('tipJarSchema', () => {
|
|
12
|
-
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
-
provider: 'kofi',
|
|
15
|
-
handle: 'ricsi',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
-
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
-
)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('lowercases revolut handles', () => {
|
|
26
|
-
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
-
provider: 'revolut',
|
|
28
|
-
handle: 'ricsi',
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
-
const out = tipJarSchema.parse({
|
|
34
|
-
provider: 'kofi',
|
|
35
|
-
handle: 'ricsi',
|
|
36
|
-
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
-
})
|
|
38
|
-
expect(out?.provider).toBe('kofi')
|
|
39
|
-
expect(out?.label).not.toContain('<')
|
|
40
|
-
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
-
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
-
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
-
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
-
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
-
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
-
it('round-trips a valid tipJar', () => {
|
|
58
|
-
const parsed = digitalConfigSchema.parse({
|
|
59
|
-
...baseConfig,
|
|
60
|
-
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
-
})
|
|
62
|
-
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
-
const parsed = digitalConfigSchema.parse({
|
|
67
|
-
...baseConfig,
|
|
68
|
-
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
-
})
|
|
70
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
-
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
-
})
|
|
77
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('tipJarSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
+
provider: 'kofi',
|
|
15
|
+
handle: 'ricsi',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
+
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('lowercases revolut handles', () => {
|
|
26
|
+
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
+
provider: 'revolut',
|
|
28
|
+
handle: 'ricsi',
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
+
const out = tipJarSchema.parse({
|
|
34
|
+
provider: 'kofi',
|
|
35
|
+
handle: 'ricsi',
|
|
36
|
+
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
+
})
|
|
38
|
+
expect(out?.provider).toBe('kofi')
|
|
39
|
+
expect(out?.label).not.toContain('<')
|
|
40
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
+
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
+
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
+
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
+
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
+
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
+
it('round-trips a valid tipJar', () => {
|
|
58
|
+
const parsed = digitalConfigSchema.parse({
|
|
59
|
+
...baseConfig,
|
|
60
|
+
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
+
})
|
|
62
|
+
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|