1077 lines
50 KiB
TypeScript
1077 lines
50 KiB
TypeScript
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<string, string | number>;
|
|
|
|
constructor(i18nKey: string, i18nArgs?: Record<string, string | number>) {
|
|
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<typeof import("@hivegrid/config")>("@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<string, string>;
|
|
|
|
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<ReturnType<typeof getUserInfo>>);
|
|
getAppConfigurationMock.mockResolvedValue({ app: { features: { tenantHome: "/:tenant/" } } } as Awaited<ReturnType<typeof getAppConfiguration>>);
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
setLoggedUserMock.mockResolvedValue({
|
|
userId: "user_1",
|
|
defaultTenantId: "tenant_1",
|
|
defaultTenant: { id: "tenant_1", name: "Ada Labs", slug: "ada-labs", active: true, deactivatedReason: null },
|
|
} as Awaited<ReturnType<typeof setLoggedUser>>);
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
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<ReturnType<typeof getUserInfo>>);
|
|
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<ReturnType<typeof getRegistrationVerificationStateByToken>>);
|
|
validateRegistrationMock.mockResolvedValue({
|
|
email: "ada@example.test",
|
|
ipAddress: "203.0.113.5",
|
|
verificationRequired: false,
|
|
registered: {
|
|
user: { id: "user_1", email: "ada@example.test", locale: "de" },
|
|
tenant: { id: "tenant_1", slug: "ada-labs" },
|
|
},
|
|
} as Awaited<ReturnType<typeof validateRegistration>>);
|
|
setLoggedUserMock.mockResolvedValue({
|
|
userId: "user_1",
|
|
defaultTenantId: "tenant_1",
|
|
defaultTenant: { id: "tenant_1", name: "Ada Labs", slug: "ada-labs", active: true, deactivatedReason: null },
|
|
} as Awaited<ReturnType<typeof setLoggedUser>>);
|
|
createUserSessionMock.mockResolvedValue(
|
|
new Response(null, {
|
|
status: 302,
|
|
headers: { Location: "/ada/dashboard", "Set-Cookie": "__Host-hivegrid_app_session=signed; Path=/; HttpOnly" },
|
|
}) as Awaited<ReturnType<typeof createUserSession>>
|
|
);
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
|
|
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<ReturnType<typeof getRegistrationVerificationStateByToken>>);
|
|
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<ReturnType<typeof getRegistrationVerificationStateByToken>>);
|
|
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<ReturnType<typeof validateRegistration>>);
|
|
const AuthService = (await import("./AuthService")).default;
|
|
|
|
const response = await AuthService.verifyFromRequest({
|
|
request: new Request("https://example.test/verify/signed-registration-token", { method: "POST" }),
|
|
params: { id: "signed-registration-token" },
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(data).toEqual({ error: "shared.unknownError", 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);
|
|
});
|
|
});
|