Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/services/authService.validation-i18n.test.ts
T

775 lines
30 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findInBlacklist: vi.fn(),
getAppConfiguration: vi.fn(),
getUserByEmail: vi.fn(),
register: vi.fn(),
createTenant: vi.fn(),
createTenantUser: vi.fn(),
getAllRoles: vi.fn(),
verifyLeadToken: vi.fn(),
validatePolicyPassword: vi.fn(),
createSignupTenant: vi.fn(),
isCaptchaConfigured: vi.fn(),
verifyCaptchaToken: vi.fn(),
recordExistingTenantLeadConversion: vi.fn(),
leadFindUnique: vi.fn(),
autosubscribeToTrialOrFreePlan: vi.fn(),
createIdempotentTenantEvent: vi.fn(),
resolveRegistrationSubscriptionPrice: vi.fn(),
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: vi.fn(async () => ({ t: (key: string) => key })),
}));
vi.mock("~/utils/session.server", () => ({
getUserInfo: vi.fn(async () => ({ lng: "en" })),
}));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
getAppConfiguration: (...args: unknown[]) => mocks.getAppConfiguration(...args),
}));
vi.mock("~/utils/app/UserValidationUtils", () => ({
default: {
validateEmail: vi.fn(() => true),
validatePassword: vi.fn(() => true),
},
}));
vi.mock("~/services/auth/password-policy.server", () => ({
validatePassword: (...args: unknown[]) => mocks.validatePolicyPassword(...args),
}));
vi.mock("~/domain/tenant-bootstrap/tenantBootstrapService.server", () => ({
createSignupTenantWithShkDefaults: (...args: unknown[]) => mocks.createSignupTenant(...args),
deriveSignupTenantId: (_email: string, creationRequestKey: string) =>
creationRequestKey === "d7077797-054b-497a-b1ce-8e4f36257a96" ? "tenant_1" : "tenant_other_key",
isSignupCreationRequestKey: (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value),
}));
vi.mock("@hivegrid/captcha", () => ({
resolveCaptchaConfig: vi.fn(() => ({ provider: "mcaptcha", failOpen: false })),
isCaptchaConfigured: (...args: unknown[]) => mocks.isCaptchaConfigured(...args),
verifyCaptchaToken: (...args: unknown[]) => mocks.verifyCaptchaToken(...args),
getSecret: vi.fn(() => "secret"),
getSitekey: vi.fn(() => "sitekey"),
}));
vi.mock("~/utils/db/blacklist.db.server", () => ({
addBlacklistAttempt: vi.fn(),
findInBlacklist: (...args: unknown[]) => mocks.findInBlacklist(...args),
}));
vi.mock("~/utils/db/users.db.server", () => ({
getUserByEmail: (...args: unknown[]) => mocks.getUserByEmail(...args),
register: (...args: unknown[]) => mocks.register(...args),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({
TenantSlugCollisionError: class TenantSlugCollisionError extends Error {},
createTenant: (...args: unknown[]) => mocks.createTenant(...args),
createTenantUser: (...args: unknown[]) => mocks.createTenantUser(...args),
}));
vi.mock("~/utils/db/permissions/roles.db.server", () => ({ getAllRoles: (...args: unknown[]) => mocks.getAllRoles(...args) }));
vi.mock("~/utils/db/registration.db.server", () => ({
createRegistration: vi.fn(),
getRegistrationByEmail: vi.fn(),
updateRegistration: vi.fn(),
}));
vi.mock("@hivegrid/db/pretenant-security.server", () => ({
findLeadForSignedConversionCapability: (...args: unknown[]) => mocks.leadFindUnique(...args),
RegistrationCreateConflictError: class RegistrationCreateConflictError extends Error {},
}));
vi.mock("~/utils/url.server", () => ({ getAbsoluteUrl: vi.fn(() => "https://example.test") }));
vi.mock("~/modules/events/services/.server/EventsService", () => ({
default: {
create: vi.fn(),
createIdempotentTenantEvent: (...args: unknown[]) => mocks.createIdempotentTenantEvent(...args),
},
}));
vi.mock("~/modules/events/utils/EventUtils", () => ({ default: { getDescription: vi.fn(() => "Account created") } }));
vi.mock("~/modules/emails/services/TransactionalEmailQueue.server", () => ({ queueTransactionalEmail: vi.fn() }));
vi.mock("~/modules/emails/utils/EmailTemplates", () => ({ default: { WELCOME_EMAIL: { parse: vi.fn(() => ({ subject: "Welcome", body: "Welcome" })) } } }));
vi.mock("~/locale/mail-translator.server", () => ({
getMailTranslator: vi.fn(async () => ({ t: (key: string) => key, locale: "en" })),
getMailTranslatorFromRequest: vi.fn(),
}));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn() } }));
vi.mock("~/utils/leads/leadToken.server", () => ({ verifyLeadToken: (...args: unknown[]) => mocks.verifyLeadToken(...args) }));
vi.mock("~/utils/leads/convertLead.server", () => ({
recordExistingTenantLeadConversion: (...args: unknown[]) => mocks.recordExistingTenantLeadConversion(...args),
}));
vi.mock("./.server/pricingService", () => ({
autosubscribeToTrialOrFreePlan: (...args: unknown[]) => mocks.autosubscribeToTrialOrFreePlan(...args),
}));
vi.mock("~/services/billing/registration-plan-selection.server", () => ({
resolveRegistrationSubscriptionPrice: (...args: unknown[]) => mocks.resolveRegistrationSubscriptionPrice(...args),
}));
vi.mock("@hivegrid/foundation", async () => {
const actual = await vi.importActual<typeof import("@hivegrid/foundation")>("@hivegrid/foundation");
return {
...actual,
withIdempotency: vi.fn(async (_input: unknown, _client: unknown, operation: () => Promise<unknown>) => ({
...(await operation()),
replayed: false,
})),
};
});
vi.mock("@hivegrid/db/outbox", () => ({ publishOutboxEvent: vi.fn(async () => ({})) }));
vi.mock("@hivegrid/queue", () => ({
buildWorkflowTriggerOutboxEvent: vi.fn(() => ({
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: {},
dispatchKey: "workflow-trigger:event_1",
})),
}));
vi.mock("~/utils/db.server", () => ({
db: {
$transaction: vi.fn(async (callback: (tx: unknown) => Promise<unknown>) => callback({ event: { create: vi.fn(async () => ({ id: "event_1" })) } })),
},
}));
import { TenantSlugCollisionError } from "~/utils/db/tenants.db.server";
import { validateRegistration } from "./authService";
const request = new Request("https://example.test/register");
const CREATION_REQUEST_KEY = "d7077797-054b-497a-b1ce-8e4f36257a96";
const OTHER_CREATION_REQUEST_KEY = "b79f4743-b5ee-48df-9287-10f24b2aedbf";
function appConfiguration(
slug: { require: boolean; type: "tenant" | "username" } = { require: false, type: "tenant" },
requireEmailVerification = false,
requiredFields: { organization?: boolean; name?: boolean } = {}
) {
return {
auth: {
requireEmailVerification,
requireOrganization: requiredFields.organization ?? false,
requireName: requiredFields.name ?? false,
slug,
},
};
}
describe("validateRegistration i18n errors", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getAppConfiguration.mockResolvedValue(appConfiguration());
mocks.findInBlacklist.mockResolvedValue(null);
mocks.getUserByEmail.mockResolvedValue(null);
mocks.createTenant.mockResolvedValue({ id: "tenant_1", name: "Ada", slug: "ada" });
mocks.register.mockResolvedValue({ id: "user_1", email: "ada@example.test", locale: "en" });
mocks.createTenantUser.mockResolvedValue({});
mocks.getAllRoles.mockResolvedValue([]);
mocks.verifyLeadToken.mockReturnValue(null);
mocks.recordExistingTenantLeadConversion.mockResolvedValue({});
mocks.leadFindUnique.mockResolvedValue(null);
mocks.validatePolicyPassword.mockResolvedValue({ valid: true, errors: [] });
mocks.createSignupTenant.mockResolvedValue({
tenant: { id: "tenant_1", name: "Ada", slug: "ada" },
user: { id: "user_1", email: "ada@example.test", locale: "en" },
replayed: false,
});
mocks.isCaptchaConfigured.mockReturnValue(false);
mocks.verifyCaptchaToken.mockResolvedValue(true);
mocks.autosubscribeToTrialOrFreePlan.mockResolvedValue(undefined);
mocks.createIdempotentTenantEvent.mockResolvedValue("account.created");
mocks.resolveRegistrationSubscriptionPrice.mockResolvedValue({
planId: "product_starter",
planCode: "starter",
titleKey: "Core",
descriptionKey: "Core plan",
priceId: "price_starter",
amountCents: 19_900,
currency: "EUR",
billingPeriod: 3,
});
delete process.env.LEAD_CONVERSION_ENABLED;
});
it("binds exact signup retries to one idempotent trial subscription and event key", async () => {
const appliedKeys = new Set<string>();
let subscriptionWrites = 0;
let logicalSubscriptionEvents = 0;
mocks.autosubscribeToTrialOrFreePlan.mockImplementation(async ({ idempotencyKey }: { idempotencyKey: string }) => {
if (!appliedKeys.has(idempotencyKey)) {
appliedKeys.add(idempotencyKey);
subscriptionWrites += 1;
logicalSubscriptionEvents += 1;
}
});
const input = {
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: true,
};
await validateRegistration(input);
await validateRegistration(input);
expect(mocks.autosubscribeToTrialOrFreePlan).toHaveBeenCalledTimes(2);
expect(mocks.autosubscribeToTrialOrFreePlan).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
tenantId: "tenant_1",
userId: "user_1",
idempotencyKey: CREATION_REQUEST_KEY,
throwOnFailure: true,
})
);
expect(subscriptionWrites).toBe(1);
expect(logicalSubscriptionEvents).toBe(1);
expect(appliedKeys).toEqual(new Set([CREATION_REQUEST_KEY]));
});
it("requires an exact canonical subscription price for public registration", async () => {
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("account.register.errors.priceNotSelected");
mocks.resolveRegistrationSubscriptionPrice.mockResolvedValueOnce(null);
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
selectedSubscriptionPriceId: "forged_price",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("account.register.errors.priceNotInDatabase");
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it("requires a complete bounded registration team size and returns it unchanged", async () => {
const baseData = {
email: "ada@example.test",
selectedSubscriptionPriceId: "price_starter",
creationRequestKey: CREATION_REQUEST_KEY,
};
await expect(
validateRegistration({
request,
registrationData: baseData,
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("auth:errors.invalidFormData");
await expect(
validateRegistration({
request,
registrationData: { ...baseData, selectedOfficeSeatQuantity: 0, selectedFieldSeatQuantity: 1 },
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("auth:errors.invalidFormData");
await expect(
validateRegistration({
request,
registrationData: { ...baseData, selectedOfficeSeatQuantity: 5 },
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("auth:errors.invalidFormData");
await expect(
validateRegistration({
request,
registrationData: { ...baseData, selectedFieldSeatQuantity: 0 },
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).rejects.toThrow("auth:errors.invalidFormData");
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, true));
await expect(
validateRegistration({
request,
registrationData: { ...baseData, selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15 },
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
})
).resolves.toMatchObject({
verificationRequired: true,
selectedOfficeSeatQuantity: 5,
selectedFieldSeatQuantity: 15,
});
});
it("binds the validated plan and team size to the direct signup bootstrap", async () => {
await validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
selectedSubscriptionPriceId: "price_starter",
selectedOfficeSeatQuantity: 5,
selectedFieldSeatQuantity: 15,
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
checkEmailVerification: false,
});
expect(mocks.createSignupTenant).toHaveBeenCalledWith(
expect.objectContaining({
creationRequestKey: CREATION_REQUEST_KEY,
billingPreference: {
selectedSubscriptionPriceId: "price_starter",
selectedOfficeSeatQuantity: 5,
selectedFieldSeatQuantity: 15,
},
})
);
});
it("propagates an atomic bootstrap failure and lets an exact signup retry commit the preference", async () => {
mocks.createSignupTenant
.mockRejectedValueOnce(new Error("preference transaction unavailable"))
.mockResolvedValueOnce({
tenant: { id: "tenant_1", name: "Ada", slug: "ada" },
user: { id: "user_1", email: "ada@example.test", locale: "en" },
replayed: false,
});
const input = {
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
selectedSubscriptionPriceId: "price_starter",
selectedOfficeSeatQuantity: 5,
selectedFieldSeatQuantity: 15,
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
requireSubscriptionSelection: true,
checkEmailVerification: false,
};
await expect(validateRegistration(input)).rejects.toThrow("preference transaction unavailable");
await expect(validateRegistration(input)).resolves.toMatchObject({ registered: { tenant: { id: "tenant_1" } } });
expect(mocks.createSignupTenant).toHaveBeenCalledTimes(2);
expect(mocks.createSignupTenant).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
billingPreference: {
selectedSubscriptionPriceId: "price_starter",
selectedOfficeSeatQuantity: 5,
selectedFieldSeatQuantity: 15,
},
})
);
});
it("propagates a required subscription failure so an exact retry can complete it once", async () => {
const logicalEntitlements = new Set<string>();
let logicalSubscriptionEvents = 0;
mocks.autosubscribeToTrialOrFreePlan
.mockRejectedValueOnce(new Error("subscription transaction unavailable"))
.mockImplementationOnce(async ({ idempotencyKey }: { idempotencyKey: string }) => {
if (!logicalEntitlements.has(idempotencyKey)) {
logicalEntitlements.add(idempotencyKey);
logicalSubscriptionEvents += 1;
}
});
const input = {
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: true,
};
await expect(validateRegistration(input)).rejects.toThrow("subscription transaction unavailable");
await expect(validateRegistration(input)).resolves.toMatchObject({ registered: { tenant: { id: "tenant_1" } } });
expect(mocks.autosubscribeToTrialOrFreePlan).toHaveBeenCalledTimes(2);
expect(logicalEntitlements).toEqual(new Set([CREATION_REQUEST_KEY]));
expect(logicalSubscriptionEvents).toBe(1);
});
it("lets an exact slug retry reach deterministic bootstrap after the tenant commit", async () => {
mocks.getAppConfiguration.mockResolvedValue(appConfiguration({ require: true, type: "tenant" }));
const input = {
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
slug: "ada-labs",
creationRequestKey: CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: false,
};
await validateRegistration(input);
await validateRegistration(input);
expect(mocks.createSignupTenant).toHaveBeenCalledTimes(2);
});
it("maps an authoritative committed-slug collision after the constraint race", async () => {
mocks.getAppConfiguration.mockResolvedValue(appConfiguration({ require: true, type: "tenant" }));
mocks.createSignupTenant.mockRejectedValueOnce(new TenantSlugCollisionError());
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
slug: "ada-labs",
creationRequestKey: OTHER_CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: false,
})
).rejects.toThrow("account.register.errors.slugTaken");
expect(mocks.createSignupTenant).toHaveBeenCalledOnce();
});
it("allows an exact lead conversion retry only for its deterministic committed tenant", async () => {
mocks.verifyLeadToken.mockReturnValue({ leadId: "lead_1", planId: "plan_pro" });
mocks.leadFindUnique
.mockResolvedValueOnce({ email: "ada@example.test", convertedToTenantId: null })
.mockResolvedValueOnce({ email: "ada@example.test", convertedToTenantId: "tenant_1" });
const logicalConversions = new Set<string>();
mocks.recordExistingTenantLeadConversion.mockImplementation(async ({ leadId, tenantId }: { leadId: string; tenantId: string }) => {
logicalConversions.add(`${leadId}:${tenantId}`);
return { alreadyConverted: logicalConversions.size === 1 };
});
const input = {
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
leadId: "lead_1",
leadToken: "signed-lead-token",
leadPlanId: "plan_pro",
creationRequestKey: CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: false,
};
await validateRegistration(input);
await validateRegistration(input);
expect(mocks.createSignupTenant).toHaveBeenCalledTimes(2);
expect(mocks.recordExistingTenantLeadConversion).toHaveBeenCalledTimes(2);
expect(logicalConversions).toEqual(new Set(["lead_1:tenant_1"]));
});
it("rejects an already-converted lead when another request key derives another tenant", async () => {
mocks.verifyLeadToken.mockReturnValue({ leadId: "lead_1", planId: "plan_pro" });
mocks.leadFindUnique.mockResolvedValue({ email: "ada@example.test", convertedToTenantId: "tenant_1" });
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
leadId: "lead_1",
leadToken: "signed-lead-token",
creationRequestKey: OTHER_CREATION_REQUEST_KEY,
},
checkEmailVerification: false,
addToTrialOrFreePlan: false,
})
).rejects.toThrow("account.register.errors.invalidLeadConversion");
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
expect(mocks.recordExistingTenantLeadConversion).not.toHaveBeenCalled();
});
it.each([
["companyMaxLength", { company: "a".repeat(101) }],
["firstNameMaxLength", { firstName: "a".repeat(51) }],
["lastNameMaxLength", { lastName: "a".repeat(51) }],
])("uses a translation key for %s", async (errorKey, registrationData) => {
await expect(
validateRegistration({
request,
registrationData: { email: "ada@example.test", password: "Password123!", creationRequestKey: CREATION_REQUEST_KEY, ...registrationData },
addToTrialOrFreePlan: false,
})
).rejects.toThrow(`account.register.errors.${errorKey}`);
});
it.each([
["missing organization", { firstName: "Ada", lastName: "Lovelace" }, { organization: true }, "organizationRequired"],
["blank organization", { company: " ", firstName: "Ada", lastName: "Lovelace" }, { organization: true }, "organizationRequired"],
["missing first name", { company: "Ada Labs", lastName: "Lovelace" }, { name: true }, "nameRequired"],
["blank last name", { company: "Ada Labs", firstName: "Ada", lastName: " " }, { name: true }, "nameRequired"],
])("rejects %s before direct account bootstrap", async (_label, registrationData, requiredFields, errorKey) => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, false, requiredFields));
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
...registrationData,
},
addToTrialOrFreePlan: false,
})
).rejects.toThrow(`account.register.errors.${errorKey}`);
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it("enforces required fields on the initial email-verification post", async () => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, true, { organization: true, name: true }));
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
firstName: "Ada",
lastName: "Lovelace",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
})
).rejects.toThrow("account.register.errors.organizationRequired");
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it("accepts non-blank configured registration fields", async () => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, false, { organization: true, name: true }));
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
firstName: "Ada",
lastName: "Lovelace",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
})
).resolves.toMatchObject({ verificationRequired: false, registered: { tenant: { id: "tenant_1" } } });
expect(mocks.createSignupTenant).toHaveBeenCalledTimes(1);
});
it.each([
["tenant", "slugRequired"],
["username", "usernameRequired"],
] as const)("uses a translation key when the %s slug is required", async (slugType, errorKey) => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: true, type: slugType }));
await expect(
validateRegistration({
request,
registrationData: { email: "ada@example.test", password: "Password123!", creationRequestKey: CREATION_REQUEST_KEY },
addToTrialOrFreePlan: false,
})
).rejects.toThrow(`account.register.errors.${errorKey}`);
});
it.each([
["tenant", "slugTaken"],
["username", "usernameTaken"],
] as const)("uses a translation key when the %s slug is already taken", async (slugType, errorKey) => {
mocks.getAppConfiguration.mockResolvedValue(appConfiguration({ require: true, type: slugType }));
mocks.createSignupTenant.mockRejectedValueOnce(new TenantSlugCollisionError());
await expect(
validateRegistration({
request,
registrationData: { email: "ada@example.test", password: "Password123!", slug: "ada", creationRequestKey: CREATION_REQUEST_KEY },
addToTrialOrFreePlan: false,
})
).rejects.toThrow(`account.register.errors.${errorKey}`);
});
it.each([
["reserved", "slugReserved", "admin"],
["too short", "slugTooShort", "x"],
] as const)("rejects a user-provided slug that is %s, independent of auth.slug.require", async (_label, errorKey, slug) => {
await expect(
validateRegistration({
request,
registrationData: { email: "ada@example.test", password: "Password123!", slug, creationRequestKey: CREATION_REQUEST_KEY },
addToTrialOrFreePlan: false,
})
).rejects.toThrow(`account.register.errors.${errorKey}`);
});
it.each([
["only plan id", { email: "ada@example.test", password: "Password123!", leadPlanId: "plan_pro" }],
["missing token", { email: "ada@example.test", password: "Password123!", leadId: "lead_1" }],
["token for a different lead", { email: "ada@example.test", password: "Password123!", leadId: "lead_1", leadToken: "signed" }],
["converted lead", { email: "ada@example.test", password: "Password123!", leadId: "lead_1", leadToken: "signed" }],
["email mismatch", { email: "ada@example.test", password: "Password123!", leadId: "lead_1", leadToken: "signed" }],
])("rejects invalid lead conversion context: %s", async (name, registrationData) => {
if (name === "token for a different lead") {
mocks.verifyLeadToken.mockReturnValueOnce({ leadId: "other_lead", planId: "plan_token" });
} else if (name === "converted lead") {
mocks.verifyLeadToken.mockReturnValueOnce({ leadId: "lead_1", planId: "plan_token" });
mocks.leadFindUnique.mockResolvedValueOnce({ email: "ada@example.test", convertedToTenantId: "tenant_existing" });
} else if (name === "email mismatch") {
mocks.verifyLeadToken.mockReturnValueOnce({ leadId: "lead_1", planId: "plan_token" });
mocks.leadFindUnique.mockResolvedValueOnce({ email: "grace@example.test", convertedToTenantId: null });
}
await expect(
validateRegistration({
request,
registrationData: { creationRequestKey: CREATION_REQUEST_KEY, ...registrationData },
addToTrialOrFreePlan: false,
})
).rejects.toThrow("account.register.errors.invalidLeadConversion");
expect(mocks.recordExistingTenantLeadConversion).not.toHaveBeenCalled();
});
it("records valid self-service lead conversion with token plan precedence", async () => {
mocks.verifyLeadToken.mockReturnValueOnce({ leadId: "lead_1", planId: "plan_from_token" });
mocks.leadFindUnique.mockResolvedValueOnce({ email: "ada@example.test", convertedToTenantId: null });
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
leadId: "lead_1",
leadToken: "signed",
leadPlanId: "plan_from_form",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
})
).resolves.toMatchObject({ verificationRequired: false, registered: { tenant: { id: "tenant_1" }, user: { id: "user_1" } } });
expect(mocks.recordExistingTenantLeadConversion).toHaveBeenCalledWith({
leadId: "lead_1",
tenantId: "tenant_1",
userId: "user_1",
expectedEmail: "ada@example.test",
planId: "plan_from_token",
conversionPath: "self-service",
});
});
it("requires a password when completing an email-verified registration", async () => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, true));
await expect(
validateRegistration({
request,
registrationData: { email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY },
addToTrialOrFreePlan: false,
checkEmailVerification: false,
})
).rejects.toThrow("account.register.errors.passwordRequired");
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it.each([
["weak", "auth:errors.passwordTooShort"],
["compromised", "auth:errors.passwordCompromised"],
])("rejects a %s password before bootstrap on the verify path", async (_label, code) => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, true));
mocks.validatePolicyPassword.mockResolvedValueOnce({ valid: false, errors: [{ code, args: {} }] });
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "bad-password",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
checkEmailVerification: false,
})
).rejects.toThrow(code);
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it("keeps captcha mandatory on the initial registration post", async () => {
mocks.isCaptchaConfigured.mockReturnValueOnce(true);
mocks.verifyCaptchaToken.mockResolvedValueOnce(false);
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
})
).rejects.toThrow("account.register.errors.captchaFailed");
expect(mocks.verifyCaptchaToken).toHaveBeenCalledTimes(1);
expect(mocks.createSignupTenant).not.toHaveBeenCalled();
});
it("does not require a second captcha after the outer verify flow validated token and bound key", async () => {
mocks.getAppConfiguration.mockResolvedValueOnce(appConfiguration({ require: false, type: "tenant" }, true));
mocks.isCaptchaConfigured.mockReturnValueOnce(true);
await expect(
validateRegistration({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
creationRequestKey: CREATION_REQUEST_KEY,
},
addToTrialOrFreePlan: false,
checkEmailVerification: false,
captchaAlreadyVerified: true,
})
).resolves.toMatchObject({ verificationRequired: false, registered: { tenant: { id: "tenant_1" } } });
expect(mocks.verifyCaptchaToken).not.toHaveBeenCalled();
expect(mocks.createSignupTenant).toHaveBeenCalledTimes(1);
});
});