@mandujs/core 0.53.3 → 0.54.0
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/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/auth/verification — flow tests.
|
|
3
|
-
*
|
|
4
|
-
* End-to-end cover for `createEmailVerification` using the in-memory email
|
|
5
|
-
* sender from `@mandujs/core/email` and a `:memory:` token store. The
|
|
6
|
-
* token store is shared across tests in a `describe` to keep boot cost
|
|
7
|
-
* low; each test mints fresh tokens, so cross-test contamination is
|
|
8
|
-
* non-existent.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
12
|
-
|
|
13
|
-
import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
|
|
14
|
-
import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
|
|
15
|
-
import { createEmailVerification } from "../verification";
|
|
16
|
-
|
|
17
|
-
// ─── Gate on Bun.SQL + CryptoHasher ─────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
const hasBun = (() => {
|
|
20
|
-
const g = globalThis as unknown as {
|
|
21
|
-
Bun?: { SQL?: unknown; CryptoHasher?: unknown };
|
|
22
|
-
};
|
|
23
|
-
return typeof g.Bun?.SQL === "function" && typeof g.Bun?.CryptoHasher === "function";
|
|
24
|
-
})();
|
|
25
|
-
const describeIfBun = hasBun ? describe : describe.skip;
|
|
26
|
-
|
|
27
|
-
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const SECRET = "verification-test-secret-32-bytes-or-more!";
|
|
30
|
-
const URL_TEMPLATE = "https://app.example.com/verify?token={token}";
|
|
31
|
-
const FROM = "noreply@example.com";
|
|
32
|
-
|
|
33
|
-
interface Fixture {
|
|
34
|
-
store: AuthTokenStore;
|
|
35
|
-
sender: MemoryEmailSender;
|
|
36
|
-
onVerifiedCalls: Array<{ userId: string; email: string }>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function makeFixture(): Fixture {
|
|
40
|
-
return {
|
|
41
|
-
store: createAuthTokenStore({
|
|
42
|
-
secret: SECRET,
|
|
43
|
-
dbPath: ":memory:",
|
|
44
|
-
gcSchedule: false,
|
|
45
|
-
}),
|
|
46
|
-
sender: createMemoryEmailSender(),
|
|
47
|
-
onVerifiedCalls: [],
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describeIfBun("@mandujs/core/auth/verification — createEmailVerification", () => {
|
|
52
|
-
let fx: Fixture;
|
|
53
|
-
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
fx = makeFixture();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterEach(async () => {
|
|
59
|
-
await fx.store.close();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("send() mints a token and dispatches an email with the rendered subject + html", async () => {
|
|
63
|
-
const renderEmail = mock(({ url, userId, email }: { url: string; userId: string; email: string }) => ({
|
|
64
|
-
subject: `Verify ${email}`,
|
|
65
|
-
html: `<a href="${url}" data-uid="${userId}">go</a>`,
|
|
66
|
-
text: `Verify: ${url}`,
|
|
67
|
-
}));
|
|
68
|
-
|
|
69
|
-
const verify = createEmailVerification({
|
|
70
|
-
store: fx.store,
|
|
71
|
-
sender: fx.sender,
|
|
72
|
-
fromAddress: FROM,
|
|
73
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
74
|
-
renderEmail,
|
|
75
|
-
onVerified: async (args) => {
|
|
76
|
-
fx.onVerifiedCalls.push(args);
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
await verify.send("u-1", "alice@example.com");
|
|
81
|
-
|
|
82
|
-
expect(renderEmail).toHaveBeenCalledTimes(1);
|
|
83
|
-
expect(fx.sender.sent).toHaveLength(1);
|
|
84
|
-
const msg = fx.sender.sent[0]!;
|
|
85
|
-
expect(msg.subject).toBe("Verify alice@example.com");
|
|
86
|
-
expect(msg.to).toBe("alice@example.com");
|
|
87
|
-
expect(msg.html).toContain("data-uid=\"u-1\"");
|
|
88
|
-
expect(msg.text).toContain("https://app.example.com/verify?token=");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("email body contains the resolved URL with the token substituted", async () => {
|
|
92
|
-
const verify = createEmailVerification({
|
|
93
|
-
store: fx.store,
|
|
94
|
-
sender: fx.sender,
|
|
95
|
-
fromAddress: FROM,
|
|
96
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
97
|
-
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
98
|
-
onVerified: async () => {},
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
await verify.send("u-2", "bob@example.com");
|
|
102
|
-
const msg = fx.sender.sent[0]!;
|
|
103
|
-
const href = /href="([^"]+)"/.exec(msg.html!)?.[1];
|
|
104
|
-
expect(href).toBeTruthy();
|
|
105
|
-
expect(href).toContain("https://app.example.com/verify?token=");
|
|
106
|
-
// The placeholder must NOT survive in the rendered URL.
|
|
107
|
-
expect(href).not.toContain("{token}");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("consume(validToken) returns { userId, email } and invokes onVerified", async () => {
|
|
111
|
-
const verify = createEmailVerification({
|
|
112
|
-
store: fx.store,
|
|
113
|
-
sender: fx.sender,
|
|
114
|
-
fromAddress: FROM,
|
|
115
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
116
|
-
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
117
|
-
onVerified: async (args) => {
|
|
118
|
-
fx.onVerifiedCalls.push(args);
|
|
119
|
-
},
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
await verify.send("u-3", "carol@example.com");
|
|
123
|
-
const msg = fx.sender.sent[0]!;
|
|
124
|
-
const token = extractTokenFromUrl(msg.html!);
|
|
125
|
-
expect(token).toBeTruthy();
|
|
126
|
-
|
|
127
|
-
const result = await verify.consume(token!);
|
|
128
|
-
expect(result).toEqual({ userId: "u-3", email: "carol@example.com" });
|
|
129
|
-
expect(fx.onVerifiedCalls).toHaveLength(1);
|
|
130
|
-
expect(fx.onVerifiedCalls[0]).toEqual({ userId: "u-3", email: "carol@example.com" });
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("consume(consumedToken) returns null on the second call", async () => {
|
|
134
|
-
const verify = createEmailVerification({
|
|
135
|
-
store: fx.store,
|
|
136
|
-
sender: fx.sender,
|
|
137
|
-
fromAddress: FROM,
|
|
138
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
139
|
-
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
140
|
-
onVerified: async () => {},
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
await verify.send("u-4", "dave@example.com");
|
|
144
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
145
|
-
|
|
146
|
-
const first = await verify.consume(token);
|
|
147
|
-
expect(first).not.toBeNull();
|
|
148
|
-
const second = await verify.consume(token);
|
|
149
|
-
expect(second).toBeNull();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("consume(expiredToken) returns null", async () => {
|
|
153
|
-
const expiringStore = createAuthTokenStore({
|
|
154
|
-
secret: SECRET,
|
|
155
|
-
dbPath: ":memory:",
|
|
156
|
-
gcSchedule: false,
|
|
157
|
-
ttlSecondsByPurpose: { "verify-email": 0 },
|
|
158
|
-
});
|
|
159
|
-
try {
|
|
160
|
-
const verify = createEmailVerification({
|
|
161
|
-
store: expiringStore,
|
|
162
|
-
sender: fx.sender,
|
|
163
|
-
fromAddress: FROM,
|
|
164
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
165
|
-
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
166
|
-
onVerified: async () => {},
|
|
167
|
-
});
|
|
168
|
-
await verify.send("u-5", "eve@example.com");
|
|
169
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
170
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
171
|
-
expect(await verify.consume(token)).toBeNull();
|
|
172
|
-
} finally {
|
|
173
|
-
await expiringStore.close();
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("consume(bogus) returns null for structurally broken / unknown tokens", async () => {
|
|
178
|
-
const verify = createEmailVerification({
|
|
179
|
-
store: fx.store,
|
|
180
|
-
sender: fx.sender,
|
|
181
|
-
fromAddress: FROM,
|
|
182
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
183
|
-
renderEmail: ({ url }) => ({ subject: "V", html: url }),
|
|
184
|
-
onVerified: async () => {},
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
expect(await verify.consume("garbage-no-dot")).toBeNull();
|
|
188
|
-
expect(await verify.consume("")).toBeNull();
|
|
189
|
-
// Malformed percent-encoding — must not throw.
|
|
190
|
-
expect(await verify.consume("%E0%A4%A")).toBeNull();
|
|
191
|
-
// Well-formed structure but nothing in the DB.
|
|
192
|
-
expect(await verify.consume("fakeid.fakenonce")).toBeNull();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("verifyUrlTemplate without the {token} placeholder throws at create time", () => {
|
|
196
|
-
expect(() =>
|
|
197
|
-
createEmailVerification({
|
|
198
|
-
store: fx.store,
|
|
199
|
-
sender: fx.sender,
|
|
200
|
-
fromAddress: FROM,
|
|
201
|
-
verifyUrlTemplate: "https://app.example.com/verify?nope=1",
|
|
202
|
-
renderEmail: () => ({ subject: "v", html: "<p>v</p>" }),
|
|
203
|
-
onVerified: async () => {},
|
|
204
|
-
}),
|
|
205
|
-
).toThrow(/\{token\}/);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("empty fromAddress throws at create time", () => {
|
|
209
|
-
expect(() =>
|
|
210
|
-
createEmailVerification({
|
|
211
|
-
store: fx.store,
|
|
212
|
-
sender: fx.sender,
|
|
213
|
-
fromAddress: "",
|
|
214
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
215
|
-
renderEmail: () => ({ subject: "v", html: "<p>v</p>" }),
|
|
216
|
-
onVerified: async () => {},
|
|
217
|
-
}),
|
|
218
|
-
).toThrow(/fromAddress/);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("fromAddress is honored on every outbound message", async () => {
|
|
222
|
-
const customFrom = "\"App Name\" <no-reply@example.org>";
|
|
223
|
-
const verify = createEmailVerification({
|
|
224
|
-
store: fx.store,
|
|
225
|
-
sender: fx.sender,
|
|
226
|
-
fromAddress: customFrom,
|
|
227
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
228
|
-
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
229
|
-
onVerified: async () => {},
|
|
230
|
-
});
|
|
231
|
-
await verify.send("u-6", "frank@example.com");
|
|
232
|
-
await verify.send("u-7", "grace@example.com");
|
|
233
|
-
expect(fx.sender.sent).toHaveLength(2);
|
|
234
|
-
expect(fx.sender.sent[0]!.from).toBe(customFrom);
|
|
235
|
-
expect(fx.sender.sent[1]!.from).toBe(customFrom);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("onVerified throwing propagates — token is already consumed (idempotency note)", async () => {
|
|
239
|
-
const verify = createEmailVerification({
|
|
240
|
-
store: fx.store,
|
|
241
|
-
sender: fx.sender,
|
|
242
|
-
fromAddress: FROM,
|
|
243
|
-
verifyUrlTemplate: URL_TEMPLATE,
|
|
244
|
-
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
245
|
-
onVerified: async () => {
|
|
246
|
-
throw new Error("DB write failed");
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
await verify.send("u-8", "heidi@example.com");
|
|
251
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
252
|
-
|
|
253
|
-
await expect(verify.consume(token)).rejects.toThrow(/DB write failed/);
|
|
254
|
-
// Token is consumed — a retry returns null, forcing a fresh send.
|
|
255
|
-
const retry = await verify.consume(token);
|
|
256
|
-
expect(retry).toBeNull();
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Extract the `token` query parameter from a URL embedded in a rendered
|
|
264
|
-
* HTML body. Returns `null` if the URL wasn't found — tests assert
|
|
265
|
-
* non-null so a missing token fails loudly.
|
|
266
|
-
*/
|
|
267
|
-
function extractTokenFromUrl(html: string): string | null {
|
|
268
|
-
// Match the URL carried inside the template. `href="..."` is our
|
|
269
|
-
// rendering convention.
|
|
270
|
-
const hrefMatch = /href="([^"]+)"/.exec(html);
|
|
271
|
-
const url = hrefMatch ? hrefMatch[1]! : html;
|
|
272
|
-
const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
|
|
273
|
-
return tokenMatch ? tokenMatch[1]! : null;
|
|
274
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/verification — flow tests.
|
|
3
|
+
*
|
|
4
|
+
* End-to-end cover for `createEmailVerification` using the in-memory email
|
|
5
|
+
* sender from `@mandujs/core/email` and a `:memory:` token store. The
|
|
6
|
+
* token store is shared across tests in a `describe` to keep boot cost
|
|
7
|
+
* low; each test mints fresh tokens, so cross-test contamination is
|
|
8
|
+
* non-existent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
|
|
14
|
+
import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
|
|
15
|
+
import { createEmailVerification } from "../verification";
|
|
16
|
+
|
|
17
|
+
// ─── Gate on Bun.SQL + CryptoHasher ─────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const hasBun = (() => {
|
|
20
|
+
const g = globalThis as unknown as {
|
|
21
|
+
Bun?: { SQL?: unknown; CryptoHasher?: unknown };
|
|
22
|
+
};
|
|
23
|
+
return typeof g.Bun?.SQL === "function" && typeof g.Bun?.CryptoHasher === "function";
|
|
24
|
+
})();
|
|
25
|
+
const describeIfBun = hasBun ? describe : describe.skip;
|
|
26
|
+
|
|
27
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SECRET = "verification-test-secret-32-bytes-or-more!";
|
|
30
|
+
const URL_TEMPLATE = "https://app.example.com/verify?token={token}";
|
|
31
|
+
const FROM = "noreply@example.com";
|
|
32
|
+
|
|
33
|
+
interface Fixture {
|
|
34
|
+
store: AuthTokenStore;
|
|
35
|
+
sender: MemoryEmailSender;
|
|
36
|
+
onVerifiedCalls: Array<{ userId: string; email: string }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeFixture(): Fixture {
|
|
40
|
+
return {
|
|
41
|
+
store: createAuthTokenStore({
|
|
42
|
+
secret: SECRET,
|
|
43
|
+
dbPath: ":memory:",
|
|
44
|
+
gcSchedule: false,
|
|
45
|
+
}),
|
|
46
|
+
sender: createMemoryEmailSender(),
|
|
47
|
+
onVerifiedCalls: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describeIfBun("@mandujs/core/auth/verification — createEmailVerification", () => {
|
|
52
|
+
let fx: Fixture;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
fx = makeFixture();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await fx.store.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("send() mints a token and dispatches an email with the rendered subject + html", async () => {
|
|
63
|
+
const renderEmail = mock(({ url, userId, email }: { url: string; userId: string; email: string }) => ({
|
|
64
|
+
subject: `Verify ${email}`,
|
|
65
|
+
html: `<a href="${url}" data-uid="${userId}">go</a>`,
|
|
66
|
+
text: `Verify: ${url}`,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const verify = createEmailVerification({
|
|
70
|
+
store: fx.store,
|
|
71
|
+
sender: fx.sender,
|
|
72
|
+
fromAddress: FROM,
|
|
73
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
74
|
+
renderEmail,
|
|
75
|
+
onVerified: async (args) => {
|
|
76
|
+
fx.onVerifiedCalls.push(args);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await verify.send("u-1", "alice@example.com");
|
|
81
|
+
|
|
82
|
+
expect(renderEmail).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(fx.sender.sent).toHaveLength(1);
|
|
84
|
+
const msg = fx.sender.sent[0]!;
|
|
85
|
+
expect(msg.subject).toBe("Verify alice@example.com");
|
|
86
|
+
expect(msg.to).toBe("alice@example.com");
|
|
87
|
+
expect(msg.html).toContain("data-uid=\"u-1\"");
|
|
88
|
+
expect(msg.text).toContain("https://app.example.com/verify?token=");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("email body contains the resolved URL with the token substituted", async () => {
|
|
92
|
+
const verify = createEmailVerification({
|
|
93
|
+
store: fx.store,
|
|
94
|
+
sender: fx.sender,
|
|
95
|
+
fromAddress: FROM,
|
|
96
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
97
|
+
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
98
|
+
onVerified: async () => {},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await verify.send("u-2", "bob@example.com");
|
|
102
|
+
const msg = fx.sender.sent[0]!;
|
|
103
|
+
const href = /href="([^"]+)"/.exec(msg.html!)?.[1];
|
|
104
|
+
expect(href).toBeTruthy();
|
|
105
|
+
expect(href).toContain("https://app.example.com/verify?token=");
|
|
106
|
+
// The placeholder must NOT survive in the rendered URL.
|
|
107
|
+
expect(href).not.toContain("{token}");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("consume(validToken) returns { userId, email } and invokes onVerified", async () => {
|
|
111
|
+
const verify = createEmailVerification({
|
|
112
|
+
store: fx.store,
|
|
113
|
+
sender: fx.sender,
|
|
114
|
+
fromAddress: FROM,
|
|
115
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
116
|
+
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
117
|
+
onVerified: async (args) => {
|
|
118
|
+
fx.onVerifiedCalls.push(args);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await verify.send("u-3", "carol@example.com");
|
|
123
|
+
const msg = fx.sender.sent[0]!;
|
|
124
|
+
const token = extractTokenFromUrl(msg.html!);
|
|
125
|
+
expect(token).toBeTruthy();
|
|
126
|
+
|
|
127
|
+
const result = await verify.consume(token!);
|
|
128
|
+
expect(result).toEqual({ userId: "u-3", email: "carol@example.com" });
|
|
129
|
+
expect(fx.onVerifiedCalls).toHaveLength(1);
|
|
130
|
+
expect(fx.onVerifiedCalls[0]).toEqual({ userId: "u-3", email: "carol@example.com" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("consume(consumedToken) returns null on the second call", async () => {
|
|
134
|
+
const verify = createEmailVerification({
|
|
135
|
+
store: fx.store,
|
|
136
|
+
sender: fx.sender,
|
|
137
|
+
fromAddress: FROM,
|
|
138
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
139
|
+
renderEmail: ({ url }) => ({ subject: "Verify", html: `<a href="${url}">v</a>` }),
|
|
140
|
+
onVerified: async () => {},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await verify.send("u-4", "dave@example.com");
|
|
144
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
145
|
+
|
|
146
|
+
const first = await verify.consume(token);
|
|
147
|
+
expect(first).not.toBeNull();
|
|
148
|
+
const second = await verify.consume(token);
|
|
149
|
+
expect(second).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("consume(expiredToken) returns null", async () => {
|
|
153
|
+
const expiringStore = createAuthTokenStore({
|
|
154
|
+
secret: SECRET,
|
|
155
|
+
dbPath: ":memory:",
|
|
156
|
+
gcSchedule: false,
|
|
157
|
+
ttlSecondsByPurpose: { "verify-email": 0 },
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
const verify = createEmailVerification({
|
|
161
|
+
store: expiringStore,
|
|
162
|
+
sender: fx.sender,
|
|
163
|
+
fromAddress: FROM,
|
|
164
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
165
|
+
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
166
|
+
onVerified: async () => {},
|
|
167
|
+
});
|
|
168
|
+
await verify.send("u-5", "eve@example.com");
|
|
169
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
170
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
171
|
+
expect(await verify.consume(token)).toBeNull();
|
|
172
|
+
} finally {
|
|
173
|
+
await expiringStore.close();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("consume(bogus) returns null for structurally broken / unknown tokens", async () => {
|
|
178
|
+
const verify = createEmailVerification({
|
|
179
|
+
store: fx.store,
|
|
180
|
+
sender: fx.sender,
|
|
181
|
+
fromAddress: FROM,
|
|
182
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
183
|
+
renderEmail: ({ url }) => ({ subject: "V", html: url }),
|
|
184
|
+
onVerified: async () => {},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(await verify.consume("garbage-no-dot")).toBeNull();
|
|
188
|
+
expect(await verify.consume("")).toBeNull();
|
|
189
|
+
// Malformed percent-encoding — must not throw.
|
|
190
|
+
expect(await verify.consume("%E0%A4%A")).toBeNull();
|
|
191
|
+
// Well-formed structure but nothing in the DB.
|
|
192
|
+
expect(await verify.consume("fakeid.fakenonce")).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("verifyUrlTemplate without the {token} placeholder throws at create time", () => {
|
|
196
|
+
expect(() =>
|
|
197
|
+
createEmailVerification({
|
|
198
|
+
store: fx.store,
|
|
199
|
+
sender: fx.sender,
|
|
200
|
+
fromAddress: FROM,
|
|
201
|
+
verifyUrlTemplate: "https://app.example.com/verify?nope=1",
|
|
202
|
+
renderEmail: () => ({ subject: "v", html: "<p>v</p>" }),
|
|
203
|
+
onVerified: async () => {},
|
|
204
|
+
}),
|
|
205
|
+
).toThrow(/\{token\}/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("empty fromAddress throws at create time", () => {
|
|
209
|
+
expect(() =>
|
|
210
|
+
createEmailVerification({
|
|
211
|
+
store: fx.store,
|
|
212
|
+
sender: fx.sender,
|
|
213
|
+
fromAddress: "",
|
|
214
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
215
|
+
renderEmail: () => ({ subject: "v", html: "<p>v</p>" }),
|
|
216
|
+
onVerified: async () => {},
|
|
217
|
+
}),
|
|
218
|
+
).toThrow(/fromAddress/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("fromAddress is honored on every outbound message", async () => {
|
|
222
|
+
const customFrom = "\"App Name\" <no-reply@example.org>";
|
|
223
|
+
const verify = createEmailVerification({
|
|
224
|
+
store: fx.store,
|
|
225
|
+
sender: fx.sender,
|
|
226
|
+
fromAddress: customFrom,
|
|
227
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
228
|
+
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
229
|
+
onVerified: async () => {},
|
|
230
|
+
});
|
|
231
|
+
await verify.send("u-6", "frank@example.com");
|
|
232
|
+
await verify.send("u-7", "grace@example.com");
|
|
233
|
+
expect(fx.sender.sent).toHaveLength(2);
|
|
234
|
+
expect(fx.sender.sent[0]!.from).toBe(customFrom);
|
|
235
|
+
expect(fx.sender.sent[1]!.from).toBe(customFrom);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("onVerified throwing propagates — token is already consumed (idempotency note)", async () => {
|
|
239
|
+
const verify = createEmailVerification({
|
|
240
|
+
store: fx.store,
|
|
241
|
+
sender: fx.sender,
|
|
242
|
+
fromAddress: FROM,
|
|
243
|
+
verifyUrlTemplate: URL_TEMPLATE,
|
|
244
|
+
renderEmail: ({ url }) => ({ subject: "V", html: `<a href="${url}">v</a>` }),
|
|
245
|
+
onVerified: async () => {
|
|
246
|
+
throw new Error("DB write failed");
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await verify.send("u-8", "heidi@example.com");
|
|
251
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
252
|
+
|
|
253
|
+
await expect(verify.consume(token)).rejects.toThrow(/DB write failed/);
|
|
254
|
+
// Token is consumed — a retry returns null, forcing a fresh send.
|
|
255
|
+
const retry = await verify.consume(token);
|
|
256
|
+
expect(retry).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract the `token` query parameter from a URL embedded in a rendered
|
|
264
|
+
* HTML body. Returns `null` if the URL wasn't found — tests assert
|
|
265
|
+
* non-null so a missing token fails loudly.
|
|
266
|
+
*/
|
|
267
|
+
function extractTokenFromUrl(html: string): string | null {
|
|
268
|
+
// Match the URL carried inside the template. `href="..."` is our
|
|
269
|
+
// rendering convention.
|
|
270
|
+
const hrefMatch = /href="([^"]+)"/.exec(html);
|
|
271
|
+
const url = hrefMatch ? hrefMatch[1]! : html;
|
|
272
|
+
const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
|
|
273
|
+
return tokenMatch ? tokenMatch[1]! : null;
|
|
274
|
+
}
|