Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/permissions/userRoles.db.server.test.ts
T

343 lines
13 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
vi.mock("../../db.server", () => ({
db: {
permission: {
count: vi.fn(),
},
role: {
findMany: vi.fn(),
},
userRole: {
count: vi.fn(),
create: vi.fn(),
createMany: vi.fn(),
deleteMany: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}));
vi.mock("~/utils/cache.server", () => ({
cachified: vi.fn((options: { getFreshValue: () => unknown }) => options.getFreshValue()),
clearCacheKey: vi.fn(),
}));
import { cachified, clearCacheKey } from "~/utils/cache.server";
import { db } from "../../db.server";
import {
countUserPermission,
createUserRole,
createUserRoles,
deleteAllUserRoles,
deleteUserRole,
ensureTenantUserAppRoles,
findPermissionByUser,
findUserRolesByIds,
getPermissionsByUser,
getUserRole,
getUserRoleInAdmin,
getUserRoleInTenant,
getUserRoles,
getUsersRolesInTenant,
userHasPermission,
} from "./userRoles.db.server";
const dbMock = vi.mocked(db);
describe("user role persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(cachified).mockImplementation((async (options: { getFreshValue: () => unknown }) => options.getFreshValue()) as never);
});
it("queries user-role relationships with tenant and admin scope", async () => {
vi.mocked(dbMock.userRole.findFirst).mockResolvedValue({ id: "ur_1" } as never);
vi.mocked(dbMock.userRole.findMany).mockResolvedValue([] as never);
await getUserRole("user_1", "role_1", "tenant_1");
await getUserRoleInTenant("user_1", "tenant_1", "Admin");
await getUserRoleInAdmin("user_1", "SuperAdmin");
await getUserRoles("user_1", "tenant_1");
expect(dbMock.userRole.findFirst).toHaveBeenNthCalledWith(1, {
where: { userId: "user_1", roleId: "role_1", tenantId: "tenant_1" },
});
expect(dbMock.userRole.findFirst).toHaveBeenNthCalledWith(2, {
where: { userId: "user_1", role: { name: "Admin" }, tenantId: "tenant_1" },
});
expect(dbMock.userRole.findFirst).toHaveBeenNthCalledWith(3, {
where: { userId: "user_1", role: { name: "SuperAdmin" }, tenantId: null },
});
expect(dbMock.userRole.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user_1", tenantId: "tenant_1" },
orderBy: { role: { order: "asc" } },
})
);
});
it("deduplicates permissions returned from cached user-role expansion", async () => {
vi.mocked(dbMock.userRole.findMany).mockResolvedValueOnce([
{
role: {
name: "Admin",
permissions: [{ permission: { name: "members.read" } }, { permission: { name: "members.read" } }],
},
},
{
role: {
name: "Member",
permissions: [{ permission: { name: "settings.update" } }],
},
},
] as never);
await expect(getPermissionsByUser("user_1", "tenant_1")).resolves.toEqual(["members.read", "settings.update"]);
expect(cachified).toHaveBeenCalledWith(
expect.objectContaining({
key: "userRoles:user_1:tenant_1",
ttl: 1000 * 60 * 60 * 24,
})
);
});
it("checks permission membership through the cached user-role expansion", async () => {
vi.mocked(dbMock.userRole.findMany)
.mockResolvedValueOnce([
{
role: {
name: "Admin",
permissions: [{ permission: { name: "members.read" } }],
},
},
] as never)
.mockResolvedValueOnce([
{
role: {
name: "Admin",
permissions: [{ permission: { name: "members.read" } }],
},
},
] as never);
await expect(userHasPermission("user_1", "tenant_1", "members.read")).resolves.toBe(true);
await expect(userHasPermission("user_1", "tenant_1", "settings.update")).resolves.toBe(false);
expect(cachified).toHaveBeenCalledWith(expect.objectContaining({ key: "userRoles:user_1:tenant_1" }));
expect(dbMock.permission.count).not.toHaveBeenCalled();
});
it("returns the first matching permission name or null", async () => {
vi.mocked(dbMock.userRole.findMany)
.mockResolvedValueOnce([
{
role: {
name: "Admin",
permissions: [{ permission: { name: "members.read" } }],
},
},
] as never)
.mockResolvedValueOnce([] as never);
await expect(findPermissionByUser("members.read", "user_1", "tenant_1")).resolves.toBe("members.read");
await expect(findPermissionByUser("members.read", "user_1", "tenant_1")).resolves.toBeNull();
expect(dbMock.userRole.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: {
userId: "user_1",
tenantId: "tenant_1",
role: { permissions: { some: { permission: { name: "members.read" } } } },
},
})
);
});
it("counts direct role permission matches and returns zero when none exist", async () => {
vi.mocked(dbMock.permission.count).mockResolvedValueOnce(2).mockResolvedValueOnce(0);
await expect(countUserPermission("user_1", "tenant_1", "members.read")).resolves.toBe(2);
await expect(countUserPermission("user_1", "tenant_1", "members.read")).resolves.toBe(0);
expect(dbMock.permission.count).toHaveBeenCalledWith({
where: {
name: "members.read",
inRoles: { some: { role: { users: { some: { userId: "user_1", tenantId: "tenant_1" } } } } },
},
});
});
it("queries bulk role helpers by ids, tenant, and type", async () => {
vi.mocked(dbMock.userRole.findMany).mockResolvedValue([] as never);
vi.mocked(dbMock.userRole.deleteMany).mockResolvedValue({ count: 2 } as never);
await findUserRolesByIds("user_1", "tenant_1", ["role_1", "role_2"]);
await getUsersRolesInTenant("tenant_1");
await deleteAllUserRoles("user_1", "app");
expect(dbMock.userRole.findMany).toHaveBeenNthCalledWith(1, {
where: { userId: "user_1", tenantId: "tenant_1", roleId: { in: ["role_1", "role_2"] } },
});
expect(dbMock.userRole.findMany).toHaveBeenNthCalledWith(2, {
where: { tenantId: "tenant_1" },
include: { role: true, user: true },
});
expect(dbMock.userRole.deleteMany).toHaveBeenCalledWith({
where: { userId: "user_1", role: { type: "app" } },
});
});
it("returns early from app-role repair when the user already has tenant app roles", async () => {
vi.mocked(dbMock.userRole.count).mockResolvedValueOnce(2);
await expect(ensureTenantUserAppRoles({ userId: "user_1", tenantId: "tenant_1", tenantUserType: TenantUserType.MEMBER })).resolves.toEqual({
created: 0,
roleCount: 2,
});
expect(dbMock.role.findMany).not.toHaveBeenCalled();
expect(clearCacheKey).not.toHaveBeenCalled();
});
it("grants all scoped app roles to tenant owners and skips already assigned roles", async () => {
vi.mocked(dbMock.userRole.count).mockResolvedValueOnce(0);
vi.mocked(dbMock.role.findMany).mockResolvedValueOnce([{ id: "role_1" }, { id: "role_2" }] as never);
vi.mocked(dbMock.userRole.findMany)
.mockResolvedValueOnce([{ id: "existing_role_2", roleId: "role_2" }] as never)
.mockResolvedValueOnce([] as never);
vi.mocked(dbMock.userRole.createMany).mockResolvedValueOnce({ count: 1 } as never);
await expect(ensureTenantUserAppRoles({ userId: "user_1", tenantId: "tenant_1", tenantUserType: TenantUserType.OWNER })).resolves.toEqual({
created: 1,
roleCount: 2,
});
expect(dbMock.role.findMany).toHaveBeenCalledWith({
where: { type: "app", OR: [{ tenantId: null }, { tenantId: "tenant_1" }] },
orderBy: { order: "asc" },
});
expect(dbMock.userRole.findMany).toHaveBeenCalledWith({
where: { userId: "user_1", tenantId: "tenant_1", roleId: { in: ["role_1", "role_2"] } },
});
expect(dbMock.userRole.createMany).toHaveBeenCalledWith({
data: [{ userId: "user_1", roleId: "role_1", tenantId: "tenant_1" }],
skipDuplicates: true,
});
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:tenant_1");
});
it("clears role cache when every scoped app role is already assigned", async () => {
vi.mocked(dbMock.userRole.count).mockResolvedValueOnce(0);
vi.mocked(dbMock.role.findMany).mockResolvedValueOnce([{ id: "role_1" }] as never);
vi.mocked(dbMock.userRole.findMany).mockResolvedValueOnce([{ id: "existing_role_1", roleId: "role_1" }] as never);
await expect(ensureTenantUserAppRoles({ userId: "user_1", tenantId: "tenant_1", tenantUserType: TenantUserType.ADMIN })).resolves.toEqual({
created: 0,
roleCount: 1,
});
expect(dbMock.userRole.createMany).not.toHaveBeenCalled();
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:tenant_1");
});
it("grants only assign-to-new-users app roles to regular members", async () => {
vi.mocked(dbMock.userRole.count).mockResolvedValueOnce(0);
vi.mocked(dbMock.role.findMany).mockResolvedValueOnce([] as never);
await ensureTenantUserAppRoles({ userId: "user_1", tenantId: "tenant_1", tenantUserType: TenantUserType.MEMBER });
expect(dbMock.role.findMany).toHaveBeenCalledWith({
where: {
type: "app",
OR: [{ tenantId: null }, { tenantId: "tenant_1" }],
assignToNewUsers: true,
},
orderBy: { order: "asc" },
});
});
it("does not duplicate an existing user role", async () => {
vi.mocked(dbMock.userRole.findFirst).mockResolvedValueOnce({ id: "ur_existing" } as never);
await expect(createUserRole("user_1", "role_1", "tenant_1")).resolves.toEqual({ id: "ur_existing" });
expect(dbMock.userRole.create).not.toHaveBeenCalled();
expect(clearCacheKey).not.toHaveBeenCalled();
});
it("returns the concurrently-created user role when the unique assignment index wins the race", async () => {
vi.mocked(dbMock.userRole.findFirst).mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "ur_concurrent" } as never);
vi.mocked(dbMock.userRole.create).mockRejectedValueOnce(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }) as never);
await expect(createUserRole("user_1", "role_admin", null)).resolves.toEqual({ id: "ur_concurrent" });
expect(dbMock.userRole.create).toHaveBeenCalledWith({ data: { userId: "user_1", roleId: "role_admin", tenantId: null } });
expect(dbMock.userRole.findFirst).toHaveBeenNthCalledWith(2, {
where: { userId: "user_1", roleId: "role_admin", tenantId: null },
});
expect(clearCacheKey).not.toHaveBeenCalled();
});
it("rethrows failed user-role creation when the error is not a recoverable unique race", async () => {
const error = new Error("database down");
vi.mocked(dbMock.userRole.findFirst).mockResolvedValueOnce(null);
vi.mocked(dbMock.userRole.create).mockRejectedValueOnce(error as never);
await expect(createUserRole("user_1", "role_1", "tenant_1")).rejects.toBe(error);
});
it("rethrows unique user-role creation races when the assignment cannot be found afterward", async () => {
const error = Object.assign(new Error("Unique constraint failed"), { code: "P2002" });
vi.mocked(dbMock.userRole.findFirst).mockResolvedValueOnce(null).mockResolvedValueOnce(null);
vi.mocked(dbMock.userRole.create).mockRejectedValueOnce(error as never);
await expect(createUserRole("user_1", "role_1", null)).rejects.toBe(error);
});
it("creates, bulk-creates, and deletes user roles while clearing tenant-specific caches", async () => {
vi.mocked(dbMock.userRole.findFirst).mockResolvedValueOnce(null);
vi.mocked(dbMock.userRole.create).mockResolvedValueOnce({ id: "ur_1" } as never);
vi.mocked(dbMock.userRole.findMany).mockResolvedValueOnce([] as never);
vi.mocked(dbMock.userRole.createMany).mockResolvedValueOnce({ count: 3 } as never);
vi.mocked(dbMock.userRole.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
await createUserRole("user_1", "role_1", "tenant_1");
await createUserRoles("user_1", [
{ id: "role_1", tenantId: "tenant_1" },
{ id: "role_2", tenantId: "tenant_1" },
{ id: "role_admin", tenantId: null },
]);
await deleteUserRole("user_1", "role_1", "tenant_1");
expect(dbMock.userRole.create).toHaveBeenCalledWith({ data: { userId: "user_1", roleId: "role_1", tenantId: "tenant_1" } });
expect(dbMock.userRole.createMany).toHaveBeenCalledWith({
data: [
{ userId: "user_1", roleId: "role_1", tenantId: "tenant_1" },
{ userId: "user_1", roleId: "role_2", tenantId: "tenant_1" },
{ userId: "user_1", roleId: "role_admin", tenantId: null },
],
skipDuplicates: true,
});
expect(dbMock.userRole.deleteMany).toHaveBeenCalledWith({
where: { userId: "user_1", roleId: "role_1", tenantId: "tenant_1" },
});
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:tenant_1");
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:null");
});
it("handles empty and already-existing bulk role assignments without writes", async () => {
await expect(createUserRoles("user_1", [])).resolves.toEqual({ count: 0 });
vi.mocked(dbMock.userRole.findMany).mockResolvedValueOnce([{ userId: "user_1", roleId: "role_1", tenantId: null }] as never);
await expect(createUserRoles("user_1", [{ id: "role_1", tenantId: null }])).resolves.toEqual({ count: 0 });
expect(dbMock.userRole.createMany).not.toHaveBeenCalled();
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:null");
});
});