775 lines
30 KiB
TypeScript
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);
|
|
});
|
|
});
|