Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/services/AuthService.passwordless.test.ts
T

104 lines
4.5 KiB
TypeScript

/**
* 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<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 }) }));
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<ReturnType<typeof getUserByEmail>>);
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();
});
});