@usequark/quark-core 2.5.4

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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,457 @@
1
+ import assert from "node:assert";
2
+ import { copyFile, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { test } from "node:test";
7
+ import { pathToFileURL } from "node:url";
8
+ import {
9
+ createLocalStorage,
10
+ createS3Storage,
11
+ createStorage,
12
+ generateStorageKey,
13
+ getAssetUrl,
14
+ } from "../src/storage.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // generateStorageKey
18
+ // ---------------------------------------------------------------------------
19
+
20
+ test("Storage - generateStorageKey returns path with prefix/year/month/sanitized name", () => {
21
+ const key = generateStorageKey("my Photo (1).png");
22
+ const parts = key.split("/");
23
+ assert.strictEqual(parts[0], "uploads");
24
+ assert.match(parts[1], /^\d{4}$/); // year
25
+ assert.match(parts[2], /^\d{2}$/); // month
26
+ assert.match(parts[3], /^[a-f0-9]{16}-my_photo_1_.png$/);
27
+ });
28
+
29
+ test("Storage - generateStorageKey accepts a custom prefix", () => {
30
+ const key = generateStorageKey("file.txt", { prefix: "avatars" });
31
+ assert(key.startsWith("avatars/"));
32
+ });
33
+
34
+ test("Storage - generateStorageKey sanitizes special characters", () => {
35
+ const key = generateStorageKey("../../../etc/passwd");
36
+ // Dots and slashes are replaced with underscores; the key stays under the prefix
37
+ assert(key.startsWith("uploads/"));
38
+ assert(key.includes("etc_passwd"));
39
+ });
40
+
41
+ test("Storage - generateStorageKey produces unique keys", () => {
42
+ const a = generateStorageKey("a.jpg");
43
+ const b = generateStorageKey("a.jpg");
44
+ assert.notStrictEqual(a, b);
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // createLocalStorage
49
+ // ---------------------------------------------------------------------------
50
+
51
+ test("Storage - createLocalStorage put/get round-trip with Buffer", async () => {
52
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
53
+ try {
54
+ const storage = createLocalStorage({ directory: dir });
55
+ const data = Buffer.from("hello world");
56
+ await storage.put("test/file.txt", data);
57
+
58
+ const result = await storage.get("test/file.txt");
59
+ assert.deepStrictEqual(result.body, data);
60
+ } finally {
61
+ await rm(dir, { recursive: true });
62
+ }
63
+ });
64
+
65
+ test("Storage - createLocalStorage put with string data", async () => {
66
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
67
+ try {
68
+ const storage = createLocalStorage({ directory: dir });
69
+ await storage.put("test/string.txt", "string content");
70
+
71
+ const result = await storage.get("test/string.txt");
72
+ assert.strictEqual(result.body.toString(), "string content");
73
+ } finally {
74
+ await rm(dir, { recursive: true });
75
+ }
76
+ });
77
+
78
+ test("Storage - createLocalStorage put with Readable stream", async () => {
79
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
80
+ try {
81
+ const storage = createLocalStorage({ directory: dir });
82
+ const stream = Readable.from(["stream ", "content"]);
83
+ await storage.put("test/stream.txt", stream);
84
+
85
+ const result = await storage.get("test/stream.txt");
86
+ assert.strictEqual(result.body.toString(), "stream content");
87
+ } finally {
88
+ await rm(dir, { recursive: true });
89
+ }
90
+ });
91
+
92
+ test("Storage - createLocalStorage put rejects invalid data", async () => {
93
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
94
+ try {
95
+ const storage = createLocalStorage({ directory: dir });
96
+ await assert.rejects(
97
+ () => storage.put("test/bad", 123),
98
+ /Buffer, string, or Readable/,
99
+ );
100
+ } finally {
101
+ await rm(dir, { recursive: true });
102
+ }
103
+ });
104
+
105
+ test("Storage - createLocalStorage exists returns true for existing file", async () => {
106
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
107
+ try {
108
+ const storage = createLocalStorage({ directory: dir });
109
+ await storage.put("exists.txt", "yes");
110
+ assert.strictEqual(await storage.exists("exists.txt"), true);
111
+ } finally {
112
+ await rm(dir, { recursive: true });
113
+ }
114
+ });
115
+
116
+ test("Storage - createLocalStorage exists returns false for missing file", async () => {
117
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
118
+ try {
119
+ const storage = createLocalStorage({ directory: dir });
120
+ assert.strictEqual(await storage.exists("nope.txt"), false);
121
+ } finally {
122
+ await rm(dir, { recursive: true });
123
+ }
124
+ });
125
+
126
+ test("Storage - createLocalStorage delete removes file", async () => {
127
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
128
+ try {
129
+ const storage = createLocalStorage({ directory: dir });
130
+ await storage.put("del.txt", "bye");
131
+ assert.strictEqual(await storage.exists("del.txt"), true);
132
+ await storage.delete("del.txt");
133
+ assert.strictEqual(await storage.exists("del.txt"), false);
134
+ } finally {
135
+ await rm(dir, { recursive: true });
136
+ }
137
+ });
138
+
139
+ test("Storage - createLocalStorage delete is idempotent (non-existent file)", async () => {
140
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
141
+ try {
142
+ const storage = createLocalStorage({ directory: dir });
143
+ await storage.delete("not-there.txt"); // should not throw
144
+ } finally {
145
+ await rm(dir, { recursive: true });
146
+ }
147
+ });
148
+
149
+ test("Storage - createLocalStorage getPublicUrl returns API path", () => {
150
+ const storage = createLocalStorage();
151
+ const url = storage.getPublicUrl("uploads/2025/01/abc-file.png");
152
+ assert(url.startsWith("/api/files/"));
153
+ });
154
+
155
+ test("Storage - createLocalStorage provider is 'local'", () => {
156
+ const storage = createLocalStorage();
157
+ assert.strictEqual(storage.provider, "local");
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Path traversal protection
162
+ // ---------------------------------------------------------------------------
163
+
164
+ test("Storage - put rejects path traversal via ../", async () => {
165
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
166
+ try {
167
+ const storage = createLocalStorage({ directory: dir });
168
+ await assert.rejects(
169
+ () => storage.put("../../etc/passwd", "evil"),
170
+ /Path traversal detected/,
171
+ );
172
+ } finally {
173
+ await rm(dir, { recursive: true });
174
+ }
175
+ });
176
+
177
+ test("Storage - get rejects path traversal via ../", async () => {
178
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
179
+ try {
180
+ const storage = createLocalStorage({ directory: dir });
181
+ await assert.rejects(
182
+ () => storage.get("../../../etc/shadow"),
183
+ /Path traversal detected/,
184
+ );
185
+ } finally {
186
+ await rm(dir, { recursive: true });
187
+ }
188
+ });
189
+
190
+ test("Storage - delete rejects path traversal via ../", async () => {
191
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
192
+ try {
193
+ const storage = createLocalStorage({ directory: dir });
194
+ await assert.rejects(
195
+ () => storage.delete("../../etc/hosts"),
196
+ /Path traversal detected/,
197
+ );
198
+ } finally {
199
+ await rm(dir, { recursive: true });
200
+ }
201
+ });
202
+
203
+ test("Storage - exists rejects path traversal via ../", async () => {
204
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
205
+ try {
206
+ const storage = createLocalStorage({ directory: dir });
207
+ await assert.rejects(
208
+ () => storage.exists("../../etc/passwd"),
209
+ /Path traversal detected/,
210
+ );
211
+ } finally {
212
+ await rm(dir, { recursive: true });
213
+ }
214
+ });
215
+
216
+ test("Storage - allows nested subdirectories within baseDir", async () => {
217
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
218
+ try {
219
+ const storage = createLocalStorage({ directory: dir });
220
+ await storage.put("uploads/2026/02/file.txt", "safe");
221
+ const result = await storage.get("uploads/2026/02/file.txt");
222
+ assert.strictEqual(result.body.toString(), "safe");
223
+ } finally {
224
+ await rm(dir, { recursive: true });
225
+ }
226
+ });
227
+
228
+ test("Storage - allows keys that start with '..' but stay within baseDir", async () => {
229
+ const dir = await mkdtemp(join(tmpdir(), "storage-"));
230
+ try {
231
+ const storage = createLocalStorage({ directory: dir });
232
+ await storage.put("..well-known/file.txt", "safe");
233
+ const result = await storage.get("..well-known/file.txt");
234
+ assert.strictEqual(result.body.toString(), "safe");
235
+ } finally {
236
+ await rm(dir, { recursive: true });
237
+ }
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // createStorage factory
242
+ // ---------------------------------------------------------------------------
243
+
244
+ test("Storage - createStorage defaults to local", () => {
245
+ const storage = createStorage();
246
+ assert.strictEqual(storage.provider, "local");
247
+ });
248
+
249
+ test("Storage - createStorage with explicit provider 'local'", () => {
250
+ const storage = createStorage({ provider: "local" });
251
+ assert.strictEqual(storage.provider, "local");
252
+ });
253
+
254
+ test("Storage - createStorage throws for unknown provider", () => {
255
+ assert.throws(
256
+ () => createStorage({ provider: "azure" }),
257
+ /Unknown STORAGE_PROVIDER/,
258
+ );
259
+ });
260
+
261
+ test("Storage - createStorage with 's3' provider requires bucket", () => {
262
+ assert.throws(
263
+ () =>
264
+ createStorage({ provider: "s3", accessKeyId: "x", secretAccessKey: "y" }),
265
+ /S3_BUCKET is required/,
266
+ );
267
+ });
268
+
269
+ test("Storage - createStorage with 's3' provider requires credentials", () => {
270
+ assert.throws(
271
+ () => createStorage({ provider: "s3", bucket: "b" }),
272
+ /S3_ACCESS_KEY_ID is required/,
273
+ );
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // createS3Storage (unit tests without real S3 - validate init)
278
+ // ---------------------------------------------------------------------------
279
+
280
+ test("Storage - createS3Storage sets provider to 's3'", () => {
281
+ const storage = createStorage({
282
+ provider: "s3",
283
+ bucket: "test-bucket",
284
+ accessKeyId: "key",
285
+ secretAccessKey: "secret",
286
+ });
287
+ assert.strictEqual(storage.provider, "s3");
288
+ });
289
+
290
+ test("Storage - createS3Storage getPublicUrl with publicUrl option", () => {
291
+ const storage = createStorage({
292
+ provider: "s3",
293
+ bucket: "test-bucket",
294
+ accessKeyId: "key",
295
+ secretAccessKey: "secret",
296
+ publicUrl: "https://cdn.example.com/",
297
+ });
298
+ assert.strictEqual(
299
+ storage.getPublicUrl("uploads/file.png"),
300
+ "https://cdn.example.com/uploads/file.png",
301
+ );
302
+ });
303
+
304
+ test("Storage - createS3Storage getPublicUrl without publicUrl falls back to API", () => {
305
+ const storage = createStorage({
306
+ provider: "s3",
307
+ bucket: "test-bucket",
308
+ accessKeyId: "key",
309
+ secretAccessKey: "secret",
310
+ });
311
+ const url = storage.getPublicUrl("uploads/file.png");
312
+ assert(url.startsWith("/api/files/"));
313
+ });
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // getAssetUrl
317
+ // ---------------------------------------------------------------------------
318
+
319
+ test("getAssetUrl - returns CDN URL when ASSET_CDN_URL is set", () => {
320
+ process.env.ASSET_CDN_URL = "https://assets.example.com";
321
+ try {
322
+ const url = getAssetUrl("uploads/2026/02/abc-photo.jpg");
323
+ assert.strictEqual(
324
+ url,
325
+ "https://assets.example.com/uploads/2026/02/abc-photo.jpg",
326
+ );
327
+ } finally {
328
+ delete process.env.ASSET_CDN_URL;
329
+ }
330
+ });
331
+
332
+ test("getAssetUrl - strips trailing slash from ASSET_CDN_URL", () => {
333
+ process.env.ASSET_CDN_URL = "https://assets.example.com/";
334
+ try {
335
+ const url = getAssetUrl("uploads/file.png");
336
+ assert.strictEqual(url, "https://assets.example.com/uploads/file.png");
337
+ } finally {
338
+ delete process.env.ASSET_CDN_URL;
339
+ }
340
+ });
341
+
342
+ test("getAssetUrl - falls back to /api/files when ASSET_CDN_URL is not set", () => {
343
+ delete process.env.ASSET_CDN_URL;
344
+ const url = getAssetUrl("uploads/2026/02/abc-photo.jpg");
345
+ assert.strictEqual(url, "/api/files/uploads%2F2026%2F02%2Fabc-photo.jpg");
346
+ });
347
+
348
+ test("getAssetUrl - percent-encodes key in fallback URL", () => {
349
+ delete process.env.ASSET_CDN_URL;
350
+ const url = getAssetUrl("uploads/file with spaces.jpg");
351
+ assert.strictEqual(url, "/api/files/uploads%2Ffile%20with%20spaces.jpg");
352
+ });
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // getSignedUploadUrl
356
+ // ---------------------------------------------------------------------------
357
+
358
+ test("getSignedUploadUrl - local adapter throws a clear error", async () => {
359
+ const storage = createLocalStorage();
360
+ await assert.rejects(
361
+ () => storage.getSignedUploadUrl("uploads/file.png"),
362
+ /getSignedUploadUrl\(\) requires STORAGE_PROVIDER=s3/,
363
+ );
364
+ });
365
+
366
+ test("getSignedUploadUrl - local adapter error message mentions POST /api/files", async () => {
367
+ const storage = createLocalStorage();
368
+ await assert.rejects(
369
+ () => storage.getSignedUploadUrl("uploads/file.png"),
370
+ /POST \/api\/files/,
371
+ );
372
+ });
373
+
374
+ test("getSignedUploadUrl - S3 adapter exposes the method as a function", () => {
375
+ const storage = createStorage({
376
+ provider: "s3",
377
+ bucket: "test-bucket",
378
+ accessKeyId: "key",
379
+ secretAccessKey: "secret",
380
+ });
381
+ assert.strictEqual(typeof storage.getSignedUploadUrl, "function");
382
+ });
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // S3 re-export from storage.js
386
+ // ---------------------------------------------------------------------------
387
+
388
+ test("Storage - createS3Storage is re-exported from storage.js", () => {
389
+ assert.strictEqual(typeof createS3Storage, "function");
390
+ const storage = createS3Storage({
391
+ bucket: "test-bucket",
392
+ accessKeyId: "key",
393
+ secretAccessKey: "secret",
394
+ });
395
+ assert.strictEqual(storage.provider, "s3");
396
+ });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Optional peer dependencies (AWS SDK)
400
+ // ---------------------------------------------------------------------------
401
+
402
+ // Copy the storage modules to a directory with no node_modules so the AWS SDK
403
+ // is unresolvable, then prove that importing storage.js still works and that
404
+ // the missing SDK only surfaces — with a helpful message — when an S3 method
405
+ // is actually called.
406
+ test("Storage - imports and runs local storage without the AWS SDK installed", async () => {
407
+ const dir = await mkdtemp(join(tmpdir(), "quark-storage-"));
408
+ try {
409
+ await copyFile(
410
+ new URL("./storage.js", import.meta.url),
411
+ join(dir, "storage.js"),
412
+ );
413
+ await copyFile(
414
+ new URL("./storage-s3.js", import.meta.url),
415
+ join(dir, "storage-s3.js"),
416
+ );
417
+ // The temp dir is outside any package with "type": "module", so Node
418
+ // would parse the copies as CommonJS and choke on `export`. Declare the
419
+ // module type explicitly rather than relying on syntax detection.
420
+ await writeFile(
421
+ join(dir, "package.json"),
422
+ JSON.stringify({ type: "module" }),
423
+ );
424
+
425
+ const isolated = await import(pathToFileURL(join(dir, "storage.js")).href);
426
+
427
+ const local = isolated.createLocalStorage({
428
+ directory: join(dir, "files"),
429
+ });
430
+ await local.put("hello.txt", "hi");
431
+ const { body } = await local.get("hello.txt");
432
+ assert.strictEqual(body.toString(), "hi");
433
+
434
+ const s3 = isolated.createS3Storage({
435
+ bucket: "test-bucket",
436
+ accessKeyId: "key",
437
+ secretAccessKey: "secret",
438
+ });
439
+ assert.strictEqual(s3.provider, "s3");
440
+ assert.strictEqual(
441
+ s3.getPublicUrl("a/b.png"),
442
+ "/api/files/a%2Fb.png",
443
+ "getPublicUrl must not require the SDK",
444
+ );
445
+
446
+ await assert.rejects(
447
+ () => s3.get("missing.png"),
448
+ /requires installing "@aws-sdk\/client-s3"/,
449
+ );
450
+ await assert.rejects(
451
+ () => s3.getSignedUploadUrl("upload.png"),
452
+ /requires installing "@aws-sdk\/client-s3"/,
453
+ );
454
+ } finally {
455
+ await rm(dir, { recursive: true, force: true });
456
+ }
457
+ });
package/src/stripe.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @techstream/quark-core - Stripe Integration
3
+ *
4
+ * Thin wrappers around the Stripe SDK for client creation and webhook
5
+ * verification. Stripe is an optional peer dependency — install it in the
6
+ * app that uses payment features.
7
+ *
8
+ * Usage:
9
+ * import { createStripeClient, getStripeWebhookEvent } from "@techstream/quark-core/stripe";
10
+ *
11
+ * const stripe = await createStripeClient();
12
+ * const session = await stripe.checkout.sessions.create({ ... });
13
+ *
14
+ * const event = await getStripeWebhookEvent(body, signature);
15
+ */
16
+
17
+ function isMissingPackageError(error, packageName) {
18
+ const message = error?.message ?? "";
19
+ return (
20
+ error?.code === "ERR_MODULE_NOT_FOUND" ||
21
+ error?.code === "MODULE_NOT_FOUND" ||
22
+ message.includes(`Cannot find package '${packageName}'`) ||
23
+ message.includes(`Cannot find module '${packageName}'`)
24
+ );
25
+ }
26
+
27
+ async function importStripePackage() {
28
+ try {
29
+ return await import("stripe");
30
+ } catch (error) {
31
+ if (isMissingPackageError(error, "stripe")) {
32
+ throw new Error(
33
+ 'Stripe support requires installing "stripe" in the app that uses @techstream/quark-core/stripe.',
34
+ );
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Creates a Stripe client instance.
42
+ * Reads STRIPE_SECRET_KEY from environment variables.
43
+ *
44
+ * @param {object} [options] - Additional Stripe SDK options
45
+ * @returns {import("stripe").Stripe} Stripe client instance
46
+ * @throws {Error} If STRIPE_SECRET_KEY is not set or stripe package is not installed
47
+ */
48
+ export async function createStripeClient(options = {}) {
49
+ const { default: Stripe } = await importStripePackage();
50
+
51
+ const secretKey = options.secretKey || process.env.STRIPE_SECRET_KEY;
52
+ if (!secretKey) {
53
+ throw new Error(
54
+ "STRIPE_SECRET_KEY is required. Set it in your environment or pass { secretKey } to createStripeClient().",
55
+ );
56
+ }
57
+
58
+ return new Stripe(secretKey, {
59
+ apiVersion: "2025-08-27.basil",
60
+ ...options,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Verifies a Stripe webhook signature and returns the parsed event.
66
+ *
67
+ * Reads STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET from environment variables.
68
+ *
69
+ * @param {string | Buffer} body - Raw request body
70
+ * @param {string | null} signature - Value of the stripe-signature header
71
+ * @returns {Promise<import("stripe").Stripe.Event>} Verified Stripe event
72
+ * @throws {Error} If signature is invalid, or required env vars are not set
73
+ */
74
+ export async function getStripeWebhookEvent(body, signature) {
75
+ const stripe = await createStripeClient();
76
+
77
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
78
+ if (!webhookSecret) {
79
+ throw new Error(
80
+ "STRIPE_WEBHOOK_SECRET is required for webhook signature verification.",
81
+ );
82
+ }
83
+
84
+ if (!signature) {
85
+ throw new Error("Missing stripe-signature header");
86
+ }
87
+
88
+ return stripe.webhooks.constructEvent(body, signature, webhookSecret);
89
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Test data factories for creating realistic test objects with sensible defaults.
3
+ *
4
+ * Usage:
5
+ * import { createTestUser, createTestSession } from "@techstream/quark-core/testing";
6
+ * const user = createTestUser({ name: "Custom Name" });
7
+ * const session = createTestSession({ user: { role: "admin" } });
8
+ *
9
+ * @module testing/factories
10
+ */
11
+
12
+ /**
13
+ * Generate a short random ID string.
14
+ * @returns {string}
15
+ */
16
+ function randomId() {
17
+ return Math.random().toString(36).slice(2, 10);
18
+ }
19
+
20
+ /**
21
+ * Create a test User object matching the Prisma User model shape.
22
+ * All fields have sensible defaults that can be overridden.
23
+ *
24
+ * @param {Object} [overrides={}]
25
+ * @returns {Object}
26
+ *
27
+ * @example
28
+ * const user = createTestUser({ role: "admin" });
29
+ */
30
+ export function createTestUser(overrides = {}) {
31
+ return {
32
+ id: overrides.id || `user_${randomId()}`,
33
+ email: overrides.email || `test-${randomId()}@example.com`,
34
+ name: overrides.name || "Test User",
35
+ role: overrides.role || "viewer",
36
+ password: overrides.password || null,
37
+ image: overrides.image || null,
38
+ emailVerified: overrides.emailVerified || null,
39
+ createdAt: overrides.createdAt || new Date(),
40
+ updatedAt: overrides.updatedAt || new Date(),
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Create a test session object compatible with NextAuth session shape.
47
+ * Optionally pass user overrides to customize the embedded user.
48
+ *
49
+ * @param {Object} [overrides={}]
50
+ * @returns {Object}
51
+ *
52
+ * @example
53
+ * const session = createTestSession({ user: { role: "admin" } });
54
+ */
55
+ export function createTestSession(overrides = {}) {
56
+ const user = createTestUser(overrides.user);
57
+ return {
58
+ user: {
59
+ id: user.id,
60
+ email: user.email,
61
+ name: user.name,
62
+ role: user.role,
63
+ ...overrides.user,
64
+ },
65
+ expires:
66
+ overrides.expires ||
67
+ new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Create a test Post object with realistic defaults.
74
+ *
75
+ * @param {Object} [overrides={}]
76
+ * @returns {Object}
77
+ */
78
+ export function createTestPost(overrides = {}) {
79
+ return {
80
+ id: overrides.id || `post_${randomId()}`,
81
+ title: overrides.title || "Test Post",
82
+ content: overrides.content || "Test content",
83
+ published: overrides.published ?? false,
84
+ authorId: overrides.authorId || `user_${randomId()}`,
85
+ createdAt: overrides.createdAt || new Date(),
86
+ updatedAt: overrides.updatedAt || new Date(),
87
+ ...overrides,
88
+ };
89
+ }