import { describe, it, expect, vi, beforeEach } from "vitest"; import bcrypt from "bcryptjs"; const { cookiesSetMock, cookiesDeleteMock, findUserMock, headersGetMock, headerStore, checkRateLimitMock, redirectMock, loggerMock, } = vi.hoisted(() => ({ cookiesSetMock: vi.fn(), cookiesDeleteMock: vi.fn(), findUserMock: vi.fn(), headersGetMock: vi.fn(() => "127.0.0.1"), headerStore: new Map(), checkRateLimitMock: vi.fn(), redirectMock: vi.fn((_path: string) => { throw new Error("redirect"); }), loggerMock: { warn: vi.fn(), info: vi.fn() }, })); vi.mock("next/headers", () => ({ cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }), headers: async () => ({ get: (k: string) => { const key = k.toLowerCase(); if (key === "x-forwarded-for") return headersGetMock(); // Tests opt-in to setting origin/host/etc. via headerStore; // unset = null which lets hasSameOriginRequest treat the // request as same-origin (Origin omitted = same-origin per RFC). return headerStore.get(key) ?? null; }, }), })); vi.mock("next/navigation", () => ({ redirect: (path: string) => redirectMock(path), })); vi.mock("@/lib/db", () => ({ db: { query: { operators: { findFirst: (...a: unknown[]) => findUserMock(...a) }, }, }, })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a), })); vi.mock("@/lib/logger", () => ({ logger: loggerMock })); const SECRET = "test-secret-not-real"; beforeEach(() => { process.env.AUTH_SECRET = SECRET; process.env.OPERATOR_TOKEN_VERSION = "1"; cookiesSetMock.mockReset(); cookiesDeleteMock.mockReset(); findUserMock.mockReset(); checkRateLimitMock.mockReset(); checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 }); redirectMock.mockReset(); redirectMock.mockImplementation((_path: string) => { throw new Error("redirect"); }); loggerMock.warn.mockReset(); headerStore.clear(); }); import { loginAction, logoutAction } from "./auth"; const REAL_HASH = bcrypt.hashSync("correct-horse", 10); const ADMIN_ROW = { id: "11111111-1111-1111-1111-111111111111", username: "admin", role: "admin" as const, displayName: "Admin", defaultTimezone: "UTC", passwordHash: REAL_HASH, }; function fd(fields: Record): FormData { const f = new FormData(); for (const [k, v] of Object.entries(fields)) f.append(k, v); return f; } describe("loginAction", () => { it("issues a session cookie when credentials are correct", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); const prevEnv = process.env.NODE_ENV; // @ts-expect-error - test override process.env.NODE_ENV = "production"; try { const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch( (e) => e, ); // Successful login redirects, so the redirect mock throws. expect((r as Error).message).toBe("redirect"); expect(cookiesSetMock).toHaveBeenCalledTimes(1); const [name, , attrs] = cookiesSetMock.mock.calls[0]!; expect(name).toBe("session"); expect(attrs).toMatchObject({ httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 30 * 86400, }); } finally { // @ts-expect-error - test restore process.env.NODE_ENV = prevEnv; } }); it("sets secure=false on the cookie when NODE_ENV !== production", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); const prevEnv = process.env.NODE_ENV; // @ts-expect-error - test override process.env.NODE_ENV = "development"; try { await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {}); const [, , attrs] = cookiesSetMock.mock.calls[0]!; expect(attrs).toMatchObject({ secure: false }); } finally { // @ts-expect-error - test restore process.env.NODE_ENV = prevEnv; } }); it("returns ok:false on wrong password and does NOT set a cookie", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); const r = await loginAction(fd({ username: "admin", password: "wrong" })); expect(r).toEqual({ ok: false, error: "Invalid username or password." }); expect(cookiesSetMock).not.toHaveBeenCalled(); expect(loggerMock.warn).toHaveBeenCalled(); }); it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => { findUserMock.mockResolvedValue(undefined); const cmpSpy = vi.spyOn(bcrypt, "compare"); await loginAction(fd({ username: "nobody", password: "irrelevant" })); expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH cmpSpy.mockRestore(); }); it("returns a clear error when the user has no password_hash set", async () => { findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null }); const r = await loginAction(fd({ username: "admin", password: "anything" })); expect(r).toEqual({ ok: false, error: "Set a password via scripts/set-password.sh before signing in.", }); }); it("rejects empty username or password without hitting the DB", async () => { const r = await loginAction(fd({ username: "", password: "x" })); expect(r).toEqual({ ok: false, error: "Username and password are required." }); expect(findUserMock).not.toHaveBeenCalled(); }); it("rejects username/password >256 chars without invoking bcrypt", async () => { const cmpSpy = vi.spyOn(bcrypt, "compare"); const long = "x".repeat(300); const r = await loginAction(fd({ username: long, password: long })); expect(r).toEqual({ ok: false, error: "Input too long." }); expect(cmpSpy).not.toHaveBeenCalled(); cmpSpy.mockRestore(); }); it("matches username case-insensitively", async () => { findUserMock.mockImplementation(async () => ADMIN_ROW); await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {}); expect(findUserMock).toHaveBeenCalled(); }); it("returns 429 when the rate limit is exhausted", async () => { checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 }); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." }); expect(findUserMock).not.toHaveBeenCalled(); }); it("logs the failed attempt with username and ip but never the password", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1"); await loginAction(fd({ username: "admin", password: "wrong" })); const [meta, msg] = loggerMock.warn.mock.calls[0]!; expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" }); expect(JSON.stringify(meta)).not.toContain("wrong"); expect(msg).toMatch(/login failed/i); }); it("redirects to safeRedirect(next) on success", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); await loginAction(fd({ username: "admin", password: "correct-horse", next: "/dashboard", })).catch(() => {}); expect(redirectMock).toHaveBeenCalledWith("/dashboard"); }); it("redirects to / when next is unsafe", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); await loginAction(fd({ username: "admin", password: "correct-horse", next: "//evil.com", })).catch(() => {}); expect(redirectMock).toHaveBeenCalledWith("/"); }); }); describe("logoutAction", () => { it("clears the session cookie and redirects to /login", async () => { await logoutAction().catch(() => {}); expect(cookiesDeleteMock).toHaveBeenCalledWith("session"); expect(redirectMock).toHaveBeenCalledWith("/login"); }); it("is idempotent — clears the cookie even when no session exists", async () => { // Real-world: a user with an expired cookie clicks Sign out. cookies.delete // doesn't care about pre-existing state and we still issue the redirect. cookiesDeleteMock.mockReset(); await logoutAction().catch(() => {}); expect(cookiesDeleteMock).toHaveBeenCalledTimes(1); expect(cookiesDeleteMock).toHaveBeenCalledWith("session"); }); }); describe("loginAction — additional cases", () => { it("issues a cookie that decrypts to role='user' for a non-admin user", async () => { findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" }); await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {}); expect(cookiesSetMock).toHaveBeenCalledTimes(1); const [, cookieValue] = cookiesSetMock.mock.calls[0]!; // The cookie is now AES-GCM encrypted, so we can't peel the payload // off raw — decrypt with the same secret loginAction used. This // also doubles as a confidentiality smoke test: 'user'/'alice' // must NOT appear verbatim in the cookie bytes. expect(cookieValue as string).not.toContain("alice"); expect(cookieValue as string).not.toContain("user"); const { verifySession } = await import("@/lib/auth-cookie"); const decoded = await verifySession(cookieValue as string, SECRET); expect(decoded?.role).toBe("user"); expect(decoded?.userId).toBe(ADMIN_ROW.id); }); it("rejects when the user row has an unrecognised role string", async () => { findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" }); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Account is not enabled." }); expect(cookiesSetMock).not.toHaveBeenCalled(); }); it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); const prev = process.env.AUTH_SECRET; delete process.env.AUTH_SECRET; try { const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." }); expect(cookiesSetMock).not.toHaveBeenCalled(); } finally { process.env.AUTH_SECRET = prev; } }); it("treats whitespace-only username as missing input", async () => { const r = await loginAction(fd({ username: " ", password: "x" })); expect(r).toEqual({ ok: false, error: "Username and password are required." }); expect(findUserMock).not.toHaveBeenCalled(); }); it("uses three rate-limit layers: per-IP, per-username, global", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); headersGetMock.mockReturnValue("198.51.100.42"); await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {}); // Three checkRateLimit calls fired in parallel via Promise.all, // in this order: ip / user / global. expect(checkRateLimitMock).toHaveBeenCalledTimes(3); const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string); expect(keys[0]).toBe("login:198.51.100.42"); // Username key is normalised to lowercase so "Admin" and "admin" // share the same bucket — otherwise an attacker rotating case // would dodge per-username throttling. expect(keys[1]).toBe("login-user:admin"); expect(keys[2]).toBe("login-global"); }); it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); // First call (ip) passes, second (user) is over, third (global) passes. checkRateLimitMock .mockResolvedValueOnce({ limited: false, count: 1 }) .mockResolvedValueOnce({ limited: true, count: 6 }) .mockResolvedValueOnce({ limited: false, count: 5 }); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." }); expect(findUserMock).not.toHaveBeenCalled(); // Logger captures which limit tripped so we can tune thresholds // without leaking the answer to the attacker. const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0]; expect(meta).toMatchObject({ limit: "username" }); }); it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); checkRateLimitMock .mockResolvedValueOnce({ limited: false, count: 1 }) .mockResolvedValueOnce({ limited: false, count: 1 }) .mockResolvedValueOnce({ limited: true, count: 101 }); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." }); const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0]; expect(meta).toMatchObject({ limit: "global" }); }); it("rejects a cross-origin POST before checking credentials", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); headerStore.set("origin", "https://attacker.example"); headerStore.set("host", "wabot.04080616.xyz"); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." }); expect(checkRateLimitMock).not.toHaveBeenCalled(); expect(findUserMock).not.toHaveBeenCalled(); }); it("accepts a same-origin POST (Origin host matches Host header)", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); headerStore.set("origin", "https://wabot.04080616.xyz"); headerStore.set("host", "wabot.04080616.xyz"); await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {}); // Got past the origin check → DB lookup ran. expect(findUserMock).toHaveBeenCalled(); }); it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => { // Browsers don't always send Origin (e.g. plain top-level form // submissions). Refusing those would brick login on some clients. findUserMock.mockResolvedValue(ADMIN_ROW); headerStore.delete("origin"); headerStore.set("host", "wabot.04080616.xyz"); await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {}); expect(findUserMock).toHaveBeenCalled(); }); it("rejects when Origin is malformed (non-URL string)", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); headerStore.set("origin", "not a url"); headerStore.set("host", "wabot.04080616.xyz"); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })); expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." }); }); it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => { findUserMock.mockResolvedValue(undefined); const cmpSpy = vi.spyOn(bcrypt, "compare"); await loginAction(fd({ username: "ghost", password: "anything" })); // findFirst was called even though we know the user doesn't exist. expect(findUserMock).toHaveBeenCalledTimes(1); expect(cmpSpy).toHaveBeenCalled(); cmpSpy.mockRestore(); }); });