diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index fc11e280d..def1271eb 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -221,6 +221,12 @@ export type GetWorkspaceSeatTypeToProjectRoleMapping = (args: { } }> +export type ValidateWorkspaceMemberProjectRole = (params: { + workspaceId: string + userId: string + projectRole: StreamRoles +}) => Promise + /** Workspace Projects */ type QueryAllWorkspaceProjectsArgs = { diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 03ae5fc9e..a15e4a626 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -14,7 +14,8 @@ import { GetWorkspaceRoleForUser, GetWorkspaceRoleToDefaultProjectRoleMapping, GetWorkspaceSeatTypeToProjectRoleMapping, - QueryAllWorkspaceProjects + QueryAllWorkspaceProjects, + ValidateWorkspaceMemberProjectRole } from '@/modules/workspaces/domain/operations' import { ServerInvitesEvents, @@ -28,12 +29,7 @@ import { logger, moduleLogger } from '@/observability/logging' import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management' import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' -import { - Roles, - StreamRoles, - throwUncoveredError, - WorkspaceRoles -} from '@speckle/shared' +import { Roles, throwUncoveredError, WorkspaceRoles } from '@speckle/shared' import { DeleteProjectRole, UpsertProjectRole @@ -51,7 +47,8 @@ import { import { queryAllWorkspaceProjectsFactory, getWorkspaceRoleToDefaultProjectRoleMappingFactory, - getWorkspaceSeatTypeToProjectRoleMappingFactory + getWorkspaceSeatTypeToProjectRoleMappingFactory, + validateWorkspaceMemberProjectRoleFactory } from '@/modules/workspaces/services/projects' import { withTransaction } from '@/modules/shared/helpers/dbHelper' import { @@ -73,10 +70,7 @@ import { getUserSsoSessionFactory, getWorkspaceSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' -import { - WorkspaceInvalidRoleError, - WorkspacesNotAuthorizedError -} from '@/modules/workspaces/errors/workspace' +import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace' import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions' import { isWorkspaceResourceTarget } from '@/modules/workspaces/services/invites' import { @@ -87,11 +81,9 @@ import { getBaseTrackingProperties, getClient } from '@/modules/shared/utils/mix import { calculateSubscriptionSeats, GetWorkspacePlan, - GetWorkspaceRoleAndSeat, GetWorkspaceRolesAndSeats, GetWorkspaceSubscription, - GetWorkspaceWithPlan, - WorkspaceSeatType + GetWorkspaceWithPlan } from '@/modules/gatekeeper/domain/billing' import { getWorkspacePlanProductId } from '@/modules/gatekeeper/stripe' import { Workspace } from '@/modules/workspacesCore/domain/types' @@ -601,63 +593,17 @@ const emitWorkspaceGraphqlSubscriptionsFactory = const blockInvalidWorkspaceProjectRoleUpdatesFactory = (deps: { getStream: GetStream - getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat - getWorkspaceWithPlan: GetWorkspaceWithPlan - getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping - getWorkspaceSeatTypeToProjectRoleMapping: GetWorkspaceSeatTypeToProjectRoleMapping + validateWorkspaceMemberProjectRole: ValidateWorkspaceMemberProjectRole }) => async ({ payload }: EventPayload) => { const project = await deps.getStream({ streamId: payload.projectId }) if (!project?.workspaceId) return // No extra validation necessary - const roleSeatParams = { - workspaceId: project.workspaceId, - userId: payload.targetUserId - } - - const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([ - deps.getWorkspaceRoleAndSeat(roleSeatParams), - deps.getWorkspaceWithPlan({ workspaceId: project.workspaceId }) - ]) - - if (!workspace || !currentWorkspaceRoleAndSeat?.role) return - const { - role: { role: workspaceRole }, - seat - } = currentWorkspaceRoleAndSeat - const seatType = seat?.type || WorkspaceSeatType.Viewer - - let allowedRoles: StreamRoles[] - const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name) - if (isNewPlan) { - const workspaceAllowedRoles = ( - await deps.getWorkspaceRoleToDefaultProjectRoleMapping({ - workspaceId: project.workspaceId - }) - ).allowed[workspaceRole] - const seatAllowedRoles = ( - await deps.getWorkspaceSeatTypeToProjectRoleMapping({ - workspaceId: project.workspaceId - }) - ).allowed[seatType] - allowedRoles = Array.from( - new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles)) - ) - } else { - const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({ - workspaceId: project.workspaceId - }) - allowedRoles = roleMapping.allowed[workspaceRole] - } - - if (!allowedRoles.includes(payload.role)) { - // User's workspace role does not allow the requested project role - throw new WorkspaceInvalidRoleError( - isNewPlan - ? `User's workspace seat type '${seatType}' does not allow project role '${payload.role}'.` - : `User's workspace role '${workspaceRole}' does not allow project role '${payload.role}'.` - ) - } + await deps.validateWorkspaceMemberProjectRole({ + userId: payload.targetUserId, + projectRole: payload.role, + workspaceId: project.workspaceId + }) } export const initializeEventListenersFactory = @@ -677,16 +623,18 @@ export const initializeEventListenersFactory = const blockInvalidWorkspaceProjectRoleUpdates = blockInvalidWorkspaceProjectRoleUpdatesFactory({ getStream, - getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), - getWorkspaceRoleToDefaultProjectRoleMapping: - getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspaceWithPlan - }), - getWorkspaceSeatTypeToProjectRoleMapping: - getWorkspaceSeatTypeToProjectRoleMappingFactory({ - getWorkspaceWithPlan - }), - getWorkspaceWithPlan + validateWorkspaceMemberProjectRole: validateWorkspaceMemberProjectRoleFactory({ + getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), + getWorkspaceRoleToDefaultProjectRoleMapping: + getWorkspaceRoleToDefaultProjectRoleMappingFactory({ + getWorkspaceWithPlan + }), + getWorkspaceSeatTypeToProjectRoleMapping: + getWorkspaceSeatTypeToProjectRoleMappingFactory({ + getWorkspaceWithPlan + }), + getWorkspaceWithPlan + }) }) const createWorkspaceSeat = createWorkspaceSeatFactory({ db }) const ensureValidWorkspaceRoleSeat = ensureValidWorkspaceRoleSeatFactory({ diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 5d61c581f..32c4c31b1 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -99,8 +99,11 @@ import { } from '@/modules/workspaces/services/management' import { createWorkspaceProjectFactory, + getWorkspaceRoleToDefaultProjectRoleMappingFactory, + getWorkspaceSeatTypeToProjectRoleMappingFactory, moveProjectToWorkspaceFactory, - queryAllWorkspaceProjectsFactory + queryAllWorkspaceProjectsFactory, + validateWorkspaceMemberProjectRoleFactory } from '@/modules/workspaces/services/projects' import { getDiscoverableWorkspacesForUserFactory, @@ -205,6 +208,7 @@ import { import { ensureValidWorkspaceRoleSeatFactory } from '@/modules/workspaces/services/workspaceSeat' import { createWorkspaceSeatFactory, + getWorkspaceRoleAndSeatFactory, getWorkspaceRolesAndSeatsFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' @@ -234,7 +238,21 @@ const buildCollectAndValidateResourceTargets = () => getStream, getWorkspace: getWorkspaceFactory({ db }), getWorkspaceDomains: getWorkspaceDomainsFactory({ db }), - findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }) + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), + getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), + validateWorkspaceMemberProjectRoleFactory: + validateWorkspaceMemberProjectRoleFactory({ + getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }), + getWorkspaceRoleToDefaultProjectRoleMapping: + getWorkspaceRoleToDefaultProjectRoleMappingFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }), + getWorkspaceSeatTypeToProjectRoleMapping: + getWorkspaceSeatTypeToProjectRoleMappingFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }) + }) }) const buildCreateAndSendServerOrProjectInvite = () => diff --git a/packages/server/modules/workspaces/services/invites.ts b/packages/server/modules/workspaces/services/invites.ts index 56a930433..0073e849c 100644 --- a/packages/server/modules/workspaces/services/invites.ts +++ b/packages/server/modules/workspaces/services/invites.ts @@ -57,7 +57,8 @@ import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/con import { GetWorkspace, GetWorkspaceBySlug, - GetWorkspaceDomains + GetWorkspaceDomains, + ValidateWorkspaceMemberProjectRole } from '@/modules/workspaces/domain/operations' import { WorkspaceInviteResourceTarget } from '@/modules/workspaces/domain/types' import { mapGqlWorkspaceRoleToMainRole } from '@/modules/workspaces/helpers/roles' @@ -72,6 +73,7 @@ import { } from '@/modules/workspaces/domain/logic' import { GetStream } from '@/modules/core/domain/streams/operations' import { GetUser } from '@/modules/core/domain/users/operations' +import { GetWorkspaceRoleAndSeat } from '@/modules/workspacesCore/domain/operations' export const isWorkspaceResourceTarget = ( target: InviteResourceTarget @@ -122,6 +124,8 @@ export const createWorkspaceInviteFactory = type CollectAndValidateWorkspaceTargetsFactoryDeps = CollectAndValidateCoreTargetsFactoryDeps & { getWorkspace: GetWorkspace + getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat + validateWorkspaceMemberProjectRoleFactory: ValidateWorkspaceMemberProjectRole getWorkspaceDomains: GetWorkspaceDomains findVerifiedEmailsByUserId: FindVerifiedEmailsByUserId getStream: GetStream @@ -191,31 +195,42 @@ export const collectAndValidateWorkspaceTargetsFactory = return [...baseTargets] } - const workspace = await deps.getWorkspace({ - workspaceId, - userId: targetUser?.id - }) + const [workspace, workspaceRoleAndSeat] = await Promise.all([ + deps.getWorkspace({ + workspaceId + }), + ...(targetUser?.id + ? [ + deps.getWorkspaceRoleAndSeat({ + workspaceId, + userId: targetUser.id + }) + ] + : []) + ]) if (!workspace) { throw new InviteCreateValidationError( 'Attempting to invite into a non-existant workspace' ) } - // If inviting to workspace project, disallow workspace guests to become project owners + const workspaceRole = workspaceRoleAndSeat?.role.role + + // If inviting to workspace project, validate target role const projectTarget = baseTargets.find(isProjectResourceTarget) - if ( - workspace?.role === Roles.Workspace.Guest && - projectTarget?.role === Roles.Stream.Owner - ) { - throw new InviteCreateValidationError( - 'Workspace guests cannot be owners of workspace projects' - ) + const projectRole = projectTarget?.role + if (projectRole && targetUser) { + await deps.validateWorkspaceMemberProjectRoleFactory({ + workspaceId, + userId: targetUser.id, + projectRole + }) } // Do further validation only if we're actually planning to invite to a workspace // (maybe the invitation is implicitly there, but user already is a member of the workspace) const isInvitingToWorkspace = - primaryWorkspaceResourceTarget || (workspace && !workspace.role) + primaryWorkspaceResourceTarget || (workspace && !workspaceRole) if (!isInvitingToWorkspace) { return [...baseTargets] } @@ -236,7 +251,7 @@ export const collectAndValidateWorkspaceTargetsFactory = } // Only check this on creation, on finalization its fine if the user's already a member - if (workspace.role && !finalizingInvite) { + if (workspaceRole && !finalizingInvite) { throw new InviteCreateValidationError( 'The target user is already a member of the specified workspace' ) diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index b8a4bac36..25573eac8 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -6,16 +6,18 @@ import { GetWorkspaceSeatTypeToProjectRoleMapping, IntersectProjectCollaboratorsAndWorkspaceCollaborators, QueryAllWorkspaceProjects, - UpdateWorkspaceRole + UpdateWorkspaceRole, + ValidateWorkspaceMemberProjectRole } from '@/modules/workspaces/domain/operations' import { WorkspaceInvalidProjectError, + WorkspaceInvalidRoleError, WorkspaceNotFoundError, WorkspaceQueryError } from '@/modules/workspaces/errors/workspace' import { GetProject, UpdateProject } from '@/modules/core/domain/projects/operations' import { chunk } from 'lodash' -import { Roles } from '@speckle/shared' +import { Roles, StreamRoles } from '@speckle/shared' import { GetStreamCollaborators, LegacyGetStreams, @@ -42,6 +44,7 @@ import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { + GetWorkspaceRoleAndSeat, GetWorkspaceRolesAndSeats, GetWorkspaceWithPlan, WorkspaceSeatType @@ -296,6 +299,69 @@ export const getWorkspaceSeatTypeToProjectRoleMappingFactory = } } +/** + * Validate that the specified workspace member can have the specified project role + */ +export const validateWorkspaceMemberProjectRoleFactory = + (deps: { + getWorkspaceRoleAndSeat: GetWorkspaceRoleAndSeat + getWorkspaceWithPlan: GetWorkspaceWithPlan + getWorkspaceRoleToDefaultProjectRoleMapping: GetWorkspaceRoleToDefaultProjectRoleMapping + getWorkspaceSeatTypeToProjectRoleMapping: GetWorkspaceSeatTypeToProjectRoleMapping + }): ValidateWorkspaceMemberProjectRole => + async (params) => { + const { workspaceId, userId, projectRole } = params + + const roleSeatParams = { + workspaceId, + userId + } + + const [currentWorkspaceRoleAndSeat, workspace] = await Promise.all([ + deps.getWorkspaceRoleAndSeat(roleSeatParams), + deps.getWorkspaceWithPlan({ workspaceId }) + ]) + + if (!workspace || !currentWorkspaceRoleAndSeat?.role) return + const { + role: { role: workspaceRole }, + seat + } = currentWorkspaceRoleAndSeat + const seatType = seat?.type || WorkspaceSeatType.Viewer + + let allowedRoles: StreamRoles[] + const isNewPlan = workspace.plan && isNewPlanType(workspace.plan.name) + if (isNewPlan) { + const workspaceAllowedRoles = ( + await deps.getWorkspaceRoleToDefaultProjectRoleMapping({ + workspaceId + }) + ).allowed[workspaceRole] + const seatAllowedRoles = ( + await deps.getWorkspaceSeatTypeToProjectRoleMapping({ + workspaceId + }) + ).allowed[seatType] + allowedRoles = Array.from( + new Set(workspaceAllowedRoles).intersection(new Set(seatAllowedRoles)) + ) + } else { + const roleMapping = await deps.getWorkspaceRoleToDefaultProjectRoleMapping({ + workspaceId + }) + allowedRoles = roleMapping.allowed[workspaceRole] + } + + if (!allowedRoles.includes(projectRole)) { + // User's workspace role does not allow the requested project role + throw new WorkspaceInvalidRoleError( + isNewPlan + ? `User's workspace seat type '${seatType}' does not allow project role '${projectRole}'.` + : `User's workspace role '${workspaceRole}' does not allow project role '${projectRole}'.` + ) + } + } + export const createWorkspaceProjectFactory = (deps: { getDefaultRegion: GetDefaultRegion }) => async (params: { input: WorkspaceProjectCreateInput; ownerId: string }) => { diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 42c915688..2e43ad91d 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -59,6 +59,7 @@ import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/env import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' import { getWorkspacePlanFactory, + getWorkspaceWithPlanFactory, upsertPaidWorkspacePlanFactory, upsertWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/repositories/billing' @@ -82,9 +83,15 @@ import { } from '@/modules/workspaces/services/workspaceSeat' import { createWorkspaceSeatFactory, + getWorkspaceRoleAndSeatFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' import dayjs from 'dayjs' +import { + getWorkspaceRoleToDefaultProjectRoleMappingFactory, + getWorkspaceSeatTypeToProjectRoleMappingFactory, + validateWorkspaceMemberProjectRoleFactory +} from '@/modules/workspaces/services/projects' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -377,7 +384,21 @@ export const createWorkspaceInviteDirectly = async ( getStream, getWorkspace: getWorkspaceFactory({ db }), getWorkspaceDomains: getWorkspaceDomainsFactory({ db }), - findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }) + findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({ db }), + getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), + validateWorkspaceMemberProjectRoleFactory: + validateWorkspaceMemberProjectRoleFactory({ + getWorkspaceRoleAndSeat: getWorkspaceRoleAndSeatFactory({ db }), + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }), + getWorkspaceRoleToDefaultProjectRoleMapping: + getWorkspaceRoleToDefaultProjectRoleMappingFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }), + getWorkspaceSeatTypeToProjectRoleMapping: + getWorkspaceSeatTypeToProjectRoleMappingFactory({ + getWorkspaceWithPlan: getWorkspaceWithPlanFactory({ db }) + }) + }) }), buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({ getStream, diff --git a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts index 9a63b2c4a..e6d2cfa28 100644 --- a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts @@ -49,7 +49,10 @@ import { } from '@/modules/core/repositories/userEmails' import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' import { createRandomPassword } from '@/modules/core/helpers/testHelpers' -import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace' +import { + WorkspaceInvalidRoleError, + WorkspaceProtectedError +} from '@/modules/workspaces/errors/workspace' import cryptoRandomString from 'crypto-random-string' import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { @@ -469,6 +472,13 @@ describe('Workspaces Invites GQL', () => { ownerId: '' } + const myProjectInviteTargetWorkspaceWithNewPlan: BasicTestWorkspace = { + name: 'My Project Invite Target Workspace w/ New Plan #1', + id: '', + slug: cryptoRandomString({ length: 10 }), + ownerId: '' + } + const myProjectInviteTargetBasicProject: BasicTestStream = { name: 'My Project Invite Target Basic Project #1', id: '', @@ -483,6 +493,13 @@ describe('Workspaces Invites GQL', () => { isPublic: false } + const myProjectInviteTargetWorkspaceNewPlanProject: BasicTestStream = { + name: 'My Project Invite Target Workspace New Plan Project #1', + id: '', + ownerId: '', + isPublic: false + } + const workspaceMemberWithNoProjectAccess: BasicTestUser = { name: 'Workspace Member With No Project Access #1', email: 'workspaceMemberWithNoProjectAccess1@example.org', @@ -497,7 +514,19 @@ describe('Workspaces Invites GQL', () => { before(async () => { await createTestUsers([workspaceMemberWithNoProjectAccess, workspaceGuest]) - await createTestWorkspaces([[myProjectInviteTargetWorkspace, me]]) + await createTestWorkspaces([ + [myProjectInviteTargetWorkspace, me], + [ + myProjectInviteTargetWorkspaceWithNewPlan, + me, + { + addPlan: { + name: 'teamUnlimited', + status: 'valid' + } + } + ] + ]) await assignToWorkspaces([ [myProjectInviteTargetWorkspace, myWorkspaceFriend, Roles.Workspace.Member], [ @@ -505,13 +534,21 @@ describe('Workspaces Invites GQL', () => { workspaceMemberWithNoProjectAccess, Roles.Workspace.Member ], - [myProjectInviteTargetWorkspace, workspaceGuest, Roles.Workspace.Guest] + [myProjectInviteTargetWorkspace, workspaceGuest, Roles.Workspace.Guest], + [ + myProjectInviteTargetWorkspaceWithNewPlan, + workspaceGuest, + Roles.Workspace.Guest + ] ]) + myProjectInviteTargetWorkspaceNewPlanProject.workspaceId = + myProjectInviteTargetWorkspaceWithNewPlan.id myProjectInviteTargetWorkspaceProject.workspaceId = myProjectInviteTargetWorkspace.id await createTestStreams([ [myProjectInviteTargetWorkspaceProject, me], + [myProjectInviteTargetWorkspaceNewPlanProject, me], [myProjectInviteTargetBasicProject, me] ]) @@ -626,9 +663,22 @@ describe('Workspaces Invites GQL', () => { ] }) - expect(res).to.haveGraphQLErrors( - 'Workspace guests cannot be owners of workspace projects' - ) + expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code }) + expect(res.data?.projectMutations.invites.createForWorkspace.id).to.not.be.ok + }) + + it("can't invite someone with a viewer seat to be a contributor", async () => { + const res = await gqlHelpers.createWorkspaceProjectInvite({ + projectId: myProjectInviteTargetWorkspaceNewPlanProject.id, + inputs: [ + { + userId: workspaceGuest.id, + role: Roles.Stream.Contributor + } + ] + }) + + expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code }) expect(res.data?.projectMutations.invites.createForWorkspace.id).to.not.be.ok })