Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/db/registration.db.server.test.ts
T

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,
});
});
});