@idirdev/formguard 1.0.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/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +23 -0
- package/src/index.js +355 -0
- package/src/messages.js +10 -0
- package/src/middleware.js +118 -0
- package/src/react.js +242 -0
- package/src/sanitizers.js +14 -0
- package/src/schema.js +16 -0
- package/src/validator.js +218 -0
- package/src/validators.js +61 -0
- package/tests/formguard.test.js +272 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file formguard.test.js
|
|
5
|
+
* @description Tests for formguard: validators, sanitizers, schema, locale.
|
|
6
|
+
* @author idirdev
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { describe, it, beforeEach } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const {
|
|
12
|
+
setLocale, getLocale,
|
|
13
|
+
validate, validateField, createSchema,
|
|
14
|
+
sanitize, escapeHtml, stripTags, slugify, trim, normalizeEmail,
|
|
15
|
+
VALIDATORS,
|
|
16
|
+
} = require('../src/index.js');
|
|
17
|
+
|
|
18
|
+
// Reset locale before each test group that touches it
|
|
19
|
+
function resetLocale() { setLocale('en'); }
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Individual validators
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('VALIDATORS.required', () => {
|
|
26
|
+
it('fails for null', () => assert.equal(VALIDATORS.required(null), false));
|
|
27
|
+
it('fails for undefined', () => assert.equal(VALIDATORS.required(undefined), false));
|
|
28
|
+
it('fails for empty string', () => assert.equal(VALIDATORS.required(''), false));
|
|
29
|
+
it('fails for whitespace-only string', () => assert.equal(VALIDATORS.required(' '), false));
|
|
30
|
+
it('passes for non-empty string', () => assert.equal(VALIDATORS.required('hello'), true));
|
|
31
|
+
it('passes for number 0', () => assert.equal(VALIDATORS.required(0), true));
|
|
32
|
+
it('passes for false boolean', () => assert.equal(VALIDATORS.required(false), true));
|
|
33
|
+
it('fails for empty array', () => assert.equal(VALIDATORS.required([]), false));
|
|
34
|
+
it('passes for non-empty array', () => assert.equal(VALIDATORS.required([1]), true));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('VALIDATORS.email', () => {
|
|
38
|
+
it('passes for valid email', () => assert.equal(VALIDATORS.email('user@example.com'), true));
|
|
39
|
+
it('fails for missing @', () => assert.equal(VALIDATORS.email('notanemail'), false));
|
|
40
|
+
it('fails for missing domain', () => assert.equal(VALIDATORS.email('user@'), false));
|
|
41
|
+
it('passes for empty value (not required)', () => assert.equal(VALIDATORS.email(''), true));
|
|
42
|
+
it('passes for null (not required)', () => assert.equal(VALIDATORS.email(null), true));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('VALIDATORS.url', () => {
|
|
46
|
+
it('passes for https URL', () => assert.equal(VALIDATORS.url('https://example.com'), true));
|
|
47
|
+
it('passes for http URL with path', () => assert.equal(VALIDATORS.url('http://foo.bar/path?q=1'), true));
|
|
48
|
+
it('fails for plain string without protocol', () => assert.equal(VALIDATORS.url('example.com'), false));
|
|
49
|
+
it('passes for empty value', () => assert.equal(VALIDATORS.url(''), true));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('VALIDATORS.min / max', () => {
|
|
53
|
+
it('min passes when value equals threshold', () => assert.equal(VALIDATORS.min(5, '5'), true));
|
|
54
|
+
it('min fails when value is below threshold', () => assert.equal(VALIDATORS.min(2, '5'), false));
|
|
55
|
+
it('max passes when value equals threshold', () => assert.equal(VALIDATORS.max(10, '10'), true));
|
|
56
|
+
it('max fails when value exceeds threshold', () => assert.equal(VALIDATORS.max(11, '10'), false));
|
|
57
|
+
it('min passes for empty value', () => assert.equal(VALIDATORS.min('', '5'), true));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('VALIDATORS.minLength / maxLength', () => {
|
|
61
|
+
it('minLength passes when length equals threshold', () => assert.equal(VALIDATORS.minLength('abc', '3'), true));
|
|
62
|
+
it('minLength fails when too short', () => assert.equal(VALIDATORS.minLength('ab', '3'), false));
|
|
63
|
+
it('maxLength passes when length equals threshold', () => assert.equal(VALIDATORS.maxLength('abc', '3'), true));
|
|
64
|
+
it('maxLength fails when too long', () => assert.equal(VALIDATORS.maxLength('abcd', '3'), false));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('VALIDATORS.numeric / alpha / alphanumeric', () => {
|
|
68
|
+
it('numeric passes for integer string', () => assert.equal(VALIDATORS.numeric('42'), true));
|
|
69
|
+
it('numeric passes for float string', () => assert.equal(VALIDATORS.numeric('3.14'), true));
|
|
70
|
+
it('numeric fails for non-numeric', () => assert.equal(VALIDATORS.numeric('abc'), false));
|
|
71
|
+
it('alpha passes for letters only', () => assert.equal(VALIDATORS.alpha('Hello'), true));
|
|
72
|
+
it('alpha fails when digits included', () => assert.equal(VALIDATORS.alpha('Hello1'), false));
|
|
73
|
+
it('alphanumeric passes for mixed', () => assert.equal(VALIDATORS.alphanumeric('abc123'), true));
|
|
74
|
+
it('alphanumeric fails for special chars', () => assert.equal(VALIDATORS.alphanumeric('abc!'), false));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('VALIDATORS.regex', () => {
|
|
78
|
+
it('passes when regex matches', () => assert.equal(VALIDATORS.regex('abc123', '^[a-z0-9]+$'), true));
|
|
79
|
+
it('fails when regex does not match', () => assert.equal(VALIDATORS.regex('ABC', '^[a-z]+$'), false));
|
|
80
|
+
it('accepts RegExp object', () => assert.equal(VALIDATORS.regex('foo', /^foo$/), true));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('VALIDATORS.in', () => {
|
|
84
|
+
it('passes when value is in list', () => assert.equal(VALIDATORS.in('red', 'red,green,blue'), true));
|
|
85
|
+
it('fails when value not in list', () => assert.equal(VALIDATORS.in('yellow', 'red,green,blue'), false));
|
|
86
|
+
it('passes with array arg', () => assert.equal(VALIDATORS.in('b', ['a', 'b', 'c']), true));
|
|
87
|
+
it('passes for empty value', () => assert.equal(VALIDATORS.in('', 'a,b'), true));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// validateField
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('validateField()', () => {
|
|
95
|
+
it('returns valid=true when all rules pass', () => {
|
|
96
|
+
const r = validateField('user@test.com', 'required|email');
|
|
97
|
+
assert.equal(r.valid, true);
|
|
98
|
+
assert.equal(r.errors.length, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns errors for failing rules', () => {
|
|
102
|
+
const r = validateField('', 'required|email', 'email');
|
|
103
|
+
assert.equal(r.valid, false);
|
|
104
|
+
assert.ok(r.errors.length >= 1);
|
|
105
|
+
assert.ok(r.errors[0].includes('email'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('parses min:arg from pipe syntax', () => {
|
|
109
|
+
const r = validateField('2', 'min:5', 'age');
|
|
110
|
+
assert.equal(r.valid, false);
|
|
111
|
+
assert.ok(r.errors[0].includes('age'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('skips unknown rules silently', () => {
|
|
115
|
+
const r = validateField('hello', 'required|unknownrule');
|
|
116
|
+
assert.equal(r.valid, true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// validate()
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('validate()', () => {
|
|
125
|
+
it('validates a complete schema successfully', () => {
|
|
126
|
+
const r = validate(
|
|
127
|
+
{ username: 'alice', email: 'alice@example.com', age: '25' },
|
|
128
|
+
{ username: 'required|minLength:3', email: 'required|email', age: 'numeric|min:18' }
|
|
129
|
+
);
|
|
130
|
+
assert.equal(r.valid, true);
|
|
131
|
+
assert.equal(r.errors.length, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('collects errors for multiple failing fields', () => {
|
|
135
|
+
const r = validate(
|
|
136
|
+
{ username: '', email: 'not-an-email' },
|
|
137
|
+
{ username: 'required', email: 'required|email' }
|
|
138
|
+
);
|
|
139
|
+
assert.equal(r.valid, false);
|
|
140
|
+
assert.equal(r.errors.length, 2);
|
|
141
|
+
const fields = r.errors.map(e => e.field);
|
|
142
|
+
assert.ok(fields.includes('username'));
|
|
143
|
+
assert.ok(fields.includes('email'));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('treats missing fields as null (fails required)', () => {
|
|
147
|
+
const r = validate({}, { name: 'required' });
|
|
148
|
+
assert.equal(r.valid, false);
|
|
149
|
+
assert.equal(r.errors[0].field, 'name');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('throws TypeError when data is not an object', () => {
|
|
153
|
+
assert.throws(() => validate(null, {}), TypeError);
|
|
154
|
+
assert.throws(() => validate('string', {}), TypeError);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// createSchema()
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
describe('createSchema()', () => {
|
|
163
|
+
it('returns an object with a validate method', () => {
|
|
164
|
+
const s = createSchema({ email: 'required|email' });
|
|
165
|
+
assert.equal(typeof s.validate, 'function');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('schema.validate() delegates to validate()', () => {
|
|
169
|
+
const s = createSchema({ email: 'required|email' });
|
|
170
|
+
const r = s.validate({ email: 'bad' });
|
|
171
|
+
assert.equal(r.valid, false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws TypeError for non-object definition', () => {
|
|
175
|
+
assert.throws(() => createSchema(null), TypeError);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Locale switching
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe('setLocale()', () => {
|
|
184
|
+
beforeEach(resetLocale);
|
|
185
|
+
|
|
186
|
+
it('default locale is en', () => assert.equal(getLocale(), 'en'));
|
|
187
|
+
|
|
188
|
+
it('switches to fr and produces French messages', () => {
|
|
189
|
+
setLocale('fr');
|
|
190
|
+
const r = validateField('', 'required', 'nom');
|
|
191
|
+
assert.equal(r.valid, false);
|
|
192
|
+
assert.ok(r.errors[0].includes('nom'));
|
|
193
|
+
assert.ok(r.errors[0].includes('obligatoire'));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('throws for unsupported locale', () => {
|
|
197
|
+
assert.throws(() => setLocale('de'), /Unsupported locale/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('can switch back to en', () => {
|
|
201
|
+
setLocale('fr');
|
|
202
|
+
setLocale('en');
|
|
203
|
+
assert.equal(getLocale(), 'en');
|
|
204
|
+
const r = validateField('', 'required', 'name');
|
|
205
|
+
assert.ok(r.errors[0].includes('required'));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Sanitizers
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe('escapeHtml()', () => {
|
|
214
|
+
it('escapes all special HTML chars', () => {
|
|
215
|
+
assert.equal(escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
|
216
|
+
});
|
|
217
|
+
it('escapes single quotes', () => assert.ok(escapeHtml("it's").includes(''')));
|
|
218
|
+
it('escapes ampersands', () => assert.equal(escapeHtml('a & b'), 'a & b'));
|
|
219
|
+
it('handles non-string input', () => assert.equal(typeof escapeHtml(42), 'string'));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('stripTags()', () => {
|
|
223
|
+
it('removes HTML tags', () => assert.equal(stripTags('<b>Hello</b>'), 'Hello'));
|
|
224
|
+
it('removes multiple tags', () => assert.equal(stripTags('<p>Hello <strong>world</strong></p>'), 'Hello world'));
|
|
225
|
+
it('returns plain string unchanged', () => assert.equal(stripTags('no tags'), 'no tags'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('slugify()', () => {
|
|
229
|
+
it('lowercases and hyphenates', () => assert.equal(slugify('Hello World'), 'hello-world'));
|
|
230
|
+
it('removes special characters', () => assert.equal(slugify('Hello, World!'), 'hello-world'));
|
|
231
|
+
it('handles multiple spaces', () => assert.equal(slugify('foo bar'), 'foo-bar'));
|
|
232
|
+
it('strips accented characters', () => assert.equal(slugify('Héllo'), 'hello'));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('trim()', () => {
|
|
236
|
+
it('trims whitespace', () => assert.equal(trim(' hello '), 'hello'));
|
|
237
|
+
it('returns empty string for null', () => assert.equal(trim(null), ''));
|
|
238
|
+
it('returns empty string for undefined', () => assert.equal(trim(undefined), ''));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('normalizeEmail()', () => {
|
|
242
|
+
it('lowercases and trims', () => assert.equal(normalizeEmail(' User@Example.COM '), 'user@example.com'));
|
|
243
|
+
it('handles non-string', () => assert.equal(normalizeEmail(42), ''));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('sanitize()', () => {
|
|
247
|
+
it('trims fields', () => {
|
|
248
|
+
const r = sanitize({ name: ' Alice ' }, { name: 'trim' });
|
|
249
|
+
assert.equal(r.name, 'Alice');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('escapes HTML in fields', () => {
|
|
253
|
+
const r = sanitize({ comment: '<b>hi</b>' }, { comment: 'escape' });
|
|
254
|
+
assert.ok(r.comment.includes('<'));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('applies array of sanitizers in order', () => {
|
|
258
|
+
const r = sanitize({ name: ' <b>Alice</b> ' }, { name: ['trim', 'stripTags'] });
|
|
259
|
+
assert.equal(r.name, 'Alice');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does not mutate the original object', () => {
|
|
263
|
+
const original = { name: ' test ' };
|
|
264
|
+
sanitize(original, { name: 'trim' });
|
|
265
|
+
assert.equal(original.name, ' test ');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('slugifies a field', () => {
|
|
269
|
+
const r = sanitize({ slug: 'My Post Title!' }, { slug: 'slugify' });
|
|
270
|
+
assert.equal(r.slug, 'my-post-title');
|
|
271
|
+
});
|
|
272
|
+
});
|