feat: SMTP full ENV override, password reset flow, and E2E email testing

- SMTP: SMTP_HOST/PORT/USER/FROM/TLS now all have ENV override support
  (previously only SMTP_PASSWORD was env-aware). ENV takes priority over DB.
- docker-compose.yml: forward all SMTP_* env vars to app container + add
  Mailhog service (ports 1025 SMTP / 8025 HTTP, always available in dev)
- Password reset: PasswordResetToken Prisma model + authRouter with
  requestPasswordReset (timing-safe, no email enumeration) + resetPassword
- UI: /auth/forgot-password, /auth/reset-password/[token] pages +
  "Forgot password?" link on sign-in page
- E2E: Mailhog helpers (getLatestEmailTo, clearMailhog, extractUrlFromEmail)
  + invite-flow.spec.ts + password-reset.spec.ts

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 08:55:39 +02:00
parent e5ecea81c5
commit fceceeee4b
14 changed files with 1030 additions and 11 deletions
@@ -0,0 +1,182 @@
/**
* Unit tests for auth tRPC router — password reset flow.
*
* Tests cover:
* - requestPasswordReset: known email → token created, email sent
* - requestPasswordReset: unknown email → success (no token, no email)
* - requestPasswordReset: second request → old token deleted, new one created
* - resetPassword: valid token → passwordHash updated, usedAt set
* - resetPassword: expired token → BAD_REQUEST
* - resetPassword: used token → BAD_REQUEST
* - resetPassword: non-existent token → NOT_FOUND
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("../lib/email.js", () => ({ sendEmail: vi.fn().mockResolvedValue(true) }));
vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$newhash") }));
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
const PAST = new Date(Date.now() - 1000);
function makeDb(overrides: {
user?: Partial<Record<string, unknown>>;
resetToken?: Partial<Record<string, unknown>>;
} = {}) {
return {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
update: vi.fn().mockResolvedValue({}),
...overrides.user,
},
passwordResetToken: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue({}),
...overrides.resetToken,
},
} as never;
}
function makeCtx(db = makeDb()) {
return { db, dbUser: null, session: null };
}
const { authRouter } = await import("../router/auth.js");
const { sendEmail } = await import("../lib/email.js");
describe("auth.requestPasswordReset", () => {
beforeEach(() => vi.clearAllMocks());
it("creates a token and sends an email for a known address", async () => {
const db = makeDb();
const ctx = makeCtx(db);
const result = await authRouter.createCaller(ctx).requestPasswordReset({
email: "user@example.com",
});
expect(result).toEqual({ success: true });
expect(db.passwordResetToken.deleteMany).toHaveBeenCalledWith({
where: { email: "user@example.com", usedAt: null },
});
expect(db.passwordResetToken.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ email: "user@example.com", token: expect.any(String) }),
}),
);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: "user@example.com", subject: expect.stringContaining("reset") }),
);
});
it("returns success silently for an unknown email (no token, no email sent)", async () => {
const db = makeDb({ user: { findUnique: vi.fn().mockResolvedValue(null) } });
const ctx = makeCtx(db);
const result = await authRouter.createCaller(ctx).requestPasswordReset({
email: "ghost@example.com",
});
expect(result).toEqual({ success: true });
expect(db.passwordResetToken.create).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
});
it("deletes existing unused tokens before creating a new one", async () => {
const db = makeDb();
const ctx = makeCtx(db);
await authRouter.createCaller(ctx).requestPasswordReset({ email: "user@example.com" });
// deleteMany called before create
const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock.invocationCallOrder[0]!;
const createOrder = vi.mocked(db.passwordResetToken.create).mock.invocationCallOrder[0]!;
expect(deleteManyOrder).toBeLessThan(createOrder);
});
});
describe("auth.resetPassword", () => {
beforeEach(() => vi.clearAllMocks());
it("updates passwordHash and sets usedAt for a valid token", async () => {
const validRecord = {
token: "valid-token",
email: "user@example.com",
expiresAt: FUTURE,
usedAt: null,
};
const db = makeDb({
resetToken: { findUnique: vi.fn().mockResolvedValue(validRecord) },
});
const ctx = makeCtx(db);
const result = await authRouter.createCaller(ctx).resetPassword({
token: "valid-token",
password: "NewPassword1!",
});
expect(result).toEqual({ success: true });
expect(db.user.update).toHaveBeenCalledWith({
where: { email: "user@example.com" },
data: { passwordHash: "$argon2id$newhash" },
});
expect(db.passwordResetToken.update).toHaveBeenCalledWith({
where: { token: "valid-token" },
data: { usedAt: expect.any(Date) },
});
});
it("throws NOT_FOUND for an unknown token", async () => {
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(null) } });
const ctx = makeCtx(db);
await expect(
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1!" }),
).rejects.toThrow(TRPCError);
const err = await authRouter
.createCaller(ctx)
.resetPassword({ token: "bad-token", password: "Password1!" })
.catch((e: TRPCError) => e);
expect((err as TRPCError).code).toBe("NOT_FOUND");
});
it("throws BAD_REQUEST for an already-used token", async () => {
const usedRecord = {
token: "used-token",
email: "user@example.com",
expiresAt: FUTURE,
usedAt: new Date(Date.now() - 5000),
};
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(usedRecord) } });
const ctx = makeCtx(db);
const err = await authRouter
.createCaller(ctx)
.resetPassword({ token: "used-token", password: "Password1!" })
.catch((e: TRPCError) => e);
expect((err as TRPCError).code).toBe("BAD_REQUEST");
expect((err as TRPCError).message).toMatch(/already been used/);
});
it("throws BAD_REQUEST for an expired token", async () => {
const expiredRecord = {
token: "expired-token",
email: "user@example.com",
expiresAt: PAST,
usedAt: null,
};
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(expiredRecord) } });
const ctx = makeCtx(db);
const err = await authRouter
.createCaller(ctx)
.resetPassword({ token: "expired-token", password: "Password1!" })
.catch((e: TRPCError) => e);
expect((err as TRPCError).code).toBe("BAD_REQUEST");
expect((err as TRPCError).message).toMatch(/expired/);
});
});
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import nodemailer from "nodemailer";
const { findUnique, sendMail, createTransport } = vi.hoisted(() => {
const sendMail = vi.fn().mockResolvedValue({ messageId: "test" });
return {
findUnique: vi.fn(),
sendMail,
createTransport: vi.fn(() => ({ sendMail })),
};
});
vi.mock("@capakraken/db", () => ({
prisma: {
systemSettings: { findUnique },
},
}));
vi.mock("nodemailer", () => ({
default: { createTransport },
}));
vi.mock("../lib/logger.js", () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
// Re-import after mocks are set up
const { sendEmail } = await import("../lib/email.js");
describe("SMTP ENV overrides", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default DB row — used as fallback
findUnique.mockResolvedValue({
smtpHost: "db-smtp.example.com",
smtpPort: 587,
smtpUser: "db-user@example.com",
smtpPassword: "db-password",
smtpFrom: "db-from@example.com",
smtpTls: true,
});
sendMail.mockResolvedValue({ messageId: "ok" });
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("SMTP_HOST env overrides DB smtpHost", async () => {
vi.stubEnv("SMTP_HOST", "env-smtp.example.com");
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ host: "env-smtp.example.com" }),
);
});
it("SMTP_PORT env overrides DB smtpPort (parsed as integer)", async () => {
vi.stubEnv("SMTP_PORT", "1025");
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ port: 1025 }),
);
});
it("SMTP_TLS=false sets secure: false", async () => {
vi.stubEnv("SMTP_TLS", "false");
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: false }),
);
});
it("SMTP_TLS=true sets secure: true", async () => {
vi.stubEnv("SMTP_TLS", "true");
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ secure: true }),
);
});
it("no SMTP_USER → auth uses DB user; with SMTP_USER env → auth uses env user", async () => {
vi.stubEnv("SMTP_USER", "env-user@example.com");
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({ user: "env-user@example.com" }),
}),
);
});
it("no SMTP_USER and no SMTP_PASSWORD → auth is undefined (Mailhog scenario)", async () => {
// Clear all user/password from both ENV and DB
vi.stubEnv("SMTP_HOST", "mailhog");
vi.stubEnv("SMTP_PORT", "1025");
vi.stubEnv("SMTP_TLS", "false");
// Explicitly no SMTP_USER / SMTP_PASSWORD
findUnique.mockResolvedValue({
smtpHost: null,
smtpPort: null,
smtpUser: null,
smtpPassword: null,
smtpFrom: "noreply@capakraken.app",
smtpTls: null,
});
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({ auth: undefined }),
);
});
it("DB values are used when no ENV overrides are set", async () => {
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith({
host: "db-smtp.example.com",
port: 587,
secure: true,
auth: { user: "db-user@example.com", pass: "db-password" },
});
});
it("ENV overrides take priority over DB for all SMTP fields simultaneously", async () => {
vi.stubEnv("SMTP_HOST", "mailhog");
vi.stubEnv("SMTP_PORT", "1025");
vi.stubEnv("SMTP_USER", ""); // empty string = no override
vi.stubEnv("SMTP_TLS", "false");
vi.stubEnv("SMTP_PASSWORD", ""); // empty = no override
findUnique.mockResolvedValue({
smtpHost: "db-smtp.example.com",
smtpPort: 587,
smtpUser: "db-user@example.com",
smtpPassword: "db-password",
smtpFrom: "from@example.com",
smtpTls: true,
});
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
expect(nodemailer.createTransport).toHaveBeenCalledWith(
expect.objectContaining({
host: "mailhog",
port: 1025,
secure: false,
}),
);
});
});