@edge-base/server 0.1.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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
SESProvider,
|
|
4
|
+
SendGridProvider,
|
|
5
|
+
createEmailProvider,
|
|
6
|
+
} from '../lib/email-provider.js';
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
globalThis.fetch = originalFetch;
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('createEmailProvider', () => {
|
|
16
|
+
it('returns null when config is missing', () => {
|
|
17
|
+
expect(createEmailProvider()).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null when apiKey or from is missing', () => {
|
|
21
|
+
expect(
|
|
22
|
+
createEmailProvider({ provider: 'sendgrid', apiKey: '', from: 'noreply@example.com' }),
|
|
23
|
+
).toBeNull();
|
|
24
|
+
expect(
|
|
25
|
+
createEmailProvider({ provider: 'sendgrid', apiKey: 'SG.key', from: '' }),
|
|
26
|
+
).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns a mock email provider when EDGEBASE_EMAIL_API_URL is set', async () => {
|
|
30
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
31
|
+
new Response(JSON.stringify({ messageId: 'mock-mail-1' }), {
|
|
32
|
+
status: 200,
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
37
|
+
|
|
38
|
+
const provider = createEmailProvider(undefined, {
|
|
39
|
+
EDGEBASE_EMAIL_API_URL: 'https://mock.example/email',
|
|
40
|
+
});
|
|
41
|
+
expect(provider).not.toBeNull();
|
|
42
|
+
|
|
43
|
+
const result = await provider!.send({
|
|
44
|
+
to: 'user@example.com',
|
|
45
|
+
subject: 'Hello',
|
|
46
|
+
html: '<p>hello</p>',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({ success: true, messageId: 'mock-mail-1' });
|
|
50
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
51
|
+
'https://mock.example/email/send',
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('SendGridProvider', () => {
|
|
60
|
+
it('returns x-message-id header on success', async () => {
|
|
61
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
62
|
+
new Response(null, {
|
|
63
|
+
status: 202,
|
|
64
|
+
headers: { 'x-message-id': 'sg-message-1' },
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
68
|
+
|
|
69
|
+
const provider = new SendGridProvider('SG.key', 'noreply@example.com');
|
|
70
|
+
const result = await provider.send({
|
|
71
|
+
to: 'user@example.com',
|
|
72
|
+
subject: 'Hello',
|
|
73
|
+
html: '<p>hello</p>',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({ success: true, messageId: 'sg-message-1' });
|
|
77
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('SESProvider', () => {
|
|
82
|
+
it('signs the outbound request with AWS SigV4 headers', async () => {
|
|
83
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
84
|
+
new Response(JSON.stringify({ MessageId: 'ses-message-1' }), {
|
|
85
|
+
status: 200,
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
90
|
+
|
|
91
|
+
const provider = new SESProvider(
|
|
92
|
+
'AKIAEXAMPLE:wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY',
|
|
93
|
+
'noreply@example.com',
|
|
94
|
+
'us-east-1',
|
|
95
|
+
);
|
|
96
|
+
const result = await provider.send({
|
|
97
|
+
to: 'user@example.com',
|
|
98
|
+
subject: 'Hello',
|
|
99
|
+
html: '<p>hello</p>',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result).toEqual({ success: true, messageId: 'ses-message-1' });
|
|
103
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
106
|
+
expect(url).toBe('https://email.us-east-1.amazonaws.com/v2/email/outbound-emails');
|
|
107
|
+
const headers = new Headers(init.headers);
|
|
108
|
+
expect(headers.get('authorization')).toMatch(/^AWS4-HMAC-SHA256 /);
|
|
109
|
+
expect(headers.get('x-amz-content-sha256')).toMatch(/^[a-f0-9]{64}$/);
|
|
110
|
+
expect(headers.get('x-amz-date')).toMatch(/^\d{8}T\d{6}Z$/);
|
|
111
|
+
expect(headers.get('host')).toBe('email.us-east-1.amazonaws.com');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('fails fast when the SES apiKey format is invalid', async () => {
|
|
115
|
+
const fetchMock = vi.fn();
|
|
116
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
117
|
+
|
|
118
|
+
const provider = new SESProvider('not-a-valid-key', 'noreply@example.com', 'us-east-1');
|
|
119
|
+
const result = await provider.send({
|
|
120
|
+
to: 'user@example.com',
|
|
121
|
+
subject: 'Hello',
|
|
122
|
+
html: '<p>hello</p>',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual({ success: false });
|
|
126
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests — lib/email-templates.ts
|
|
3
|
+
*
|
|
4
|
+
* Run: cd packages/server && npx vitest run src/__tests__/email-templates.test.ts
|
|
5
|
+
*
|
|
6
|
+
* Tests:
|
|
7
|
+
* renderVerifyEmail / renderPasswordReset / renderMagicLink
|
|
8
|
+
* renderEmailOtp / renderEmailChange
|
|
9
|
+
* + escapeHtml XSS prevention
|
|
10
|
+
* + custom template override via {{variable}} placeholders
|
|
11
|
+
* + i18n: locale-aware rendering, lang attribute, fallback chain
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
renderVerifyEmail,
|
|
17
|
+
renderPasswordReset,
|
|
18
|
+
renderMagicLink,
|
|
19
|
+
renderEmailOtp,
|
|
20
|
+
renderEmailChange,
|
|
21
|
+
} from '../lib/email-templates.js';
|
|
22
|
+
import { getStrings, getDefaultSubject, SUPPORTED_LOCALES } from '../lib/email-translations.js';
|
|
23
|
+
|
|
24
|
+
// ─── A. renderVerifyEmail ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('renderVerifyEmail', () => {
|
|
27
|
+
const vars = {
|
|
28
|
+
appName: 'MyApp',
|
|
29
|
+
verifyUrl: 'https://example.com/verify?token=abc',
|
|
30
|
+
token: 'ABC123',
|
|
31
|
+
expiresInHours: 24,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it('returns valid HTML document', () => {
|
|
35
|
+
const html = renderVerifyEmail(vars);
|
|
36
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
37
|
+
expect(html).toContain('</html>');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('includes app name in header', () => {
|
|
41
|
+
const html = renderVerifyEmail(vars);
|
|
42
|
+
expect(html).toContain('MyApp');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('includes verify URL in href', () => {
|
|
46
|
+
const html = renderVerifyEmail(vars);
|
|
47
|
+
expect(html).toContain('href="https://example.com/verify?token=abc"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('includes token in code block', () => {
|
|
51
|
+
const html = renderVerifyEmail(vars);
|
|
52
|
+
expect(html).toContain('ABC123');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('includes expiry hours', () => {
|
|
56
|
+
const html = renderVerifyEmail(vars);
|
|
57
|
+
expect(html).toContain('24 hours');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('escapes HTML in appName (XSS prevention)', () => {
|
|
61
|
+
const html = renderVerifyEmail({
|
|
62
|
+
...vars,
|
|
63
|
+
appName: '<script>alert("xss")</script>',
|
|
64
|
+
});
|
|
65
|
+
expect(html).not.toContain('<script>');
|
|
66
|
+
expect(html).toContain('<script>');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('escapes HTML in verifyUrl', () => {
|
|
70
|
+
const html = renderVerifyEmail({
|
|
71
|
+
...vars,
|
|
72
|
+
verifyUrl: 'https://evil.com/"><script>alert(1)</script>',
|
|
73
|
+
});
|
|
74
|
+
expect(html).not.toContain('"><script>');
|
|
75
|
+
expect(html).toContain('"><script>');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('escapes single quotes in token', () => {
|
|
79
|
+
const html = renderVerifyEmail({
|
|
80
|
+
...vars,
|
|
81
|
+
token: "test'token",
|
|
82
|
+
});
|
|
83
|
+
expect(html).toContain(''');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('escapes ampersand', () => {
|
|
87
|
+
const html = renderVerifyEmail({
|
|
88
|
+
...vars,
|
|
89
|
+
appName: 'A & B',
|
|
90
|
+
});
|
|
91
|
+
expect(html).toContain('A & B');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('uses custom template when provided', () => {
|
|
95
|
+
const html = renderVerifyEmail(vars, '<h1>{{appName}}</h1><a href="{{verifyUrl}}">{{token}}</a><p>{{expiresInHours}}h</p>');
|
|
96
|
+
expect(html).toContain('<h1>MyApp</h1>');
|
|
97
|
+
expect(html).toContain('href="https://example.com/verify?token=abc"');
|
|
98
|
+
expect(html).toContain('ABC123');
|
|
99
|
+
expect(html).toContain('24h');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('custom template escapes HTML in variables', () => {
|
|
103
|
+
const html = renderVerifyEmail(
|
|
104
|
+
{ ...vars, appName: '<script>xss</script>' },
|
|
105
|
+
'{{appName}}',
|
|
106
|
+
);
|
|
107
|
+
expect(html).toContain('<script>');
|
|
108
|
+
expect(html).not.toContain('<script>');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('custom template leaves unknown placeholders as-is', () => {
|
|
112
|
+
const html = renderVerifyEmail(vars, '{{unknown}} {{appName}}');
|
|
113
|
+
expect(html).toContain('{{unknown}}');
|
|
114
|
+
expect(html).toContain('MyApp');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── B. renderPasswordReset ─────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe('renderPasswordReset', () => {
|
|
121
|
+
const vars = {
|
|
122
|
+
appName: 'TestApp',
|
|
123
|
+
resetUrl: 'https://example.com/reset',
|
|
124
|
+
token: 'RESET-TOKEN',
|
|
125
|
+
expiresInMinutes: 30,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
it('includes reset URL', () => {
|
|
129
|
+
const html = renderPasswordReset(vars);
|
|
130
|
+
expect(html).toContain('https://example.com/reset');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('includes token', () => {
|
|
134
|
+
const html = renderPasswordReset(vars);
|
|
135
|
+
expect(html).toContain('RESET-TOKEN');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('includes expiry minutes', () => {
|
|
139
|
+
const html = renderPasswordReset(vars);
|
|
140
|
+
expect(html).toContain('30 minutes');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('includes footer with app name', () => {
|
|
144
|
+
const html = renderPasswordReset(vars);
|
|
145
|
+
expect(html).toContain('© TestApp');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('escapes XSS in resetUrl', () => {
|
|
149
|
+
const html = renderPasswordReset({
|
|
150
|
+
...vars,
|
|
151
|
+
resetUrl: '"><img onerror=alert(1) src=x>',
|
|
152
|
+
});
|
|
153
|
+
expect(html).not.toContain('<img');
|
|
154
|
+
expect(html).toContain('<img');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('uses custom template when provided', () => {
|
|
158
|
+
const html = renderPasswordReset(vars, '<p>Reset: {{resetUrl}} ({{expiresInMinutes}}min)</p>');
|
|
159
|
+
expect(html).toContain('Reset: https://example.com/reset (30min)');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── C. renderMagicLink ─────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe('renderMagicLink', () => {
|
|
166
|
+
const vars = {
|
|
167
|
+
appName: 'MagicApp',
|
|
168
|
+
magicLinkUrl: 'https://example.com/magic?token=xyz',
|
|
169
|
+
expiresInMinutes: 15,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
it('includes magic link URL', () => {
|
|
173
|
+
const html = renderMagicLink(vars);
|
|
174
|
+
expect(html).toContain('https://example.com/magic?token=xyz');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('includes expiry minutes', () => {
|
|
178
|
+
const html = renderMagicLink(vars);
|
|
179
|
+
expect(html).toContain('15 minutes');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('has login button', () => {
|
|
183
|
+
const html = renderMagicLink(vars);
|
|
184
|
+
expect(html).toContain('class="btn"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('escapes XSS in magicLinkUrl', () => {
|
|
188
|
+
const html = renderMagicLink({
|
|
189
|
+
...vars,
|
|
190
|
+
magicLinkUrl: 'javascript:alert(1)',
|
|
191
|
+
});
|
|
192
|
+
// Should be escaped but still present as text
|
|
193
|
+
expect(html).toContain('javascript:alert(1)');
|
|
194
|
+
// The important thing: it should not create an executable context
|
|
195
|
+
// (it's in an href, but the escapeHtml prevents tag injection)
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('uses custom template when provided', () => {
|
|
199
|
+
const html = renderMagicLink(vars, '<a href="{{magicLinkUrl}}">Login to {{appName}}</a>');
|
|
200
|
+
expect(html).toContain('href="https://example.com/magic?token=xyz"');
|
|
201
|
+
expect(html).toContain('Login to MagicApp');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── D. renderEmailOtp ──────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('renderEmailOtp', () => {
|
|
208
|
+
const vars = {
|
|
209
|
+
appName: 'OtpApp',
|
|
210
|
+
code: '123456',
|
|
211
|
+
expiresInMinutes: 5,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
it('includes OTP code', () => {
|
|
215
|
+
const html = renderEmailOtp(vars);
|
|
216
|
+
expect(html).toContain('123456');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('includes expiry minutes', () => {
|
|
220
|
+
const html = renderEmailOtp(vars);
|
|
221
|
+
expect(html).toContain('5 minutes');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('code in code block div', () => {
|
|
225
|
+
const html = renderEmailOtp(vars);
|
|
226
|
+
expect(html).toContain('class="code"');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('escapes code with HTML characters', () => {
|
|
230
|
+
const html = renderEmailOtp({
|
|
231
|
+
...vars,
|
|
232
|
+
code: '<b>bold</b>',
|
|
233
|
+
});
|
|
234
|
+
expect(html).not.toContain('<b>bold</b>');
|
|
235
|
+
expect(html).toContain('<b>');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('uses custom template when provided', () => {
|
|
239
|
+
const html = renderEmailOtp(vars, '<div>Your code: {{code}} (expires in {{expiresInMinutes}} min)</div>');
|
|
240
|
+
expect(html).toContain('Your code: 123456 (expires in 5 min)');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ─── E. renderEmailChange ───────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe('renderEmailChange', () => {
|
|
247
|
+
const vars = {
|
|
248
|
+
appName: 'ChangeApp',
|
|
249
|
+
verifyUrl: 'https://example.com/change',
|
|
250
|
+
token: 'CHANGE-TOKEN',
|
|
251
|
+
newEmail: 'new@example.com',
|
|
252
|
+
expiresInHours: 48,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
it('includes new email address', () => {
|
|
256
|
+
const html = renderEmailChange(vars);
|
|
257
|
+
expect(html).toContain('new@example.com');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('includes token', () => {
|
|
261
|
+
const html = renderEmailChange(vars);
|
|
262
|
+
expect(html).toContain('CHANGE-TOKEN');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('includes verify URL', () => {
|
|
266
|
+
const html = renderEmailChange(vars);
|
|
267
|
+
expect(html).toContain('https://example.com/change');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('includes expiry hours', () => {
|
|
271
|
+
const html = renderEmailChange(vars);
|
|
272
|
+
expect(html).toContain('48 hours');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('escapes XSS in newEmail', () => {
|
|
276
|
+
const html = renderEmailChange({
|
|
277
|
+
...vars,
|
|
278
|
+
newEmail: '<script>alert("xss")</script>@evil.com',
|
|
279
|
+
});
|
|
280
|
+
expect(html).not.toContain('<script>');
|
|
281
|
+
expect(html).toContain('<script>');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('escapes all five HTML entities', () => {
|
|
285
|
+
const html = renderEmailChange({
|
|
286
|
+
...vars,
|
|
287
|
+
appName: '&<>"\'',
|
|
288
|
+
});
|
|
289
|
+
expect(html).toContain('&');
|
|
290
|
+
expect(html).toContain('<');
|
|
291
|
+
expect(html).toContain('>');
|
|
292
|
+
expect(html).toContain('"');
|
|
293
|
+
expect(html).toContain(''');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('uses custom template when provided', () => {
|
|
297
|
+
const html = renderEmailChange(vars, '<p>Change to {{newEmail}}: {{verifyUrl}}</p>');
|
|
298
|
+
expect(html).toContain('Change to new@example.com: https://example.com/change');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ─── F. i18n — Locale-Aware Rendering ────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe('i18n — locale-aware rendering', () => {
|
|
305
|
+
const verifyVars = {
|
|
306
|
+
appName: 'MyApp',
|
|
307
|
+
verifyUrl: 'https://example.com/verify',
|
|
308
|
+
token: 'TOK123',
|
|
309
|
+
expiresInHours: 24,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const resetVars = {
|
|
313
|
+
appName: 'MyApp',
|
|
314
|
+
resetUrl: 'https://example.com/reset',
|
|
315
|
+
token: 'RST456',
|
|
316
|
+
expiresInMinutes: 30,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const magicVars = {
|
|
320
|
+
appName: 'MyApp',
|
|
321
|
+
magicLinkUrl: 'https://example.com/magic',
|
|
322
|
+
expiresInMinutes: 15,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const otpVars = {
|
|
326
|
+
appName: 'MyApp',
|
|
327
|
+
code: '847293',
|
|
328
|
+
expiresInMinutes: 10,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const changeVars = {
|
|
332
|
+
appName: 'MyApp',
|
|
333
|
+
verifyUrl: 'https://example.com/change',
|
|
334
|
+
token: 'CHG789',
|
|
335
|
+
newEmail: 'new@test.com',
|
|
336
|
+
expiresInHours: 48,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ─── HTML lang attribute ───
|
|
340
|
+
|
|
341
|
+
it('sets lang="en" by default (no locale)', () => {
|
|
342
|
+
const html = renderVerifyEmail(verifyVars);
|
|
343
|
+
expect(html).toContain('<html lang="en">');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('sets lang="ko" when locale is "ko"', () => {
|
|
347
|
+
const html = renderVerifyEmail(verifyVars, undefined, 'ko');
|
|
348
|
+
expect(html).toContain('<html lang="ko">');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('sets lang="ja" when locale is "ja"', () => {
|
|
352
|
+
const html = renderMagicLink(magicVars, undefined, 'ja');
|
|
353
|
+
expect(html).toContain('<html lang="ja">');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('lang attribute is set for all render functions', () => {
|
|
357
|
+
expect(renderVerifyEmail(verifyVars, undefined, 'fr')).toContain('<html lang="fr">');
|
|
358
|
+
expect(renderPasswordReset(resetVars, undefined, 'de')).toContain('<html lang="de">');
|
|
359
|
+
expect(renderMagicLink(magicVars, undefined, 'es')).toContain('<html lang="es">');
|
|
360
|
+
expect(renderEmailOtp(otpVars, undefined, 'pt')).toContain('<html lang="pt">');
|
|
361
|
+
expect(renderEmailChange(changeVars, undefined, 'zh')).toContain('<html lang="zh">');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ─── Korean translations ───
|
|
365
|
+
|
|
366
|
+
it('renderVerifyEmail uses Korean text when locale is "ko"', () => {
|
|
367
|
+
const html = renderVerifyEmail(verifyVars, undefined, 'ko');
|
|
368
|
+
expect(html).toContain('이메일 주소를 인증해주세요');
|
|
369
|
+
expect(html).toContain('이메일 인증'); // CTA button
|
|
370
|
+
expect(html).not.toContain('Verify Email'); // English CTA should NOT appear
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('renderPasswordReset uses Korean text when locale is "ko"', () => {
|
|
374
|
+
const html = renderPasswordReset(resetVars, undefined, 'ko');
|
|
375
|
+
expect(html).toContain('비밀번호 재설정을 요청하셨습니다');
|
|
376
|
+
expect(html).toContain('비밀번호 재설정'); // CTA
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('renderMagicLink uses Korean text when locale is "ko"', () => {
|
|
380
|
+
const html = renderMagicLink(magicVars, undefined, 'ko');
|
|
381
|
+
expect(html).toContain('계정 로그인 링크가 요청되었습니다');
|
|
382
|
+
expect(html).toContain('로그인'); // CTA
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('renderEmailOtp uses Korean text when locale is "ko"', () => {
|
|
386
|
+
const html = renderEmailOtp(otpVars, undefined, 'ko');
|
|
387
|
+
expect(html).toContain('로그인 인증 코드입니다');
|
|
388
|
+
expect(html).toContain('847293'); // code is still present
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('renderEmailChange uses Korean text when locale is "ko"', () => {
|
|
392
|
+
const html = renderEmailChange(changeVars, undefined, 'ko');
|
|
393
|
+
expect(html).toContain('계정의 이메일 변경이 요청되었습니다');
|
|
394
|
+
expect(html).toContain('이메일 변경 확인'); // CTA
|
|
395
|
+
expect(html).toContain('new@test.com'); // newEmail still present
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ─── Japanese translations ───
|
|
399
|
+
|
|
400
|
+
it('renderEmailOtp uses Japanese text when locale is "ja"', () => {
|
|
401
|
+
const html = renderEmailOtp(otpVars, undefined, 'ja');
|
|
402
|
+
expect(html).toContain('ログイン認証コードです');
|
|
403
|
+
expect(html).toContain('847293');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ─── Chinese translations ───
|
|
407
|
+
|
|
408
|
+
it('renderMagicLink uses Chinese text when locale is "zh"', () => {
|
|
409
|
+
const html = renderMagicLink(magicVars, undefined, 'zh');
|
|
410
|
+
expect(html).toContain('您的账户收到了登录链接请求');
|
|
411
|
+
expect(html).toContain('登录'); // CTA
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ─── Fallback chain: unknown locale → 'en' ───
|
|
415
|
+
|
|
416
|
+
it('falls back to English for unknown locale', () => {
|
|
417
|
+
const html = renderVerifyEmail(verifyVars, undefined, 'xx');
|
|
418
|
+
expect(html).toContain('Please verify your email address');
|
|
419
|
+
expect(html).toContain('<html lang="xx">'); // lang attr still uses the passed locale
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ─── Fallback chain: regional locale → base language ───
|
|
423
|
+
|
|
424
|
+
it('zh-TW falls back to zh translations', () => {
|
|
425
|
+
const html = renderMagicLink(magicVars, undefined, 'zh-TW');
|
|
426
|
+
expect(html).toContain('您的账户收到了登录链接请求'); // Chinese text
|
|
427
|
+
expect(html).toContain('<html lang="zh-TW">');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('ko-KR falls back to ko translations', () => {
|
|
431
|
+
const html = renderEmailOtp(otpVars, undefined, 'ko-KR');
|
|
432
|
+
expect(html).toContain('로그인 인증 코드입니다'); // Korean text
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ─── Custom template ALWAYS wins over locale ───
|
|
436
|
+
|
|
437
|
+
it('custom template overrides locale translations', () => {
|
|
438
|
+
const customTpl = '<h1>Custom: {{appName}}</h1>';
|
|
439
|
+
const html = renderVerifyEmail(verifyVars, customTpl, 'ko');
|
|
440
|
+
expect(html).toContain('<h1>Custom: MyApp</h1>');
|
|
441
|
+
expect(html).not.toContain('이메일'); // Korean text should NOT appear
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('custom template still works with any locale', () => {
|
|
445
|
+
const customTpl = '<p>Code: {{code}}</p>';
|
|
446
|
+
const html = renderEmailOtp(otpVars, customTpl, 'ja');
|
|
447
|
+
expect(html).toContain('<p>Code: 847293</p>');
|
|
448
|
+
expect(html).not.toContain('ログイン'); // Japanese text should NOT appear
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ─── Variables are still properly rendered in translated templates ───
|
|
452
|
+
|
|
453
|
+
it('Korean verify email still contains proper URLs and tokens', () => {
|
|
454
|
+
const html = renderVerifyEmail(verifyVars, undefined, 'ko');
|
|
455
|
+
expect(html).toContain('href="https://example.com/verify"');
|
|
456
|
+
expect(html).toContain('TOK123');
|
|
457
|
+
expect(html).toContain('MyApp');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('Korean password reset still has proper URLs', () => {
|
|
461
|
+
const html = renderPasswordReset(resetVars, undefined, 'ko');
|
|
462
|
+
expect(html).toContain('href="https://example.com/reset"');
|
|
463
|
+
expect(html).toContain('RST456');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ─── XSS prevention in translated templates ───
|
|
467
|
+
|
|
468
|
+
it('translated templates still escape XSS in variables', () => {
|
|
469
|
+
const html = renderVerifyEmail(
|
|
470
|
+
{ ...verifyVars, appName: '<script>alert(1)</script>' },
|
|
471
|
+
undefined,
|
|
472
|
+
'ko',
|
|
473
|
+
);
|
|
474
|
+
expect(html).not.toContain('<script>');
|
|
475
|
+
expect(html).toContain('<script>');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ─── G. email-translations.ts unit tests ─────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
describe('email-translations', () => {
|
|
482
|
+
it('SUPPORTED_LOCALES contains 8 locales', () => {
|
|
483
|
+
expect(SUPPORTED_LOCALES).toHaveLength(8);
|
|
484
|
+
expect(SUPPORTED_LOCALES).toContain('en');
|
|
485
|
+
expect(SUPPORTED_LOCALES).toContain('ko');
|
|
486
|
+
expect(SUPPORTED_LOCALES).toContain('ja');
|
|
487
|
+
expect(SUPPORTED_LOCALES).toContain('zh');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('getStrings returns English for "en"', () => {
|
|
491
|
+
const s = getStrings('en', 'verification');
|
|
492
|
+
expect(s.heading).toBe('Please verify your email address.');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('getStrings returns Korean for "ko"', () => {
|
|
496
|
+
const s = getStrings('ko', 'verification');
|
|
497
|
+
expect(s.heading).toBe('이메일 주소를 인증해주세요.');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('getStrings falls back to base language', () => {
|
|
501
|
+
const s = getStrings('ko-KR', 'passwordReset');
|
|
502
|
+
expect(s.heading).toBe('비밀번호 재설정을 요청하셨습니다.');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('getStrings falls back to English for unknown locale', () => {
|
|
506
|
+
const s = getStrings('xyz', 'magicLink');
|
|
507
|
+
expect(s.heading).toBe('A login link was requested for your account.');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('getDefaultSubject returns translated subject with {{appName}} placeholder', () => {
|
|
511
|
+
expect(getDefaultSubject('en', 'verification')).toBe('[{{appName}}] Verify your email');
|
|
512
|
+
expect(getDefaultSubject('ko', 'verification')).toBe('[{{appName}}] 이메일 인증');
|
|
513
|
+
expect(getDefaultSubject('ja', 'emailOtp')).toBe('[{{appName}}] ログイン認証コード');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('all locales have all 5 email types with required fields', () => {
|
|
517
|
+
const emailTypes = ['verification', 'passwordReset', 'magicLink', 'emailOtp', 'emailChange'] as const;
|
|
518
|
+
for (const locale of SUPPORTED_LOCALES) {
|
|
519
|
+
for (const type of emailTypes) {
|
|
520
|
+
const s = getStrings(locale, type);
|
|
521
|
+
expect(s.subject, `${locale}/${type} missing subject`).toBeTruthy();
|
|
522
|
+
expect(s.heading, `${locale}/${type} missing heading`).toBeTruthy();
|
|
523
|
+
expect(s.expires, `${locale}/${type} missing expires`).toBeTruthy();
|
|
524
|
+
expect(s.ignore, `${locale}/${type} missing ignore`).toBeTruthy();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|