@nextsparkjs/plugin-amplitude 0.1.0-beta.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.
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Test suite for Amplitude validation and security
3
+ */
4
+
5
+ import {
6
+ isAmplitudeAPIKey,
7
+ isValidUserId,
8
+ isAmplitudeEvent,
9
+ AmplitudeAPIKeySchema,
10
+ UserIdSchema,
11
+ EventTypeSchema,
12
+ EventPropertiesSchema,
13
+ UserPropertiesSchema
14
+ } from '../types/amplitude.types';
15
+ import { DataSanitizer, SlidingWindowRateLimiter, SecurityAuditLogger } from '../lib/security';
16
+
17
+ describe('Type Guards and Validation', () => {
18
+ describe('isAmplitudeAPIKey', () => {
19
+ it('should validate correct API key format', () => {
20
+ const validKey = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6';
21
+ expect(isAmplitudeAPIKey(validKey)).toBe(true);
22
+ });
23
+
24
+ it('should reject invalid API key formats', () => {
25
+ const invalidKeys = [
26
+ 'too-short',
27
+ 'this-key-is-way-too-long-to-be-valid',
28
+ 'contains-invalid-chars!@#',
29
+ '',
30
+ 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p', // 31 chars
31
+ 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p67', // 33 chars
32
+ ];
33
+
34
+ invalidKeys.forEach(key => {
35
+ expect(isAmplitudeAPIKey(key)).toBe(false);
36
+ });
37
+ });
38
+ });
39
+
40
+ describe('isValidUserId', () => {
41
+ it('should validate non-empty user IDs', () => {
42
+ const validIds = ['user123', 'test@example.com', 'uuid-1234-5678'];
43
+
44
+ validIds.forEach(id => {
45
+ expect(isValidUserId(id)).toBe(true);
46
+ });
47
+ });
48
+
49
+ it('should reject empty or invalid user IDs', () => {
50
+ const invalidIds = ['', ' ', null, undefined];
51
+
52
+ invalidIds.forEach(id => {
53
+ expect(isValidUserId(id as any)).toBe(false);
54
+ });
55
+ });
56
+ });
57
+
58
+ describe('isAmplitudeEvent', () => {
59
+ it('should validate correct event objects', () => {
60
+ const validEvents = [
61
+ { eventType: 'Page Viewed' },
62
+ { eventType: 'Button Clicked', properties: { buttonId: 'submit' } },
63
+ { eventType: 'User Signup', properties: { plan: 'premium', source: 'organic' } },
64
+ ];
65
+
66
+ validEvents.forEach(event => {
67
+ expect(isAmplitudeEvent(event)).toBe(true);
68
+ });
69
+ });
70
+
71
+ it('should reject invalid event objects', () => {
72
+ const invalidEvents = [
73
+ {},
74
+ { eventType: '' },
75
+ { eventType: null },
76
+ { eventType: undefined },
77
+ { properties: { test: 'value' } }, // missing eventType
78
+ null,
79
+ undefined,
80
+ 'not an object',
81
+ ];
82
+
83
+ invalidEvents.forEach(event => {
84
+ expect(isAmplitudeEvent(event)).toBe(false);
85
+ });
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('Zod Schema Validation', () => {
91
+ describe('AmplitudeAPIKeySchema', () => {
92
+ it('should validate correct API key', () => {
93
+ const validKey = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6';
94
+ const result = AmplitudeAPIKeySchema.safeParse(validKey);
95
+ expect(result.success).toBe(true);
96
+ });
97
+
98
+ it('should reject invalid API key', () => {
99
+ const invalidKey = 'invalid-key';
100
+ const result = AmplitudeAPIKeySchema.safeParse(invalidKey);
101
+ expect(result.success).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe('UserIdSchema', () => {
106
+ it('should validate non-empty user ID', () => {
107
+ const validId = 'user123';
108
+ const result = UserIdSchema.safeParse(validId);
109
+ expect(result.success).toBe(true);
110
+ });
111
+
112
+ it('should reject empty user ID', () => {
113
+ const invalidId = '';
114
+ const result = UserIdSchema.safeParse(invalidId);
115
+ expect(result.success).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe('EventTypeSchema', () => {
120
+ it('should validate non-empty event type', () => {
121
+ const validEventType = 'Page Viewed';
122
+ const result = EventTypeSchema.safeParse(validEventType);
123
+ expect(result.success).toBe(true);
124
+ });
125
+
126
+ it('should reject empty event type', () => {
127
+ const invalidEventType = '';
128
+ const result = EventTypeSchema.safeParse(invalidEventType);
129
+ expect(result.success).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe('EventPropertiesSchema', () => {
134
+ it('should validate valid event properties', () => {
135
+ const validProperties = {
136
+ page: '/home',
137
+ source: 'organic',
138
+ value: 123,
139
+ metadata: { nested: 'object' },
140
+ };
141
+ const result = EventPropertiesSchema.safeParse(validProperties);
142
+ expect(result.success).toBe(true);
143
+ });
144
+
145
+ it('should handle empty properties', () => {
146
+ const emptyProperties = {};
147
+ const result = EventPropertiesSchema.safeParse(emptyProperties);
148
+ expect(result.success).toBe(true);
149
+ });
150
+ });
151
+
152
+ describe('UserPropertiesSchema', () => {
153
+ it('should validate valid user properties', () => {
154
+ const validProperties = {
155
+ name: 'John Doe',
156
+ email: 'john@example.com',
157
+ age: 30,
158
+ preferences: { theme: 'dark' },
159
+ };
160
+ const result = UserPropertiesSchema.safeParse(validProperties);
161
+ expect(result.success).toBe(true);
162
+ });
163
+ });
164
+ });
165
+
166
+ describe('Data Sanitization', () => {
167
+ const piiPatterns = [
168
+ { regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, mask: '[EMAIL]' },
169
+ { regex: /\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/, mask: '[PHONE]' },
170
+ { regex: /\b\d{4}[-.\s]?\d{4}[-.\s]?\d{4}[-.\s]?\d{4}\b/, mask: '[CARD]' },
171
+ ];
172
+
173
+ describe('sanitizeEventProperties', () => {
174
+ it('should sanitize PII in event properties', () => {
175
+ const properties = {
176
+ email: 'user@example.com',
177
+ phone: '555-123-4567',
178
+ cardNumber: '1234-5678-9012-3456',
179
+ regularField: 'safe value',
180
+ };
181
+
182
+ const sanitized = DataSanitizer.sanitizeEventProperties(properties, piiPatterns);
183
+
184
+ expect(sanitized).toEqual({
185
+ email: '[EMAIL]',
186
+ phone: '[PHONE]',
187
+ cardNumber: '[CARD]',
188
+ regularField: 'safe value',
189
+ });
190
+ });
191
+
192
+ it('should handle nested objects', () => {
193
+ const properties = {
194
+ user: {
195
+ contact: {
196
+ email: 'user@example.com',
197
+ phone: '555-123-4567',
198
+ },
199
+ name: 'John Doe',
200
+ },
201
+ metadata: {
202
+ source: 'safe value',
203
+ },
204
+ };
205
+
206
+ const sanitized = DataSanitizer.sanitizeEventProperties(properties, piiPatterns);
207
+
208
+ expect(sanitized?.user?.contact?.email).toBe('[EMAIL]');
209
+ expect(sanitized?.user?.contact?.phone).toBe('[PHONE]');
210
+ expect(sanitized?.user?.name).toBe('John Doe');
211
+ expect(sanitized?.metadata?.source).toBe('safe value');
212
+ });
213
+
214
+ it('should handle undefined properties', () => {
215
+ const sanitized = DataSanitizer.sanitizeEventProperties(undefined, piiPatterns);
216
+ expect(sanitized).toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe('sanitizeUserProperties', () => {
221
+ it('should sanitize PII in user properties', () => {
222
+ const properties = {
223
+ email: 'user@example.com',
224
+ phoneNumber: '555-123-4567',
225
+ name: 'John Doe',
226
+ age: 30,
227
+ };
228
+
229
+ const sanitized = DataSanitizer.sanitizeUserProperties(properties, piiPatterns);
230
+
231
+ expect(sanitized).toEqual({
232
+ email: '[EMAIL]',
233
+ phoneNumber: '[PHONE]',
234
+ name: 'John Doe',
235
+ age: 30,
236
+ });
237
+ });
238
+
239
+ it('should handle undefined properties', () => {
240
+ const sanitized = DataSanitizer.sanitizeUserProperties(undefined, piiPatterns);
241
+ expect(sanitized).toBeUndefined();
242
+ });
243
+ });
244
+ });
245
+
246
+ describe('Rate Limiting', () => {
247
+ let rateLimiter: SlidingWindowRateLimiter;
248
+
249
+ beforeEach(() => {
250
+ rateLimiter = new SlidingWindowRateLimiter(3, 1000); // 3 requests per second
251
+ jest.useFakeTimers();
252
+ });
253
+
254
+ afterEach(() => {
255
+ jest.useRealTimers();
256
+ });
257
+
258
+ it('should allow requests within limit', () => {
259
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
260
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
261
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
262
+ });
263
+
264
+ it('should reject requests over limit', () => {
265
+ // Use up the limit
266
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
267
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
268
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
269
+
270
+ // This should be rejected
271
+ expect(rateLimiter.checkRateLimit('user1')).toBe(false);
272
+ });
273
+
274
+ it('should reset after window expires', () => {
275
+ // Use up the limit
276
+ rateLimiter.checkRateLimit('user1');
277
+ rateLimiter.checkRateLimit('user1');
278
+ rateLimiter.checkRateLimit('user1');
279
+
280
+ expect(rateLimiter.checkRateLimit('user1')).toBe(false);
281
+
282
+ // Advance time past window
283
+ jest.advanceTimersByTime(1100);
284
+
285
+ // Should allow requests again
286
+ expect(rateLimiter.checkRateLimit('user1')).toBe(true);
287
+ });
288
+
289
+ it('should track different users separately', () => {
290
+ // Use up limit for user1
291
+ rateLimiter.checkRateLimit('user1');
292
+ rateLimiter.checkRateLimit('user1');
293
+ rateLimiter.checkRateLimit('user1');
294
+
295
+ expect(rateLimiter.checkRateLimit('user1')).toBe(false);
296
+
297
+ // user2 should still have full quota
298
+ expect(rateLimiter.checkRateLimit('user2')).toBe(true);
299
+ expect(rateLimiter.checkRateLimit('user2')).toBe(true);
300
+ expect(rateLimiter.checkRateLimit('user2')).toBe(true);
301
+ });
302
+
303
+ it('should return correct remaining requests', () => {
304
+ expect(rateLimiter.getRemainingRequests('user1')).toBe(3);
305
+
306
+ rateLimiter.checkRateLimit('user1');
307
+ expect(rateLimiter.getRemainingRequests('user1')).toBe(2);
308
+
309
+ rateLimiter.checkRateLimit('user1');
310
+ expect(rateLimiter.getRemainingRequests('user1')).toBe(1);
311
+
312
+ rateLimiter.checkRateLimit('user1');
313
+ expect(rateLimiter.getRemainingRequests('user1')).toBe(0);
314
+ });
315
+ });
316
+
317
+ describe('Security Audit Logger', () => {
318
+ let auditLogger: SecurityAuditLogger;
319
+
320
+ beforeEach(() => {
321
+ auditLogger = new SecurityAuditLogger(7, 100); // 7 days retention, max 100 logs
322
+ jest.spyOn(console, 'log').mockImplementation();
323
+ jest.spyOn(console, 'warn').mockImplementation();
324
+ jest.spyOn(console, 'error').mockImplementation();
325
+ });
326
+
327
+ afterEach(() => {
328
+ jest.restoreAllMocks();
329
+ });
330
+
331
+ it('should log events with correct structure', () => {
332
+ auditLogger.log('TEST_EVENT', { test: 'data' }, 'INFO');
333
+
334
+ const logs = auditLogger.getLogs();
335
+ expect(logs).toHaveLength(1);
336
+
337
+ const log = logs[0];
338
+ expect(log.event).toBe('TEST_EVENT');
339
+ expect(log.data).toEqual({ test: 'data' });
340
+ expect(log.severity).toBe('INFO');
341
+ expect(log.source).toBe('amplitude-plugin');
342
+ expect(log.id).toBeDefined();
343
+ expect(log.timestamp).toBeDefined();
344
+ });
345
+
346
+ it('should filter logs by severity', () => {
347
+ auditLogger.log('INFO_EVENT', {}, 'INFO');
348
+ auditLogger.log('WARN_EVENT', {}, 'WARN');
349
+ auditLogger.log('ERROR_EVENT', {}, 'ERROR');
350
+ auditLogger.log('CRITICAL_EVENT', {}, 'CRITICAL');
351
+
352
+ expect(auditLogger.getLogs(undefined, undefined, 'ERROR')).toHaveLength(1);
353
+ expect(auditLogger.getLogs(undefined, undefined, 'WARN')).toHaveLength(1);
354
+ expect(auditLogger.getLogs(undefined, undefined, 'CRITICAL')).toHaveLength(1);
355
+ });
356
+
357
+ it('should filter logs by time range', () => {
358
+ const now = Date.now();
359
+ auditLogger.log('OLD_EVENT', {}, 'INFO');
360
+
361
+ // Simulate time passing
362
+ jest.spyOn(Date, 'now').mockReturnValue(now + 1000);
363
+ auditLogger.log('NEW_EVENT', {}, 'INFO');
364
+
365
+ const recentLogs = auditLogger.getLogs(now + 500);
366
+ expect(recentLogs).toHaveLength(1);
367
+ expect(recentLogs[0].event).toBe('NEW_EVENT');
368
+ });
369
+
370
+ it('should enforce log retention limits', () => {
371
+ // Add logs up to the limit
372
+ for (let i = 0; i < 150; i++) {
373
+ auditLogger.log(`EVENT_${i}`, {}, 'INFO');
374
+ }
375
+
376
+ const logs = auditLogger.getLogs();
377
+ expect(logs.length).toBeLessThanOrEqual(100);
378
+ });
379
+
380
+ it('should log to console with appropriate level', () => {
381
+ auditLogger.log('INFO_EVENT', {}, 'INFO');
382
+ expect(console.log).toHaveBeenCalled();
383
+
384
+ auditLogger.log('WARN_EVENT', {}, 'WARN');
385
+ expect(console.warn).toHaveBeenCalled();
386
+
387
+ auditLogger.log('ERROR_EVENT', {}, 'ERROR');
388
+ expect(console.error).toHaveBeenCalled();
389
+
390
+ auditLogger.log('CRITICAL_EVENT', {}, 'CRITICAL');
391
+ expect(console.error).toHaveBeenCalled();
392
+ });
393
+ });