@mandujs/core 0.41.2 → 0.43.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/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,296 +1,296 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/auth/reset — flow tests.
|
|
3
|
-
*
|
|
4
|
-
* Cover `createPasswordReset` end-to-end: mint + send via in-memory email,
|
|
5
|
-
* consume + onReset via `hashPassword`. The KDF hot path is CPU-bound —
|
|
6
|
-
* we pass minimal argon2id cost parameters everywhere to keep wall-clock
|
|
7
|
-
* time short.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
11
|
-
|
|
12
|
-
import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
|
|
13
|
-
import { verifyPassword } from "../password";
|
|
14
|
-
import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
|
|
15
|
-
import { createPasswordReset } from "../reset";
|
|
16
|
-
|
|
17
|
-
// ─── Gate on Bun.SQL + CryptoHasher + Bun.password ──────────────────────────
|
|
18
|
-
|
|
19
|
-
const hasBun = (() => {
|
|
20
|
-
const g = globalThis as unknown as {
|
|
21
|
-
Bun?: { SQL?: unknown; CryptoHasher?: unknown; password?: unknown };
|
|
22
|
-
};
|
|
23
|
-
return (
|
|
24
|
-
typeof g.Bun?.SQL === "function" &&
|
|
25
|
-
typeof g.Bun?.CryptoHasher === "function" &&
|
|
26
|
-
g.Bun?.password !== undefined
|
|
27
|
-
);
|
|
28
|
-
})();
|
|
29
|
-
const describeIfBun = hasBun ? describe : describe.skip;
|
|
30
|
-
|
|
31
|
-
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
const SECRET = "reset-flow-test-secret-32-bytes-or-more!!";
|
|
34
|
-
const URL_TEMPLATE = "https://app.example.com/reset?token={token}";
|
|
35
|
-
const FROM = "noreply@example.com";
|
|
36
|
-
const ARGON2ID_PREFIX = "$argon2id$";
|
|
37
|
-
const FAST_ARGON2 = { algorithm: "argon2id", memoryCost: 4, timeCost: 2 } as const;
|
|
38
|
-
|
|
39
|
-
interface Fixture {
|
|
40
|
-
store: AuthTokenStore;
|
|
41
|
-
sender: MemoryEmailSender;
|
|
42
|
-
onResetCalls: Array<{ userId: string; newHash: string }>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function makeFixture(): Fixture {
|
|
46
|
-
return {
|
|
47
|
-
store: createAuthTokenStore({
|
|
48
|
-
secret: SECRET,
|
|
49
|
-
dbPath: ":memory:",
|
|
50
|
-
gcSchedule: false,
|
|
51
|
-
}),
|
|
52
|
-
sender: createMemoryEmailSender(),
|
|
53
|
-
onResetCalls: [],
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describeIfBun("@mandujs/core/auth/reset — createPasswordReset", () => {
|
|
58
|
-
let fx: Fixture;
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
fx = makeFixture();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
afterEach(async () => {
|
|
65
|
-
await fx.store.close();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("send() mints a reset token and dispatches an email with the link", async () => {
|
|
69
|
-
const renderEmail = mock(({ url }: { url: string }) => ({
|
|
70
|
-
subject: "Reset your password",
|
|
71
|
-
html: `<a href="${url}">reset</a>`,
|
|
72
|
-
}));
|
|
73
|
-
const reset = createPasswordReset({
|
|
74
|
-
store: fx.store,
|
|
75
|
-
sender: fx.sender,
|
|
76
|
-
fromAddress: FROM,
|
|
77
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
78
|
-
renderEmail,
|
|
79
|
-
passwordOptions: FAST_ARGON2,
|
|
80
|
-
onReset: async (args) => {
|
|
81
|
-
fx.onResetCalls.push(args);
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
await reset.send("u-1", "alice@example.com");
|
|
86
|
-
expect(fx.sender.sent).toHaveLength(1);
|
|
87
|
-
const msg = fx.sender.sent[0]!;
|
|
88
|
-
expect(msg.from).toBe(FROM);
|
|
89
|
-
expect(msg.to).toBe("alice@example.com");
|
|
90
|
-
expect(msg.subject).toBe("Reset your password");
|
|
91
|
-
expect(msg.html).toContain("https://app.example.com/reset?token=");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("consume(validToken, newPassword) returns { userId } and calls onReset with an argon2id hash", async () => {
|
|
95
|
-
const reset = createPasswordReset({
|
|
96
|
-
store: fx.store,
|
|
97
|
-
sender: fx.sender,
|
|
98
|
-
fromAddress: FROM,
|
|
99
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
100
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
101
|
-
passwordOptions: FAST_ARGON2,
|
|
102
|
-
onReset: async (args) => {
|
|
103
|
-
fx.onResetCalls.push(args);
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
await reset.send("u-2", "bob@example.com");
|
|
108
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
109
|
-
|
|
110
|
-
const result = await reset.consume(token, "newPass123");
|
|
111
|
-
expect(result).toEqual({ userId: "u-2" });
|
|
112
|
-
expect(fx.onResetCalls).toHaveLength(1);
|
|
113
|
-
expect(fx.onResetCalls[0]!.userId).toBe("u-2");
|
|
114
|
-
expect(fx.onResetCalls[0]!.newHash.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("empty newPassword throws TypeError without consuming the token", async () => {
|
|
118
|
-
const reset = createPasswordReset({
|
|
119
|
-
store: fx.store,
|
|
120
|
-
sender: fx.sender,
|
|
121
|
-
fromAddress: FROM,
|
|
122
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
123
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
124
|
-
passwordOptions: FAST_ARGON2,
|
|
125
|
-
onReset: async () => {},
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
await reset.send("u-3", "carol@example.com");
|
|
129
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
130
|
-
|
|
131
|
-
await expect(reset.consume(token, "")).rejects.toThrow(/non-empty/);
|
|
132
|
-
// Token was NOT consumed — a real call still works.
|
|
133
|
-
const ok = await reset.consume(token, "validPass123");
|
|
134
|
-
expect(ok).toEqual({ userId: "u-3" });
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("onReset receives the hash, not the plaintext password", async () => {
|
|
138
|
-
let captured: { userId: string; newHash: string } | null = null;
|
|
139
|
-
const reset = createPasswordReset({
|
|
140
|
-
store: fx.store,
|
|
141
|
-
sender: fx.sender,
|
|
142
|
-
fromAddress: FROM,
|
|
143
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
144
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
145
|
-
passwordOptions: FAST_ARGON2,
|
|
146
|
-
onReset: async (args) => {
|
|
147
|
-
captured = args;
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
await reset.send("u-4", "dave@example.com");
|
|
151
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
152
|
-
|
|
153
|
-
const plaintext = "totally-secret-password";
|
|
154
|
-
await reset.consume(token, plaintext);
|
|
155
|
-
|
|
156
|
-
expect(captured).not.toBeNull();
|
|
157
|
-
const hash = captured!.newHash;
|
|
158
|
-
// The plaintext must NEVER appear in the hash payload.
|
|
159
|
-
expect(hash).not.toContain(plaintext);
|
|
160
|
-
// verifyPassword round-trips correctly against the hash.
|
|
161
|
-
expect(await verifyPassword(plaintext, hash)).toBe(true);
|
|
162
|
-
expect(await verifyPassword("wrong-password", hash)).toBe(false);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("reusing a consumed token returns null and onReset fires exactly once", async () => {
|
|
166
|
-
const reset = createPasswordReset({
|
|
167
|
-
store: fx.store,
|
|
168
|
-
sender: fx.sender,
|
|
169
|
-
fromAddress: FROM,
|
|
170
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
171
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
172
|
-
passwordOptions: FAST_ARGON2,
|
|
173
|
-
onReset: async (args) => {
|
|
174
|
-
fx.onResetCalls.push(args);
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
await reset.send("u-5", "eve@example.com");
|
|
178
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
179
|
-
|
|
180
|
-
const first = await reset.consume(token, "first-pass");
|
|
181
|
-
expect(first).not.toBeNull();
|
|
182
|
-
const second = await reset.consume(token, "second-pass");
|
|
183
|
-
expect(second).toBeNull();
|
|
184
|
-
expect(fx.onResetCalls).toHaveLength(1);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("reset does NOT auto-login — we don't touch sessions", async () => {
|
|
188
|
-
// This test guards the contract: the returned shape is `{ userId }`
|
|
189
|
-
// only — there are no cookie side-effects, no session helpers called.
|
|
190
|
-
// We construct the flow with NO session storage at all and expect
|
|
191
|
-
// consume() to still succeed.
|
|
192
|
-
const reset = createPasswordReset({
|
|
193
|
-
store: fx.store,
|
|
194
|
-
sender: fx.sender,
|
|
195
|
-
fromAddress: FROM,
|
|
196
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
197
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
198
|
-
passwordOptions: FAST_ARGON2,
|
|
199
|
-
onReset: async () => {},
|
|
200
|
-
});
|
|
201
|
-
await reset.send("u-6", "frank@example.com");
|
|
202
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
203
|
-
|
|
204
|
-
const result = await reset.consume(token, "fresh-pass");
|
|
205
|
-
expect(result).toEqual({ userId: "u-6" });
|
|
206
|
-
// Contract: ReturnType<consume> is never {userId, cookie, session, …}.
|
|
207
|
-
// The runtime shape must match — if a future refactor adds fields we
|
|
208
|
-
// need to revisit the doc + this guard.
|
|
209
|
-
expect(Object.keys(result!).sort()).toEqual(["userId"]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("hashPassword is invoked with the raw plaintext (argon2id default)", async () => {
|
|
213
|
-
// Black-box: the only way to observe "hashPassword was called with
|
|
214
|
-
// this plaintext" without reaching past the module boundary is to
|
|
215
|
-
// verify the emitted hash authenticates the plaintext.
|
|
216
|
-
let newHash: string | null = null;
|
|
217
|
-
const reset = createPasswordReset({
|
|
218
|
-
store: fx.store,
|
|
219
|
-
sender: fx.sender,
|
|
220
|
-
fromAddress: FROM,
|
|
221
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
222
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
223
|
-
passwordOptions: FAST_ARGON2,
|
|
224
|
-
onReset: async (args) => {
|
|
225
|
-
newHash = args.newHash;
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
await reset.send("u-7", "grace@example.com");
|
|
229
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
230
|
-
await reset.consume(token, "plaintext-checked");
|
|
231
|
-
|
|
232
|
-
expect(newHash).not.toBeNull();
|
|
233
|
-
expect(newHash!.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
234
|
-
expect(await verifyPassword("plaintext-checked", newHash!)).toBe(true);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("bcrypt's 72-byte limit: hashPassword error propagates (token is already spent)", async () => {
|
|
238
|
-
const reset = createPasswordReset({
|
|
239
|
-
store: fx.store,
|
|
240
|
-
sender: fx.sender,
|
|
241
|
-
fromAddress: FROM,
|
|
242
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
243
|
-
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
244
|
-
// Force bcrypt so the 72-byte guard in hashPassword fires.
|
|
245
|
-
passwordOptions: { algorithm: "bcrypt", cost: 4 },
|
|
246
|
-
onReset: async () => {
|
|
247
|
-
throw new Error("onReset should not be invoked when hashPassword throws");
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
await reset.send("u-8", "heidi@example.com");
|
|
251
|
-
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
252
|
-
|
|
253
|
-
const tooLong = "a".repeat(100); // 100 UTF-8 bytes, over the 72 limit.
|
|
254
|
-
await expect(reset.consume(token, tooLong)).rejects.toThrow(/72-byte limit/);
|
|
255
|
-
// Token IS consumed by the time hashPassword throws — a retry returns null.
|
|
256
|
-
const retry = await reset.consume(token, "shortPass");
|
|
257
|
-
expect(retry).toBeNull();
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("resetUrlTemplate without {token} throws at create time", () => {
|
|
261
|
-
expect(() =>
|
|
262
|
-
createPasswordReset({
|
|
263
|
-
store: fx.store,
|
|
264
|
-
sender: fx.sender,
|
|
265
|
-
fromAddress: FROM,
|
|
266
|
-
resetUrlTemplate: "https://app.example.com/reset?nope=1",
|
|
267
|
-
renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
|
|
268
|
-
passwordOptions: FAST_ARGON2,
|
|
269
|
-
onReset: async () => {},
|
|
270
|
-
}),
|
|
271
|
-
).toThrow(/\{token\}/);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("empty fromAddress throws at create time", () => {
|
|
275
|
-
expect(() =>
|
|
276
|
-
createPasswordReset({
|
|
277
|
-
store: fx.store,
|
|
278
|
-
sender: fx.sender,
|
|
279
|
-
fromAddress: "",
|
|
280
|
-
resetUrlTemplate: URL_TEMPLATE,
|
|
281
|
-
renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
|
|
282
|
-
passwordOptions: FAST_ARGON2,
|
|
283
|
-
onReset: async () => {},
|
|
284
|
-
}),
|
|
285
|
-
).toThrow(/fromAddress/);
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
function extractTokenFromUrl(html: string): string | null {
|
|
292
|
-
const hrefMatch = /href="([^"]+)"/.exec(html);
|
|
293
|
-
const url = hrefMatch ? hrefMatch[1]! : html;
|
|
294
|
-
const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
|
|
295
|
-
return tokenMatch ? tokenMatch[1]! : null;
|
|
296
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/reset — flow tests.
|
|
3
|
+
*
|
|
4
|
+
* Cover `createPasswordReset` end-to-end: mint + send via in-memory email,
|
|
5
|
+
* consume + onReset via `hashPassword`. The KDF hot path is CPU-bound —
|
|
6
|
+
* we pass minimal argon2id cost parameters everywhere to keep wall-clock
|
|
7
|
+
* time short.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
|
|
13
|
+
import { verifyPassword } from "../password";
|
|
14
|
+
import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
|
|
15
|
+
import { createPasswordReset } from "../reset";
|
|
16
|
+
|
|
17
|
+
// ─── Gate on Bun.SQL + CryptoHasher + Bun.password ──────────────────────────
|
|
18
|
+
|
|
19
|
+
const hasBun = (() => {
|
|
20
|
+
const g = globalThis as unknown as {
|
|
21
|
+
Bun?: { SQL?: unknown; CryptoHasher?: unknown; password?: unknown };
|
|
22
|
+
};
|
|
23
|
+
return (
|
|
24
|
+
typeof g.Bun?.SQL === "function" &&
|
|
25
|
+
typeof g.Bun?.CryptoHasher === "function" &&
|
|
26
|
+
g.Bun?.password !== undefined
|
|
27
|
+
);
|
|
28
|
+
})();
|
|
29
|
+
const describeIfBun = hasBun ? describe : describe.skip;
|
|
30
|
+
|
|
31
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const SECRET = "reset-flow-test-secret-32-bytes-or-more!!";
|
|
34
|
+
const URL_TEMPLATE = "https://app.example.com/reset?token={token}";
|
|
35
|
+
const FROM = "noreply@example.com";
|
|
36
|
+
const ARGON2ID_PREFIX = "$argon2id$";
|
|
37
|
+
const FAST_ARGON2 = { algorithm: "argon2id", memoryCost: 4, timeCost: 2 } as const;
|
|
38
|
+
|
|
39
|
+
interface Fixture {
|
|
40
|
+
store: AuthTokenStore;
|
|
41
|
+
sender: MemoryEmailSender;
|
|
42
|
+
onResetCalls: Array<{ userId: string; newHash: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeFixture(): Fixture {
|
|
46
|
+
return {
|
|
47
|
+
store: createAuthTokenStore({
|
|
48
|
+
secret: SECRET,
|
|
49
|
+
dbPath: ":memory:",
|
|
50
|
+
gcSchedule: false,
|
|
51
|
+
}),
|
|
52
|
+
sender: createMemoryEmailSender(),
|
|
53
|
+
onResetCalls: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describeIfBun("@mandujs/core/auth/reset — createPasswordReset", () => {
|
|
58
|
+
let fx: Fixture;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
fx = makeFixture();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
await fx.store.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("send() mints a reset token and dispatches an email with the link", async () => {
|
|
69
|
+
const renderEmail = mock(({ url }: { url: string }) => ({
|
|
70
|
+
subject: "Reset your password",
|
|
71
|
+
html: `<a href="${url}">reset</a>`,
|
|
72
|
+
}));
|
|
73
|
+
const reset = createPasswordReset({
|
|
74
|
+
store: fx.store,
|
|
75
|
+
sender: fx.sender,
|
|
76
|
+
fromAddress: FROM,
|
|
77
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
78
|
+
renderEmail,
|
|
79
|
+
passwordOptions: FAST_ARGON2,
|
|
80
|
+
onReset: async (args) => {
|
|
81
|
+
fx.onResetCalls.push(args);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await reset.send("u-1", "alice@example.com");
|
|
86
|
+
expect(fx.sender.sent).toHaveLength(1);
|
|
87
|
+
const msg = fx.sender.sent[0]!;
|
|
88
|
+
expect(msg.from).toBe(FROM);
|
|
89
|
+
expect(msg.to).toBe("alice@example.com");
|
|
90
|
+
expect(msg.subject).toBe("Reset your password");
|
|
91
|
+
expect(msg.html).toContain("https://app.example.com/reset?token=");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("consume(validToken, newPassword) returns { userId } and calls onReset with an argon2id hash", async () => {
|
|
95
|
+
const reset = createPasswordReset({
|
|
96
|
+
store: fx.store,
|
|
97
|
+
sender: fx.sender,
|
|
98
|
+
fromAddress: FROM,
|
|
99
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
100
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
101
|
+
passwordOptions: FAST_ARGON2,
|
|
102
|
+
onReset: async (args) => {
|
|
103
|
+
fx.onResetCalls.push(args);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await reset.send("u-2", "bob@example.com");
|
|
108
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
109
|
+
|
|
110
|
+
const result = await reset.consume(token, "newPass123");
|
|
111
|
+
expect(result).toEqual({ userId: "u-2" });
|
|
112
|
+
expect(fx.onResetCalls).toHaveLength(1);
|
|
113
|
+
expect(fx.onResetCalls[0]!.userId).toBe("u-2");
|
|
114
|
+
expect(fx.onResetCalls[0]!.newHash.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("empty newPassword throws TypeError without consuming the token", async () => {
|
|
118
|
+
const reset = createPasswordReset({
|
|
119
|
+
store: fx.store,
|
|
120
|
+
sender: fx.sender,
|
|
121
|
+
fromAddress: FROM,
|
|
122
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
123
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
124
|
+
passwordOptions: FAST_ARGON2,
|
|
125
|
+
onReset: async () => {},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await reset.send("u-3", "carol@example.com");
|
|
129
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
130
|
+
|
|
131
|
+
await expect(reset.consume(token, "")).rejects.toThrow(/non-empty/);
|
|
132
|
+
// Token was NOT consumed — a real call still works.
|
|
133
|
+
const ok = await reset.consume(token, "validPass123");
|
|
134
|
+
expect(ok).toEqual({ userId: "u-3" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("onReset receives the hash, not the plaintext password", async () => {
|
|
138
|
+
let captured: { userId: string; newHash: string } | null = null;
|
|
139
|
+
const reset = createPasswordReset({
|
|
140
|
+
store: fx.store,
|
|
141
|
+
sender: fx.sender,
|
|
142
|
+
fromAddress: FROM,
|
|
143
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
144
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
145
|
+
passwordOptions: FAST_ARGON2,
|
|
146
|
+
onReset: async (args) => {
|
|
147
|
+
captured = args;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
await reset.send("u-4", "dave@example.com");
|
|
151
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
152
|
+
|
|
153
|
+
const plaintext = "totally-secret-password";
|
|
154
|
+
await reset.consume(token, plaintext);
|
|
155
|
+
|
|
156
|
+
expect(captured).not.toBeNull();
|
|
157
|
+
const hash = captured!.newHash;
|
|
158
|
+
// The plaintext must NEVER appear in the hash payload.
|
|
159
|
+
expect(hash).not.toContain(plaintext);
|
|
160
|
+
// verifyPassword round-trips correctly against the hash.
|
|
161
|
+
expect(await verifyPassword(plaintext, hash)).toBe(true);
|
|
162
|
+
expect(await verifyPassword("wrong-password", hash)).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("reusing a consumed token returns null and onReset fires exactly once", async () => {
|
|
166
|
+
const reset = createPasswordReset({
|
|
167
|
+
store: fx.store,
|
|
168
|
+
sender: fx.sender,
|
|
169
|
+
fromAddress: FROM,
|
|
170
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
171
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
172
|
+
passwordOptions: FAST_ARGON2,
|
|
173
|
+
onReset: async (args) => {
|
|
174
|
+
fx.onResetCalls.push(args);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
await reset.send("u-5", "eve@example.com");
|
|
178
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
179
|
+
|
|
180
|
+
const first = await reset.consume(token, "first-pass");
|
|
181
|
+
expect(first).not.toBeNull();
|
|
182
|
+
const second = await reset.consume(token, "second-pass");
|
|
183
|
+
expect(second).toBeNull();
|
|
184
|
+
expect(fx.onResetCalls).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reset does NOT auto-login — we don't touch sessions", async () => {
|
|
188
|
+
// This test guards the contract: the returned shape is `{ userId }`
|
|
189
|
+
// only — there are no cookie side-effects, no session helpers called.
|
|
190
|
+
// We construct the flow with NO session storage at all and expect
|
|
191
|
+
// consume() to still succeed.
|
|
192
|
+
const reset = createPasswordReset({
|
|
193
|
+
store: fx.store,
|
|
194
|
+
sender: fx.sender,
|
|
195
|
+
fromAddress: FROM,
|
|
196
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
197
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
198
|
+
passwordOptions: FAST_ARGON2,
|
|
199
|
+
onReset: async () => {},
|
|
200
|
+
});
|
|
201
|
+
await reset.send("u-6", "frank@example.com");
|
|
202
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
203
|
+
|
|
204
|
+
const result = await reset.consume(token, "fresh-pass");
|
|
205
|
+
expect(result).toEqual({ userId: "u-6" });
|
|
206
|
+
// Contract: ReturnType<consume> is never {userId, cookie, session, …}.
|
|
207
|
+
// The runtime shape must match — if a future refactor adds fields we
|
|
208
|
+
// need to revisit the doc + this guard.
|
|
209
|
+
expect(Object.keys(result!).sort()).toEqual(["userId"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("hashPassword is invoked with the raw plaintext (argon2id default)", async () => {
|
|
213
|
+
// Black-box: the only way to observe "hashPassword was called with
|
|
214
|
+
// this plaintext" without reaching past the module boundary is to
|
|
215
|
+
// verify the emitted hash authenticates the plaintext.
|
|
216
|
+
let newHash: string | null = null;
|
|
217
|
+
const reset = createPasswordReset({
|
|
218
|
+
store: fx.store,
|
|
219
|
+
sender: fx.sender,
|
|
220
|
+
fromAddress: FROM,
|
|
221
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
222
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
223
|
+
passwordOptions: FAST_ARGON2,
|
|
224
|
+
onReset: async (args) => {
|
|
225
|
+
newHash = args.newHash;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await reset.send("u-7", "grace@example.com");
|
|
229
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
230
|
+
await reset.consume(token, "plaintext-checked");
|
|
231
|
+
|
|
232
|
+
expect(newHash).not.toBeNull();
|
|
233
|
+
expect(newHash!.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
234
|
+
expect(await verifyPassword("plaintext-checked", newHash!)).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("bcrypt's 72-byte limit: hashPassword error propagates (token is already spent)", async () => {
|
|
238
|
+
const reset = createPasswordReset({
|
|
239
|
+
store: fx.store,
|
|
240
|
+
sender: fx.sender,
|
|
241
|
+
fromAddress: FROM,
|
|
242
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
243
|
+
renderEmail: ({ url }) => ({ subject: "R", html: `<a href="${url}">r</a>` }),
|
|
244
|
+
// Force bcrypt so the 72-byte guard in hashPassword fires.
|
|
245
|
+
passwordOptions: { algorithm: "bcrypt", cost: 4 },
|
|
246
|
+
onReset: async () => {
|
|
247
|
+
throw new Error("onReset should not be invoked when hashPassword throws");
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
await reset.send("u-8", "heidi@example.com");
|
|
251
|
+
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
|
|
252
|
+
|
|
253
|
+
const tooLong = "a".repeat(100); // 100 UTF-8 bytes, over the 72 limit.
|
|
254
|
+
await expect(reset.consume(token, tooLong)).rejects.toThrow(/72-byte limit/);
|
|
255
|
+
// Token IS consumed by the time hashPassword throws — a retry returns null.
|
|
256
|
+
const retry = await reset.consume(token, "shortPass");
|
|
257
|
+
expect(retry).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("resetUrlTemplate without {token} throws at create time", () => {
|
|
261
|
+
expect(() =>
|
|
262
|
+
createPasswordReset({
|
|
263
|
+
store: fx.store,
|
|
264
|
+
sender: fx.sender,
|
|
265
|
+
fromAddress: FROM,
|
|
266
|
+
resetUrlTemplate: "https://app.example.com/reset?nope=1",
|
|
267
|
+
renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
|
|
268
|
+
passwordOptions: FAST_ARGON2,
|
|
269
|
+
onReset: async () => {},
|
|
270
|
+
}),
|
|
271
|
+
).toThrow(/\{token\}/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("empty fromAddress throws at create time", () => {
|
|
275
|
+
expect(() =>
|
|
276
|
+
createPasswordReset({
|
|
277
|
+
store: fx.store,
|
|
278
|
+
sender: fx.sender,
|
|
279
|
+
fromAddress: "",
|
|
280
|
+
resetUrlTemplate: URL_TEMPLATE,
|
|
281
|
+
renderEmail: () => ({ subject: "r", html: "<p>r</p>" }),
|
|
282
|
+
passwordOptions: FAST_ARGON2,
|
|
283
|
+
onReset: async () => {},
|
|
284
|
+
}),
|
|
285
|
+
).toThrow(/fromAddress/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function extractTokenFromUrl(html: string): string | null {
|
|
292
|
+
const hrefMatch = /href="([^"]+)"/.exec(html);
|
|
293
|
+
const url = hrefMatch ? hrefMatch[1]! : html;
|
|
294
|
+
const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
|
|
295
|
+
return tokenMatch ? tokenMatch[1]! : null;
|
|
296
|
+
}
|