From 86485e8b5267a7407a7a1d6aafb76478b5680d6b Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 22 Jun 2026 22:13:55 -0700 Subject: [PATCH] fix paths to orphaned team-owner --- .../[teamId]/users/[userId]/route.test.ts | 123 ++++++++++++++++++ .../teams/[teamId]/users/[userId]/route.ts | 6 +- 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/app/api/teams/[teamId]/users/[userId]/route.test.ts diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.test.ts b/src/app/api/teams/[teamId]/users/[userId]/route.test.ts new file mode 100644 index 000000000..387ce8af6 --- /dev/null +++ b/src/app/api/teams/[teamId]/users/[userId]/route.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { ROLES } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { canDeleteTeamUser, canUpdateTeam } from '@/permissions'; +import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries/prisma'; +import { DELETE, POST } from './route'; + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/permissions', () => ({ + canDeleteTeamUser: vi.fn(), + canUpdateTeam: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + deleteTeamUser: vi.fn(), + getTeamUser: vi.fn(), + updateTeamUser: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const canUpdateTeamMock = vi.mocked(canUpdateTeam); +const canDeleteTeamUserMock = vi.mocked(canDeleteTeamUser); +const getTeamUserMock = vi.mocked(getTeamUser); +const updateTeamUserMock = vi.mocked(updateTeamUser); +const deleteTeamUserMock = vi.mocked(deleteTeamUser); + +beforeEach(() => { + parseRequestMock.mockReset(); + canUpdateTeamMock.mockReset(); + canDeleteTeamUserMock.mockReset(); + getTeamUserMock.mockReset(); + updateTeamUserMock.mockReset(); + deleteTeamUserMock.mockReset(); +}); + +test('POST rejects a manager modifying the team owner role', async () => { + parseRequestMock.mockResolvedValue({ + auth: { + user: { + id: 'manager-id', + isAdmin: false, + }, + }, + body: { + role: ROLES.teamViewOnly, + }, + error: undefined, + }); + canUpdateTeamMock.mockResolvedValue(true); + getTeamUserMock + .mockResolvedValueOnce({ + id: 'target-team-user', + role: ROLES.teamOwner, + } as any) + .mockResolvedValueOnce({ + id: 'actor-team-user', + role: ROLES.teamManager, + } as any); + + const response = await POST(new Request('http://localhost/api/teams/team-1/users/owner-id', { method: 'POST' }), { + params: Promise.resolve({ teamId: 'team-1', userId: 'owner-id' }), + }); + + expect(response.status).toBe(401); + expect(updateTeamUserMock).not.toHaveBeenCalled(); +}); + +test('DELETE rejects a manager removing the team owner', async () => { + parseRequestMock.mockResolvedValue({ + auth: { + user: { + id: 'manager-id', + isAdmin: false, + }, + }, + error: undefined, + }); + canDeleteTeamUserMock.mockResolvedValue(true); + getTeamUserMock.mockResolvedValue({ + id: 'owner-team-user', + role: ROLES.teamOwner, + } as any); + + const response = await DELETE( + new Request('http://localhost/api/teams/team-1/users/owner-id', { method: 'DELETE' }), + { + params: Promise.resolve({ teamId: 'team-1', userId: 'owner-id' }), + }, + ); + + expect(response.status).toBe(401); + expect(deleteTeamUserMock).not.toHaveBeenCalled(); +}); + +test('DELETE rejects owner self-removal to prevent orphaning the team', async () => { + parseRequestMock.mockResolvedValue({ + auth: { + user: { + id: 'owner-id', + isAdmin: false, + }, + }, + error: undefined, + }); + canDeleteTeamUserMock.mockResolvedValue(true); + getTeamUserMock.mockResolvedValue({ + id: 'owner-team-user', + role: ROLES.teamOwner, + } as any); + + const response = await DELETE( + new Request('http://localhost/api/teams/team-1/users/owner-id', { method: 'DELETE' }), + { + params: Promise.resolve({ teamId: 'team-1', userId: 'owner-id' }), + }, + ); + + expect(response.status).toBe(401); + expect(deleteTeamUserMock).not.toHaveBeenCalled(); +}); diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.ts b/src/app/api/teams/[teamId]/users/[userId]/route.ts index 32d820215..17d2a2651 100644 --- a/src/app/api/teams/[teamId]/users/[userId]/route.ts +++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { TEAM_ROLE_RANK } from '@/lib/constants'; +import { ROLES, TEAM_ROLE_RANK } from '@/lib/constants'; import { parseRequest } from '@/lib/request'; import { badRequest, json, ok, unauthorized } from '@/lib/response'; import { teamRoleParam } from '@/lib/schema'; @@ -91,6 +91,10 @@ export async function DELETE( return badRequest({ message: 'The User does not exists on this team.' }); } + if (!auth.user.isAdmin && teamUser.role === ROLES.teamOwner) { + return unauthorized({ message: 'You do not have permission to remove this user.' }); + } + // Server-side rank check: actor must outrank target to remove them. if (!auth.user.isAdmin && userId !== auth.user.id) { const actorTeamUser = await getTeamUser(teamId, auth.user.id);