@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
package/src/db.test.js ADDED
@@ -0,0 +1,365 @@
1
+ import assert from "node:assert/strict";
2
+ import { copyFile, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import {
9
+ createPrismaClient,
10
+ getConnectionString,
11
+ getPoolConfig,
12
+ pingDatabase,
13
+ } from "./db.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // getConnectionString
17
+ // ---------------------------------------------------------------------------
18
+
19
+ test("db - getConnectionString returns DATABASE_URL when set", () => {
20
+ const orig = process.env.DATABASE_URL;
21
+ try {
22
+ process.env.DATABASE_URL = "postgresql://user:pass@host:5432/db";
23
+ const conn = getConnectionString();
24
+ assert.equal(conn, "postgresql://user:pass@host:5432/db");
25
+ } finally {
26
+ if (orig === undefined) delete process.env.DATABASE_URL;
27
+ else process.env.DATABASE_URL = orig;
28
+ }
29
+ });
30
+
31
+ test("db - getConnectionString assembles from POSTGRES_* vars", () => {
32
+ const backup = {};
33
+ const vars = [
34
+ "DATABASE_URL",
35
+ "POSTGRES_USER",
36
+ "POSTGRES_PASSWORD",
37
+ "POSTGRES_HOST",
38
+ "POSTGRES_PORT",
39
+ "POSTGRES_DB",
40
+ ];
41
+ for (const v of vars) {
42
+ backup[v] = process.env[v];
43
+ delete process.env[v];
44
+ }
45
+ try {
46
+ process.env.POSTGRES_USER = "alice";
47
+ process.env.POSTGRES_PASSWORD = "secret";
48
+ process.env.POSTGRES_HOST = "db.example.com";
49
+ process.env.POSTGRES_PORT = "5433";
50
+ process.env.POSTGRES_DB = "mydb";
51
+ const conn = getConnectionString();
52
+ assert.equal(
53
+ conn,
54
+ "postgresql://alice:secret@db.example.com:5433/mydb?schema=public",
55
+ );
56
+ } finally {
57
+ for (const v of vars) {
58
+ if (backup[v] === undefined) delete process.env[v];
59
+ else process.env[v] = backup[v];
60
+ }
61
+ }
62
+ });
63
+
64
+ test("db - getConnectionString defaults host/port when POSTGRES_HOST/PORT not set", () => {
65
+ const backup = {};
66
+ const vars = [
67
+ "DATABASE_URL",
68
+ "POSTGRES_USER",
69
+ "POSTGRES_PASSWORD",
70
+ "POSTGRES_DB",
71
+ "POSTGRES_HOST",
72
+ "POSTGRES_PORT",
73
+ ];
74
+ for (const v of vars) {
75
+ backup[v] = process.env[v];
76
+ delete process.env[v];
77
+ }
78
+ try {
79
+ process.env.POSTGRES_USER = "u";
80
+ process.env.POSTGRES_PASSWORD = "p";
81
+ process.env.POSTGRES_DB = "d";
82
+ const conn = getConnectionString();
83
+ assert.equal(conn, "postgresql://u:p@localhost:5432/d?schema=public");
84
+ } finally {
85
+ for (const v of vars) {
86
+ if (backup[v] === undefined) delete process.env[v];
87
+ else process.env[v] = backup[v];
88
+ }
89
+ }
90
+ });
91
+
92
+ test("db - getConnectionString throws when throwOnMissing=true and vars missing", () => {
93
+ const backup = {};
94
+ const vars = [
95
+ "DATABASE_URL",
96
+ "POSTGRES_USER",
97
+ "POSTGRES_PASSWORD",
98
+ "POSTGRES_DB",
99
+ ];
100
+ for (const v of vars) {
101
+ backup[v] = process.env[v];
102
+ delete process.env[v];
103
+ }
104
+ try {
105
+ assert.throws(
106
+ () => getConnectionString({ throwOnMissing: true }),
107
+ /Missing required database environment variables/,
108
+ );
109
+ } finally {
110
+ for (const v of vars) {
111
+ if (backup[v] === undefined) delete process.env[v];
112
+ else process.env[v] = backup[v];
113
+ }
114
+ }
115
+ });
116
+
117
+ test("db - getConnectionString returns placeholder when throwOnMissing=false and vars missing", () => {
118
+ const backup = {};
119
+ const vars = [
120
+ "DATABASE_URL",
121
+ "POSTGRES_USER",
122
+ "POSTGRES_PASSWORD",
123
+ "POSTGRES_DB",
124
+ ];
125
+ for (const v of vars) {
126
+ backup[v] = process.env[v];
127
+ delete process.env[v];
128
+ }
129
+ try {
130
+ const conn = getConnectionString({ throwOnMissing: false });
131
+ assert.ok(conn.includes("placeholder"));
132
+ } finally {
133
+ for (const v of vars) {
134
+ if (backup[v] === undefined) delete process.env[v];
135
+ else process.env[v] = backup[v];
136
+ }
137
+ }
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // getPoolConfig
142
+ // ---------------------------------------------------------------------------
143
+
144
+ test("db - getPoolConfig returns production defaults in production", () => {
145
+ const orig = process.env.NODE_ENV;
146
+ const origMax = process.env.DB_POOL_MAX;
147
+ const origIdle = process.env.DB_POOL_IDLE_TIMEOUT;
148
+ const origConn = process.env.DB_POOL_CONNECTION_TIMEOUT;
149
+ try {
150
+ process.env.NODE_ENV = "production";
151
+ delete process.env.DB_POOL_MAX;
152
+ delete process.env.DB_POOL_IDLE_TIMEOUT;
153
+ delete process.env.DB_POOL_CONNECTION_TIMEOUT;
154
+ const config = getPoolConfig();
155
+ assert.equal(config.max, 10);
156
+ assert.equal(config.idleTimeoutMillis, 30_000);
157
+ assert.equal(config.connectionTimeoutMillis, 5_000);
158
+ } finally {
159
+ if (orig === undefined) delete process.env.NODE_ENV;
160
+ else process.env.NODE_ENV = orig;
161
+ if (origMax === undefined) delete process.env.DB_POOL_MAX;
162
+ else process.env.DB_POOL_MAX = origMax;
163
+ if (origIdle === undefined) delete process.env.DB_POOL_IDLE_TIMEOUT;
164
+ else process.env.DB_POOL_IDLE_TIMEOUT = origIdle;
165
+ if (origConn === undefined) delete process.env.DB_POOL_CONNECTION_TIMEOUT;
166
+ else process.env.DB_POOL_CONNECTION_TIMEOUT = origConn;
167
+ }
168
+ });
169
+
170
+ test("db - getPoolConfig returns dev defaults in development", () => {
171
+ const orig = process.env.NODE_ENV;
172
+ const origMax = process.env.DB_POOL_MAX;
173
+ try {
174
+ process.env.NODE_ENV = "development";
175
+ delete process.env.DB_POOL_MAX;
176
+ const config = getPoolConfig();
177
+ assert.equal(config.max, 5);
178
+ } finally {
179
+ if (orig === undefined) delete process.env.NODE_ENV;
180
+ else process.env.NODE_ENV = orig;
181
+ if (origMax === undefined) delete process.env.DB_POOL_MAX;
182
+ else process.env.DB_POOL_MAX = origMax;
183
+ }
184
+ });
185
+
186
+ test("db - getPoolConfig respects DB_POOL_MAX override", () => {
187
+ const orig = process.env.DB_POOL_MAX;
188
+ try {
189
+ process.env.DB_POOL_MAX = "20";
190
+ const config = getPoolConfig();
191
+ assert.equal(config.max, 20);
192
+ } finally {
193
+ if (orig === undefined) delete process.env.DB_POOL_MAX;
194
+ else process.env.DB_POOL_MAX = orig;
195
+ }
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // createPrismaClient
200
+ // ---------------------------------------------------------------------------
201
+
202
+ test("db - createPrismaClient returns a proxy", () => {
203
+ class FakeClient {
204
+ constructor(opts) {
205
+ this.opts = opts;
206
+ }
207
+ get user() {
208
+ return { findMany: () => [] };
209
+ }
210
+ }
211
+
212
+ const client = createPrismaClient(FakeClient);
213
+ // Proxy should be an object
214
+ assert.equal(typeof client, "object");
215
+ delete globalThis.__quark_prisma_FakeClient;
216
+ });
217
+
218
+ test("db - createPrismaClient defers instantiation until first access", () => {
219
+ let constructed = false;
220
+ class TrackingClient {
221
+ constructor() {
222
+ constructed = true;
223
+ }
224
+ get ready() {
225
+ return true;
226
+ }
227
+ }
228
+
229
+ const client = createPrismaClient(TrackingClient);
230
+ assert.equal(constructed, false, "should not construct at creation time");
231
+
232
+ // Access a property to trigger construction
233
+ const _ = client.ready;
234
+ assert.equal(constructed, true, "should construct on first access");
235
+ delete globalThis.__quark_prisma_TrackingClient;
236
+ });
237
+
238
+ test("db - createPrismaClient passes adapterOptions to constructor", () => {
239
+ let receivedOpts;
240
+ class OptsClient {
241
+ constructor(opts) {
242
+ receivedOpts = opts;
243
+ }
244
+ get check() {
245
+ return true;
246
+ }
247
+ }
248
+
249
+ // Reset global
250
+ delete globalThis.__quark_prisma_OptsClient;
251
+
252
+ const client = createPrismaClient(OptsClient, {
253
+ adapterOptions: { connectionString: "test" },
254
+ });
255
+ const _ = client.check;
256
+ assert.deepEqual(receivedOpts, { connectionString: "test" });
257
+ delete globalThis.__quark_prisma_OptsClient;
258
+ });
259
+
260
+ test("db - createPrismaClient keeps a separate slot per client class", () => {
261
+ class AlphaClient {
262
+ get marker() {
263
+ return "alpha";
264
+ }
265
+ }
266
+ class BetaClient {
267
+ get marker() {
268
+ return "beta";
269
+ }
270
+ }
271
+
272
+ try {
273
+ const alpha = createPrismaClient(AlphaClient);
274
+ const beta = createPrismaClient(BetaClient);
275
+ assert.equal(alpha.marker, "alpha");
276
+ assert.equal(beta.marker, "beta");
277
+ } finally {
278
+ delete globalThis.__quark_prisma_AlphaClient;
279
+ delete globalThis.__quark_prisma_BetaClient;
280
+ }
281
+ });
282
+
283
+ test("db - createPrismaClient honours an explicit globalKey", () => {
284
+ const globalKey = "__quark_prisma_test_explicit";
285
+ class FirstClient {
286
+ get marker() {
287
+ return 1;
288
+ }
289
+ }
290
+ class SecondClient {
291
+ get marker() {
292
+ return 2;
293
+ }
294
+ }
295
+
296
+ try {
297
+ const first = createPrismaClient(FirstClient, { globalKey });
298
+ assert.equal(first.marker, 1);
299
+
300
+ // Same explicit slot → the existing client wins (singleton semantics)
301
+ const second = createPrismaClient(SecondClient, { globalKey });
302
+ assert.equal(second.marker, 1);
303
+ } finally {
304
+ delete globalThis[globalKey];
305
+ }
306
+ });
307
+
308
+ test("db - createPrismaClient supports a named slot for same-named classes", () => {
309
+ class PrismaClient {
310
+ get marker() {
311
+ return "primary";
312
+ }
313
+ }
314
+ class OtherPrismaClient {
315
+ get marker() {
316
+ return "secondary";
317
+ }
318
+ }
319
+
320
+ try {
321
+ const primary = createPrismaClient(PrismaClient, { name: "primary" });
322
+ const secondary = createPrismaClient(OtherPrismaClient, {
323
+ name: "secondary",
324
+ });
325
+ assert.equal(primary.marker, "primary");
326
+ assert.equal(secondary.marker, "secondary");
327
+ } finally {
328
+ delete globalThis.__quark_prisma_primary;
329
+ delete globalThis.__quark_prisma_secondary;
330
+ }
331
+ });
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // pingDatabase
335
+ // ---------------------------------------------------------------------------
336
+
337
+ test("db - pingDatabase reports ok or error without throwing", async () => {
338
+ // This test verifies the error-handling path. In most CI environments
339
+ // pg IS installed, so we test the general error handling by verifying
340
+ // the function never throws.
341
+ const result = await pingDatabase({ timeout: 1000 });
342
+ assert.ok(result);
343
+ assert.ok(["ok", "error"].includes(result.status));
344
+ if (result.status === "error") {
345
+ assert.equal(typeof result.message, "string");
346
+ }
347
+ });
348
+
349
+ // Copy db.js to a directory with no node_modules so `pg` is unresolvable and
350
+ // prove that the optional peer dependency surfaces as an error result rather
351
+ // than an import-time/build-time failure.
352
+ test("db - pingDatabase reports a missing pg install without throwing", async () => {
353
+ const dir = await mkdtemp(join(tmpdir(), "quark-db-"));
354
+ try {
355
+ await copyFile(new URL("./db.js", import.meta.url), join(dir, "db.js"));
356
+
357
+ const isolated = await import(pathToFileURL(join(dir, "db.js")).href);
358
+ const result = await isolated.pingDatabase({ timeout: 500 });
359
+
360
+ assert.equal(result.status, "error");
361
+ assert.match(result.message, /pg is not installed/);
362
+ } finally {
363
+ await rm(dir, { recursive: true, force: true });
364
+ }
365
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @techstream/quark-core - Email Templates
3
+ *
4
+ * Reusable HTML email templates with plain-text fallbacks.
5
+ * Each template function returns { subject, html, text }.
6
+ */
7
+
8
+ /**
9
+ * Escape HTML entities to prevent XSS in user-provided values
10
+ * @param {string} str
11
+ * @returns {string}
12
+ */
13
+ function escapeHtml(str) {
14
+ return String(str)
15
+ .replace(/&/g, "&")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#039;");
20
+ }
21
+
22
+ /**
23
+ * Shared outer layout for all emails
24
+ * @param {string} body - Inner HTML content
25
+ * @returns {string}
26
+ */
27
+ function layout(body) {
28
+ return `<!DOCTYPE html>
29
+ <html lang="en">
30
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
31
+ <body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f4f4f5">
32
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
33
+ <tr><td align="center">
34
+ <table width="100%" style="max-width:560px;background:#fff;border-radius:8px;border:1px solid #e4e4e7;padding:32px">
35
+ <tr><td>${body}</td></tr>
36
+ </table>
37
+ <p style="color:#a1a1aa;font-size:12px;margin-top:24px">
38
+ You received this email because an account was created with this address.
39
+ </p>
40
+ </td></tr>
41
+ </table>
42
+ </body>
43
+ </html>`;
44
+ }
45
+
46
+ /**
47
+ * Welcome email - sent after user registration
48
+ *
49
+ * @param {{ name?: string, appName?: string, loginUrl?: string }} data
50
+ * @returns {{ subject: string, html: string, text: string }}
51
+ */
52
+ export function welcomeEmail(data = {}) {
53
+ const name = data.name ? escapeHtml(data.name) : "there";
54
+ const appName = data.appName || "Quark";
55
+ const loginUrl = data.loginUrl || "";
56
+
57
+ const buttonHtml = loginUrl
58
+ ? `<p style="text-align:center;margin:24px 0">
59
+ <a href="${escapeHtml(loginUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
60
+ Sign in to ${escapeHtml(appName)}
61
+ </a>
62
+ </p>`
63
+ : "";
64
+
65
+ const html = layout(`
66
+ <h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Welcome, ${name}!</h1>
67
+ <p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
68
+ Your account has been created successfully. You can now sign in and start using ${escapeHtml(appName)}.
69
+ </p>
70
+ ${buttonHtml}
71
+ <p style="color:#71717a;font-size:14px;margin:0">
72
+ If you didn't create this account, you can safely ignore this email.
73
+ </p>
74
+ `);
75
+
76
+ const text = [
77
+ `Welcome, ${data.name || "there"}!`,
78
+ "",
79
+ `Your account has been created successfully. You can now sign in and start using ${appName}.`,
80
+ ...(loginUrl ? ["", `Sign in: ${loginUrl}`] : []),
81
+ "",
82
+ "If you didn't create this account, you can safely ignore this email.",
83
+ ].join("\n");
84
+
85
+ return { subject: `Welcome to ${appName}!`, html, text };
86
+ }
87
+
88
+ /**
89
+ * Password reset email - sent when user requests a reset
90
+ *
91
+ * @param {{ name?: string, resetUrl: string, appName?: string, expiresIn?: string }} data
92
+ * @returns {{ subject: string, html: string, text: string }}
93
+ */
94
+ export function passwordResetEmail(data) {
95
+ if (!data?.resetUrl) {
96
+ throw new Error("resetUrl is required for password reset email");
97
+ }
98
+
99
+ const name = data.name ? escapeHtml(data.name) : "there";
100
+ const appName = data.appName || "Quark";
101
+ const expiresIn = data.expiresIn || "1 hour";
102
+
103
+ const html = layout(`
104
+ <h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Reset your password</h1>
105
+ <p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
106
+ Hi ${name}, we received a request to reset your ${escapeHtml(appName)} password.
107
+ </p>
108
+ <p style="text-align:center;margin:24px 0">
109
+ <a href="${escapeHtml(data.resetUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
110
+ Reset password
111
+ </a>
112
+ </p>
113
+ <p style="color:#71717a;font-size:14px;margin:0 0 8px">
114
+ This link will expire in ${escapeHtml(expiresIn)}.
115
+ </p>
116
+ <p style="color:#71717a;font-size:14px;margin:0">
117
+ If you didn't request this, you can safely ignore this email. Your password will not be changed.
118
+ </p>
119
+ `);
120
+
121
+ const text = [
122
+ `Reset your password`,
123
+ "",
124
+ `Hi ${data.name || "there"}, we received a request to reset your ${appName} password.`,
125
+ "",
126
+ `Reset your password: ${data.resetUrl}`,
127
+ "",
128
+ `This link will expire in ${expiresIn}.`,
129
+ "",
130
+ "If you didn't request this, you can safely ignore this email. Your password will not be changed.",
131
+ ].join("\n");
132
+
133
+ return { subject: `Reset your ${appName} password`, html, text };
134
+ }
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { passwordResetEmail, welcomeEmail } from "./email-templates.js";
4
+
5
+ test("welcomeEmail", async (t) => {
6
+ await t.test("returns subject, html, and text", () => {
7
+ const result = welcomeEmail({});
8
+ assert.ok(result.subject);
9
+ assert.ok(result.html);
10
+ assert.ok(result.text);
11
+ });
12
+
13
+ await t.test("uses default app name in subject", () => {
14
+ const result = welcomeEmail({});
15
+ assert.strictEqual(result.subject, "Welcome to Quark!");
16
+ });
17
+
18
+ await t.test("uses custom app name in subject", () => {
19
+ const result = welcomeEmail({ appName: "MyApp" });
20
+ assert.strictEqual(result.subject, "Welcome to MyApp!");
21
+ });
22
+
23
+ await t.test("includes user name in html when provided", () => {
24
+ const result = welcomeEmail({ name: "Alice" });
25
+ assert.ok(result.html.includes("Alice"));
26
+ assert.ok(result.text.includes("Alice"));
27
+ });
28
+
29
+ await t.test("uses fallback greeting when no name provided", () => {
30
+ const result = welcomeEmail({});
31
+ assert.ok(result.html.includes("there"));
32
+ assert.ok(result.text.includes("there"));
33
+ });
34
+
35
+ await t.test("includes login URL when provided", () => {
36
+ const result = welcomeEmail({ loginUrl: "https://app.test/login" });
37
+ assert.ok(result.html.includes("https://app.test/login"));
38
+ assert.ok(result.text.includes("https://app.test/login"));
39
+ });
40
+
41
+ await t.test("omits sign-in button when no login URL", () => {
42
+ const result = welcomeEmail({});
43
+ assert.ok(!result.html.includes("Sign In"));
44
+ });
45
+
46
+ await t.test("escapes HTML in user name", () => {
47
+ const result = welcomeEmail({ name: "<script>alert('xss')</script>" });
48
+ assert.ok(!result.html.includes("<script>"));
49
+ assert.ok(result.html.includes("&lt;script&gt;"));
50
+ });
51
+
52
+ await t.test("wraps content in html layout", () => {
53
+ const result = welcomeEmail({});
54
+ assert.ok(result.html.includes("<!DOCTYPE html>"));
55
+ assert.ok(result.html.includes("</html>"));
56
+ });
57
+ });
58
+
59
+ test("passwordResetEmail", async (t) => {
60
+ const validArgs = { resetUrl: "https://app.test/reset?token=abc123" };
61
+
62
+ await t.test("returns subject, html, and text", () => {
63
+ const result = passwordResetEmail(validArgs);
64
+ assert.ok(result.subject);
65
+ assert.ok(result.html);
66
+ assert.ok(result.text);
67
+ });
68
+
69
+ await t.test("throws when resetUrl is missing", () => {
70
+ assert.throws(() => passwordResetEmail({}), /resetUrl is required/);
71
+ });
72
+
73
+ await t.test("includes reset URL in html and text", () => {
74
+ const result = passwordResetEmail(validArgs);
75
+ assert.ok(result.html.includes(validArgs.resetUrl));
76
+ assert.ok(result.text.includes(validArgs.resetUrl));
77
+ });
78
+
79
+ await t.test("uses default app name", () => {
80
+ const result = passwordResetEmail(validArgs);
81
+ assert.ok(result.subject.includes("Quark"));
82
+ });
83
+
84
+ await t.test("uses custom app name", () => {
85
+ const result = passwordResetEmail({ ...validArgs, appName: "MyApp" });
86
+ assert.ok(result.subject.includes("MyApp"));
87
+ });
88
+
89
+ await t.test("includes user name when provided", () => {
90
+ const result = passwordResetEmail({ ...validArgs, name: "Bob" });
91
+ assert.ok(result.html.includes("Bob"));
92
+ assert.ok(result.text.includes("Bob"));
93
+ });
94
+
95
+ await t.test("uses default expiry time", () => {
96
+ const result = passwordResetEmail(validArgs);
97
+ assert.ok(result.html.includes("1 hour"));
98
+ assert.ok(result.text.includes("1 hour"));
99
+ });
100
+
101
+ await t.test("uses custom expiry time", () => {
102
+ const result = passwordResetEmail({
103
+ ...validArgs,
104
+ expiresIn: "30 minutes",
105
+ });
106
+ assert.ok(result.html.includes("30 minutes"));
107
+ assert.ok(result.text.includes("30 minutes"));
108
+ });
109
+
110
+ await t.test("escapes HTML in user name", () => {
111
+ const result = passwordResetEmail({
112
+ ...validArgs,
113
+ name: '<img src=x onerror="alert(1)">',
114
+ });
115
+ assert.ok(!result.html.includes("<img"));
116
+ assert.ok(result.html.includes("&lt;img"));
117
+ });
118
+
119
+ await t.test("wraps content in html layout", () => {
120
+ const result = passwordResetEmail(validArgs);
121
+ assert.ok(result.html.includes("<!DOCTYPE html>"));
122
+ assert.ok(result.html.includes("</html>"));
123
+ });
124
+ });