/** * Phase 01 / #27 — password-policy login soft-check. * * Asserts that AuthService.loginFromRequest calls validatePassword in * non-throwing mode after a successful credential verification and * propagates the result onto the new session as `passwordPolicyOutdated`. * Critically: a policy-violating password must NEVER block the login — * the soft-banner is the only consequence. */ 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({ userId: "u1", defaultTenantId: "t1", defaultTenant: { id: "t1", slug: "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().mockResolvedValue({ app: { features: { tenantHome: "/" } } }), })); 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 }) })); vi.mock("~/services/auth/password-policy.server", () => ({ validatePassword: vi.fn(), })); import { getUserByEmail } from "~/utils/db/users.db.server"; import { createUserSession } from "~/utils/session.server"; import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server"; import AuthService from "./AuthService"; const getUserByEmailMock = vi.mocked(getUserByEmail); const createUserSessionMock = vi.mocked(createUserSession); const validatePolicyPasswordMock = vi.mocked(validatePolicyPassword); function buildForm(): FormData { const f = new FormData(); f.append("email", "user@example.com"); f.append("password", "whatever-they-typed"); f.append("redirectTo", "/"); f.append("csrf", ""); return f; } const baseUser = { id: "u1", email: "user@example.com", passwordHash: "$argon2id$dummy", firstName: "Test", lastName: "User", pendingDeletion: false, pseudonymized: false, admin: null, locale: null, defaultTenantId: "t1", }; describe("AuthService password-policy soft-check (#27)", () => { beforeEach(() => { vi.clearAllMocks(); createUserSessionMock.mockResolvedValue(new Response(null, { status: 302 })); }); it("flags passwordPolicyOutdated=true on the session when the submitted password fails the policy", async () => { getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited>); validatePolicyPasswordMock.mockResolvedValue({ valid: false, errors: [{ code: "auth.password.errors.tooShort", args: { minLength: 12 } }], }); const req = new Request("https://app.example.com/login", { method: "POST" }); await AuthService.loginFromRequest(req, buildForm()); expect(validatePolicyPasswordMock).toHaveBeenCalledWith("whatever-they-typed", { email: "user@example.com" }); expect(createUserSessionMock).toHaveBeenCalledTimes(1); const sessionArg = createUserSessionMock.mock.calls[0][0]; expect(sessionArg.passwordPolicyOutdated).toBe(true); }); it("omits the flag when the submitted password satisfies the policy", async () => { getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited>); validatePolicyPasswordMock.mockResolvedValue({ valid: true, errors: [] }); const req = new Request("https://app.example.com/login", { method: "POST" }); await AuthService.loginFromRequest(req, buildForm()); expect(createUserSessionMock).toHaveBeenCalledTimes(1); const sessionArg = createUserSessionMock.mock.calls[0][0]; expect(sessionArg.passwordPolicyOutdated).toBeUndefined(); }); it("never blocks login when validatePassword throws — fails open with policyOutdated=false", async () => { getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited>); validatePolicyPasswordMock.mockRejectedValue(new Error("HIBP down")); const req = new Request("https://app.example.com/login", { method: "POST" }); await AuthService.loginFromRequest(req, buildForm()); // Session is still created — login is never blocked by the soft-check. expect(createUserSessionMock).toHaveBeenCalledTimes(1); const sessionArg = createUserSessionMock.mock.calls[0][0]; expect(sessionArg.passwordPolicyOutdated).toBeUndefined(); }); });