/** * Phase 01 / #20 — Magic-link-only account login guard. * * Asserts that AuthService.loginFromRequest refuses a password-login * attempt for a user whose `passwordHash` is null (magic-link-only * account provisioned by the invitation flow), and surfaces a * `requiresMagicLink: true` hint pointing at /forgot-password. * * The critical guarantee: no path grants a session on a null hash, and * the dummy-hash fallthrough never produces "bad credentials" for these * accounts — the user gets a friendly, actionable error instead. */ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) })); vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } })); vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() })); vi.mock("~/utils/db/users.db.server", () => ({ getUserByEmail: vi.fn(), updateUserPassword: vi.fn(), getUser: vi.fn(), getUserTotpStatus: vi.fn(), getActiveTotpSecretEncrypted: vi.fn(), consumeUserMfaRecoveryCode: vi.fn(), })); vi.mock("~/utils/session.server", () => ({ createUserSession: vi.fn(), getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }), setLoggedUser: vi.fn().mockResolvedValue({ defaultTenantId: "t1" }), validateCSRFToken: vi.fn(), })); vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) })); vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() })); vi.mock("~/utils/auth/passwordHasher.server", () => ({ verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }), })); vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } })); vi.mock("~/services/auth/admin-fallback.server", () => ({ assertAdminFallbackLoginAllowed: vi.fn(), AdminFallbackLoginRejected: class extends Error {} })); vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() })); vi.mock("@toolctrl/config", async () => { const actual = await vi.importActual("@toolctrl/config"); return { ...actual, loadRequiredWebConfig: () => ({ nodeEnv: "test", routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" }, }), }; }); vi.mock("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) })); vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) })); import { getUserByEmail } from "~/utils/db/users.db.server"; import { verifyPassword } from "~/utils/auth/passwordHasher.server"; import { setLoggedUser } from "~/utils/session.server"; import AuthService from "./AuthService"; const getUserByEmailMock = vi.mocked(getUserByEmail); const verifyPasswordMock = vi.mocked(verifyPassword); const setLoggedUserMock = vi.mocked(setLoggedUser); function buildForm(): FormData { const f = new FormData(); f.append("email", "invited@example.com"); f.append("password", "anything-they-typed"); f.append("redirectTo", "/"); f.append("csrf", ""); return f; } describe("AuthService magic-link-only account guard (#20)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("rejects login when the user's passwordHash is null and signals requiresMagicLink", async () => { getUserByEmailMock.mockResolvedValue({ id: "u-invited", email: "invited@example.com", passwordHash: null, firstName: "Inv", lastName: "Ited", pendingDeletion: false, pseudonymized: false, admin: null, locale: null, } as unknown as Awaited>); const req = new Request("https://app.example.com/login", { method: "POST" }); const result = (await AuthService.loginFromRequest(req, buildForm())) as Response; expect(result).toBeInstanceOf(Response); expect(result.status).toBe(400); const body = await result.json(); expect(body.requiresMagicLink).toBe(true); expect(body.magicLinkHref).toBe("/forgot-password"); expect(body.error).toBe("auth:errors.passwordlessAccountUseMagicLink"); // Critical: must never have called verifyPassword for a null-hash row. expect(verifyPasswordMock).not.toHaveBeenCalled(); // Critical: must never have created a session. expect(setLoggedUserMock).not.toHaveBeenCalled(); }); });