136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
/**
|
|
* 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<typeof import("@toolctrl/config")>("@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<ReturnType<typeof getUserByEmail>>);
|
|
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<ReturnType<typeof getUserByEmail>>);
|
|
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<ReturnType<typeof getUserByEmail>>);
|
|
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();
|
|
});
|
|
});
|