From d5ea115d317f7cb37d423a7dd702f020da159b3d Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 14:24:35 +0200 Subject: [PATCH] test(actions): add unit tests for twofactor authentication actions --- src/actions/twofactor.test.ts | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/actions/twofactor.test.ts diff --git a/src/actions/twofactor.test.ts b/src/actions/twofactor.test.ts new file mode 100644 index 00000000..fb2239fa --- /dev/null +++ b/src/actions/twofactor.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted mocks +const mockRedirect = vi.hoisted(() => + vi.fn((url: string) => { + const err = new Error(`NEXT_REDIRECT: ${url}`); + // biome-ignore lint/suspicious/noExplicitAny: Next.js redirect signature + (err as any).digest = `NEXT_REDIRECT;replace;${url};307;;`; + throw err; + }), +); +vi.mock("next/navigation", () => ({ + redirect: mockRedirect, +})); + +const mockRevalidatePath = vi.hoisted(() => vi.fn()); +vi.mock("next/cache", () => ({ + revalidatePath: mockRevalidatePath, + unstable_cache: (fn: unknown) => fn, +})); + +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn(), + }, +})); + +vi.mock("next-auth", () => ({ + default: vi.fn(() => ({ + handlers: {}, + auth: vi.fn(), + signOut: vi.fn(), + })), +})); + +const mockAuth = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth", () => ({ + auth: mockAuth, + handlers: {}, + signOut: vi.fn(), +})); + +const mockRateLimit = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/rate-limit", () => ({ + rateLimit: mockRateLimit, +})); + +const mockVerifyTotp = vi.hoisted(() => vi.fn()); +const mockGenerateTotpSecret = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth/totp", () => ({ + verifyTotp: mockVerifyTotp, + generateTotpSecret: mockGenerateTotpSecret, +})); + +const mockEncrypt = vi.hoisted(() => vi.fn()); +const mockDecrypt = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth/laravel-encrypter", () => ({ + LaravelEncrypter: class { + encrypt = mockEncrypt; + decrypt = mockDecrypt; + }, +})); + +vi.mock("@/env", () => ({ + env: { + APP_KEY: "base64:dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTEyMw==", + }, +})); + +const hoistedInsertValues = vi.hoisted(() => vi.fn()); +const hoistedSelectLimit = vi.hoisted(() => vi.fn()); +const hoistedSelectWhere = vi.hoisted(() => + vi.fn(() => ({ limit: hoistedSelectLimit })), +); +const hoistedSelectFrom = vi.hoisted(() => + vi.fn(() => ({ where: hoistedSelectWhere })), +); +const hoistedUpdateWhere = vi.hoisted(() => vi.fn()); +const hoistedUpdateSet = vi.hoisted(() => + vi.fn(() => ({ where: hoistedUpdateWhere })), +); + +vi.mock("@/lib/db", () => ({ + db: { + select: vi.fn(() => ({ from: hoistedSelectFrom })), + insert: vi.fn(() => ({ values: hoistedInsertValues })), + update: vi.fn(() => ({ set: hoistedUpdateSet })), + }, + User: { + id: "user.id", + twoFactorSecret: "user.twoFactorSecret", + twoFactorConfirmedAt: "user.twoFactorConfirmedAt", + twoFactorRecoveryCodes: "user.twoFactorRecoveryCodes", + }, +})); + +import { db, User } from "@/lib/db"; +import { + beginTwoFactor, + confirmTwoFactor, + disableTwoFactor, +} from "./twofactor"; + +const fakeForm = (data: Record) => + ({ + get: (key: string) => data[key] ?? null, + }) as unknown as FormData; + +beforeEach(() => { + vi.clearAllMocks(); + mockAuth.mockResolvedValue({ user: { id: "42" } }); + mockRateLimit.mockResolvedValue({ ok: true }); + mockGenerateTotpSecret.mockReturnValue("JBSWY3DPEHPK3PXP"); + mockEncrypt.mockReturnValue("encrypted-secret-payload"); + mockDecrypt.mockReturnValue("JBSWY3DPEHPK3PXP"); + hoistedUpdateWhere.mockResolvedValue([]); + hoistedSelectLimit.mockResolvedValue([ + { + twoFactorSecret: "encrypted-secret-payload", + twoFactorRecoveryCodes: JSON.stringify(["CODE-1234", "CODE-5678"]), + }, + ]); +}); + +describe("beginTwoFactor", () => { + it("redirects to login if not signed in", async () => { + mockAuth.mockResolvedValueOnce(null); + await expect(beginTwoFactor()).rejects.toThrow("NEXT_REDIRECT: /login"); + expect(mockRedirect).toHaveBeenCalledWith("/login"); + }); + + it("generates secret, updates database, and revalidates path", async () => { + await beginTwoFactor(); + expect(mockGenerateTotpSecret).toHaveBeenCalled(); + expect(mockEncrypt).toHaveBeenCalledWith("JBSWY3DPEHPK3PXP"); + expect(db.update).toHaveBeenCalledWith(User); + expect(hoistedUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + twoFactorSecret: "encrypted-secret-payload", + twoFactorConfirmedAt: null, + twoFactorRecoveryCodes: expect.any(String), + }), + ); + expect(mockRevalidatePath).toHaveBeenCalledWith("/settings/2fa"); + }); +}); + +describe("confirmTwoFactor", () => { + it("redirects on rate limit", async () => { + mockRateLimit.mockResolvedValueOnce({ ok: false }); + await expect( + confirmTwoFactor(fakeForm({ code: "123456" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?error=ratelimit"); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?error=ratelimit"); + }); + + it("redirects on invalid TOTP and recovery code", async () => { + mockVerifyTotp.mockReturnValueOnce(false); + await expect( + confirmTwoFactor(fakeForm({ code: "INVALID" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?error=badcode"); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?error=badcode"); + }); + + it("confirms when TOTP code is valid", async () => { + mockVerifyTotp.mockReturnValueOnce(true); + await expect( + confirmTwoFactor(fakeForm({ code: "123456" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?enabled=1"); + expect(hoistedUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + twoFactorConfirmedAt: expect.any(Date), + }), + ); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?enabled=1"); + }); + + it("confirms when recovery code is valid", async () => { + mockVerifyTotp.mockReturnValueOnce(false); + await expect( + confirmTwoFactor(fakeForm({ code: "CODE-1234" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?enabled=1"); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?enabled=1"); + }); +}); + +describe("disableTwoFactor", () => { + it("redirects on invalid code", async () => { + mockVerifyTotp.mockReturnValueOnce(false); + await expect( + disableTwoFactor(fakeForm({ code: "INVALID" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?error=badcode"); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?error=badcode"); + }); + + it("clears two-factor secret and recovery codes on valid verification", async () => { + mockVerifyTotp.mockReturnValueOnce(true); + await expect( + disableTwoFactor(fakeForm({ code: "123456" })), + ).rejects.toThrow("NEXT_REDIRECT: /settings/2fa?disabled=1"); + expect(hoistedUpdateSet).toHaveBeenCalledWith({ + twoFactorSecret: null, + twoFactorRecoveryCodes: null, + twoFactorConfirmedAt: null, + }); + expect(mockRedirect).toHaveBeenCalledWith("/settings/2fa?disabled=1"); + }); +});