#41 (critical): Replace plain Error throws in authorize() with CredentialsSignin subclasses (MfaRequiredError / MfaRequiredSetupError / InvalidTotpError). Auth.js v5 forwards CredentialsSignin.code to the client via SignInResponse.code; plain throws become CallbackRouteError and the message is never visible. Signin page now checks result.code ?? result.error for exact code matching. #38: MfaPromptBanner converted to fully client-side component via trpc.user.getMfaStatus.useQuery() — disappears immediately after MFA enable without requiring page reload. Snooze key remains userId-scoped via useSession(). Server-side prisma.user.findUnique call removed from (app)/layout.tsx. #40: NEXTAUTH_URL default fallback removed from docker-compose.yml. The variable is now required (:?) — docker compose up fails with a descriptive error if the value is missing, preventing silent localhost redirect bugs. Tests: auth.test.ts (5), MfaPromptBanner.test.ts (7), reset-password.test.ts (6) All new tests green. pnpm --filter @capakraken/web exec tsc --noEmit clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Unit tests for setUserPassword (admin password reset).
|
||||
*
|
||||
* Tests cover:
|
||||
* - Happy path: passwordHash is updated in the DB
|
||||
* - Audit entry "Password reset by admin" is created
|
||||
* - Non-existent user throws NOT_FOUND
|
||||
* - Short password is rejected by Zod schema before reaching the function
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setUserPassword, SetUserPasswordInputSchema } from "../router/user-procedure-support.js";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
const hashMock = vi.hoisted(() => vi.fn().mockResolvedValue("$argon2id$hashed"));
|
||||
|
||||
vi.mock("@node-rs/argon2", () => ({ hash: hashMock }));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function makeCtx(userRow: Record<string, unknown> | null = null) {
|
||||
return {
|
||||
db: {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue(userRow),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
} as never,
|
||||
dbUser: { id: "admin_1" },
|
||||
};
|
||||
}
|
||||
|
||||
const EXISTING_USER = { id: "user_1", name: "Alice", email: "alice@example.com" };
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setUserPassword — happy path", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it("hashes the new password and updates the DB", async () => {
|
||||
const ctx = makeCtx(EXISTING_USER);
|
||||
const result = await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" });
|
||||
|
||||
expect(hashMock).toHaveBeenCalledWith("NewPass123!");
|
||||
expect(ctx.db.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user_1" },
|
||||
data: { passwordHash: "$argon2id$hashed" },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("creates an audit entry with summary 'Password reset by admin'", async () => {
|
||||
const { createAuditEntry } = await import("../lib/audit.js");
|
||||
const ctx = makeCtx(EXISTING_USER);
|
||||
await setUserPassword(ctx, { userId: "user_1", password: "NewPass123!" });
|
||||
|
||||
// createAuditEntry is called fire-and-forget (void), so we give microtasks a tick
|
||||
await Promise.resolve();
|
||||
expect(createAuditEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ summary: "Password reset by admin" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserPassword — not found", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it("throws when the user does not exist", async () => {
|
||||
const ctx = makeCtx(null); // findUnique returns null
|
||||
await expect(
|
||||
setUserPassword(ctx, { userId: "ghost", password: "NewPass123!" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetUserPasswordInputSchema — validation", () => {
|
||||
it("accepts a valid input", () => {
|
||||
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "Valid123!" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a password shorter than 8 characters", () => {
|
||||
const result = SetUserPasswordInputSchema.safeParse({ userId: "u1", password: "short" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing userId", () => {
|
||||
const result = SetUserPasswordInputSchema.safeParse({ password: "Valid123!" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user