@mandujs/core 0.21.0 → 0.22.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 +94 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/password tests
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise real argon2id and bcrypt hashing through `Bun.password`.
|
|
5
|
+
* They are CPU-bound (~50-200ms per hash op on laptop hardware); the suite
|
|
6
|
+
* uses the minimum cost parameters Bun accepts whenever the test does not
|
|
7
|
+
* care about actual KDF strength. Total runtime ~2-5s is expected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "bun:test";
|
|
11
|
+
import { hashPassword, verifyPassword } from "../password";
|
|
12
|
+
|
|
13
|
+
// Argon2 hashes start with "$argon2id$", "$argon2d$", or "$argon2i$".
|
|
14
|
+
const ARGON2ID_PREFIX = "$argon2id$";
|
|
15
|
+
// bcrypt hashes use $2a$/$2b$/$2y$ prefixes; Bun emits $2b$.
|
|
16
|
+
const BCRYPT_PREFIX_RE = /^\$2[aby]\$/;
|
|
17
|
+
|
|
18
|
+
// Minimum argon2 cost params — keep tests fast without changing behavior.
|
|
19
|
+
const FAST_ARGON2 = {
|
|
20
|
+
algorithm: "argon2id",
|
|
21
|
+
memoryCost: 4,
|
|
22
|
+
timeCost: 2,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
describe("@mandujs/core/auth/password — hashPassword", () => {
|
|
26
|
+
it("produces an argon2id hash by default", async () => {
|
|
27
|
+
const h = await hashPassword("hunter2", FAST_ARGON2);
|
|
28
|
+
expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("produces a bcrypt hash when algorithm=bcrypt is requested", async () => {
|
|
32
|
+
const h = await hashPassword("hunter2", { algorithm: "bcrypt", cost: 4 });
|
|
33
|
+
expect(h).toMatch(BCRYPT_PREFIX_RE);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws on empty plaintext", async () => {
|
|
37
|
+
await expect(hashPassword("", FAST_ARGON2)).rejects.toThrow(
|
|
38
|
+
/non-empty string/,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws when bcrypt input exceeds 72 bytes", async () => {
|
|
43
|
+
// 73 ASCII bytes (each char = 1 UTF-8 byte).
|
|
44
|
+
const tooLong = "a".repeat(73);
|
|
45
|
+
await expect(
|
|
46
|
+
hashPassword(tooLong, { algorithm: "bcrypt", cost: 4 }),
|
|
47
|
+
).rejects.toThrow(/72-byte limit/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("accepts >72-byte input when using argon2id (bcrypt-only limit)", async () => {
|
|
51
|
+
const longButFine = "a".repeat(100);
|
|
52
|
+
const h = await hashPassword(longButFine, FAST_ARGON2);
|
|
53
|
+
expect(h.startsWith(ARGON2ID_PREFIX)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("counts UTF-8 bytes (not code points) when enforcing the bcrypt limit", async () => {
|
|
57
|
+
// "é" is 2 bytes in UTF-8 — 40 of them = 80 bytes, over the 72 limit.
|
|
58
|
+
const multibyte = "é".repeat(40);
|
|
59
|
+
await expect(
|
|
60
|
+
hashPassword(multibyte, { algorithm: "bcrypt", cost: 4 }),
|
|
61
|
+
).rejects.toThrow(/72-byte limit/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("honors non-default argon2 timeCost via option pass-through", async () => {
|
|
65
|
+
// Bun embeds the cost parameters in the hash string:
|
|
66
|
+
// $argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=1$<salt>$<hash>
|
|
67
|
+
// We assert the timeCost is preserved end-to-end.
|
|
68
|
+
const h = await hashPassword("same-password", {
|
|
69
|
+
algorithm: "argon2id",
|
|
70
|
+
memoryCost: 4,
|
|
71
|
+
timeCost: 3,
|
|
72
|
+
});
|
|
73
|
+
expect(h).toMatch(/\$argon2id\$v=\d+\$m=4,t=3,p=\d+\$/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("@mandujs/core/auth/password — verifyPassword", () => {
|
|
78
|
+
it("returns true for a matching argon2id roundtrip", async () => {
|
|
79
|
+
const h = await hashPassword("correct-horse-battery-staple", FAST_ARGON2);
|
|
80
|
+
const ok = await verifyPassword("correct-horse-battery-staple", h);
|
|
81
|
+
expect(ok).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns true for a matching bcrypt roundtrip", async () => {
|
|
85
|
+
const h = await hashPassword("correct-horse-battery-staple", {
|
|
86
|
+
algorithm: "bcrypt",
|
|
87
|
+
cost: 4,
|
|
88
|
+
});
|
|
89
|
+
const ok = await verifyPassword("correct-horse-battery-staple", h);
|
|
90
|
+
expect(ok).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns false for wrong plaintext", async () => {
|
|
94
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
95
|
+
expect(await verifyPassword("incorrect", h)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false for a tampered hash (one char mutated)", async () => {
|
|
99
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
100
|
+
// Flip the last character of the base64 hash segment.
|
|
101
|
+
const last = h.charAt(h.length - 1);
|
|
102
|
+
const replacement = last === "A" ? "B" : "A";
|
|
103
|
+
const tampered = h.slice(0, -1) + replacement;
|
|
104
|
+
expect(tampered).not.toBe(h);
|
|
105
|
+
expect(await verifyPassword("correct", tampered)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns false (never throws) for a malformed hash string", async () => {
|
|
109
|
+
// Bun.password.verify throws on unparseable hashes; our wrapper collapses
|
|
110
|
+
// every failure mode to `false` so login handlers stay branchless.
|
|
111
|
+
expect(await verifyPassword("anything", "not-a-hash")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns false for empty plaintext", async () => {
|
|
115
|
+
const h = await hashPassword("correct", FAST_ARGON2);
|
|
116
|
+
expect(await verifyPassword("", h)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns false for empty hash", async () => {
|
|
120
|
+
expect(await verifyPassword("correct", "")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/auth/tokens — store tests.
|
|
3
|
+
*
|
|
4
|
+
* Each case builds a fresh `:memory:` store (gated on Bun.SQL availability)
|
|
5
|
+
* with `gcSchedule: false` so no real cron fires during tests. Closed in
|
|
6
|
+
* `afterEach`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createAuthTokenStore,
|
|
13
|
+
type AuthTokenStore,
|
|
14
|
+
type TokenRecord,
|
|
15
|
+
} from "../tokens";
|
|
16
|
+
|
|
17
|
+
// ─── Gate on Bun.SQL + Bun.CryptoHasher ─────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const hasBunSql = (() => {
|
|
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 = hasBunSql ? describe : describe.skip;
|
|
26
|
+
|
|
27
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SECRET = "tokens-test-secret-at-least-32-bytes-long!";
|
|
30
|
+
|
|
31
|
+
describeIfBun("@mandujs/core/auth/tokens — createAuthTokenStore", () => {
|
|
32
|
+
let store: AuthTokenStore;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
store = createAuthTokenStore({
|
|
36
|
+
secret: SECRET,
|
|
37
|
+
dbPath: ":memory:",
|
|
38
|
+
gcSchedule: false,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await store.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("mint → consume roundtrip returns the record with userId + purpose", async () => {
|
|
47
|
+
const { token, record } = await store.mint("verify-email", "u-1", {
|
|
48
|
+
email: "a@b.com",
|
|
49
|
+
});
|
|
50
|
+
expect(token).toContain(".");
|
|
51
|
+
expect(record.userId).toBe("u-1");
|
|
52
|
+
expect(record.purpose).toBe("verify-email");
|
|
53
|
+
expect(record.consumedAt).toBeNull();
|
|
54
|
+
expect(record.tokenHash).toMatch(/^[0-9a-f]{64}$/);
|
|
55
|
+
|
|
56
|
+
const consumed = await store.consume("verify-email", token);
|
|
57
|
+
expect(consumed).not.toBeNull();
|
|
58
|
+
expect(consumed!.userId).toBe("u-1");
|
|
59
|
+
expect(consumed!.id).toBe(record.id);
|
|
60
|
+
expect(consumed!.consumedAt).toBeTypeOf("number");
|
|
61
|
+
expect(consumed!.consumedAt).not.toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("consuming twice: second call returns null (single-use)", async () => {
|
|
65
|
+
const { token } = await store.mint("verify-email", "u-2", { email: "a@b.com" });
|
|
66
|
+
const first = await store.consume("verify-email", token);
|
|
67
|
+
expect(first).not.toBeNull();
|
|
68
|
+
const second = await store.consume("verify-email", token);
|
|
69
|
+
expect(second).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("expired token returns null", async () => {
|
|
73
|
+
// Zero TTL means expires_at == now at insert. The insert completes at
|
|
74
|
+
// T, and consume() reads `now > T` → treated as expired. We sleep a
|
|
75
|
+
// few ms to be defensive against same-millisecond flakiness.
|
|
76
|
+
const shortLived = createAuthTokenStore({
|
|
77
|
+
secret: SECRET,
|
|
78
|
+
dbPath: ":memory:",
|
|
79
|
+
gcSchedule: false,
|
|
80
|
+
ttlSecondsByPurpose: { "verify-email": 0 },
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const { token } = await shortLived.mint("verify-email", "u-3");
|
|
84
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
85
|
+
const result = await shortLived.consume("verify-email", token);
|
|
86
|
+
expect(result).toBeNull();
|
|
87
|
+
} finally {
|
|
88
|
+
await shortLived.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("tampered nonce returns null (hash mismatch)", async () => {
|
|
93
|
+
const { token } = await store.mint("verify-email", "u-4");
|
|
94
|
+
const [id, nonce] = token.split(".");
|
|
95
|
+
// Flip one character in the nonce. Keep length the same to exercise
|
|
96
|
+
// the byte-wise hash compare (length mismatch would short-circuit).
|
|
97
|
+
const flipped = (nonce!.charAt(0) === "A" ? "B" : "A") + nonce!.slice(1);
|
|
98
|
+
const tampered = `${id}.${flipped}`;
|
|
99
|
+
const result = await store.consume("verify-email", tampered);
|
|
100
|
+
expect(result).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("tampered id (pointing to real row) still returns null — hash mismatch", async () => {
|
|
104
|
+
const { token: a } = await store.mint("verify-email", "u-5a");
|
|
105
|
+
const { token: b } = await store.mint("verify-email", "u-5b");
|
|
106
|
+
const aNonce = a.split(".")[1]!;
|
|
107
|
+
const bId = b.split(".")[0]!;
|
|
108
|
+
// Forged token: use B's id with A's nonce. B's stored hash was
|
|
109
|
+
// computed against B's nonce, so the compare fails.
|
|
110
|
+
const forged = `${bId}.${aNonce}`;
|
|
111
|
+
const result = await store.consume("verify-email", forged);
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
|
|
114
|
+
// Sanity: the real B token still consumes cleanly afterwards. The
|
|
115
|
+
// forged attempt did NOT mark B as consumed.
|
|
116
|
+
const real = await store.consume("verify-email", b);
|
|
117
|
+
expect(real).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("wrong purpose returns null (purpose binds to the hash)", async () => {
|
|
121
|
+
const { token } = await store.mint("verify-email", "u-6");
|
|
122
|
+
const result = await store.consume("reset-password", token);
|
|
123
|
+
expect(result).toBeNull();
|
|
124
|
+
|
|
125
|
+
// Real purpose still works — wrong-purpose attempt did NOT mark it
|
|
126
|
+
// consumed.
|
|
127
|
+
const real = await store.consume("verify-email", token);
|
|
128
|
+
expect(real).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("unknown id returns null without touching other rows", async () => {
|
|
132
|
+
const { token } = await store.mint("verify-email", "u-7");
|
|
133
|
+
// Use a random UUID as the id so the row simply doesn't exist.
|
|
134
|
+
const fakeId = crypto.randomUUID();
|
|
135
|
+
const nonce = token.split(".")[1]!;
|
|
136
|
+
const bogus = `${fakeId}.${nonce}`;
|
|
137
|
+
const result = await store.consume("verify-email", bogus);
|
|
138
|
+
expect(result).toBeNull();
|
|
139
|
+
|
|
140
|
+
// Real token still usable.
|
|
141
|
+
const real = await store.consume("verify-email", token);
|
|
142
|
+
expect(real).not.toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("gcNow() sweeps expired + consumed rows and returns the count", async () => {
|
|
146
|
+
// Consume one row to create a "consumed" candidate.
|
|
147
|
+
const { token: consumedToken } = await store.mint("verify-email", "u-8a");
|
|
148
|
+
await store.consume("verify-email", consumedToken);
|
|
149
|
+
|
|
150
|
+
// Build a short-lived entry via a second store on the same (in-memory)
|
|
151
|
+
// semantics — we can't share a :memory: DB across handles, so mint
|
|
152
|
+
// into a different short-TTL store to prove the delete query works.
|
|
153
|
+
const expiring = createAuthTokenStore({
|
|
154
|
+
secret: SECRET,
|
|
155
|
+
dbPath: ":memory:",
|
|
156
|
+
gcSchedule: false,
|
|
157
|
+
ttlSecondsByPurpose: { "verify-email": 0 },
|
|
158
|
+
});
|
|
159
|
+
try {
|
|
160
|
+
await expiring.mint("verify-email", "u-8b");
|
|
161
|
+
await expiring.mint("verify-email", "u-8c");
|
|
162
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
163
|
+
const deleted = await expiring.gcNow();
|
|
164
|
+
expect(deleted).toBe(2);
|
|
165
|
+
// Second sweep is a no-op.
|
|
166
|
+
expect(await expiring.gcNow()).toBe(0);
|
|
167
|
+
} finally {
|
|
168
|
+
await expiring.close();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The original store has exactly one row (consumed); gcNow removes it.
|
|
172
|
+
const cleaned = await store.gcNow();
|
|
173
|
+
expect(cleaned).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("meta round-trips as JSON for verify-email tokens", async () => {
|
|
177
|
+
const { token, record: minted } = await store.mint("verify-email", "u-9", {
|
|
178
|
+
email: "alice@example.com",
|
|
179
|
+
signupSource: "marketing-page",
|
|
180
|
+
});
|
|
181
|
+
expect(minted.meta?.email).toBe("alice@example.com");
|
|
182
|
+
|
|
183
|
+
const consumed = await store.consume("verify-email", token);
|
|
184
|
+
expect(consumed).not.toBeNull();
|
|
185
|
+
expect(consumed!.meta?.email).toBe("alice@example.com");
|
|
186
|
+
expect(consumed!.meta?.signupSource).toBe("marketing-page");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("sequential consumes serialise through the UPDATE predicate — only the first wins", async () => {
|
|
190
|
+
// True wall-clock concurrency is not expressible against a single
|
|
191
|
+
// SQLite connection (Bun.SQL surfaces "cannot start a transaction
|
|
192
|
+
// within a transaction" when two callers share the handle). The
|
|
193
|
+
// atomicity guarantee we actually care about is observable
|
|
194
|
+
// sequentially: consume #2 must see `consumed_at IS NOT NULL` from
|
|
195
|
+
// consume #1's committed transaction and return null.
|
|
196
|
+
//
|
|
197
|
+
// The defense-in-depth `UPDATE … WHERE consumed_at IS NULL` predicate
|
|
198
|
+
// is what guards against a hypothetical interleaving if multiple
|
|
199
|
+
// writer connections ever land in the future (e.g., Postgres).
|
|
200
|
+
const { token } = await store.mint("verify-email", "u-10");
|
|
201
|
+
const a = await store.consume("verify-email", token);
|
|
202
|
+
const b = await store.consume("verify-email", token);
|
|
203
|
+
const successes: TokenRecord[] = [];
|
|
204
|
+
if (a) successes.push(a);
|
|
205
|
+
if (b) successes.push(b);
|
|
206
|
+
expect(successes).toHaveLength(1);
|
|
207
|
+
expect(a!.consumedAt).not.toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("malformed token (no dot) returns null without throwing", async () => {
|
|
211
|
+
// Exercise the `parseToken` no-dot path, the empty-string path, and
|
|
212
|
+
// the multi-dot path — all must collapse to null.
|
|
213
|
+
const nodot = await store.consume("verify-email", "not-a-token");
|
|
214
|
+
expect(nodot).toBeNull();
|
|
215
|
+
|
|
216
|
+
const empty = await store.consume("verify-email", "");
|
|
217
|
+
expect(empty).toBeNull();
|
|
218
|
+
|
|
219
|
+
const multiDot = await store.consume("verify-email", "a.b.c");
|
|
220
|
+
expect(multiDot).toBeNull();
|
|
221
|
+
|
|
222
|
+
const leadingDot = await store.consume("verify-email", ".nonce");
|
|
223
|
+
expect(leadingDot).toBeNull();
|
|
224
|
+
|
|
225
|
+
const trailingDot = await store.consume("verify-email", "id.");
|
|
226
|
+
expect(trailingDot).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("close() then mint/consume/gcNow rejects with a clear error", async () => {
|
|
230
|
+
const tempStore = createAuthTokenStore({
|
|
231
|
+
secret: SECRET,
|
|
232
|
+
dbPath: ":memory:",
|
|
233
|
+
gcSchedule: false,
|
|
234
|
+
});
|
|
235
|
+
// Populate one row so gcNow has something to scan.
|
|
236
|
+
await tempStore.mint("verify-email", "u-11");
|
|
237
|
+
await tempStore.close();
|
|
238
|
+
|
|
239
|
+
await expect(tempStore.mint("verify-email", "u-12")).rejects.toThrow(
|
|
240
|
+
/store is closed/,
|
|
241
|
+
);
|
|
242
|
+
await expect(tempStore.consume("verify-email", "x.y")).rejects.toThrow(
|
|
243
|
+
/store is closed/,
|
|
244
|
+
);
|
|
245
|
+
await expect(tempStore.gcNow()).rejects.toThrow(/store is closed/);
|
|
246
|
+
// close() is idempotent.
|
|
247
|
+
await expect(tempStore.close()).resolves.toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("empty secret is rejected synchronously at construction", () => {
|
|
251
|
+
expect(() =>
|
|
252
|
+
createAuthTokenStore({ secret: "", dbPath: ":memory:", gcSchedule: false }),
|
|
253
|
+
).toThrow(/secret/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("invalid table name is rejected synchronously at construction", () => {
|
|
257
|
+
expect(() =>
|
|
258
|
+
createAuthTokenStore({
|
|
259
|
+
secret: SECRET,
|
|
260
|
+
dbPath: ":memory:",
|
|
261
|
+
table: "bad-name; DROP TABLE users",
|
|
262
|
+
gcSchedule: false,
|
|
263
|
+
}),
|
|
264
|
+
).toThrow(/table name/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("reset-password token consumed at the wrong purpose returns null", async () => {
|
|
268
|
+
const { token } = await store.mint("reset-password", "u-13");
|
|
269
|
+
const wrongPurpose = await store.consume("verify-email", token);
|
|
270
|
+
expect(wrongPurpose).toBeNull();
|
|
271
|
+
const rightPurpose = await store.consume("reset-password", token);
|
|
272
|
+
expect(rightPurpose).not.toBeNull();
|
|
273
|
+
});
|
|
274
|
+
});
|