@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.
- package/CODE_REVIEW_REPORT.md +462 -0
- package/README.md +619 -0
- package/__tests__/amplitude-core.test.ts +279 -0
- package/__tests__/hooks.test.ts +478 -0
- package/__tests__/validation.test.ts +393 -0
- package/components/AnalyticsDashboard.tsx +339 -0
- package/components/ConsentManager.tsx +265 -0
- package/components/ExperimentWrapper.tsx +440 -0
- package/components/PerformanceMonitor.tsx +578 -0
- package/hooks/useAmplitude.ts +132 -0
- package/hooks/useAmplitudeEvents.ts +100 -0
- package/hooks/useExperiment.ts +195 -0
- package/hooks/useSessionReplay.ts +238 -0
- package/jest.setup.ts +276 -0
- package/lib/amplitude-core.ts +178 -0
- package/lib/cache.ts +181 -0
- package/lib/performance.ts +319 -0
- package/lib/queue.ts +389 -0
- package/lib/security.ts +188 -0
- package/package.json +15 -0
- package/plugin.config.ts +58 -0
- package/providers/AmplitudeProvider.tsx +113 -0
- package/styles/amplitude.css +593 -0
- package/translations/en.json +45 -0
- package/translations/es.json +45 -0
- package/tsconfig.json +47 -0
- package/types/amplitude.types.ts +105 -0
- package/utils/debounce.ts +133 -0
|
@@ -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
|
+
});
|