99 lines
4.3 KiB
TypeScript
99 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|