357 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|