import { beforeEach, describe, expect, it, vi } from "vitest"; import { CatalogError, foundationErrors } from "@hivegrid/foundation"; const loggerMocks = vi.hoisted(() => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() })); const registrationRecoveryMocks = vi.hoisted(() => ({ read: vi.fn(), commit: vi.fn(), clear: vi.fn(), })); vi.mock("~/utils/logger.server", () => ({ createScopedLogger: () => loggerMocks })); 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/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() })); vi.mock("~/utils/services/authService", () => ({ createRegistrationForm: vi.fn(), getRegistrationFormData: vi.fn(), isSignupCreationRequestKey: vi.fn((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)), RegistrationValidationError: class RegistrationValidationError extends Error { readonly i18nKey: string; readonly i18nArgs?: Record; constructor(i18nKey: string, i18nArgs?: Record) { super(i18nKey); this.i18nKey = i18nKey; this.i18nArgs = i18nArgs; } }, resolvePendingRegistrationResendRecovery: vi.fn(), validateRegistration: vi.fn(), })); vi.mock("~/services/auth/registration-resend-recovery.server", () => ({ readRegistrationResendRecovery: (...args: unknown[]) => registrationRecoveryMocks.read(...args), commitRegistrationResendRecovery: (...args: unknown[]) => registrationRecoveryMocks.commit(...args), clearRegistrationResendRecovery: (...args: unknown[]) => registrationRecoveryMocks.clear(...args), })); vi.mock("~/utils/db/registration.db.server", () => ({ getRegistrationVerificationStateByToken: vi.fn(), updateRegistration: vi.fn() })); vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: vi.fn() })); vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn() } })); vi.mock("@hivegrid/config", async () => { const actual = await vi.importActual("@hivegrid/config"); return { ...actual, loadRequiredWebConfig: () => ({ routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https", }, }), }; }); import { getTranslations } from "~/locale/i18next.server"; import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server"; import { createRegistrationForm, getRegistrationFormData, RegistrationValidationError, resolvePendingRegistrationResendRecovery, validateRegistration, } from "~/utils/services/authService"; import { createUserSession, getUserInfo, setLoggedUser, validateCSRFToken } from "~/utils/session.server"; import { getRegistrationVerificationStateByToken, updateRegistration } from "~/utils/db/registration.db.server"; import { getSafeLoginRedirect, isProductionTestAccountBlocked, isStagingDemoLoginAllowed } from "./AuthService"; import { deriveVerificationCreationRequestKey } from "~/domain/tenant-bootstrap/signupCreationRequestKey.server"; import { checkAuthRateLimit } from "~/services/auth/auth-rate-limit.server"; 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 resolvePendingRegistrationResendRecoveryMock = vi.mocked(resolvePendingRegistrationResendRecovery); const getRegistrationVerificationStateByTokenMock = vi.mocked(getRegistrationVerificationStateByToken); 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); const checkAuthRateLimitMock = vi.mocked(checkAuthRateLimit); const CREATION_REQUEST_KEY = "d7077797-054b-497a-b1ce-8e4f36257a96"; const VERIFICATION_TOKEN = "signed-registration-token"; const BOUND_VERIFICATION_KEY = deriveVerificationCreationRequestKey("registration_1"); function verificationSubmission(creationRequestKey = BOUND_VERIFICATION_KEY, password = "Password123!") { return { email: "attacker@example.test", password, company: "Attacker Ltd", firstName: "Mallory", lastName: "Evil", slug: "attacker", leadId: "attacker-lead", leadToken: "attacker-token", leadPlanId: "attacker-plan", selectedSubscriptionPriceId: "attacker-price", creationRequestKey, captchaToken: "captcha-proof", }; } describe("AuthService login redirects", () => { beforeEach(() => { vi.clearAllMocks(); }); 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 () => { await expect( getSafeLoginRedirect({ redirectTo: "/tenant-1/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" }) ).resolves.toBe("/dashboard"); }); it("uses the membership-proven slug consistently without a cache-dependent tenant lookup", async () => { const input = { redirectTo: "/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", defaultTenantSlug: "staging-admin", fallback: "/dashboard", }; await expect(getSafeLoginRedirect(input)).resolves.toBe("https://app.example.com/staging-admin/dashboard"); await expect(getSafeLoginRedirect(input)).resolves.toBe("https://app.example.com/staging-admin/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", defaultTenantSlug: "staging-admin", 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", defaultTenantSlug: "staging-admin", 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", () => { const stagingDemoEnv = { SENTRY_ENVIRONMENT: "staging", STAGING_DEMO_LOGIN_ENABLED: "true" }; it("blocks local default and legacy demo accounts even when staging demo login is enabled", () => { expect(isProductionTestAccountBlocked("admin@email.com", stagingDemoEnv)).toBe(true); expect(isProductionTestAccountBlocked("alex.martinez@company.com", stagingDemoEnv)).toBe(true); }); it("keeps seeded CRM demo users blocked unless the staging demo guard is explicit", () => { expect(isStagingDemoLoginAllowed(stagingDemoEnv)).toBe(true); expect(isProductionTestAccountBlocked("john.doe@company.com", stagingDemoEnv)).toBe(false); expect(isProductionTestAccountBlocked("luna.davis@company.com", stagingDemoEnv)).toBe(false); expect(isProductionTestAccountBlocked("john.doe@company.com", { SENTRY_ENVIRONMENT: "staging" })).toBe(true); expect(isProductionTestAccountBlocked("luna.davis@company.com", { STAGING_DEMO_LOGIN_ENABLED: "true" })).toBe(true); }); it("allows managed staging employees only in the managed staging runtime", () => { const email = "anna.becker.01@mitarbeiter.example.test"; expect(isProductionTestAccountBlocked(email, stagingDemoEnv)).toBe(false); expect(isProductionTestAccountBlocked(email, { SENTRY_ENVIRONMENT: "production", STAGING_DEMO_LOGIN_ENABLED: "true" })).toBe(true); expect(isProductionTestAccountBlocked(email, { SENTRY_ENVIRONMENT: "staging" })).toBe(true); }); it("never unlocks a demo account through a SEED_* key, which the deploy contract forbids at runtime", () => { // Regression: the gate used to ride on these, so enforcing the RLS deploy // contract (no SEED_* on runtime resources) silently locked staging out. const seedShapedEnv = { SENTRY_ENVIRONMENT: "staging", SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true", SEED_DEMO_PRO_MANAGED: "true", } as Record; expect(isStagingDemoLoginAllowed(seedShapedEnv)).toBe(false); expect(isProductionTestAccountBlocked("john.doe@company.com", seedShapedEnv)).toBe(true); expect(isProductionTestAccountBlocked("anna.becker.01@mitarbeiter.example.test", seedShapedEnv)).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>); getRegistrationFormDataMock.mockImplementation(async (request) => { const formData = await request.formData(); const intent = formData.get("intent")?.toString().trim() || undefined; const selectedOfficeSeatQuantity = formData.get("selectedOfficeSeatQuantity")?.toString().trim(); const selectedFieldSeatQuantity = formData.get("selectedFieldSeatQuantity")?.toString().trim(); 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, selectedSubscriptionPriceId: formData.get("selectedSubscriptionPriceId")?.toString().trim() || undefined, ...(selectedOfficeSeatQuantity ? { selectedOfficeSeatQuantity: Number(selectedOfficeSeatQuantity) } : {}), ...(selectedFieldSeatQuantity ? { selectedFieldSeatQuantity: Number(selectedFieldSeatQuantity) } : {}), creationRequestKey: formData.get("creationRequestKey")?.toString().trim() || undefined, acceptTerms: formData.get("acceptTerms") === "true", acceptPrivacy: formData.get("acceptPrivacy") === "true", ...(intent ? { intent } : {}), }; }); 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", defaultTenant: { id: "tenant_1", name: "Ada Labs", slug: "ada-labs", active: true, deactivatedReason: null }, } as Awaited>); createUserSessionMock.mockImplementation( async (_session, redirectTo) => new Response(null, { status: 302, headers: { Location: redirectTo, "Set-Cookie": "__Host-hivegrid_app_session=signed; Path=/; HttpOnly" }, }) ); registrationRecoveryMocks.read.mockResolvedValue(null); registrationRecoveryMocks.commit.mockResolvedValue("__Host-hivegrid_registration_resend=signed; Path=/; HttpOnly"); registrationRecoveryMocks.clear.mockResolvedValue("__Host-hivegrid_registration_resend=; Max-Age=0; Path=/; HttpOnly"); resolvePendingRegistrationResendRecoveryMock.mockResolvedValue(null); }); 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", selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: "5", selectedFieldSeatQuantity: "15", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "true", }); const request = new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formData, }); validateRegistrationMock.mockResolvedValueOnce({ email: "ada@example.test", ipAddress: "203.0.113.5", verificationRequired: false, selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15, registered: { user: { id: "user_1", email: "ada@example.test", locale: "de" }, tenant: { id: "tenant_1", slug: "ada-labs" }, }, } as Awaited>); const response = await AuthService.registerFromRequest(request); expect(response.status).toBe(302); expect(response.headers.get("Location")).toBe( "https://app.example.com/ada-labs/settings/billing?subscription_required=1&selected_price=price_pro&office_seats=5&field_seats=15" ); expect(response.headers.get("Set-Cookie")).toContain("__Host-hivegrid_app_session=signed"); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0"); expect(registrationRecoveryMocks.clear).toHaveBeenCalledOnce(); 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", selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15, creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: true, acceptPrivacy: true, }, addToTrialOrFreePlan: false, requireSubscriptionSelection: 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/settings/billing?subscription_required=1&selected_price=price_pro&office_seats=5&field_seats=15", { request: expect.any(Request), rotate: true } ); }); it("returns the rotated registration key in action data after a manual resend", async () => { const AuthService = (await import("./AuthService")).default; const rotatedKey = "b79f4743-b5ee-48df-9287-10f24b2aedbf"; validateRegistrationMock.mockResolvedValue({ email: "ada@example.test", ipAddress: "203.0.113.5", verificationRequired: true, } as Awaited>); createRegistrationFormMock.mockResolvedValueOnce({ creationRequestKey: CREATION_REQUEST_KEY }).mockResolvedValueOnce({ creationRequestKey: rotatedKey }); const body = { email: "ada@example.test", company: "Ada Labs", "first-name": "Ada", "last-name": "Lovelace", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "true", }; const initial = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), }) ); const resend = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ ...body, intent: "resendVerification" }), }) ); await expect(initial.json()).resolves.toEqual({ verificationEmailSent: true, creationRequestKey: CREATION_REQUEST_KEY }); await expect(resend.json()).resolves.toEqual({ verificationEmailSent: true, creationRequestKey: rotatedKey }); expect(initial.headers.get("Set-Cookie")).toContain("HttpOnly"); expect(resend.headers.get("Cache-Control")).toBe("no-store"); expect(registrationRecoveryMocks.commit).toHaveBeenNthCalledWith(1, { email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY, }); expect(registrationRecoveryMocks.commit).toHaveBeenNthCalledWith(2, { email: "ada@example.test", creationRequestKey: rotatedKey, }); expect(createRegistrationFormMock).toHaveBeenCalledTimes(2); expect(createRegistrationFormMock.mock.calls.map(([input]) => input.creationRequestKey)).toEqual([CREATION_REQUEST_KEY, CREATION_REQUEST_KEY]); expect(validateRegistrationMock.mock.calls[1]?.[0]).toMatchObject({ captchaAlreadyVerified: true }); expect(createRegistrationFormMock.mock.calls[1]?.[0]).toMatchObject({ resendOnly: true }); }); it("recovers a reload resend only for the signed email and exact pending Registration binding", async () => { const AuthService = (await import("./AuthService")).default; const recoveredKey = "b79f4743-b5ee-48df-9287-10f24b2aedbf"; registrationRecoveryMocks.read.mockResolvedValue({ email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY, issuedAt: Date.now(), }); resolvePendingRegistrationResendRecoveryMock.mockResolvedValue({ email: "ada@example.test", creationRequestKey: recoveredKey, registrationId: "registration_1", catchUpOnly: true, }); validateRegistrationMock.mockImplementation(async ({ registrationData }) => ({ email: registrationData.email!, ipAddress: "203.0.113.5", verificationRequired: true, })); createRegistrationFormMock.mockResolvedValue({ creationRequestKey: recoveredKey }); const response = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: "recovery=signed" }, body: new URLSearchParams({ email: "ada@example.test", intent: "resendVerification", creationRequestKey: "1ac679b8-fbc8-4d51-aa2d-51aa31ffa03f", }), }) ); expect(resolvePendingRegistrationResendRecoveryMock).toHaveBeenCalledWith( expect.objectContaining({ email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY }) ); expect(validateRegistrationMock).toHaveBeenCalledWith( expect.objectContaining({ registrationData: expect.objectContaining({ creationRequestKey: recoveredKey }), captchaAlreadyVerified: true }) ); expect(createRegistrationFormMock).toHaveBeenCalledWith( expect.objectContaining({ email: "ada@example.test", creationRequestKey: recoveredKey, resendOnly: true, resendRecovery: { registrationId: "registration_1", catchUpOnly: true }, }) ); await expect(response.json()).resolves.toEqual({ verificationEmailSent: true, creationRequestKey: recoveredKey }); }); it("never applies a signed recovery binding to another email or a consumed Registration", async () => { const AuthService = (await import("./AuthService")).default; registrationRecoveryMocks.read.mockResolvedValue({ email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY, issuedAt: Date.now(), }); resolvePendingRegistrationResendRecoveryMock.mockResolvedValue(null); validateRegistrationMock.mockImplementation(async ({ registrationData }) => ({ email: registrationData.email!, ipAddress: "203.0.113.5", verificationRequired: true, })); createRegistrationFormMock.mockRejectedValue(new CatalogError(foundationErrors.idempotencyConflict)); const response = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: "recovery=signed" }, body: new URLSearchParams({ email: "mallory@example.test", intent: "resendVerification", creationRequestKey: "1ac679b8-fbc8-4d51-aa2d-51aa31ffa03f", }), }) ); expect(resolvePendingRegistrationResendRecoveryMock).not.toHaveBeenCalled(); expect(createRegistrationFormMock).toHaveBeenCalledWith(expect.not.objectContaining({ resendRecovery: expect.anything() })); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ verificationEmailSent: true, creationRequestKey: "1ac679b8-fbc8-4d51-aa2d-51aa31ffa03f", }); expect(registrationRecoveryMocks.commit).toHaveBeenCalledWith({ email: "mallory@example.test", creationRequestKey: "1ac679b8-fbc8-4d51-aa2d-51aa31ffa03f", }); }); it("returns the same public shape for a fresh send, an existing account, and a foreign pending registration", async () => { const AuthService = (await import("./AuthService")).default; validateRegistrationMock.mockResolvedValue({ email: "ada@example.test", ipAddress: "203.0.113.5", verificationRequired: true, } as Awaited>); createRegistrationFormMock .mockResolvedValueOnce({ creationRequestKey: CREATION_REQUEST_KEY }) .mockRejectedValueOnce(new RegistrationValidationError("api.errors.userAlreadyRegistered")) .mockRejectedValueOnce(new CatalogError(foundationErrors.idempotencyConflict)); const body = new URLSearchParams({ email: "ada@example.test", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "true", }); const responses = []; for (const variant of ["fresh", "registered", "pending"]) { responses.push( await AuthService.registerFromRequest( new Request(`https://app.example.com/register?variant=${variant}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }) ) ); } const responseBodies = await Promise.all(responses.map((response) => response.json())); expect(responses.map((response) => response.status)).toEqual([200, 200, 200]); expect(responses.map((response) => response.headers.get("Cache-Control"))).toEqual(["no-store", "no-store", "no-store"]); expect(responses.map((response) => response.headers.get("Set-Cookie"))).toEqual( Array.from({ length: 3 }, () => "__Host-hivegrid_registration_resend=signed; Path=/; HttpOnly") ); expect(responseBodies).toEqual(Array.from({ length: 3 }, () => ({ verificationEmailSent: true, creationRequestKey: CREATION_REQUEST_KEY }))); expect(loggerMocks.info).toHaveBeenCalledTimes(2); expect(JSON.stringify(loggerMocks.info.mock.calls)).not.toContain("ada@example.test"); }); it("retries an exact direct signup after session response loss with the same creation key", async () => { const AuthService = (await import("./AuthService")).default; createUserSessionMock.mockRejectedValueOnce(new Error("response lost after commit")); const body = { email: "ada@example.test", password: "Password123!", company: "Ada Labs", "first-name": "Ada", "last-name": "Lovelace", slug: "ada-labs", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "true", }; const first = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), }) ); const retry = await AuthService.registerFromRequest( new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), }) ); await expect(first.json()).resolves.toMatchObject({ error: "shared.unknownError", creationRequestKey: CREATION_REQUEST_KEY }); expect(retry.status).toBe(302); expect(validateRegistrationMock).toHaveBeenCalledTimes(2); expect(validateRegistrationMock.mock.calls.map((call) => call[0].registrationData.creationRequestKey)).toEqual([ CREATION_REQUEST_KEY, CREATION_REQUEST_KEY, ]); }); it("never exposes unknown database or provider error details to the unauthenticated client or logger", async () => { const AuthService = (await import("./AuthService")).default; const secretError = "postgres://signup_user:super-secret@db.internal:5432/hivegrid"; validateRegistrationMock.mockRejectedValueOnce(new Error(secretError)); const request = new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ email: "ada@example.test", password: "Password123!", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "true", }), }); const response = await AuthService.registerFromRequest(request); const responseBody = await response.json(); expect(response.status).toBe(400); expect(responseBody).toEqual({ error: "shared.unknownError", creationRequestKey: CREATION_REQUEST_KEY, captchaResetKey: expect.any(String), }); expect(JSON.stringify(responseBody)).not.toContain(secretError); expect(JSON.stringify(loggerMocks.warn.mock.calls)).not.toContain(secretError); expect(loggerMocks.warn).toHaveBeenCalledWith( expect.objectContaining({ err: { name: "Error", message: "[redacted]" }, op: "verifyAndRegister" }), "Auth verify-and-register failed" ); }); it("rejects a crafted signup POST when either required consent is missing", async () => { const AuthService = (await import("./AuthService")).default; const request = new Request("https://app.example.com/register", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ email: "ada@example.test", password: "Password123!", creationRequestKey: CREATION_REQUEST_KEY, acceptTerms: "true", acceptPrivacy: "false", }), }); const response = await AuthService.registerFromRequest(request); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: "account.register.errors.acceptTermsAndPrivacy", creationRequestKey: CREATION_REQUEST_KEY, captchaResetKey: expect.any(String), }); expect(validateRegistrationMock).not.toHaveBeenCalled(); expect(createRegistrationFormMock).not.toHaveBeenCalled(); }); }); describe("AuthService verifyFromRequest", () => { beforeEach(() => { vi.clearAllMocks(); getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" }); getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited>); validateCSRFTokenMock.mockResolvedValue(undefined); checkAuthRateLimitMock.mockResolvedValue({ allowed: true }); getRegistrationFormDataMock.mockResolvedValue(verificationSubmission()); getRegistrationVerificationStateByTokenMock.mockResolvedValue({ status: "pending", registration: { id: "registration_1", createdTenantId: null, email: "ada@example.test", company: "Ada Labs", firstName: "Ada", lastName: "Lovelace", slug: "ada-labs", leadConversionLeadId: "lead_1", leadConversionToken: "signed-token", leadConversionPlanId: "plan_pro", selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15, }, } 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-labs" }, }, } as Awaited>); setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1", defaultTenant: { id: "tenant_1", name: "Ada Labs", slug: "ada-labs", active: true, deactivatedReason: null }, } as Awaited>); createUserSessionMock.mockResolvedValue( new Response(null, { status: 302, headers: { Location: "/ada/dashboard", "Set-Cookie": "__Host-hivegrid_app_session=signed; Path=/; HttpOnly" }, }) as Awaited> ); registrationRecoveryMocks.clear.mockResolvedValue("__Host-hivegrid_registration_resend=; Max-Age=0; Path=/; HttpOnly"); }); it("uses only persisted identity and lead fields when validating the verified registration", async () => { const AuthService = (await import("./AuthService")).default; const request = new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }); validateRegistrationMock.mockResolvedValueOnce({ email: "ada@example.test", ipAddress: "203.0.113.5", verificationRequired: false, selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15, registered: { user: { id: "user_1", email: "ada@example.test", locale: "de" }, tenant: { id: "tenant_1", slug: "ada-labs" }, }, } as Awaited>); const response = await AuthService.verifyFromRequest({ request, params: { id: VERIFICATION_TOKEN } }); 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", selectedSubscriptionPriceId: "price_pro", selectedOfficeSeatQuantity: 5, selectedFieldSeatQuantity: 15, creationRequestKey: BOUND_VERIFICATION_KEY, captchaToken: "captcha-proof", }, checkEmailVerification: false, captchaAlreadyVerified: true, addToTrialOrFreePlan: false, requireSubscriptionSelection: true, requireRegistrationSeatQuantities: true, }); expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", { expectedToken: VERIFICATION_TOKEN }, { createdTenantId: "tenant_1" }); expect(createUserSessionMock).toHaveBeenCalledWith( { lng: "de", userId: "user_1", defaultTenantId: "tenant_1" }, "https://app.example.com/ada-labs/settings/billing?subscription_required=1&selected_price=price_pro&office_seats=5&field_seats=15", { request, rotate: true } ); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0"); expect(response.headers.get("Set-Cookie")).toContain("__Host-hivegrid_app_session=signed"); expect(registrationRecoveryMocks.clear).toHaveBeenCalledOnce(); expect(validateCSRFTokenMock).toHaveBeenCalledWith(request); expect(validateCSRFTokenMock.mock.invocationCallOrder[0]).toBeLessThan( getRegistrationVerificationStateByTokenMock.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER ); }); it("stops before token lookup and account creation when CSRF validation fails", async () => { validateCSRFTokenMock.mockRejectedValueOnce(new Error("invalid csrf")); const AuthService = (await import("./AuthService")).default; const request = new Request("https://example.test/verify/signed-registration-token", { method: "POST" }); await expect(AuthService.verifyFromRequest({ request, params: { id: "signed-registration-token" } })).rejects.toThrow("invalid csrf"); expect(getRegistrationVerificationStateByTokenMock).not.toHaveBeenCalled(); expect(getRegistrationFormDataMock).not.toHaveBeenCalled(); expect(validateRegistrationMock).not.toHaveBeenCalled(); expect(updateRegistrationMock).not.toHaveBeenCalled(); }); it("rate-limits verification before token lookup, hashing or bootstrap", async () => { checkAuthRateLimitMock.mockResolvedValueOnce({ allowed: false, retryAfterSeconds: 37 }); const AuthService = (await import("./AuthService")).default; const request = new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }); const response = await AuthService.verifyFromRequest({ request, params: { id: VERIFICATION_TOKEN } }); expect(response.status).toBe(429); expect(response.headers.get("Retry-After")).toBe("37"); expect(response.headers.get("Cache-Control")).toBe("no-store"); await expect(response.json()).resolves.toEqual({ error: "api.errors.tooManyRequests" }); expect(checkAuthRateLimitMock).toHaveBeenCalledWith({ request, action: "registration-verify", identifier: expect.stringMatching(/^[a-f0-9]{64}$/u), limit: 5, windowMs: 15 * 60 * 1000, }); expect(checkAuthRateLimitMock.mock.calls[0]?.[0].identifier).not.toContain(VERIFICATION_TOKEN); expect(getUserInfoMock).not.toHaveBeenCalled(); expect(getRegistrationVerificationStateByTokenMock).not.toHaveBeenCalled(); expect(getRegistrationFormDataMock).not.toHaveBeenCalled(); expect(validateRegistrationMock).not.toHaveBeenCalled(); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("rejects missing verification tokens before parsing form data or mutating registration state", async () => { getRegistrationVerificationStateByTokenMock.mockResolvedValueOnce({ status: "invalid" }); 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: "account.verify.invalidLink" }); expect(getRegistrationFormDataMock).not.toHaveBeenCalled(); expect(validateRegistrationMock).not.toHaveBeenCalled(); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("does not bootstrap or issue a session when the TTL helper rejects an expired token", async () => { getRegistrationVerificationStateByTokenMock.mockResolvedValueOnce({ status: "expired", registration: { id: "registration_1" }, } as Awaited>); const AuthService = (await import("./AuthService")).default; const response = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: "account.verify.expiredLink" }); 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 () => { getRegistrationVerificationStateByTokenMock.mockResolvedValueOnce({ status: "used", registration: { 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: "account.verify.usedLink" }); 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", creationRequestKey: BOUND_VERIFICATION_KEY }); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("rejects a browser-selected creation key before bootstrap", async () => { const AuthService = (await import("./AuthService")).default; getRegistrationFormDataMock.mockResolvedValueOnce(verificationSubmission("b79f4743-b5ee-48df-9287-10f24b2aedbf")); const response = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); expect(response.status).toBe(400); expect(validateRegistrationMock).not.toHaveBeenCalled(); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("does not consume the token or create a session when exact password replay validation fails", async () => { const AuthService = (await import("./AuthService")).default; getRegistrationFormDataMock.mockResolvedValueOnce(verificationSubmission(BOUND_VERIFICATION_KEY, "wrong password")); validateRegistrationMock.mockRejectedValueOnce(new Error("signup replay conflict")); const response = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); expect(response.status).toBe(400); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); }); it("leaves verification unconsumed until required subscription effects succeed on retry", async () => { const AuthService = (await import("./AuthService")).default; validateRegistrationMock.mockRejectedValueOnce(new Error("subscription transaction unavailable")); const first = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); expect(first.status).toBe(400); expect(updateRegistrationMock).not.toHaveBeenCalled(); expect(setLoggedUserMock).not.toHaveBeenCalled(); expect(createUserSessionMock).not.toHaveBeenCalled(); const retry = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); expect(retry.status).toBe(302); expect(validateRegistrationMock).toHaveBeenCalledTimes(2); expect(updateRegistrationMock).toHaveBeenCalledTimes(1); expect(setLoggedUserMock).toHaveBeenCalledTimes(1); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("replays safely after account commit when response loss happens before token consumption", async () => { const AuthService = (await import("./AuthService")).default; updateRegistrationMock.mockRejectedValueOnce(new Error("connection lost before CAS response")).mockResolvedValueOnce(true); const first = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); const retry = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); await expect(first.json()).resolves.toEqual({ error: "shared.unknownError", creationRequestKey: BOUND_VERIFICATION_KEY }); expect(retry.status).toBe(302); expect(validateRegistrationMock).toHaveBeenCalledTimes(2); expect(updateRegistrationMock).toHaveBeenCalledTimes(2); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("recovers with the same bootstrap identity when resend rotates the token after account commit", async () => { const AuthService = (await import("./AuthService")).default; const rotatedToken = "rotated-registration-token"; updateRegistrationMock.mockRejectedValueOnce(new Error("stale token after concurrent resend")).mockResolvedValueOnce(true); const staleAttempt = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }); const currentAttempt = await AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${rotatedToken}`, { method: "POST" }), params: { id: rotatedToken }, }); await expect(staleAttempt.json()).resolves.toEqual({ error: "shared.unknownError", creationRequestKey: BOUND_VERIFICATION_KEY }); expect(currentAttempt.status).toBe(302); expect(validateRegistrationMock.mock.calls.map(([input]) => input.registrationData.creationRequestKey)).toEqual([ BOUND_VERIFICATION_KEY, BOUND_VERIFICATION_KEY, ]); expect(updateRegistrationMock).toHaveBeenNthCalledWith(1, "registration_1", { expectedToken: VERIFICATION_TOKEN }, { createdTenantId: "tenant_1" }); expect(updateRegistrationMock).toHaveBeenNthCalledWith(2, "registration_1", { expectedToken: rotatedToken }, { createdTenantId: "tenant_1" }); expect(setLoggedUserMock).toHaveBeenCalledTimes(1); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("allows only the registration-bound key when parallel verify posts submit different UUIDs", async () => { const AuthService = (await import("./AuthService")).default; getRegistrationFormDataMock.mockImplementation(async (request) => new URL(request.url).searchParams.has("tampered") ? verificationSubmission("b79f4743-b5ee-48df-9287-10f24b2aedbf") : verificationSubmission() ); const responses = await Promise.all([ AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }), AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}?tampered=1`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }), ]); expect(responses.map((response) => response.status).sort()).toEqual([302, 400]); expect(validateRegistrationMock).toHaveBeenCalledTimes(1); expect(updateRegistrationMock).toHaveBeenCalledTimes(1); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); it("issues at most one session when parallel verify posts race the token consume CAS", async () => { const AuthService = (await import("./AuthService")).default; let consumed = false; updateRegistrationMock.mockImplementation(async () => { if (consumed) throw new Error("registration capability was already consumed"); consumed = true; return true; }); const responses = await Promise.all([ AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}?attempt=1`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }), AuthService.verifyFromRequest({ request: new Request(`https://example.test/verify/${VERIFICATION_TOKEN}?attempt=2`, { method: "POST" }), params: { id: VERIFICATION_TOKEN }, }), ]); expect(responses.map((response) => response.status).sort()).toEqual([302, 400]); expect(validateRegistrationMock).toHaveBeenCalledTimes(2); expect(updateRegistrationMock).toHaveBeenCalledTimes(2); expect(setLoggedUserMock).toHaveBeenCalledTimes(1); expect(createUserSessionMock).toHaveBeenCalledTimes(1); }); });