fix paths to orphaned team-owner

This commit is contained in:
Francis Cao
2026-06-22 22:13:55 -07:00
parent 129681ebd7
commit 86485e8b52
2 changed files with 128 additions and 1 deletions
@@ -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();
});
@@ -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);