feat(server): support editor -> viewer seat downgrades (#4181)

* new seat based project role checks implemented

* everything done

* minor bugfix
This commit is contained in:
Kristaps Fabians Geikins
2025-03-14 14:21:25 +02:00
committed by GitHub
parent 50fd05afe8
commit d903e8ffc4
30 changed files with 975 additions and 337 deletions
@@ -12,6 +12,7 @@ import {
createWebhookConfigFactory,
createWebhookEventFactory
} from '@/modules/webhooks/repositories/webhooks'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
assignToWorkspace,
BasicTestWorkspace,
@@ -281,9 +282,7 @@ describe('Workspace project GQL CRUD', () => {
})
const newRole = await getUserStreamRole(workspaceGuest.id, roleProject.id)
expect(res).to.haveGraphQLErrors(
'Workspace guests cannot be project owners'
)
expect(res).to.haveGraphQLErrors({ code: WorkspaceInvalidRoleError.code })
expect(newRole).to.eq(Roles.Stream.Reviewer)
})
@@ -310,12 +309,12 @@ describe('Workspace project GQL CRUD', () => {
expect(resB).to.not.haveGraphQLErrors()
expect(newRole).to.eq(Roles.Stream.Owner)
} else {
expect(resA).to.haveGraphQLErrors(
'Workspace viewers can only be project reviewers.'
)
expect(resB).to.haveGraphQLErrors(
'Workspace viewers can only be project reviewers.'
)
expect(resA).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(resB).to.haveGraphQLErrors({
code: WorkspaceInvalidRoleError.code
})
expect(newRole).to.eq(Roles.Stream.Reviewer)
}
})
@@ -250,7 +250,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal('User not found')
@@ -270,7 +274,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal(WorkspaceNotFoundError.defaultMessage)
@@ -298,7 +306,11 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
ensureValidWorkspaceRoleSeat: async () => {
throw new Error('Should not happen')
}
})({ workspaceId: createRandomString(), userId: createRandomString() })
})({
workspaceId: createRandomString(),
userId: createRandomString(),
approvedByUserId: createRandomString()
})
)
expect(err.message).to.equal('Workspace join request not found')
@@ -364,7 +376,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
createdAt: new Date(),
updatedAt: new Date()
})
})({ workspaceId: workspace.id, userId: user.id })
})({ workspaceId: workspace.id, userId: user.id, approvedByUserId: user.id })
).to.equal(true)
expect(
@@ -3,6 +3,7 @@ import {
createRandomEmail,
createRandomString
} from '@/modules/core/helpers/testHelpers'
import { deleteProjectRoleFactory } from '@/modules/core/repositories/streams'
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import { getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
import {
@@ -12,12 +13,14 @@ import {
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUser } from '@/test/authHelper'
import {
GetProjectCollaboratorsDocument,
UpdateWorkspaceSeatTypeDocument,
WorkspaceUpdateSeatTypeInput
} from '@/test/graphql/generated/graphql'
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { StripeClientMock } from '@/test/mocks/global'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
@@ -112,7 +115,7 @@ describe('Workspace Seats @graphql', () => {
expect(res.data?.workspaceMutations.updateSeatType).to.not.be.ok
})
it('should assign a workspace seat with the provided type and reconcile subscription', async () => {
it('should upgrade a workspace seat and reconcile subscription', async () => {
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
@@ -151,5 +154,107 @@ describe('Workspace Seats @graphql', () => {
expect(reconcileArgs.prorationBehavior).to.eq('always_invoice') // new plan
expect(reconcileArgs.subscriptionData.products.length).to.be.ok
})
it('should downgrade a workspace seat', async () => {
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
}
await createTestUser(user)
await assignToWorkspace(
testWorkspace1,
user,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
)
const res = await updateSeatType({
workspaceId: testWorkspace1.id,
userId: user.id,
seatType: WorkspaceSeatType.Viewer
})
expect(res).to.not.haveGraphQLErrors()
expect(
res.data?.workspaceMutations.updateSeatType.team.items.find(
(i) => i.id === user.id
)?.seatType
).to.eq(WorkspaceSeatType.Viewer)
})
it('should assign away project ownership on downgrade to viewer seat', async () => {
const testWorkspace2: BasicTestWorkspace = {
id: '',
slug: '',
ownerId: '',
name: 'Test Workspace 2'
}
await createTestWorkspace(testWorkspace2, workspaceAdmin, {
addPlan: { name: 'pro', status: 'valid' }
})
const user: BasicTestUser = {
id: createRandomString(),
name: createRandomString(),
email: createRandomEmail(),
role: Roles.Server.User,
verified: true
}
await createTestUser(user)
await assignToWorkspace(
testWorkspace2,
user,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
)
const userOwnedProject: BasicTestStream = {
name: 'User Owned Project',
isPublic: false,
id: '',
ownerId: '',
workspaceId: testWorkspace2.id
}
await createTestStream(userOwnedProject, user)
// Manually remove admin stream role, to test that it's being added
await deleteProjectRoleFactory({ db })({
projectId: userOwnedProject.id,
userId: workspaceAdmin.id
})
const res1 = await updateSeatType({
workspaceId: testWorkspace2.id,
userId: user.id,
seatType: WorkspaceSeatType.Viewer
})
expect(res1).to.not.haveGraphQLErrors()
expect(
res1.data?.workspaceMutations.updateSeatType.team.items.find(
(i) => i.id === user.id
)?.seatType
).to.eq(WorkspaceSeatType.Viewer)
// Check project ownership
const res2 = await apollo.execute(
GetProjectCollaboratorsDocument,
{
projectId: userOwnedProject.id
},
{ assertNoErrors: true }
)
expect(res2.data?.project.id).to.eq(userOwnedProject.id)
expect(res2.data?.project.team.length).to.greaterThanOrEqual(2)
const adminRes = res2.data?.project.team.find((t) => t.id === workspaceAdmin.id)
const userRes = res2.data?.project.team.find((t) => t.id === user.id)
expect(adminRes?.role).to.eq(Roles.Stream.Owner)
expect(userRes?.role).to.eq(Roles.Stream.Reviewer)
})
})
})