/**
* @mandujs/core/auth/reset — flow tests.
*
* Cover `createPasswordReset` end-to-end: mint + send via in-memory email,
* consume + onReset via `hashPassword`. The KDF hot path is CPU-bound —
* we pass minimal argon2id cost parameters everywhere to keep wall-clock
* time short.
*/
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { createMemoryEmailSender, type MemoryEmailSender } from "../../email";
import { verifyPassword } from "../password";
import { createAuthTokenStore, type AuthTokenStore } from "../tokens";
import { createPasswordReset } from "../reset";
// ─── Gate on Bun.SQL + CryptoHasher + Bun.password ──────────────────────────
const hasBun = (() => {
const g = globalThis as unknown as {
Bun?: { SQL?: unknown; CryptoHasher?: unknown; password?: unknown };
};
return (
typeof g.Bun?.SQL === "function" &&
typeof g.Bun?.CryptoHasher === "function" &&
g.Bun?.password !== undefined
);
})();
const describeIfBun = hasBun ? describe : describe.skip;
// ─── Fixtures ───────────────────────────────────────────────────────────────
const SECRET = "reset-flow-test-secret-32-bytes-or-more!!";
const URL_TEMPLATE = "https://app.example.com/reset?token={token}";
const FROM = "noreply@example.com";
const ARGON2ID_PREFIX = "$argon2id$";
const FAST_ARGON2 = { algorithm: "argon2id", memoryCost: 4, timeCost: 2 } as const;
interface Fixture {
store: AuthTokenStore;
sender: MemoryEmailSender;
onResetCalls: Array<{ userId: string; newHash: string }>;
}
function makeFixture(): Fixture {
return {
store: createAuthTokenStore({
secret: SECRET,
dbPath: ":memory:",
gcSchedule: false,
}),
sender: createMemoryEmailSender(),
onResetCalls: [],
};
}
describeIfBun("@mandujs/core/auth/reset — createPasswordReset", () => {
let fx: Fixture;
beforeEach(() => {
fx = makeFixture();
});
afterEach(async () => {
await fx.store.close();
});
it("send() mints a reset token and dispatches an email with the link", async () => {
const renderEmail = mock(({ url }: { url: string }) => ({
subject: "Reset your password",
html: `reset`,
}));
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail,
passwordOptions: FAST_ARGON2,
onReset: async (args) => {
fx.onResetCalls.push(args);
},
});
await reset.send("u-1", "alice@example.com");
expect(fx.sender.sent).toHaveLength(1);
const msg = fx.sender.sent[0]!;
expect(msg.from).toBe(FROM);
expect(msg.to).toBe("alice@example.com");
expect(msg.subject).toBe("Reset your password");
expect(msg.html).toContain("https://app.example.com/reset?token=");
});
it("consume(validToken, newPassword) returns { userId } and calls onReset with an argon2id hash", async () => {
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async (args) => {
fx.onResetCalls.push(args);
},
});
await reset.send("u-2", "bob@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
const result = await reset.consume(token, "newPass123");
expect(result).toEqual({ userId: "u-2" });
expect(fx.onResetCalls).toHaveLength(1);
expect(fx.onResetCalls[0]!.userId).toBe("u-2");
expect(fx.onResetCalls[0]!.newHash.startsWith(ARGON2ID_PREFIX)).toBe(true);
});
it("empty newPassword throws TypeError without consuming the token", async () => {
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async () => {},
});
await reset.send("u-3", "carol@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
await expect(reset.consume(token, "")).rejects.toThrow(/non-empty/);
// Token was NOT consumed — a real call still works.
const ok = await reset.consume(token, "validPass123");
expect(ok).toEqual({ userId: "u-3" });
});
it("onReset receives the hash, not the plaintext password", async () => {
let captured: { userId: string; newHash: string } | null = null;
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async (args) => {
captured = args;
},
});
await reset.send("u-4", "dave@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
const plaintext = "totally-secret-password";
await reset.consume(token, plaintext);
expect(captured).not.toBeNull();
const hash = captured!.newHash;
// The plaintext must NEVER appear in the hash payload.
expect(hash).not.toContain(plaintext);
// verifyPassword round-trips correctly against the hash.
expect(await verifyPassword(plaintext, hash)).toBe(true);
expect(await verifyPassword("wrong-password", hash)).toBe(false);
});
it("reusing a consumed token returns null and onReset fires exactly once", async () => {
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async (args) => {
fx.onResetCalls.push(args);
},
});
await reset.send("u-5", "eve@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
const first = await reset.consume(token, "first-pass");
expect(first).not.toBeNull();
const second = await reset.consume(token, "second-pass");
expect(second).toBeNull();
expect(fx.onResetCalls).toHaveLength(1);
});
it("reset does NOT auto-login — we don't touch sessions", async () => {
// This test guards the contract: the returned shape is `{ userId }`
// only — there are no cookie side-effects, no session helpers called.
// We construct the flow with NO session storage at all and expect
// consume() to still succeed.
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async () => {},
});
await reset.send("u-6", "frank@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
const result = await reset.consume(token, "fresh-pass");
expect(result).toEqual({ userId: "u-6" });
// Contract: ReturnType is never {userId, cookie, session, …}.
// The runtime shape must match — if a future refactor adds fields we
// need to revisit the doc + this guard.
expect(Object.keys(result!).sort()).toEqual(["userId"]);
});
it("hashPassword is invoked with the raw plaintext (argon2id default)", async () => {
// Black-box: the only way to observe "hashPassword was called with
// this plaintext" without reaching past the module boundary is to
// verify the emitted hash authenticates the plaintext.
let newHash: string | null = null;
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
passwordOptions: FAST_ARGON2,
onReset: async (args) => {
newHash = args.newHash;
},
});
await reset.send("u-7", "grace@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
await reset.consume(token, "plaintext-checked");
expect(newHash).not.toBeNull();
expect(newHash!.startsWith(ARGON2ID_PREFIX)).toBe(true);
expect(await verifyPassword("plaintext-checked", newHash!)).toBe(true);
});
it("bcrypt's 72-byte limit: hashPassword error propagates (token is already spent)", async () => {
const reset = createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: URL_TEMPLATE,
renderEmail: ({ url }) => ({ subject: "R", html: `r` }),
// Force bcrypt so the 72-byte guard in hashPassword fires.
passwordOptions: { algorithm: "bcrypt", cost: 4 },
onReset: async () => {
throw new Error("onReset should not be invoked when hashPassword throws");
},
});
await reset.send("u-8", "heidi@example.com");
const token = extractTokenFromUrl(fx.sender.sent[0]!.html!)!;
const tooLong = "a".repeat(100); // 100 UTF-8 bytes, over the 72 limit.
await expect(reset.consume(token, tooLong)).rejects.toThrow(/72-byte limit/);
// Token IS consumed by the time hashPassword throws — a retry returns null.
const retry = await reset.consume(token, "shortPass");
expect(retry).toBeNull();
});
it("resetUrlTemplate without {token} throws at create time", () => {
expect(() =>
createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: FROM,
resetUrlTemplate: "https://app.example.com/reset?nope=1",
renderEmail: () => ({ subject: "r", html: "r
" }),
passwordOptions: FAST_ARGON2,
onReset: async () => {},
}),
).toThrow(/\{token\}/);
});
it("empty fromAddress throws at create time", () => {
expect(() =>
createPasswordReset({
store: fx.store,
sender: fx.sender,
fromAddress: "",
resetUrlTemplate: URL_TEMPLATE,
renderEmail: () => ({ subject: "r", html: "r
" }),
passwordOptions: FAST_ARGON2,
onReset: async () => {},
}),
).toThrow(/fromAddress/);
});
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function extractTokenFromUrl(html: string): string | null {
const hrefMatch = /href="([^"]+)"/.exec(html);
const url = hrefMatch ? hrefMatch[1]! : html;
const tokenMatch = /[?&]token=([^&"\s]+)/.exec(url);
return tokenMatch ? tokenMatch[1]! : null;
}