@nfcard/validation 0.25.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.js +2 -2
- 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/tipJar.test.ts +77 -77
package/dist/index.js
CHANGED
|
@@ -643,7 +643,7 @@ function isWebAddress(value) {
|
|
|
643
643
|
}
|
|
644
644
|
var webAddressSchema = z2.string().refine(isWebAddress, "invalidUrl").optional().or(z2.literal(""));
|
|
645
645
|
function stripUnsafeChars(s) {
|
|
646
|
-
return s.replace(/[
|
|
646
|
+
return s.replace(/[\x00-\x1f\x7f<>"'`\\]/g, "").trim();
|
|
647
647
|
}
|
|
648
648
|
function sanitizeHttpUrl(raw) {
|
|
649
649
|
if (typeof raw !== "string") return "";
|
|
@@ -663,7 +663,7 @@ function sanitizeHttpUrl(raw) {
|
|
|
663
663
|
}
|
|
664
664
|
function sanitizeText(raw, max = 50) {
|
|
665
665
|
if (typeof raw !== "string") return "";
|
|
666
|
-
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[
|
|
666
|
+
const noTags = raw.replace(/<[^>]*>/g, "").replace(/[\x00-\x1f\x7f]/g, "");
|
|
667
667
|
return noTags.trim().slice(0, max);
|
|
668
668
|
}
|
|
669
669
|
var secureUrl = z2.string().max(2048).optional().or(z2.literal("")).transform((v) => sanitizeHttpUrl(v));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.25.
|
|
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
|
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
|
+
})
|