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

249 lines
11 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
syncGroupChats: vi.fn(),
syncProjectChats: vi.fn(),
lockGroups: vi.fn(),
logActivity: vi.fn(),
publishAudit: vi.fn(),
withTenantRLS: vi.fn(),
}));
vi.mock("../../db.server", () => ({
db: {
group: { findFirst: vi.fn(), create: vi.fn(), deleteMany: vi.fn() },
tenantUser: { findMany: vi.fn() },
groupUser: {
create: vi.fn(),
createMany: vi.fn(),
deleteMany: vi.fn(),
},
teamChatGroupSource: { deleteMany: vi.fn() },
projektMitglied: { findMany: vi.fn(), updateMany: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@hivegrid/chat/group-sync.server", () => ({
syncPermissionGroupChatsIfEnabledWithTx: (...args: unknown[]) => mocks.syncGroupChats(...args),
}));
vi.mock("@hivegrid/chat/project-sync.server", () => ({
syncProjectChannelsForPermissionGroupWithTx: (...args: unknown[]) => mocks.syncProjectChats(...args),
}));
vi.mock("@hivegrid/chat/sync-locks.server", () => ({
lockPermissionGroupsWithTx: (...args: unknown[]) => mocks.lockGroups(...args),
}));
vi.mock("@hivegrid/activity", () => ({
logActivity: (...args: unknown[]) => mocks.logActivity(...args),
}));
vi.mock("@hivegrid/db/audit-events.server", () => ({
publishAuditEventViaOutbox: (...args: unknown[]) => mocks.publishAudit(...args),
}));
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
withTenantRLS: (...args: unknown[]) => mocks.withTenantRLS(...args),
}));
import { db } from "../../db.server";
import {
addGroupUsersAndSync,
createPermissionGroupWithUsers,
deletePermissionGroupAndSync,
removeGroupUsersAndSync,
replaceGroupUsers,
} from "./userGroups.db.server";
const dbMock = vi.mocked(db);
const syncContext = { actorUserId: "user_actor", requestId: "request_group_change" } as const;
describe("user group persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(dbMock.$transaction).mockImplementation((async (callback: (tx: typeof dbMock) => Promise<unknown>) => callback(dbMock)) as never);
vi.mocked(dbMock.group.findFirst).mockResolvedValue({ id: "group_1", name: "Dispatch" } as never);
vi.mocked(dbMock.tenantUser.findMany).mockImplementation(
(async (args: { where: { userId: { in: string[] } } }) => args.where.userId.in.map((userId) => ({ userId }))) as never
);
mocks.withTenantRLS.mockImplementation((_tenantId: string, callback: (tx: typeof dbMock) => Promise<unknown>) => callback(dbMock));
mocks.syncGroupChats.mockResolvedValue({ changedChatIds: [], addedUserIds: [], removedUserIds: [] });
mocks.syncProjectChats.mockResolvedValue([]);
mocks.lockGroups.mockResolvedValue(["group_1"]);
mocks.logActivity.mockResolvedValue(undefined);
mocks.publishAudit.mockResolvedValue(undefined);
});
it("creates a permission group and deduplicated memberships inside tenant RLS", async () => {
vi.mocked(dbMock.group.create).mockResolvedValueOnce({ id: "group_1" } as never);
vi.mocked(dbMock.groupUser.createMany).mockResolvedValueOnce({ count: 2 } as never);
await expect(
createPermissionGroupWithUsers(
"tenant_1",
{ createdByUserId: "user_actor", name: "Dispatch", description: "Dispatch team", color: 2 },
["user_1", "user_2", "user_1"]
)
).resolves.toEqual({ id: "group_1" });
expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant_1", expect.any(Function));
expect(dbMock.group.create).toHaveBeenCalledWith({
data: { tenantId: "tenant_1", createdByUserId: "user_actor", name: "Dispatch", description: "Dispatch team", color: 2 },
});
expect(dbMock.groupUser.createMany).toHaveBeenCalledWith({
data: [
{ userId: "user_1", groupId: "group_1" },
{ userId: "user_2", groupId: "group_1" },
],
skipDuplicates: true,
});
});
it("creates a permission group without a membership write when no users are selected", async () => {
vi.mocked(dbMock.group.create).mockResolvedValueOnce({ id: "group_1" } as never);
await expect(
createPermissionGroupWithUsers(
"tenant_1",
{ createdByUserId: "user_actor", name: "Empty", description: "", color: 0 },
[]
)
).resolves.toEqual({ id: "group_1" });
expect(dbMock.groupUser.createMany).not.toHaveBeenCalled();
});
it("revokes group-derived chat access and archives project history before deleting the group", async () => {
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 2 } as never);
vi.mocked(dbMock.teamChatGroupSource.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(dbMock.projektMitglied.findMany).mockResolvedValueOnce([
{ id: "membership_1", projektId: "project_1", rolle: "monteur" },
{ id: "membership_2", projektId: "project_1", rolle: "beobachter" },
] as never);
vi.mocked(dbMock.projektMitglied.updateMany).mockResolvedValueOnce({ count: 2 } as never);
vi.mocked(dbMock.group.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
await expect(deletePermissionGroupAndSync("group_1", "tenant_1", syncContext)).resolves.toEqual({ count: 1 });
expect(dbMock.groupUser.deleteMany).toHaveBeenCalledWith({ where: { groupId: "group_1", group: { tenantId: "tenant_1" } } });
expect(mocks.syncGroupChats).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({ tenantId: "tenant_1", groupId: "group_1", actorUserId: "user_actor" })
);
expect(mocks.syncProjectChats).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({ tenantId: "tenant_1", groupId: "group_1", actorUserId: "user_actor" })
);
expect(dbMock.teamChatGroupSource.deleteMany).toHaveBeenCalledWith({ where: { tenantId: "tenant_1", groupId: "group_1" } });
expect(dbMock.projektMitglied.updateMany).toHaveBeenCalledWith({
where: { tenantId: "tenant_1", groupId: "group_1", archivedAt: null },
data: { archivedAt: expect.any(Date) },
});
expect(mocks.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: "tenant_1",
actorUserId: "user_actor",
activityType: "project_member_removed",
subjectId: "project_1",
summaryText: "activity.runtime.permissions.groupProjectMembershipArchived",
metadata: expect.objectContaining({ groupId: "group_1", membershipIds: ["membership_1", "membership_2"] }),
}),
{ client: dbMock }
);
expect(mocks.publishAudit).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({
tenantId: "tenant_1",
actorId: "user_actor",
action: "permissions.group.deleted",
targetId: "group_1",
})
);
expect(dbMock.group.deleteMany).toHaveBeenCalledWith({ where: { tenantId: "tenant_1", id: "group_1" } });
});
it("replaces group memberships inside one transaction", async () => {
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 3 } as never);
vi.mocked(dbMock.groupUser.createMany).mockResolvedValueOnce({ count: 2 } as never);
await expect(replaceGroupUsers("group_1", "tenant_1", ["user_1", "user_2", "user_1"], syncContext)).resolves.toEqual({ count: 2 });
expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant_1", expect.any(Function));
expect(dbMock.groupUser.deleteMany).toHaveBeenCalledWith({ where: { groupId: "group_1", group: { tenantId: "tenant_1" } } });
expect(dbMock.groupUser.createMany).toHaveBeenCalledWith({
data: [
{ userId: "user_1", groupId: "group_1" },
{ userId: "user_2", groupId: "group_1" },
],
skipDuplicates: true,
});
expect(mocks.syncGroupChats).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({ tenantId: "tenant_1", groupId: "group_1", actorUserId: "user_actor" })
);
expect(mocks.syncProjectChats).toHaveBeenCalledWith(
dbMock,
expect.objectContaining({ tenantId: "tenant_1", groupId: "group_1", actorUserId: "user_actor" })
);
});
it("fails closed before replacing a group with a user outside the active tenant membership", async () => {
vi.mocked(dbMock.tenantUser.findMany).mockResolvedValueOnce([{ userId: "user_1" }] as never);
await expect(replaceGroupUsers("group_1", "tenant_1", ["user_1", "foreign_user"], syncContext)).rejects.toThrow(
"outside the active tenant membership"
);
expect(dbMock.groupUser.deleteMany).not.toHaveBeenCalled();
expect(dbMock.groupUser.createMany).not.toHaveBeenCalled();
expect(mocks.syncGroupChats).not.toHaveBeenCalled();
expect(mocks.syncProjectChats).not.toHaveBeenCalled();
});
it("replaces group memberships with an empty set without bulk insert", async () => {
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 3 } as never);
await expect(replaceGroupUsers("group_1", "tenant_1", [], syncContext)).resolves.toEqual({ count: 0 });
expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant_1", expect.any(Function));
expect(dbMock.groupUser.deleteMany).toHaveBeenCalledWith({ where: { groupId: "group_1", group: { tenantId: "tenant_1" } } });
expect(dbMock.groupUser.createMany).not.toHaveBeenCalled();
expect(mocks.syncGroupChats).toHaveBeenCalledOnce();
expect(mocks.syncProjectChats).toHaveBeenCalledOnce();
});
it("syncs dependent group and project chats after additive and subtractive membership changes", async () => {
vi.mocked(dbMock.groupUser.createMany).mockResolvedValueOnce({ count: 2 } as never);
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
await expect(addGroupUsersAndSync("group_1", "tenant_1", ["user_1", "user_2"], syncContext)).resolves.toEqual({ count: 2 });
await expect(removeGroupUsersAndSync("group_1", "tenant_1", ["user_2"], syncContext)).resolves.toEqual({ count: 1 });
expect(mocks.syncGroupChats).toHaveBeenCalledTimes(2);
expect(mocks.syncProjectChats).toHaveBeenCalledTimes(2);
expect(mocks.lockGroups).toHaveBeenCalledTimes(2);
});
it("returns without opening an RLS transaction when additive or subtractive inputs are empty", async () => {
await expect(addGroupUsersAndSync("group_1", "tenant_1", [], syncContext)).resolves.toEqual({ count: 0 });
await expect(removeGroupUsersAndSync("group_1", "tenant_1", [], syncContext)).resolves.toEqual({ count: 0 });
expect(mocks.withTenantRLS).not.toHaveBeenCalled();
expect(mocks.lockGroups).not.toHaveBeenCalled();
});
it("fails closed when the permission group does not belong to the tenant", async () => {
vi.mocked(dbMock.group.findFirst).mockResolvedValueOnce(null);
await expect(addGroupUsersAndSync("foreign_group", "tenant_1", ["user_1"], syncContext)).rejects.toThrow(
"Permission group is not available in the tenant"
);
expect(dbMock.groupUser.createMany).not.toHaveBeenCalled();
expect(mocks.syncGroupChats).not.toHaveBeenCalled();
expect(mocks.syncProjectChats).not.toHaveBeenCalled();
});
});