forked from Remitwise-Org/Remitwise-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidation-properties.test.ts
More file actions
340 lines (320 loc) · 11 KB
/
Copy pathvalidation-properties.test.ts
File metadata and controls
340 lines (320 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
import { Keypair } from '@stellar/stellar-sdk';
import {
validateAmount,
validateFutureDate,
validateGoalId,
validateGoalName,
} from '@/lib/validation/savings-goals';
import {
validatePercentages,
validateStellarAddress,
ValidationError,
SplitPercentages,
} from '@/lib/validation/percentages';
/**
* Property-Based Tests for Validation Functions
* Feature: savings-goals-transactions
*
* These tests verify correctness properties across many randomly generated inputs.
*/
describe('Validation Properties - Property-Based Tests', () => {
/**
* Property 2: Amount validation rejects non-positive values
* Validates: Requirements 1.3, 2.2, 3.2
*
* For any amount that is zero, negative, NaN, or infinite,
* the validation function should return isValid: false with an appropriate error message.
*/
it('Property 2: Amount validation rejects non-positive values', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant(0),
fc.double({ min: -1000000, max: -0.0001 }),
fc.constant(NaN),
fc.constant(Infinity),
fc.constant(-Infinity)
),
(invalidAmount) => {
const result = validateAmount(invalidAmount);
return result.isValid === false && result.error !== undefined;
}
),
{ numRuns: 100 }
);
});
it('Property 2 (positive case): Amount validation accepts positive values', () => {
fc.assert(
fc.property(
fc.double({ min: 0.0001, max: 1000000, noNaN: true }),
(validAmount) => {
const result = validateAmount(validAmount);
return result.isValid === true;
}
),
{ numRuns: 100 }
);
});
/**
* Property 3: Goal ID validation rejects empty strings
* Validates: Requirements 2.3, 3.3, 4.2, 5.2
*
* For any string that is empty or contains only whitespace,
* the goal ID validation should return isValid: false.
*/
it('Property 3: Goal ID validation rejects empty strings', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant(''),
fc.constant(' '),
fc.constant('\t\t'),
fc.constant('\n\n')
),
(emptyOrWhitespace) => {
const result = validateGoalId(emptyOrWhitespace);
return result.isValid === false;
}
),
{ numRuns: 100 }
);
});
it('Property 3 (positive case): Goal ID validation accepts non-empty strings', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
(validGoalId) => {
const result = validateGoalId(validGoalId);
return result.isValid === true;
}
),
{ numRuns: 100 }
);
});
/**
* Property 4: Goal name validation enforces length constraints
* Validates: Requirements 1.2
*
* For any string with length less than 1 or greater than 100 characters,
* the goal name validation should return isValid: false.
*/
it('Property 4: Goal name validation rejects names over 100 characters', () => {
fc.assert(
fc.property(
fc.string({ minLength: 101, maxLength: 200 }),
(longName) => {
const result = validateGoalName(longName);
// Names longer than 100 chars are rejected as "too long"; a string of
// only whitespace is rejected earlier as "required" (it trims empty).
return (
result.isValid === false &&
(result.error === 'goal_name_too_long' ||
result.error === 'goal_name_required')
);
}
),
{ numRuns: 100 }
);
});
it('Property 4: Goal name validation rejects empty names', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant(''),
fc.constant(' '),
fc.constant('\t\t'),
fc.constant('\n\n')
),
(emptyName) => {
const result = validateGoalName(emptyName);
return result.isValid === false;
}
),
{ numRuns: 100 }
);
});
it('Property 4 (positive case): Goal name validation accepts valid names', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
(validName) => {
const result = validateGoalName(validName);
return result.isValid === true;
}
),
{ numRuns: 100 }
);
});
/**
* Property 5: Future date validation rejects past dates
* Validates: Requirements 1.4
*
* For any date string representing a time in the past or present,
* the date validation should return isValid: false.
*/
it('Property 5: Future date validation rejects past dates', () => {
const now = Date.now();
fc.assert(
fc.property(
fc.date({ max: new Date(now - 1000) }).filter(d => !isNaN(d.getTime())), // At least 1 second in the past
(pastDate) => {
const result = validateFutureDate(pastDate.toISOString());
return result.isValid === false && result.error?.includes('future');
}
),
{ numRuns: 100 }
);
});
it('Property 5 (positive case): Future date validation accepts future dates', () => {
const now = Date.now();
fc.assert(
fc.property(
fc.date({ min: new Date(now + 60000) }).filter(d => !isNaN(d.getTime())), // At least 1 minute in the future
(futureDate) => {
const result = validateFutureDate(futureDate.toISOString());
return result.isValid === true;
}
),
{ numRuns: 100 }
);
});
/**
* Property 10: Percentage validation accepts sets that sum to ~100
*/
it('Property 10: validatePercentages accepts sets that sum to 100', () => {
fc.assert(
fc.property(
// Use a sane lower bound so the array never sums to a sub-normal value;
// normalizing denormalized doubles (e.g. 5e-324) is numerically unstable
// and would drift outside validatePercentages' 0.01 tolerance.
fc.array(fc.double({ min: 0.01, max: 100, noNaN: true, noDefaultInfinity: true }), { minLength: 4, maxLength: 4 }),
(arr) => {
const total = arr.reduce((s, v) => s + v, 0);
const factor = total === 0 ? 0 : 100 / total;
const percentages: SplitPercentages = {
spending: total === 0 ? 25 : arr[0] * factor,
savings: total === 0 ? 25 : arr[1] * factor,
bills: total === 0 ? 25 : arr[2] * factor,
insurance: total === 0 ? 25 : arr[3] * factor,
};
// Should not throw
expect(() => validatePercentages(percentages)).not.toThrow();
}
),
{ numRuns: 100 }
);
});
it('Property 11: validatePercentages rejects negative values', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 3 }),
fc.double({ min: -100, max: -0.01, noNaN: true, noDefaultInfinity: true }),
(negativeIndex, negativeValue) => {
const p: SplitPercentages = { spending: 25, savings: 25, bills: 25, insurance: 25 };
const keys = ['spending', 'savings', 'bills', 'insurance'] as const;
p[keys[negativeIndex]] = negativeValue;
expect(() => validatePercentages(p)).toThrow('must be non-negative');
}
),
{ numRuns: 100 }
);
});
it('Property 12: validatePercentages rejects sums not equal to 100', () => {
fc.assert(
fc.property(
fc.double({ noNaN: true, noDefaultInfinity: true }).filter(s => Math.abs(s - 100) > 0.1),
(invalidSum) => {
const p: SplitPercentages = {
spending: invalidSum / 4,
savings: invalidSum / 4,
bills: invalidSum / 4,
insurance: invalidSum / 4
};
// Filter out negative results which might trigger the negative value check first
if (p.spending < 0) return true;
try {
validatePercentages(p);
return false;
} catch (e) {
return (e as Error).message.includes('sum');
}
}
),
{ numRuns: 100 }
);
});
/**
* Property 13: validateStellarAddress accepts genuinely valid addresses
*
* `validateStellarAddress` performs a structural regex check AND verifies the
* StrKey CRC16 checksum via `StrKey.isValidEd25519PublicKey`. A random Base32
* string of the right shape will almost never have a valid checksum, so we
* generate real keypairs (which are checksum-valid by construction) to assert
* that legitimate addresses are accepted.
*/
it('Property 13: validateStellarAddress accepts genuinely valid addresses', () => {
fc.assert(
fc.property(
// The integer is just a source of entropy to force fresh runs; each run
// produces a brand-new, checksum-valid Ed25519 public key.
fc.integer(),
() => {
const address = Keypair.random().publicKey();
expect(() => validateStellarAddress(address)).not.toThrow();
}
),
{ numRuns: 100 }
);
});
it('Property 14: validateStellarAddress rejects adversarial inputs', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string().filter(s => s.length !== 56), // Wrong length
fc.string({ minLength: 56, maxLength: 56 }).filter(s => !s.startsWith('G')), // Wrong prefix
fc.string({ minLength: 56, maxLength: 56, unit: fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')) }), // Lowercase
fc.string({ minLength: 56, maxLength: 56 }).filter(s => /[^A-Z0-9]/.test(s)), // Illegal characters
fc.constant(''), // Empty
fc.constant(null as any), // Non-string
fc.constant(undefined as any) // Non-string
),
(invalidAddress) => {
// All these should throw ValidationError
expect(() => validateStellarAddress(invalidAddress)).toThrow();
}
),
{ numRuns: 100 }
);
});
/**
* Property 9: Error responses have consistent structure
* Validates: Requirements 8.2
*
* For any error response from any validation function,
* the response should contain an "error" field with a string message.
*/
it('Property 9: All validation errors have consistent structure', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constant({ fn: validateAmount, input: -1 }),
fc.constant({ fn: validateGoalId, input: '' }),
fc.constant({ fn: validateGoalName, input: '' }),
fc.constant({ fn: validateFutureDate, input: '2020-01-01' })
),
(testCase) => {
const validate = testCase.fn as (input: unknown) => { isValid: boolean; error?: string };
const result = validate(testCase.input);
return (
result.isValid === false &&
typeof result.error === 'string' &&
result.error.length > 0
);
}
),
{ numRuns: 100 }
);
});
});