feat(server): workspace roles taken into account in project queries (#4319)

* Workspace.projects fixed

* Query.project tested & fixed

* personalOnly flag added

* withProjectRoleOnly flag

* authorizeResolver implicit workspace roles

* minor cleanup

* reorg + support for throwing auth errors

* global error mapping

* undo special borkage

* CR fixes

* more CR fixes

* shared tests fix

* minor adjustment

* tests fix

* see if removing cached roles fixes it?

* more fixes

* clean up debugging garbage
This commit is contained in:
Kristaps Fabians Geikins
2025-04-07 12:52:07 +03:00
committed by GitHub
parent e3d3c1446b
commit 820a1e2ebf
68 changed files with 1813 additions and 986 deletions
@@ -342,12 +342,17 @@ export const unassignFromWorkspaces = async (
}
export const assignToWorkspaces = async (
pairs: [BasicTestWorkspace, BasicTestUser, MaybeNullOrUndefined<WorkspaceRoles>][]
pairs: [
BasicTestWorkspace,
BasicTestUser,
MaybeNullOrUndefined<WorkspaceRoles>,
seatType?: MaybeNullOrUndefined<WorkspaceSeatType>
][]
) => {
// Serial execution is somehow faster with bigger batch sizes, assignToWorkspace
// may be quite heavy on the DB
for (const [workspace, user, role] of pairs) {
await assignToWorkspace(workspace, user, role || undefined)
for (const [workspace, user, role, seatType] of pairs) {
await assignToWorkspace(workspace, user, role || undefined, seatType || undefined)
}
}
@@ -1,25 +1,22 @@
import { db } from '@/db/knex'
import { AllScopes } from '@/modules/core/helpers/mainConstants'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import { getWorkspaceUserSeatsFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
assignToWorkspace,
assignToWorkspaces,
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { describeEach } from '@/test/assertionHelper'
import {
BasicTestUser,
createAuthTokenForUser,
createTestUser,
createTestUsers
} from '@/test/authHelper'
import { describeEach, itEach } from '@/test/assertionHelper'
import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper'
import {
ActiveUserProjectsWorkspaceDocument,
CreateWorkspaceProjectDocument,
GetProjectDocument,
GetWorkspaceProjectsDocument,
GetWorkspaceProjectsQuery,
GetWorkspaceTeamDocument,
MoveProjectToWorkspaceDocument,
ProjectUpdateRoleInput,
@@ -27,22 +24,26 @@ import {
UpdateWorkspaceProjectRoleDocument
} from '@/test/graphql/generated/graphql'
import {
createTestContext,
ExecuteOperationResponse,
testApolloServer,
TestApolloServer
} from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { mockAdminOverride } from '@/test/mocks/global'
import {
addToStream,
BasicTestStream,
createTestStream,
getUserStreamRole
} from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { isNonNullable, Nullable, Optional, Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import dayjs from 'dayjs'
import { times } from 'lodash'
const grantStreamPermissions = grantStreamPermissionsFactory({ db })
const adminOverrideMock = mockAdminOverride()
describe('Workspace project GQL CRUD', () => {
let apollo: TestApolloServer
@@ -71,15 +72,8 @@ describe('Workspace project GQL CRUD', () => {
before(async () => {
await beforeEachContext()
await createTestUsers([serverAdminUser, serverMemberUser])
const token = await createAuthTokenForUser(serverAdminUser.id, AllScopes)
apollo = await testApolloServer({
context: await createTestContext({
auth: true,
userId: serverAdminUser.id,
token,
role: serverAdminUser.role,
scopes: AllScopes
})
authUserId: serverAdminUser.id
})
await createTestWorkspace(workspace, serverAdminUser)
@@ -282,72 +276,446 @@ describe('Workspace project GQL CRUD', () => {
})
})
describe('when querying workspace projects', () => {
it('should return multiple projects', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id
})
describe('when querying projects', () => {
const PAGE_SIZE = 5
const PAGE_COUNT = 3
const TOTAL_COUNT = PAGE_COUNT * PAGE_SIZE
const GUEST_PROJECT_COUNT = PAGE_SIZE + 1
const NON_WORKSPACE_PROJECT_COUNT = 5
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.items.length).to.be.greaterThanOrEqual(3)
const queryWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: '',
name: 'Query Workspace'
}
const workspaceGuest: BasicTestUser = {
id: '',
email: '',
name: 'Query Workspace Guest'
}
const workspaceAdmin = serverMemberUser
const workspaceMember: BasicTestUser = {
id: '',
email: '',
name: 'Query Workspace Member'
}
let projects: BasicTestStream[]
let nonWorkspaceProjects: BasicTestStream[]
let apollo: TestApolloServer
before(async () => {
await createTestUsers([workspaceGuest, workspaceMember])
await createTestWorkspace(queryWorkspace, workspaceAdmin, {
addPlan: { name: 'team', status: 'valid' }
})
await assignToWorkspaces([
[
queryWorkspace,
workspaceGuest,
Roles.Workspace.Guest,
WorkspaceSeatType.Editor
],
[
queryWorkspace,
workspaceMember,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
]
])
projects = times(
TOTAL_COUNT,
(i): BasicTestStream => ({
id: '',
ownerId: '',
name: `Query Workspace Project - #${i}`,
isPublic: false, // have to be private for tests below
workspaceId: queryWorkspace.id
})
)
nonWorkspaceProjects = times(
NON_WORKSPACE_PROJECT_COUNT,
(i): BasicTestStream => ({
id: '',
ownerId: '',
name: `Non Workspace Project - #${i}`,
isPublic: false
})
)
// CREATE CONCURRENTLY TO TEST COMPOSITE CURSOR (same updatedAt)
await Promise.all([
...projects.map((project) => createTestStream(project, workspaceAdmin)),
...nonWorkspaceProjects.map((project) =>
createTestStream(project, workspaceGuest)
)
])
// ONLY ADD EXPLICIT PROJECT ASSIGNMENTS TO GUEST
const projectsToAssign = projects.slice(0, GUEST_PROJECT_COUNT)
await Promise.all(
projectsToAssign.map((project) =>
addToStream(project, workspaceGuest, Roles.Stream.Contributor)
)
)
await Promise.all([
// Add explicit single assignment to workspaceMember to 1st non-workspace project
addToStream(nonWorkspaceProjects[0], workspaceMember, Roles.Stream.Contributor),
// Add explicit single assignment to workspaceMember to 1st workspace project
addToStream(projects[0], workspaceMember, Roles.Stream.Contributor)
])
apollo = await testApolloServer({
authUserId: workspaceAdmin.id
})
})
it('should respect limits', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id,
limit: 1
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.items.length).to.equal(1)
afterEach(async () => {
adminOverrideMock.disable()
})
it('should respect pagination', async () => {
const resA = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id,
limit: 10
})
describe('through Workspace.projects', () => {
it('should return all projects for workspace members', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 999 // get everything
})
const resB = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id,
limit: 10,
cursor: resA.data?.workspace.projects.cursor
})
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection?.items.length).to.equal(TOTAL_COUNT)
expect(collection?.cursor).to.be.ok
expect(collection?.totalCount).to.eq(TOTAL_COUNT)
const projectA = resA.data?.workspace.projects.items[0]
const projectB = resB.data?.workspace.projects.items[0]
expect(resA).to.not.haveGraphQLErrors()
expect(resB).to.not.haveGraphQLErrors()
expect(projectA).to.exist
expect(projectB).to.not.exist
expect(projectA?.name).to.not.equal(projectB?.name)
})
it('should respect search filters', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id,
limit: 1,
filter: {
search: 'Workspace Project B'
// validate sorting
const projects = collection?.items || []
let lastUpdatedAt: Optional<string> = undefined
for (const project of projects) {
const date = project.updatedAt
if (!lastUpdatedAt) {
lastUpdatedAt = date
continue
}
expect(
dayjs(date).isSame(dayjs(lastUpdatedAt)) ||
dayjs(date).isBefore(dayjs(lastUpdatedAt))
).to.be.true
lastUpdatedAt = date
}
})
const project = res.data?.workspace.projects.items[0]
itEach(
[{ adminOverrideEnabled: true }, { adminOverrideEnabled: false }],
({ adminOverrideEnabled }) =>
adminOverrideEnabled
? 'should return all projects for server admins if override enabled'
: 'should fail retrieving projects for server admins if no override enabled',
async ({ adminOverrideEnabled }) => {
const apollo = await testApolloServer({
authUserId: serverAdminUser.id
})
expect(res).to.not.haveGraphQLErrors()
expect(project).to.exist
expect(project?.name).to.equal('Workspace Project B')
adminOverrideMock.enable(adminOverrideEnabled)
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 999 // get everything
})
if (adminOverrideEnabled) {
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection?.items.length).to.equal(TOTAL_COUNT)
expect(collection?.cursor).to.be.ok
expect(collection?.totalCount).to.eq(TOTAL_COUNT)
} else {
expect(res).to.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection).to.not.be.ok
}
}
)
it('should return only explicitly assigned projects for guests', async () => {
const apollo = await testApolloServer({
authUserId: workspaceGuest.id
})
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 999 // get everything
})
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection?.items.length).to.equal(GUEST_PROJECT_COUNT)
expect(collection?.cursor).to.be.ok
expect(collection?.totalCount).to.equal(GUEST_PROJECT_COUNT)
})
it('should respect limits', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 1
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.items.length).to.equal(1)
expect(res.data?.workspace.projects.cursor).to.be.ok
expect(res.data?.workspace.projects.totalCount).to.equal(TOTAL_COUNT)
})
it('should only return totalCount if limit === 0', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 0
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.items.length).to.equal(0)
expect(res.data?.workspace.projects.cursor).to.be.null
expect(res.data?.workspace.projects.totalCount).to.equal(TOTAL_COUNT)
})
it('should respect pagination', async () => {
let newCursor: Nullable<string> = null
for (let page = 1; page <= PAGE_COUNT + 1; page++) {
const res: ExecuteOperationResponse<GetWorkspaceProjectsQuery> =
await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: PAGE_SIZE,
cursor: newCursor
})
newCursor = res.data?.workspace.projects.cursor || null
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.totalCount).to.equal(TOTAL_COUNT)
if (page <= PAGE_COUNT) {
expect(res.data?.workspace.projects.items.length).to.equal(PAGE_SIZE)
expect(res.data?.workspace.projects.cursor).to.be.ok
} else {
expect(res.data?.workspace.projects.items.length).to.eq(0)
expect(res.data?.workspace.projects.cursor).to.be.null
}
}
})
it('should respect search filters', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
filter: {
search: 'Query Workspace Project - #0'
}
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.projects.items.length).to.equal(1)
expect(res.data?.workspace.projects.totalCount).to.equal(1)
expect(res.data?.workspace.projects.cursor).to.be.ok
const project = res.data?.workspace.projects.items[0]
expect(project).to.exist
expect(project?.name).to.equal('Query Workspace Project - #0')
})
it('should respect withProjectRoleOnly flag', async () => {
const apollo = await testApolloServer({
authUserId: workspaceMember.id
})
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
filter: {
withProjectRoleOnly: true
}
})
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection).to.be.ok
expect(collection?.items.length).to.equal(1)
expect(collection?.items[0].id).to.equal(projects[0].id)
expect(collection?.totalCount).to.equal(1)
})
})
it('should return workspace info on project types', async () => {
const res = await apollo.execute(ActiveUserProjectsWorkspaceDocument, {})
describe('for a specific one', () => {
const randomServerGuy: BasicTestUser = {
id: '',
name: 'Random Server Guy',
email: ''
}
const projects = res.data?.activeUser?.projects.items
before(async () => {
await createTestUser(randomServerGuy)
})
expect(res).to.not.haveGraphQLErrors()
expect(projects).to.exist
expect(projects?.every((project) => !!project?.workspace?.id)).to.be.ok
// projects at the end have no explicit project assignments,
// and first X ones are explicitly assigned to guest user
const implicitProject = () => projects.at(-1)!
const explicitGuestProject = () => projects.at(0)!
it('it should be accessible to workspace member', async () => {
const apollo = await testApolloServer({
authUserId: workspaceMember.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.project.id).to.be.ok
})
it('it should not be accessible to random outside workspace guy', async () => {
const apollo = await testApolloServer({
authUserId: randomServerGuy.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
})
expect(res).to.haveGraphQLErrors()
expect(res.data?.project).to.not.be.ok
})
itEach(
[{ explicit: false }, { explicit: true }],
({ explicit }) =>
explicit
? 'it should be accessible to workspace guest with explicit project role'
: 'it should not be accessible to workspace guest without explicit project role',
async ({ explicit }) => {
const apollo = await testApolloServer({
authUserId: workspaceGuest.id
})
const res = await apollo.execute(GetProjectDocument, {
id: explicit ? explicitGuestProject().id : implicitProject().id
})
if (explicit) {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.project.id).to.be.ok
} else {
expect(res).to.haveGraphQLErrors()
expect(res.data?.project).to.not.be.ok
}
}
)
itEach(
[{ adminOverrideEnabled: true }, { adminOverrideEnabled: false }],
({ adminOverrideEnabled }) =>
adminOverrideEnabled
? 'it should return project for server admins if override enabled'
: 'it should not return project for server admins if override disabled',
async ({ adminOverrideEnabled }) => {
const apollo = await testApolloServer({
authUserId: serverAdminUser.id
})
adminOverrideMock.enable(adminOverrideEnabled)
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
})
if (adminOverrideEnabled) {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.project.id).to.be.ok
} else {
expect(res).to.haveGraphQLErrors()
expect(res.data?.project).to.not.be.ok
}
}
)
})
describe('through ActiveUser.projects', () => {
let apollo: TestApolloServer
before(async () => {
apollo = await testApolloServer({
authUserId: workspaceGuest.id
})
})
it('should return all projects user is explicitly assigned to', async () => {
// guest
const apolloGuest = await testApolloServer({
authUserId: workspaceGuest.id
})
const guestRes = await apolloGuest.execute(
ActiveUserProjectsWorkspaceDocument,
{ limit: 999 },
{ assertNoErrors: true }
)
const guestCollection = guestRes.data?.activeUser?.projects
const expectedGuestCount = GUEST_PROJECT_COUNT + NON_WORKSPACE_PROJECT_COUNT
expect(guestCollection).to.be.ok
expect(guestCollection!.totalCount).to.equal(expectedGuestCount)
expect(guestCollection!.items.length).to.equal(expectedGuestCount)
expect(
guestCollection!.items.map((i) => i.workspace?.id).filter(isNonNullable)
).to.have.length(GUEST_PROJECT_COUNT)
// member
const apolloMember = await testApolloServer({
authUserId: workspaceMember.id
})
const memberRes = await apolloMember.execute(
ActiveUserProjectsWorkspaceDocument,
{ limit: 999 },
{ assertNoErrors: true }
)
const memberCollection = memberRes.data?.activeUser?.projects
const expectedMemberCount = 2 // only 2 explicit assignments
expect(memberCollection).to.be.ok
expect(memberCollection!.totalCount).to.equal(expectedMemberCount)
expect(memberCollection!.items.length).to.equal(expectedMemberCount)
expect([
memberCollection!.items[0].id,
memberCollection!.items[1].id
]).to.deep.equalInAnyOrder([nonWorkspaceProjects[0].id, projects[0].id])
})
it('should only return workspace projects if filter set', async () => {
const res = await apollo.execute(ActiveUserProjectsWorkspaceDocument, {
filter: {
workspaceId: queryWorkspace.id
},
limit: 999
})
const expectedCount = GUEST_PROJECT_COUNT
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.activeUser?.projects
expect(collection).to.be.ok
expect(collection?.items.length).to.equal(expectedCount)
expect(collection?.totalCount).to.equal(expectedCount)
expect(
collection?.items.map((i) => i.workspace?.id).filter(isNonNullable)
).to.have.length(expectedCount)
})
it('should only return non-workspace projects if filter set', async () => {
const res = await apollo.execute(ActiveUserProjectsWorkspaceDocument, {
filter: {
personalOnly: true
},
limit: 999
})
const expectedCount = NON_WORKSPACE_PROJECT_COUNT
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.activeUser?.projects
expect(collection).to.be.ok
expect(collection?.items.length).to.equal(expectedCount)
expect(collection?.totalCount).to.equal(expectedCount)
expect(
collection?.items.map((i) => i.workspace?.id).filter((v) => !v)
).to.have.length(expectedCount)
})
})
})