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("@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>); }); 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>); 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>); getAppConfigurationMock.mockResolvedValue({ app: { features: { tenantHome: "/:tenant/" } } } as Awaited>); getTenantMock.mockResolvedValue({ id: "tenant_1", slug: "ada-labs" } as Awaited>); 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>); setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited>); 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>); 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>); 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>); setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited>); createUserSessionMock.mockResolvedValue(new Response(null, { status: 302, headers: { Location: "/ada/dashboard" } }) as Awaited>); }); 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>); 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>); 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(); }); });