This commit is contained in:
Nikolai Giman
2026-08-04 20:40:31 +02:00
parent e80ab33dda
commit 1f1a1b770f
10 changed files with 336 additions and 17 deletions
@@ -0,0 +1,109 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { initApi } from './init-api'
describe('Collaboration roles access control', () => {
let user1Api: TvApi
let user2Api: TvApi
let user2Email: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
const found = allPermissions.find(p => p.name === TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status, `Expected ${status}, got ${e.response?.status}`).toBe(status)
}
}
async function createGoalWithUser2(grantManageUsers: boolean) {
const goal = await user1Api.goals.createGoal({ name: `Roles access ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await user1Api.collaboration.inviteUserToGoal({ email: user2Email, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await user1Api.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
if (grantManageUsers) {
const toggled = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
}
}
await user1Api.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role }
}
it('collaborator with goal_can_manage_users can read the role-to-permission matrix', async () => {
const { goal, role } = await createGoalWithUser2(true)
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// the granted permission is visible in the matrix of the role user2 holds
expect(matrix?.some(row => row.roleId === role.id && row.permissionId === manageUsersPermissionId)).toBe(true)
})
it('collaborator without goal_can_manage_users cannot read the matrix', async () => {
const { goal } = await createGoalWithUser2(false)
await expectHttpStatus(user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id), 403)
})
it('changing role permissions stays owner-only', async () => {
const { goal, role } = await createGoalWithUser2(true)
// reading is allowed...
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// ...but editing the matrix is not
await expectHttpStatus(
user2Api.collaboration.toggleRolePermission({ roleId: role.id, permissionId: manageUsersPermissionId }),
403,
)
// owner still can edit
const ownerToggle = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
expect(ownerToggle?.add).toBe(false)
await expect(user1Api.goals.deleteGoal(goal.id)).resolves.toBeTruthy()
})
})
@@ -308,6 +308,127 @@ describe('Collaboration', () => {
});
// Regression for #98, the reporter's exact path: an org admin who is a collaborator
// in several projects created a new project in that org and lost every role he had.
// The trigger is creating a goal in an organization owned by SOMEONE ELSE - only then
// does the API add the creator as a collaborator, which used to wipe his other roles.
it('an org member creating a project in that org keeps his roles in other projects', async () => {
const { $tvApiForSecondUser, user2Email } = await initApi();
const org = await $api.organizations.create({ name: `Roles org ${Date.now()}` });
expect(org?.id).toBeDefined();
// the reporter's case: the second user is an ADMIN of the organization
await $api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'admin',
});
// owner creates two projects in the org and gives the admin roles in both
const projectA = await $api.goals.createGoal({ name: 'Org project A', organizationId: org.id });
const projectB = await $api.goals.createGoal({ name: 'Org project B', organizationId: org.id });
expect(projectA?.id).toBeDefined();
expect(projectB?.id).toBeDefined();
const collabA = await $api.collaboration.inviteUserToGoal({ goalId: projectA!.id, email: user2Email });
const collabB = await $api.collaboration.inviteUserToGoal({ goalId: projectB!.id, email: user2Email });
expect(collabA?.id).toBeDefined();
expect(collabB?.id).toEqual(collabA?.id);
const editorA = (await $api.collaboration.fetchRolesForGoal(projectA!.id))?.find((r) => r.name === 'editor');
const editorB = (await $api.collaboration.fetchRolesForGoal(projectB!.id))?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
await $api.collaboration.toggleUserRoles({
goalId: projectA!.id,
userId: collabA!.id,
roles: [editorA!.id],
});
await $api.collaboration.toggleUserRoles({
goalId: projectB!.id,
userId: collabA!.id,
roles: [editorB!.id],
});
// ...the admin now creates his own project INSIDE the owner's organization
const ownProject = await $tvApiForSecondUser.goals.createGoal({
name: 'Project created by the org admin',
organizationId: org.id,
});
expect(ownProject?.id).toBeDefined();
// roles in the pre-existing projects must survive
const usersA = await $api.collaboration.fetchUsersForGoal(projectA!.id);
const usersB = await $api.collaboration.fetchUsersForGoal(projectB!.id);
expect(usersA?.find((u) => u.email === user2Email)?.roles).toEqual([editorA!.id]);
expect(usersB?.find((u) => u.email === user2Email)?.roles).toEqual([editorB!.id]);
// and the new project is still usable by its creator
const ownGoals = await $tvApiForSecondUser.goals.fetchGoals(org.id);
expect(ownGoals?.some((g) => g.id === ownProject!.id)).toBe(true);
await $api.organizations.delete(org.id).catch(() => { });
});
// Regression for #98: toggling roles in one goal wiped the user's roles in every other goal
it('toggling roles in one goal must not touch the same user roles in another goal', async () => {
const email = `multi-goal-${Date.now()}@fff.com`;
const goalA = await $api.goals.createGoal({ name: 'Roles isolation goal A' });
const goalB = await $api.goals.createGoal({ name: 'Roles isolation goal B' });
expect(goalA).toBeTruthy();
expect(goalB).toBeTruthy();
const userInA = await $api.collaboration.inviteUserToGoal({ goalId: goalA!.id, email });
const userInB = await $api.collaboration.inviteUserToGoal({ goalId: goalB!.id, email });
expect(userInA?.id).toBeDefined();
// the same collaboration user is shared between goals
expect(userInB?.id).toEqual(userInA?.id);
const rolesA = await $api.collaboration.fetchRolesForGoal(goalA!.id);
const rolesB = await $api.collaboration.fetchRolesForGoal(goalB!.id);
const editorA = rolesA?.find((r) => r.name === 'editor');
const editorB = rolesB?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
// assign a role in goal B first
const toggledB = await $api.collaboration.toggleUserRoles({
goalId: goalB!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
});
expect(toggledB).toEqual([editorB?.id!]);
// toggling roles in goal A must not clear the role in goal B
const toggledA = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorA?.id!],
});
expect(toggledA).toEqual([editorA?.id!]);
const usersInB = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
// each goal's collaborator list shows only that goal's roles
const usersInA = await $api.collaboration.fetchUsersForGoal(goalA!.id);
expect(usersInA?.find((u) => u.email === email)?.roles).toEqual([editorA?.id!]);
// a role id belonging to another goal must not be assignable through this goal
const toggledForeign = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
}).catch(() => null);
expect(toggledForeign ?? []).toEqual([]);
const usersInB2 = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB2?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
});
it('should handle inviting already existing collaborator', async () => {
const addResult1 = await $api.collaboration.inviteUserToGoal({
goalId: collaborationGoal?.id!,