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

357 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn() }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: vi.fn(), validatePassword: vi.fn() } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({ getUserByEmail: vi.fn() }));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn(),
setLoggedUser: vi.fn(),
validateCSRFToken: vi.fn(),
}));
vi.mock("bcryptjs", () => ({ default: { compare: vi.fn() } }));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn() }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
vi.mock("~/utils/services/authService", () => ({
createRegistrationForm: vi.fn(),
getRegistrationFormData: vi.fn(),
validateRegistration: vi.fn(),
}));
vi.mock("~/utils/db/registration.db.server", () => ({ getRegistrationByToken: vi.fn(), updateRegistration: vi.fn() }));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn() } }));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
routing: {
appBaseDomain: "example.com",
webAppHost: "app.example.com",
adminAppHost: "admin.example.com",
apiHost: "api.example.com",
protocol: "https",
},
}),
};
});
import { getTenant } from "~/utils/db/tenants.db.server";
import { getTranslations } from "~/locale/i18next.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { createRegistrationForm, getRegistrationFormData, validateRegistration } from "~/utils/services/authService";
import { createUserSession, getUserInfo, setLoggedUser, validateCSRFToken } from "~/utils/session.server";
import { getRegistrationByToken, updateRegistration } from "~/utils/db/registration.db.server";
import { getSafeLoginRedirect, isProductionTestAccountBlocked, isStagingDemoLoginAllowed } from "./AuthService";
const getTenantMock = vi.mocked(getTenant);
const getTranslationsMock = vi.mocked(getTranslations);
const getAppConfigurationMock = vi.mocked(getAppConfiguration);
const createRegistrationFormMock = vi.mocked(createRegistrationForm);
const getRegistrationFormDataMock = vi.mocked(getRegistrationFormData);
const validateRegistrationMock = vi.mocked(validateRegistration);
const getRegistrationByTokenMock = vi.mocked(getRegistrationByToken);
const updateRegistrationMock = vi.mocked(updateRegistration);
const setLoggedUserMock = vi.mocked(setLoggedUser);
const createUserSessionMock = vi.mocked(createUserSession);
const getUserInfoMock = vi.mocked(getUserInfo);
const validateCSRFTokenMock = vi.mocked(validateCSRFToken);
describe("AuthService login redirects", () => {
beforeEach(() => {
vi.clearAllMocks();
getTenantMock.mockResolvedValue({ id: "tenant-1", slug: "staging-admin" } as Awaited<ReturnType<typeof getTenant>>);
});
it("rejects unsafe redirect targets", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "https://evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "//evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("rejects removed app and API redirect targets", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"/dashboard"
);
await expect(getSafeLoginRedirect({ redirectTo: "/v1/health", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "/api/v1/health", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("keeps app tenant redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://app.example.com/staging-admin/dashboard"
);
});
it("does not treat default tenant IDs as URL slugs", async () => {
getTenantMock.mockResolvedValueOnce({ id: "tenant-1", slug: null } as unknown as Awaited<ReturnType<typeof getTenant>>);
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-1/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"/dashboard"
);
});
it("rejects legacy admin redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/admin", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("rejects canonical admin-host redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
"/staging-admin/dashboard"
);
await expect(
getSafeLoginRedirect({ redirectTo: "/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })
).resolves.toBe("/staging-admin/dashboard");
});
it("keeps non-default tenant deep links on the app host for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-b/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
"https://app.example.com/tenant-b/dashboard"
);
});
it("moves non-admin root fallback redirects to the app host", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("https://app.example.com/");
});
it("falls back to admin home when an admin login carries an app tenant redirect", async () => {
await expect(
getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
).resolves.toBe("https://admin.example.com/dashboard");
await expect(
getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
).resolves.toBe("https://admin.example.com/dashboard");
});
it("moves admin redirects to the admin host for admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/accounts/users", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/accounts/users"
);
});
it("canonicalizes legacy admin redirects for admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/dashboard"
);
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/accounts/users?filter=active"
);
});
});
describe("AuthService production demo login guard", () => {
it("blocks local default and legacy demo accounts even when staging demo login is enabled", () => {
const env = { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" };
expect(isProductionTestAccountBlocked("admin@email.com", env)).toBe(true);
expect(isProductionTestAccountBlocked("alex.martinez@company.com", env)).toBe(true);
});
it("keeps seeded CRM demo users blocked unless the staging demo guard is explicit", () => {
expect(isStagingDemoLoginAllowed({ SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
expect(isProductionTestAccountBlocked("luna.davis@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true" })).toBe(true);
expect(isProductionTestAccountBlocked("luna.davis@company.com", { ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
});
});
describe("AuthService registerFromRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
getAppConfigurationMock.mockResolvedValue({ app: { features: { tenantHome: "/:tenant/" } } } as Awaited<ReturnType<typeof getAppConfiguration>>);
getTenantMock.mockResolvedValue({ id: "tenant_1", slug: "ada-labs" } as Awaited<ReturnType<typeof getTenant>>);
getRegistrationFormDataMock.mockImplementation(async (request) => {
const formData = await request.formData();
return {
email: formData.get("email")?.toString().toLowerCase().trim(),
password: formData.get("password")?.toString(),
company: formData.get("company")?.toString(),
firstName: formData.get("first-name")?.toString(),
lastName: formData.get("last-name")?.toString(),
slug: formData.get("slug")?.toString(),
leadId: formData.get("leadId")?.toString().trim() || undefined,
leadToken: formData.get("leadToken")?.toString().trim() || undefined,
leadPlanId: formData.get("leadPlanId")?.toString().trim() || undefined,
};
});
validateRegistrationMock.mockResolvedValue({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: {
user: { id: "user_1", email: "ada@example.test", locale: "de" },
tenant: { id: "tenant_1", slug: "ada-labs" },
},
} as Awaited<ReturnType<typeof validateRegistration>>);
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
createUserSessionMock.mockImplementation(async (_session, redirectTo) => new Response(null, { status: 302, headers: { Location: redirectTo } }));
});
it("passes lead conversion form fields to direct registration and starts the tenant session", async () => {
const AuthService = (await import("./AuthService")).default;
const formData = new URLSearchParams({
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
"first-name": "Ada",
"last-name": "Lovelace",
slug: "ada-labs",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
});
const request = new Request("https://app.example.com/register", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
const response = await AuthService.registerFromRequest(request);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://app.example.com/ada-labs/dashboard");
expect(validateCSRFTokenMock).toHaveBeenCalledWith(request);
expect(getRegistrationFormDataMock).toHaveBeenCalledWith(request);
expect(validateRegistrationMock).toHaveBeenCalledWith({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
firstName: "Ada",
lastName: "Lovelace",
slug: "ada-labs",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
},
addToTrialOrFreePlan: true,
});
expect(createRegistrationFormMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).toHaveBeenCalledWith({ id: "user_1", email: "ada@example.test", locale: "de" });
expect(createUserSessionMock).toHaveBeenCalledWith(
{
lng: "de",
userId: "user_1",
defaultTenantId: "tenant_1",
},
"https://app.example.com/ada-labs/dashboard"
);
});
});
describe("AuthService verifyFromRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
getRegistrationFormDataMock.mockResolvedValue({ email: "ada@example.test", password: "Password123!" });
getRegistrationByTokenMock.mockResolvedValue({
id: "registration_1",
createdTenantId: null,
leadConversionLeadId: "lead_1",
leadConversionToken: "signed-token",
leadConversionPlanId: "plan_pro",
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
validateRegistrationMock.mockResolvedValue({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: {
user: { id: "user_1", email: "ada@example.test", locale: "de" },
tenant: { id: "tenant_1", slug: "ada" },
},
} as Awaited<ReturnType<typeof validateRegistration>>);
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
createUserSessionMock.mockResolvedValue(new Response(null, { status: 302, headers: { Location: "/ada/dashboard" } }) as Awaited<ReturnType<typeof createUserSession>>);
});
it("hydrates persisted lead conversion fields before validating the verified registration", async () => {
const AuthService = (await import("./AuthService")).default;
const request = new Request("https://example.test/verify/signed-registration-token", { method: "POST" });
await AuthService.verifyFromRequest({ request, params: { id: "signed-registration-token" } });
expect(validateRegistrationMock).toHaveBeenCalledWith({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
},
checkEmailVerification: false,
addToTrialOrFreePlan: true,
});
expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", { createdTenantId: "tenant_1" });
});
it("rejects missing verification tokens before parsing form data or mutating registration state", async () => {
getRegistrationByTokenMock.mockResolvedValueOnce(null);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/missing-token", { method: "POST" }),
params: { id: "missing-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
expect(validateRegistrationMock).not.toHaveBeenCalled();
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("rejects already-consumed verification tokens before tenant/user creation work", async () => {
getRegistrationByTokenMock.mockResolvedValueOnce({
id: "registration_1",
createdTenantId: "tenant_1",
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/used-token", { method: "POST" }),
params: { id: "used-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
expect(validateRegistrationMock).not.toHaveBeenCalled();
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("does not consume the verification token when registration validation does not create an account", async () => {
validateRegistrationMock.mockResolvedValueOnce({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: undefined,
} as Awaited<ReturnType<typeof validateRegistration>>);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/signed-registration-token", { method: "POST" }),
params: { id: "signed-registration-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "shared.unknownError" });
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
});