343 lines
13 KiB
TypeScript
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");
|
|
});
|
|
});
|