import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server"; import EventsService from "~/modules/events/services/.server/EventsService"; import { deleteUserWithItsTenants } from "./userService"; import { deleteUser } from "../db/users.db.server"; import { deleteAndCancelTenant } from "./tenantService"; const dbMock = vi.hoisted(() => ({ tenant: { findMany: vi.fn() }, tenantUser: { findMany: vi.fn() }, })); vi.mock("../db.server", () => ({ db: dbMock })); vi.mock("../cache.server", () => ({ clearCacheKey: vi.fn() })); vi.mock("../db/permissions/userRoles.db.server", () => ({ createUserRoles: vi.fn(), deleteAllUserRoles: vi.fn() })); vi.mock("../db/tenants.db.server", () => ({})); vi.mock("../db/users.db.server", () => ({ deleteUser: vi.fn() })); vi.mock("./tenantService", () => ({ deleteAndCancelTenant: vi.fn() })); vi.mock("~/modules/events/services/.server/EventsService", () => ({ default: { createUserEventBeforeMutation: vi.fn(async (_args, mutation) => mutation()) }, })); const actorUserId = "user_1"; const targetUser = { id: actorUserId, email: "user-1@example.test", firstName: "Ada", lastName: "Lovelace", }; const soloTenant = { id: "tenant_solo", name: "Solo" }; const sharedTenant = { id: "tenant_shared", name: "Shared" }; function deleteInAuthenticatedContext() { return runWithRlsContext({ userId: actorUserId, tenantId: "tenant_active" }, () => deleteUserWithItsTenants({ actorUserId, targetUser, request: new Request("https://app.example.test/settings/profile") }) ); } describe("deleteUserWithItsTenants", () => { beforeEach(() => { vi.clearAllMocks(); dbMock.tenant.findMany.mockImplementation(async () => { expect(getRlsExecutionContext()).toEqual({ userId: actorUserId }); return [soloTenant, sharedTenant] as never; }); dbMock.tenantUser.findMany.mockImplementation(async () => { const tenantId = getRlsExecutionContext()?.tenantId; return tenantId === soloTenant.id ? [{ userId: actorUserId }] : [{ userId: actorUserId }, { userId: "user_2" }]; }); vi.mocked(deleteAndCancelTenant).mockImplementation(async ({ tenantId, authorizeDeletion }) => { expect(getRlsExecutionContext()).toEqual({ userId: actorUserId, tenantId }); expect(await authorizeDeletion?.()).toBe(true); return tenantId === soloTenant.id ? (soloTenant as never) : (null as never); }); vi.mocked(deleteUser).mockResolvedValue({ id: actorUserId } as never); }); it("re-proves each tenant boundary and deletes only a sole-owned tenant", async () => { const result = await deleteInAuthenticatedContext(); expect(dbMock.tenantUser.findMany).toHaveBeenCalledTimes(3); expect(deleteAndCancelTenant).toHaveBeenCalledTimes(1); expect(deleteAndCancelTenant).toHaveBeenCalledWith(expect.objectContaining({ tenantId: soloTenant.id, userId: actorUserId })); expect(deleteUser).toHaveBeenCalledWith(actorUserId); expect(result.deletedTenants).toEqual([soloTenant]); expect(EventsService.createUserEventBeforeMutation).toHaveBeenCalledWith( expect.objectContaining({ event: "user.profile.deleted", userId: actorUserId }), expect.any(Function) ); }); it("does not trust a listed tenant when the exact-context membership proof is missing", async () => { dbMock.tenantUser.findMany.mockResolvedValue([]); await deleteInAuthenticatedContext(); expect(deleteAndCancelTenant).not.toHaveBeenCalled(); expect(deleteUser).toHaveBeenCalledWith(actorUserId); }); it("does not delete the user if tenant cancellation fails", async () => { vi.mocked(deleteAndCancelTenant).mockRejectedValueOnce(new Error("billing unavailable") as never); await expect(deleteInAuthenticatedContext()).rejects.toThrow("billing unavailable"); expect(deleteUser).not.toHaveBeenCalled(); expect(EventsService.createUserEventBeforeMutation).not.toHaveBeenCalled(); }); it("rejects a caller that tries to isolate a different authenticated actor", async () => { await expect( runWithRlsContext({ userId: "attacker", tenantId: "tenant_active" }, () => deleteUserWithItsTenants({ actorUserId, targetUser })) ).rejects.toThrow("matching authenticated user context"); expect(dbMock.tenant.findMany).not.toHaveBeenCalled(); expect(deleteUser).not.toHaveBeenCalled(); }); });