fix: Remove team members as org admin (#24020)

* Fallback to org admin

* Prevent accidental privilege escalation as code changes in the future

* When org admin, we don't actually need to do the db query

* Use findMany and Map to drill down permission adjustments

* Exclude .MEMBER from overriding role, we likely don't want to demote

* refactor logic

* Add tests for services/factories + removeHandler

* fix type check

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Sean Brydon <sean@cal.com>
This commit is contained in:
Joe Au-Yeung
2025-09-24 09:33:31 +01:00
committed by GitHub
co-authored by Alex van Andel Sean Brydon
parent 77c61abef8
commit b2239374bc
11 changed files with 1682 additions and 93 deletions
@@ -1,121 +1,72 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { isTeamOwner } from "@calcom/features/ee/teams/lib/queries";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TRemoveMemberInputSchema } from "./removeMember.schema";
import { RemoveMemberServiceFactory } from "./removeMember/RemoveMemberServiceFactory";
type RemoveMemberOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
sourceIp?: string;
user: {
id: number;
organization?: {
isOrgAdmin: boolean;
};
};
};
input: TRemoveMemberInputSchema;
};
export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => {
export const removeMemberHandler = async ({
ctx: {
user: { id: userId, organization },
},
input,
}: RemoveMemberOptions) => {
await checkRateLimitAndThrowError({
identifier: `removeMember.${ctx.user.id}`,
identifier: `removeMember.${userId}`,
});
const { memberIds, teamIds, isOrg } = input;
const isOrgAdmin = organization?.isOrgAdmin ?? false;
// Check PBAC permissions for each team
const hasRemovePermission = await Promise.all(
teamIds.map(async (teamId) => {
// Get user's membership role in this team
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: teamId,
},
select: {
role: true,
},
});
// Note: This assumes that all teams in the request have the same PBAC setting 9999% chance they do.
const primaryTeamId = teamIds[0];
if (!primaryTeamId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "At least one team ID must be provided",
});
}
if (!membership) return false;
// Get the appropriate service based on feature flag
const service = await RemoveMemberServiceFactory.create(primaryTeamId);
// Check PBAC permissions for removing team members
const permissions = await getSpecificPermissions({
userId: ctx.user.id,
teamId: teamId,
resource: isOrg ? Resource.Organization : Resource.Team,
userRole: membership.role,
actions: [CustomAction.Remove],
fallbackRoles: {
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
const { hasPermission } = await service.checkRemovePermissions({
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg,
});
return permissions[CustomAction.Remove];
})
).then((results) => results.every((result) => result));
// Check if user is trying to remove themselves (allowed for non-owners)
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === ctx.user.id;
// Allow if user has remove permission OR if they're removing themselves
if (!hasRemovePermission && !isRemovingSelf) {
if (!hasPermission) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// TODO(SEAN): Remove this after PBAC is rolled out.
// Check if any team has PBAC enabled
const featuresRepository = new FeaturesRepository(prisma);
const pbacEnabledForTeams = await Promise.all(
teamIds.map(async (teamId) => await featuresRepository.checkIfTeamHasFeature(teamId, "pbac"))
await service.validateRemoval(
{
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg,
},
hasPermission
);
const isAnyTeamPBACEnabled = pbacEnabledForTeams.some((enabled) => enabled);
// Only apply traditional owner-based logic if PBAC is not enabled for any teams
if (!isAnyTeamPBACEnabled) {
// Only a team owner can remove another team owner.
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
memberIds.map(async (memberId) => {
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
teamIds.map(async (teamId) => {
return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
})
).then((results) => results.some((result) => result));
return isAnyTeamOwnerAndCurrentUserNotOwner;
})
).then((results) => results.some((result) => result));
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
});
}
// Check if user is trying to remove themselves from a team they own (prevent this)
if (isRemovingSelf && hasRemovePermission) {
// Additional check: ensure they're not an owner trying to remove themselves
const isOwnerOfAnyTeam = await Promise.all(
teamIds.map(async (teamId) => await isTeamOwner(ctx.user.id, teamId))
).then((results) => results.some((result) => result));
if (isOwnerOfAnyTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
}
}
}
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
// Perform the removal
await service.removeMembers(memberIds, teamIds, isOrg);
};
export default removeMemberHandler;
@@ -0,0 +1,21 @@
import { TeamService } from "@calcom/lib/server/service/teamService";
import type {
IRemoveMemberService,
RemoveMemberContext,
RemoveMemberPermissionResult,
} from "./IRemoveMemberService";
/**
* Base abstract class for remove member services
* Provides common functionality and defines abstract methods for specific implementations
*/
export abstract class BaseRemoveMemberService implements IRemoveMemberService {
abstract checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult>;
abstract validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void>;
async removeMembers(memberIds: number[], teamIds: number[], isOrg: boolean): Promise<void> {
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
}
}
@@ -0,0 +1,31 @@
import type { MembershipRole } from "@calcom/prisma/enums";
export interface RemoveMemberContext {
userId: number;
isOrgAdmin: boolean;
memberIds: number[];
teamIds: number[];
isOrg: boolean;
}
export interface RemoveMemberPermissionResult {
hasPermission: boolean;
userRoles?: Map<number, MembershipRole | null>;
}
export interface IRemoveMemberService {
/**
* Checks if the user has permission to remove members from teams
*/
checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult>;
/**
* Validates that the removal can proceed (e.g., owner checks)
*/
validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void>;
/**
* Performs the actual removal of members from teams
*/
removeMembers(memberIds: number[], teamIds: number[], isOrg: boolean): Promise<void>;
}
@@ -0,0 +1,108 @@
import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { BaseRemoveMemberService } from "./BaseRemoveMemberService";
import type { RemoveMemberContext, RemoveMemberPermissionResult } from "./IRemoveMemberService";
export class LegacyRemoveMemberService extends BaseRemoveMemberService {
async checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult> {
const { userId, isOrgAdmin, teamIds } = context;
// Org admins have full permission
if (isOrgAdmin) {
// Return admin role for all teams
const userRoles = new Map<number, MembershipRole | null>();
teamIds.forEach((teamId) => {
userRoles.set(teamId, MembershipRole.ADMIN);
});
return {
hasPermission: true,
userRoles,
};
}
// Get all memberships in a single query
const membershipRoles = await prisma.membership.findMany({
where: {
userId,
teamId: {
in: teamIds,
},
},
select: {
role: true,
teamId: true,
},
});
// Create a map for O(1) lookup
const userRoles = new Map<number, MembershipRole | null>();
membershipRoles.forEach((m) => {
userRoles.set(m.teamId, m.role);
});
// Check if user has admin or owner role in all teams
const allowedRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
let hasPermission = true;
for (const teamId of teamIds) {
const userRole = userRoles.get(teamId);
if (!userRole || !allowedRoles.includes(userRole)) {
hasPermission = false;
break;
}
}
return {
hasPermission,
userRoles,
};
}
async validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void> {
const { userId, memberIds, teamIds, isOrgAdmin } = context;
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === userId;
// Only a team owner can remove another team owner (org admins are exempt)
if (!isOrgAdmin) {
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
memberIds.map(async (memberId) => {
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
teamIds.map(async (teamId) => {
return (
(await teamQueries.isTeamOwner(memberId, teamId)) &&
!(await teamQueries.isTeamOwner(userId, teamId))
);
})
).then((results) => results.some((result) => result));
return isAnyTeamOwnerAndCurrentUserNotOwner;
})
).then((results) => results.some((result) => result));
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
});
}
}
// Check if user is trying to remove themselves from a team they own (prevent this)
if (isRemovingSelf && hasPermission) {
const isOwnerOfAnyTeam = await Promise.all(
teamIds.map(async (teamId) => await teamQueries.isTeamOwner(userId, teamId))
).then((results) => results.some((result) => result));
if (isOwnerOfAnyTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
}
}
}
}
@@ -0,0 +1,60 @@
import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TRPCError } from "@trpc/server";
import { BaseRemoveMemberService } from "./BaseRemoveMemberService";
import type { RemoveMemberContext, RemoveMemberPermissionResult } from "./IRemoveMemberService";
export class PBACRemoveMemberService extends BaseRemoveMemberService {
private permissionService = new PermissionCheckService();
async checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult> {
const { userId, teamIds, isOrg } = context;
const resource = isOrg ? Resource.Organization : Resource.Team;
const removePermission = PermissionMapper.toPermissionString({
resource,
action: CustomAction.Remove,
});
const teamsWithPermission = await this.permissionService.getTeamIdsWithPermission(
userId,
removePermission
);
// Convert to Set for O(1) lookup
const teamsWithPermissionSet = new Set(teamsWithPermission);
// Check if user has permission for ALL requested teams
const hasPermission = teamIds.every((teamId) => teamsWithPermissionSet.has(teamId));
return {
hasPermission,
};
}
async validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void> {
const { userId, memberIds, teamIds } = context;
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === userId;
/**
* TODO: Figure out the best way to prevent someone bricking a team
* by removing all people with updateRole permissions
*/
if (isRemovingSelf && hasPermission) {
const isOwnerOfAnyTeam = await Promise.all(
teamIds.map(async (teamId) => await teamQueries.isTeamOwner(userId, teamId))
).then((results) => results.some((result) => result));
if (isOwnerOfAnyTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
}
}
}
}
@@ -0,0 +1,21 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import type { IRemoveMemberService } from "./IRemoveMemberService";
import { LegacyRemoveMemberService } from "./LegacyRemoveMemberService";
import { PBACRemoveMemberService } from "./PBACRemoveMemberService";
export class RemoveMemberServiceFactory {
/**
* Creates the appropriate RemoveMemberService based on whether PBAC is enabled
* Caches the service per team/org to avoid repeated feature flag checks
*/
static async create(teamId: number): Promise<IRemoveMemberService> {
const featuresRepository = new FeaturesRepository(prisma);
const isPBACEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, "pbac");
const service = isPBACEnabled ? new PBACRemoveMemberService() : new LegacyRemoveMemberService();
return service;
}
}
@@ -0,0 +1,501 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { LegacyRemoveMemberService } from "../LegacyRemoveMemberService";
vi.mock("@calcom/prisma", () => ({
prisma: {
membership: {
findMany: vi.fn(),
},
},
}));
vi.mock("@calcom/lib/server/service/teamService");
vi.mock("@calcom/features/ee/teams/lib/queries");
describe("LegacyRemoveMemberService", () => {
let service: LegacyRemoveMemberService;
beforeEach(() => {
vi.clearAllMocks();
service = new LegacyRemoveMemberService();
});
describe("checkRemovePermissions", () => {
describe("Org Admin Scenarios", () => {
it("should allow org admin to remove members from teams they are NOT part of", async () => {
const userId = 1;
const isOrgAdmin = true;
const teamIds = [100, 200]; // Teams the org admin is not part of
const memberIds = [2, 3];
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg: true,
});
expect(result.hasPermission).toBe(true);
// Should not query database for org admin
expect(prisma.membership.findMany).not.toHaveBeenCalled();
});
it("should bypass membership checks for org admins", async () => {
const userId = 1;
const isOrgAdmin = true;
const teamIds = [1, 2, 3];
const memberIds = [4, 5, 6];
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg: true,
});
expect(result.hasPermission).toBe(true);
expect(result.userRoles).toBeInstanceOf(Map);
// Org admin should have ADMIN role for all teams
teamIds.forEach((teamId) => {
expect(result.userRoles?.get(teamId)).toBe(MembershipRole.ADMIN);
});
// Should not query database
expect(prisma.membership.findMany).not.toHaveBeenCalled();
});
it("should allow org admin to remove from multiple teams at once", async () => {
const userId = 1;
const isOrgAdmin = true;
const teamIds = [1, 2, 3, 4, 5];
const memberIds = [10, 20];
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg: true,
});
expect(result.hasPermission).toBe(true);
expect(prisma.membership.findMany).not.toHaveBeenCalled();
});
it("should work for org admin even with isOrg=false", async () => {
const userId = 1;
const isOrgAdmin = true;
const teamIds = [1];
const memberIds = [2];
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin,
memberIds,
teamIds,
isOrg: false, // Note: isOrg is false
});
expect(result.hasPermission).toBe(true);
expect(prisma.membership.findMany).not.toHaveBeenCalled();
});
});
describe("Regular User Scenarios", () => {
it("should allow ADMIN to remove members", async () => {
const userId = 1;
const teamIds = [1, 2];
const memberIds = [3];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
{ id: 2, userId, teamId: 2, role: MembershipRole.ADMIN } as any,
]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(true);
expect(result.userRoles?.get(1)).toBe(MembershipRole.ADMIN);
expect(result.userRoles?.get(2)).toBe(MembershipRole.ADMIN);
});
it("should allow OWNER to remove members", async () => {
const userId = 1;
const teamIds = [1];
const memberIds = [2];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.OWNER } as any,
]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(true);
expect(result.userRoles?.get(1)).toBe(MembershipRole.OWNER);
});
it("should deny MEMBER from removing members", async () => {
const userId = 1;
const teamIds = [1];
const memberIds = [2];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.MEMBER } as any,
]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(false);
});
it("should deny non-member from removing members", async () => {
const userId = 1;
const teamIds = [1];
const memberIds = [2];
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(false);
});
it("should require ADMIN/OWNER role in ALL teams for multi-team removal", async () => {
const userId = 1;
const teamIds = [1, 2, 3];
const memberIds = [4];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
{ id: 2, userId, teamId: 2, role: MembershipRole.MEMBER } as any, // Not admin/owner
{ id: 3, userId, teamId: 3, role: MembershipRole.OWNER } as any,
]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(false);
});
it("should allow when user has ADMIN/OWNER in all teams", async () => {
const userId = 1;
const teamIds = [1, 2, 3];
const memberIds = [4, 5];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
{ id: 2, userId, teamId: 2, role: MembershipRole.OWNER } as any,
{ id: 3, userId, teamId: 3, role: MembershipRole.ADMIN } as any,
]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(true);
});
});
});
describe("validateRemoval", () => {
describe("Owner Protection", () => {
it("should prevent non-owner from removing owner", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.ADMIN]]);
// Member 2 is owner, but userId 1 is not owner
vi.mocked(teamQueries.isTeamOwner)
.mockResolvedValueOnce(true) // isTeamOwner(2, 1) - member is owner
.mockResolvedValueOnce(false); // isTeamOwner(1, 1) - current user is not owner
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).rejects.toThrow(
expect.objectContaining({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
})
);
});
it("should allow owner to remove another owner", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.OWNER]]);
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).resolves.not.toThrow();
});
it("should allow org admin to remove owner", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.ADMIN]]);
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true);
// Org admin can remove owner
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: true, // Org admin
memberIds,
teamIds,
isOrg: true,
},
{ hasPermission: true, userRoles }
)
).resolves.not.toThrow();
// isTeamOwner should not be called for org admins
expect(teamQueries.isTeamOwner).not.toHaveBeenCalled();
});
});
describe("Self-Removal Prevention", () => {
it("should prevent owner from removing themselves", async () => {
const userId = 1;
const memberIds = [1]; // Same as userId
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.OWNER]]);
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true); // User is owner
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true // hasPermission - this should be boolean, not object
)
).rejects.toThrow(
expect.objectContaining({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
})
);
});
it("should allow admin to remove themselves", async () => {
const userId = 1;
const memberIds = [1]; // Same as userId
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.ADMIN]]);
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(false);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
{ hasPermission: true, userRoles }
)
).resolves.not.toThrow();
});
it("should allow member to remove themselves", async () => {
const userId = 1;
const memberIds = [1]; // Same as userId
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.MEMBER]]);
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(false);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
{ hasPermission: true, userRoles }
)
).resolves.not.toThrow();
});
});
describe("Multi-Member Validation", () => {
it("should validate each member independently", async () => {
const userId = 1;
const memberIds = [2, 3, 4];
const teamIds = [1];
const userRoles = new Map([[1, MembershipRole.ADMIN]]);
// Member 2 is not owner, member 3 is owner, member 4 is not owner
// Current user (1) is not owner
vi.mocked(teamQueries.isTeamOwner)
.mockResolvedValueOnce(false) // isTeamOwner(2, 1) - member 2 is not owner
.mockResolvedValueOnce(true) // isTeamOwner(3, 1) - member 3 is owner
.mockResolvedValueOnce(false) // isTeamOwner(1, 1) - current user is not owner
.mockResolvedValueOnce(false); // isTeamOwner(4, 1) - member 4 is not owner (if reached)
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
{ hasPermission: true, userRoles }
)
).rejects.toThrow(
expect.objectContaining({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
})
);
expect(teamQueries.isTeamOwner).toHaveBeenCalledTimes(4); // Checks member 2, user 1, member 3 (owner), user 1
});
});
});
describe("removeMembers", () => {
it("should call TeamService.removeMembers with correct parameters", async () => {
const memberIds = [1, 2, 3];
const teamIds = [4, 5];
const isOrg = true;
vi.mocked(TeamService.removeMembers).mockResolvedValue(undefined);
await service.removeMembers(memberIds, teamIds, isOrg);
expect(TeamService.removeMembers).toHaveBeenCalledWith({
userIds: memberIds,
teamIds,
isOrg,
});
});
it("should propagate errors from TeamService", async () => {
const memberIds = [1];
const teamIds = [2];
const isOrg = false;
const error = new Error("Database error");
vi.mocked(TeamService.removeMembers).mockRejectedValue(error);
await expect(service.removeMembers(memberIds, teamIds, isOrg)).rejects.toThrow("Database error");
});
});
describe("Edge Cases", () => {
it("should handle empty memberIds array", async () => {
const userId = 1;
const memberIds: number[] = [];
const teamIds = [1];
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
// Should still check permissions even with no members to remove
expect(prisma.membership.findMany).toHaveBeenCalled();
});
it("should handle permission check with no teams in database", async () => {
const userId = 1;
const teamIds = [999]; // Non-existent team
const memberIds = [2];
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
});
expect(result.hasPermission).toBe(false);
});
});
});
@@ -0,0 +1,474 @@
import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { PBACRemoveMemberService } from "../PBACRemoveMemberService";
vi.mock("@calcom/prisma", () => ({
prisma: {
membership: {
findMany: vi.fn(),
},
},
}));
vi.mock("@calcom/lib/server/service/teamService");
vi.mock("@calcom/features/ee/teams/lib/queries");
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/pbac/domain/mappers/PermissionMapper");
describe("PBACRemoveMemberService", () => {
let service: PBACRemoveMemberService;
let mockPermissionCheckService: {
getTeamIdsWithPermission: Mock;
};
beforeEach(() => {
vi.clearAllMocks();
mockPermissionCheckService = {
getTeamIdsWithPermission: vi.fn(),
};
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
service = new PBACRemoveMemberService();
});
describe("checkRemovePermissions", () => {
describe("PBAC Permission Checks", () => {
it("should check team.remove permission for team context", async () => {
const userId = 1;
const teamIds = [1, 2];
const memberIds = [3];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([1, 2]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(true);
expect(PermissionMapper.toPermissionString).toHaveBeenCalledWith({
resource: Resource.Team,
action: CustomAction.Remove,
});
expect(mockPermissionCheckService.getTeamIdsWithPermission).toHaveBeenCalledWith(
userId,
removePermission
);
});
it("should check organization.remove permission for org context", async () => {
const userId = 1;
const teamIds = [1, 2];
const memberIds = [3];
const isOrg = true;
const removePermission = "organization.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([1, 2]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(true);
expect(PermissionMapper.toPermissionString).toHaveBeenCalledWith({
resource: Resource.Organization,
action: CustomAction.Remove,
});
});
it("should deny when user lacks permission for some teams", async () => {
const userId = 1;
const teamIds = [1, 2, 3];
const memberIds = [4];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
// User only has permission for teams 1 and 2, not 3
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([1, 2]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(false);
});
it("should allow when user has permission for all teams", async () => {
const userId = 1;
const teamIds = [1, 2, 3];
const memberIds = [4, 5];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([1, 2, 3, 4]); // Has more permissions
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(true);
});
it("should deny when user has no permissions", async () => {
const userId = 1;
const teamIds = [1];
const memberIds = [2];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(false);
});
});
describe("Multi-team Operations", () => {
it("should require permission for ALL teams in request", async () => {
const userId = 1;
const teamIds = [10, 20, 30, 40];
const memberIds = [100];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
// Missing permission for team 30
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue([10, 20, 40]);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(false);
});
it("should handle large team lists efficiently", async () => {
const userId = 1;
const teamIds = Array.from({ length: 100 }, (_, i) => i + 1);
const memberIds = [999];
const isOrg = true;
const removePermission = "organization.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue(teamIds);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(true);
});
});
});
describe("validateRemoval", () => {
describe("Owner Protection", () => {
it("should allow removing owners with PBAC permissions", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true); // Member 2 is owner
// Get user's role
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
]);
// PBAC service doesn't have owner-to-owner protection logic
// If user has PBAC remove permission, they can remove owners (unlike Legacy service)
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true // hasPermission
)
).resolves.not.toThrow();
});
it("should allow owner to remove another owner with PBAC", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true); // Member 2 is owner
// User is owner
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.OWNER } as any,
]);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).resolves.not.toThrow();
});
});
describe("Self-Removal Prevention", () => {
it("should prevent owner from removing themselves", async () => {
const userId = 1;
const memberIds = [1]; // Same as userId
const teamIds = [1];
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(true); // User is owner of the team
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.OWNER } as any,
]);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).rejects.toThrow(
expect.objectContaining({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
})
);
});
it("should allow admin to remove themselves", async () => {
const userId = 1;
const memberIds = [1];
const teamIds = [1];
vi.mocked(teamQueries.isTeamOwner).mockResolvedValue(false);
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
]);
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).resolves.not.toThrow();
});
});
describe("Multi-Member Validation", () => {
it("should allow removing multiple members including owners", async () => {
const userId = 1;
const memberIds = [2, 3, 4];
const teamIds = [1];
// User is admin
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ id: 1, userId, teamId: 1, role: MembershipRole.ADMIN } as any,
]);
// Member 3 is owner
vi.mocked(teamQueries.isTeamOwner)
.mockResolvedValueOnce(false) // member 2
.mockResolvedValueOnce(true) // member 3 is owner
.mockResolvedValueOnce(false); // member 4
// PBAC service doesn't validate owner-to-owner removal
// It only prevents self-removal by owners
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
true
)
).resolves.not.toThrow();
});
});
describe("Edge Cases", () => {
it("should handle validation when hasPermission is false", async () => {
const userId = 1;
const memberIds = [2];
const teamIds = [1];
// Should not perform validation if no permission
await expect(
service.validateRemoval(
{
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg: false,
},
false // No permission
)
).resolves.not.toThrow();
// Should not check ownership or roles
expect(teamQueries.isTeamOwner).not.toHaveBeenCalled();
expect(prisma.membership.findMany).not.toHaveBeenCalled();
});
});
});
describe("removeMembers", () => {
it("should call TeamService.removeMembers with correct parameters", async () => {
const memberIds = [1, 2, 3];
const teamIds = [4, 5];
const isOrg = true;
vi.mocked(TeamService.removeMembers).mockResolvedValue(undefined);
await service.removeMembers(memberIds, teamIds, isOrg);
expect(TeamService.removeMembers).toHaveBeenCalledWith({
userIds: memberIds,
teamIds,
isOrg,
});
});
it("should propagate errors from TeamService", async () => {
const memberIds = [1];
const teamIds = [2];
const isOrg = false;
const error = new Error("Database connection failed");
vi.mocked(TeamService.removeMembers).mockRejectedValue(error);
await expect(service.removeMembers(memberIds, teamIds, isOrg)).rejects.toThrow(
"Database connection failed"
);
});
});
describe("Service Initialization", () => {
it("should create PermissionCheckService on instantiation", () => {
const newService = new PBACRemoveMemberService();
expect(PermissionCheckService).toHaveBeenCalled();
});
});
describe("Permission Service Integration", () => {
it("should handle permission service errors gracefully", async () => {
const userId = 1;
const teamIds = [1];
const memberIds = [2];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockRejectedValue(
new Error("Permission service unavailable")
);
await expect(
service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
})
).rejects.toThrow("Permission service unavailable");
});
it("should handle empty permission results", async () => {
const userId = 1;
const teamIds = [1, 2];
const memberIds = [3];
const isOrg = false;
const removePermission = "team.remove";
vi.mocked(PermissionMapper.toPermissionString).mockReturnValue(removePermission);
mockPermissionCheckService.getTeamIdsWithPermission.mockResolvedValue(null as any);
const result = await service.checkRemovePermissions({
userId,
isOrgAdmin: false,
memberIds,
teamIds,
isOrg,
});
expect(result.hasPermission).toBe(false);
});
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { LegacyRemoveMemberService } from "../LegacyRemoveMemberService";
import { PBACRemoveMemberService } from "../PBACRemoveMemberService";
import { RemoveMemberServiceFactory } from "../RemoveMemberServiceFactory";
vi.mock("@calcom/features/flags/features.repository");
vi.mock("../LegacyRemoveMemberService");
vi.mock("../PBACRemoveMemberService");
describe("RemoveMemberServiceFactory", () => {
let mockFeaturesRepository: {
checkIfTeamHasFeature: vi.Mock;
};
beforeEach(() => {
vi.clearAllMocks();
mockFeaturesRepository = {
checkIfTeamHasFeature: vi.fn(),
};
vi.mocked(FeaturesRepository).mockImplementation(() => mockFeaturesRepository as any);
});
describe("Service Creation", () => {
it("should create LegacyRemoveMemberService when PBAC is disabled", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
const teamId = 1;
const service = await RemoveMemberServiceFactory.create(teamId);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(teamId, "pbac");
expect(service).toBeInstanceOf(LegacyRemoveMemberService);
});
it("should create PBACRemoveMemberService when PBAC is enabled", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
const teamId = 1;
const service = await RemoveMemberServiceFactory.create(teamId);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(teamId, "pbac");
expect(service).toBeInstanceOf(PBACRemoveMemberService);
});
});
describe("Service Creation for Different Teams", () => {
it("should create different services for different teams", async () => {
// Team 1 has PBAC disabled
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
// Team 2 has PBAC enabled
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
const service1 = await RemoveMemberServiceFactory.create(1);
const service2 = await RemoveMemberServiceFactory.create(2);
expect(service1).toBeInstanceOf(LegacyRemoveMemberService);
expect(service2).toBeInstanceOf(PBACRemoveMemberService);
expect(service1).not.toBe(service2);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledTimes(2);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(1, "pbac");
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(2, "pbac");
});
it("should create service for each call (no caching in current implementation)", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
const teamId = 1;
// Make multiple calls
const service1 = await RemoveMemberServiceFactory.create(teamId);
const service2 = await RemoveMemberServiceFactory.create(teamId);
const service3 = await RemoveMemberServiceFactory.create(teamId);
// All should be different instances (no caching currently)
expect(service1).not.toBe(service2);
expect(service2).not.toBe(service3);
// Feature flag should be called each time
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledTimes(3);
});
});
describe("Error Handling", () => {
it("should propagate errors from features repository", async () => {
const error = new Error("Features repository error");
mockFeaturesRepository.checkIfTeamHasFeature.mockRejectedValue(error);
await expect(RemoveMemberServiceFactory.create(1)).rejects.toThrow("Features repository error");
});
it("should handle null/undefined feature flag gracefully", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(null as any);
const service = await RemoveMemberServiceFactory.create(1);
// Should default to Legacy when feature flag is falsy
expect(service).toBeInstanceOf(LegacyRemoveMemberService);
});
});
});
@@ -0,0 +1,308 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { TRPCError } from "@trpc/server";
import { removeMemberHandler } from "../../removeMember.handler";
import type { IRemoveMemberService } from "../IRemoveMemberService";
import { RemoveMemberServiceFactory } from "../RemoveMemberServiceFactory";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("../RemoveMemberServiceFactory");
describe("removeMemberHandler", () => {
const mockService: IRemoveMemberService = {
checkRemovePermissions: vi.fn(),
validateRemoval: vi.fn(),
removeMembers: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue({
success: true,
remaining: 99,
limit: 100,
reset: new Date().getTime() + 60 * 1000,
});
vi.mocked(RemoveMemberServiceFactory.create).mockResolvedValue(mockService);
vi.mocked(mockService.checkRemovePermissions).mockResolvedValue({
hasPermission: true,
userRoles: new Map(),
});
});
describe("Rate Limiting", () => {
it("should check rate limit before processing", async () => {
const userId = 1;
const input = {
teamIds: [1],
memberIds: [2],
isOrg: false,
};
await removeMemberHandler({
ctx: { user: { id: userId } },
input,
});
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
identifier: `removeMember.${userId}`,
});
});
});
describe("Input Validation", () => {
it("should throw BAD_REQUEST when no team IDs provided", async () => {
const input = {
teamIds: [],
memberIds: [2],
isOrg: false,
};
await expect(
removeMemberHandler({
ctx: { user: { id: 1 } },
input,
})
).rejects.toThrow(
expect.objectContaining({
code: "BAD_REQUEST",
message: "At least one team ID must be provided",
})
);
});
});
describe("Service Factory Integration", () => {
it("should create service using primary team ID", async () => {
const primaryTeamId = 1;
const input = {
teamIds: [primaryTeamId, 2, 3],
memberIds: [4, 5],
isOrg: false,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: true });
await removeMemberHandler({
ctx: { user: { id: 1 } },
input,
});
expect(RemoveMemberServiceFactory.create).toHaveBeenCalledWith(primaryTeamId);
});
});
describe("Permission Checking", () => {
it("should pass org admin status to permission check", async () => {
const userId = 1;
const isOrgAdmin = true;
const input = {
teamIds: [1],
memberIds: [2],
isOrg: true,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: true });
await removeMemberHandler({
ctx: {
user: {
id: userId,
organization: { isOrgAdmin },
},
},
input,
});
expect(mockService.checkRemovePermissions).toHaveBeenCalledWith({
userId,
isOrgAdmin,
memberIds: input.memberIds,
teamIds: input.teamIds,
isOrg: input.isOrg,
});
});
it("should default isOrgAdmin to false when not provided", async () => {
const userId = 1;
const input = {
teamIds: [1],
memberIds: [2],
isOrg: false,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: true });
await removeMemberHandler({
ctx: { user: { id: userId } },
input,
});
expect(mockService.checkRemovePermissions).toHaveBeenCalledWith(
expect.objectContaining({
isOrgAdmin: false,
})
);
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
const input = {
teamIds: [1],
memberIds: [2],
isOrg: false,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: false });
await expect(
removeMemberHandler({
ctx: { user: { id: 1 } },
input,
})
).rejects.toThrow(
expect.objectContaining({
code: "UNAUTHORIZED",
})
);
expect(mockService.validateRemoval).not.toHaveBeenCalled();
expect(mockService.removeMembers).not.toHaveBeenCalled();
});
});
describe("Removal Flow", () => {
it("should complete full removal flow when user has permission", async () => {
const userId = 1;
const isOrgAdmin = false;
const input = {
teamIds: [1, 2],
memberIds: [3, 4],
isOrg: false,
};
const hasPermissionResult = {
hasPermission: true,
userRoles: new Map([
[1, "ADMIN"],
[2, "OWNER"],
]),
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue(hasPermissionResult);
mockService.validateRemoval = vi.fn().mockResolvedValue(undefined);
mockService.removeMembers = vi.fn().mockResolvedValue(undefined);
await removeMemberHandler({
ctx: { user: { id: userId } },
input,
});
// Verify service calls in order
expect(mockService.checkRemovePermissions).toHaveBeenCalledWith({
userId,
isOrgAdmin,
memberIds: input.memberIds,
teamIds: input.teamIds,
isOrg: input.isOrg,
});
expect(mockService.validateRemoval).toHaveBeenCalledWith(
{
userId,
isOrgAdmin,
memberIds: input.memberIds,
teamIds: input.teamIds,
isOrg: input.isOrg,
},
hasPermissionResult.hasPermission
);
expect(mockService.removeMembers).toHaveBeenCalledWith(input.memberIds, input.teamIds, input.isOrg);
});
it("should handle org admin removing members from teams they are not part of", async () => {
const userId = 1;
const isOrgAdmin = true;
const input = {
teamIds: [100, 200], // Teams the org admin is not part of
memberIds: [3, 4],
isOrg: true,
};
const hasPermissionResult = { hasPermission: true };
mockService.checkRemovePermissions = vi.fn().mockResolvedValue(hasPermissionResult);
mockService.validateRemoval = vi.fn().mockResolvedValue(undefined);
mockService.removeMembers = vi.fn().mockResolvedValue(undefined);
await removeMemberHandler({
ctx: {
user: {
id: userId,
organization: { isOrgAdmin },
},
},
input,
});
expect(mockService.checkRemovePermissions).toHaveBeenCalledWith({
userId,
isOrgAdmin: true,
memberIds: input.memberIds,
teamIds: input.teamIds,
isOrg: input.isOrg,
});
expect(mockService.removeMembers).toHaveBeenCalled();
});
});
describe("Error Handling", () => {
it("should propagate validation errors", async () => {
const input = {
teamIds: [1],
memberIds: [2],
isOrg: false,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: true });
mockService.validateRemoval = vi
.fn()
.mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Cannot remove owner" }));
await expect(
removeMemberHandler({
ctx: { user: { id: 1 } },
input,
})
).rejects.toThrow(
expect.objectContaining({
code: "BAD_REQUEST",
message: "Cannot remove owner",
})
);
});
it("should propagate service removal errors", async () => {
const input = {
teamIds: [1],
memberIds: [2],
isOrg: false,
};
mockService.checkRemovePermissions = vi.fn().mockResolvedValue({ hasPermission: true });
mockService.validateRemoval = vi.fn().mockResolvedValue(undefined);
mockService.removeMembers = vi.fn().mockRejectedValue(new Error("Database error"));
await expect(
removeMemberHandler({
ctx: { user: { id: 1 } },
input,
})
).rejects.toThrow("Database error");
});
});
});
@@ -0,0 +1,9 @@
export { RemoveMemberServiceFactory } from "./RemoveMemberServiceFactory";
export type {
IRemoveMemberService,
RemoveMemberContext,
RemoveMemberPermissionResult,
} from "./IRemoveMemberService";
export { BaseRemoveMemberService } from "./BaseRemoveMemberService";
export { PBACRemoveMemberService } from "./PBACRemoveMemberService";
export { LegacyRemoveMemberService } from "./LegacyRemoveMemberService";