254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@hivegrid/db/pretenant-security.server", () => ({
|
|
createRegistrationCapability: vi.fn(),
|
|
findRegistrationBillingPreferenceCapability: vi.fn(),
|
|
findRegistrationByTokenCapability: vi.fn(),
|
|
findRegistrationSummaryByEmail: vi.fn(),
|
|
rotateRegistrationVerificationCapability: vi.fn(),
|
|
updateRegistrationCapability: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
createRegistrationCapability,
|
|
findRegistrationBillingPreferenceCapability,
|
|
findRegistrationByTokenCapability,
|
|
findRegistrationSummaryByEmail,
|
|
rotateRegistrationVerificationCapability,
|
|
updateRegistrationCapability,
|
|
} from "@hivegrid/db/pretenant-security.server";
|
|
import {
|
|
createRegistration,
|
|
getRegistrationBillingPreference,
|
|
getRegistrationByEmail,
|
|
getRegistrationByToken,
|
|
getRegistrationVerificationStateByToken,
|
|
getRegistrationVerificationViewByToken,
|
|
REGISTRATION_VERIFICATION_TTL_MS,
|
|
rotateRegistrationVerification,
|
|
updateRegistration,
|
|
} from "./registration.db.server";
|
|
|
|
const createCapabilityMock = vi.mocked(createRegistrationCapability);
|
|
const findBillingPreferenceMock = vi.mocked(findRegistrationBillingPreferenceCapability);
|
|
const findByEmailMock = vi.mocked(findRegistrationSummaryByEmail);
|
|
const findByTokenMock = vi.mocked(findRegistrationByTokenCapability);
|
|
const updateCapabilityMock = vi.mocked(updateRegistrationCapability);
|
|
const rotateCapabilityMock = vi.mocked(rotateRegistrationVerificationCapability);
|
|
|
|
describe("registration db helpers", () => {
|
|
const createdAt = new Date("2026-07-15T08:00:00.000Z");
|
|
const verificationToken = "0123456789abcdef0123456789abcdef01234567";
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("uses narrow pre-tenant capabilities for email and token lookups", async () => {
|
|
findByEmailMock.mockResolvedValueOnce({ id: "registration_1", createdTenantId: null });
|
|
findByTokenMock.mockResolvedValueOnce({ id: "registration_2", createdAt } as Awaited<ReturnType<typeof findRegistrationByTokenCapability>>);
|
|
|
|
await expect(getRegistrationByEmail("ada@example.test")).resolves.toEqual({
|
|
id: "registration_1",
|
|
createdTenantId: null,
|
|
});
|
|
await expect(getRegistrationByToken(verificationToken, new Date(createdAt.getTime() + 1))).resolves.toMatchObject({ id: "registration_2" });
|
|
|
|
expect(findByEmailMock).toHaveBeenCalledWith("ada@example.test");
|
|
expect(findByTokenMock).toHaveBeenCalledWith(verificationToken);
|
|
});
|
|
|
|
it("delegates the consumed registration preference to the tenant-member capability", async () => {
|
|
findBillingPreferenceMock.mockResolvedValueOnce({
|
|
selectedSubscriptionPriceId: "price_swarm",
|
|
selectedOfficeSeatQuantity: 5,
|
|
selectedFieldSeatQuantity: 15,
|
|
});
|
|
|
|
await expect(getRegistrationBillingPreference("tenant_1", "user_1")).resolves.toEqual({
|
|
selectedSubscriptionPriceId: "price_swarm",
|
|
selectedOfficeSeatQuantity: 5,
|
|
selectedFieldSeatQuantity: 15,
|
|
});
|
|
expect(findBillingPreferenceMock).toHaveBeenCalledWith("tenant_1", "user_1");
|
|
});
|
|
|
|
it("projects only verification-page fields from the token capability", async () => {
|
|
findByTokenMock.mockResolvedValueOnce({
|
|
email: "ada@example.test",
|
|
createdTenantId: null,
|
|
firstName: "Ada",
|
|
id: "registration_1",
|
|
createdAt,
|
|
} as Awaited<ReturnType<typeof findRegistrationByTokenCapability>>);
|
|
|
|
await expect(getRegistrationVerificationViewByToken(verificationToken, new Date(createdAt.getTime() + 1))).resolves.toEqual({
|
|
status: "pending",
|
|
email: "ada@example.test",
|
|
creationRequestKey: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
|
|
});
|
|
expect(findByTokenMock).toHaveBeenCalledWith(verificationToken);
|
|
});
|
|
|
|
it("keeps the projected bootstrap key stable when a resend rotates the token", async () => {
|
|
const rotatedToken = "f".repeat(40);
|
|
findByTokenMock.mockResolvedValue({
|
|
email: "ada@example.test",
|
|
createdTenantId: null,
|
|
firstName: "Ada",
|
|
id: "registration_1",
|
|
createdAt,
|
|
} as Awaited<ReturnType<typeof findRegistrationByTokenCapability>>);
|
|
|
|
const beforeResend = await getRegistrationVerificationViewByToken(verificationToken, new Date(createdAt.getTime() + 1));
|
|
const afterResend = await getRegistrationVerificationViewByToken(rotatedToken, new Date(createdAt.getTime() + 2));
|
|
|
|
expect(beforeResend).toMatchObject({ status: "pending" });
|
|
expect(afterResend).toMatchObject({ status: "pending" });
|
|
if (beforeResend.status !== "pending" || afterResend.status !== "pending") throw new Error("Expected pending registration views");
|
|
expect(afterResend.creationRequestKey).toBe(beforeResend.creationRequestKey);
|
|
expect(findByTokenMock).toHaveBeenNthCalledWith(1, verificationToken);
|
|
expect(findByTokenMock).toHaveBeenNthCalledWith(2, rotatedToken);
|
|
});
|
|
|
|
it.each([
|
|
["39 characters", "a".repeat(39)],
|
|
["41 characters", "a".repeat(41)],
|
|
["non-hex", `${"a".repeat(39)}g`],
|
|
["oversized", "a".repeat(2048)],
|
|
])("rejects %s before the SECURITY-DEFINER token lookup", async (_label, invalidToken) => {
|
|
await expect(getRegistrationByToken(invalidToken)).resolves.toBeNull();
|
|
expect(findByTokenMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([verificationToken, "ABCDEF0123456789ABCDEF0123456789ABCDEF01"])("accepts a valid legacy/new 40-hex token: %s", async (validToken) => {
|
|
findByTokenMock.mockResolvedValueOnce({ id: "registration_2", createdAt } as Awaited<ReturnType<typeof findRegistrationByTokenCapability>>);
|
|
|
|
await expect(getRegistrationByToken(validToken, new Date(createdAt.getTime() + 1))).resolves.toMatchObject({ id: "registration_2" });
|
|
expect(findByTokenMock).toHaveBeenCalledWith(validToken);
|
|
});
|
|
|
|
it("accepts verification at 23:59:59.999 and rejects it exactly at the 24h boundary for loader and action helpers", async () => {
|
|
const registration = { id: "registration_1", email: "ada@example.test", createdAt, createdTenantId: null } as Awaited<
|
|
ReturnType<typeof findRegistrationByTokenCapability>
|
|
>;
|
|
findByTokenMock.mockResolvedValue(registration);
|
|
|
|
await expect(getRegistrationByToken(verificationToken, new Date(createdAt.getTime() + REGISTRATION_VERIFICATION_TTL_MS - 1))).resolves.toMatchObject({
|
|
id: "registration_1",
|
|
});
|
|
await expect(getRegistrationByToken(verificationToken, new Date(createdAt.getTime() + REGISTRATION_VERIFICATION_TTL_MS))).resolves.toBeNull();
|
|
await expect(
|
|
getRegistrationVerificationViewByToken(verificationToken, new Date(createdAt.getTime() + REGISTRATION_VERIFICATION_TTL_MS - 1))
|
|
).resolves.toMatchObject({ email: "ada@example.test" });
|
|
await expect(getRegistrationVerificationViewByToken(verificationToken, new Date(createdAt.getTime() + REGISTRATION_VERIFICATION_TTL_MS))).resolves.toEqual({
|
|
status: "expired",
|
|
});
|
|
});
|
|
|
|
it("distinguishes expired, consumed, and invalid links without exposing registration PII", async () => {
|
|
findByTokenMock
|
|
.mockResolvedValueOnce({ id: "registration_expired", email: "expired@example.test", createdAt, createdTenantId: null } as Awaited<
|
|
ReturnType<typeof findRegistrationByTokenCapability>
|
|
>)
|
|
.mockResolvedValueOnce({ id: "registration_used", email: "used@example.test", createdAt, createdTenantId: "tenant_1" } as Awaited<
|
|
ReturnType<typeof findRegistrationByTokenCapability>
|
|
>)
|
|
.mockResolvedValueOnce(null);
|
|
const afterTtl = new Date(createdAt.getTime() + REGISTRATION_VERIFICATION_TTL_MS);
|
|
|
|
const expired = await getRegistrationVerificationStateByToken(verificationToken, afterTtl);
|
|
const used = await getRegistrationVerificationStateByToken(verificationToken, afterTtl);
|
|
const invalidView = await getRegistrationVerificationViewByToken(verificationToken, afterTtl);
|
|
|
|
expect(expired.status).toBe("expired");
|
|
expect(used.status).toBe("used");
|
|
expect(invalidView).toEqual({ status: "invalid" });
|
|
expect(JSON.stringify(invalidView)).not.toContain("example.test");
|
|
});
|
|
|
|
it("persists lead conversion context through the create capability", async () => {
|
|
createCapabilityMock.mockResolvedValueOnce({ id: "registration_1" } as Awaited<ReturnType<typeof createRegistration>>);
|
|
|
|
await createRegistration({
|
|
email: "ada@example.test",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
token: "registration-token",
|
|
company: "Analytical Engines",
|
|
selectedSubscriptionPriceId: "price_1",
|
|
selectedOfficeSeatQuantity: 4,
|
|
selectedFieldSeatQuantity: 8,
|
|
leadConversionLeadId: "lead_1",
|
|
leadConversionToken: "signed-lead-token",
|
|
leadConversionPlanId: "plan_pro",
|
|
ipAddress: "127.0.0.1",
|
|
slug: "ada",
|
|
});
|
|
|
|
expect(createCapabilityMock).toHaveBeenCalledWith({
|
|
email: "ada@example.test",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
token: "registration-token",
|
|
company: "Analytical Engines",
|
|
selectedSubscriptionPriceId: "price_1",
|
|
selectedOfficeSeatQuantity: 4,
|
|
selectedFieldSeatQuantity: 8,
|
|
leadConversionLeadId: "lead_1",
|
|
leadConversionToken: "signed-lead-token",
|
|
leadConversionPlanId: "plan_pro",
|
|
ipAddress: "127.0.0.1",
|
|
slug: "ada",
|
|
});
|
|
});
|
|
|
|
it("clears missing lead conversion fields to null when creating registrations", async () => {
|
|
createCapabilityMock.mockResolvedValueOnce({ id: "registration_1" } as Awaited<ReturnType<typeof createRegistration>>);
|
|
|
|
await createRegistration({
|
|
email: "ada@example.test",
|
|
firstName: "Ada",
|
|
lastName: "Lovelace",
|
|
token: "registration-token",
|
|
company: null,
|
|
selectedSubscriptionPriceId: null,
|
|
selectedOfficeSeatQuantity: null,
|
|
selectedFieldSeatQuantity: null,
|
|
ipAddress: null,
|
|
slug: null,
|
|
});
|
|
|
|
expect(createCapabilityMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
leadConversionLeadId: null,
|
|
leadConversionToken: null,
|
|
leadConversionPlanId: null,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("binds tenant consumption to the expected token and rejects stale capabilities", async () => {
|
|
updateCapabilityMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
|
|
await expect(updateRegistration("registration_1", { expectedToken: "token_1" }, { createdTenantId: "tenant_1" })).resolves.toBe(true);
|
|
await expect(updateRegistration("registration_1", { expectedToken: "stale-token" }, { createdTenantId: "tenant_1" })).rejects.toThrow(
|
|
"Registration update capability was rejected"
|
|
);
|
|
});
|
|
|
|
it("rotates a resend only through the previous-token capability", async () => {
|
|
rotateCapabilityMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
|
|
await expect(rotateRegistrationVerification("registration_1", "old-token", verificationToken)).resolves.toBe(true);
|
|
await expect(rotateRegistrationVerification("registration_1", "stale-token", verificationToken)).rejects.toThrow(
|
|
"Registration verification rotation capability was rejected"
|
|
);
|
|
expect(rotateCapabilityMock).toHaveBeenNthCalledWith(1, {
|
|
id: "registration_1",
|
|
expectedToken: "old-token",
|
|
token: verificationToken,
|
|
});
|
|
});
|
|
});
|