feat: "workspace" project visibility (#4704)

* WIP new visi

* test fixes

* visibility seems to work

* authz policies & authorizeResolver updated

* various test fixes

* users tests

* frontend changes

* minor adjustments

* shared test fix

* test fixes

* force rerun CI
This commit is contained in:
Kristaps Fabians Geikins
2025-05-14 15:20:26 +03:00
committed by GitHub
parent 02b97bcb86
commit 4db1531064
92 changed files with 1602 additions and 1435 deletions
@@ -44,7 +44,8 @@ import {
ServerAclRecord,
BranchRecord,
StreamAclRecord,
StreamRecord
StreamRecord,
ProjectRecordVisibility
} from '@/modules/core/helpers/types'
import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace'
import {
@@ -564,8 +565,11 @@ const getPaginatedWorkspaceProjectsBaseQueryFactory =
/**
* If userId is set:
* - If no workspace role, user should be server admin w/ admin override enabled
* - If workspace role is guest, user should have explicit stream roles
* - If workspace role other than guest, just get all workspace streams
* - If workspace role is admin: user can get all workspace streams
* - If workspace role is guest: user should have explicit stream roles
* - If workspace role is member:
* - Public/Workspace visibility: get stream
* - Private visibility: user should have explicit stream roles
*
* If withProjectRoleOnly is set: Require project role always
*/
@@ -590,10 +594,21 @@ const getPaginatedWorkspaceProjectsBaseQueryFactory =
}
w.orWhere((w2) => {
// Ensure workspace role exists and its not guest or the user has explicit stream roles
// Ensure workspace role exists and:
// user has explicit stream role or is admin or is a non-guest in a non-private project
w2.whereNotNull(DbWorkspaceAcl.col.role).andWhere((w3) => {
if (!withProjectRoleOnly) {
w3.whereNot(DbWorkspaceAcl.col.role, Roles.Workspace.Guest)
w3.where(DbWorkspaceAcl.col.role, Roles.Workspace.Admin).orWhere(
(w4) => {
w4.whereNot(
DbWorkspaceAcl.col.role,
Roles.Workspace.Guest
).andWhereNot(
Streams.col.visibility,
ProjectRecordVisibility.Private
)
}
)
}
w3.orWhereExists(
@@ -1,4 +1,4 @@
import { StreamRecord } from '@/modules/core/helpers/types'
import { ProjectRecordVisibility, StreamRecord } from '@/modules/core/helpers/types'
import {
GetDefaultRegion,
GetWorkspaceDomains,
@@ -211,7 +211,17 @@ export const moveProjectToWorkspaceFactory =
}
// Assign project to workspace
return await updateProject({ projectUpdate: { id: projectId, workspaceId } })
return await updateProject({
projectUpdate: {
id: projectId,
workspaceId,
visibility:
// Migrate from Private -> Workspace visibility
project.visibility === ProjectRecordVisibility.Private
? ProjectRecordVisibility.Workspace
: project.visibility
}
})
}
export const getWorkspaceRoleToDefaultProjectRoleMappingFactory =
@@ -32,6 +32,7 @@ import { expect } from 'chai'
import { MaybeAsync, StreamRoles, WorkspaceRoles } from '@speckle/shared'
import { expectToThrow } from '@/test/assertionHelper'
import { ForbiddenError } from '@/modules/shared/errors'
import { isBoolean } from 'lodash'
export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }) => {
const { apollo } = deps
@@ -80,7 +81,7 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
) => apollo.execute(UseWorkspaceProjectInviteDocument, args, options)
const validateResourceAccess = async (params: {
shouldHaveAccess: boolean
shouldHaveAccess: boolean | { workspace: boolean; project: boolean }
userId: string
workspaceId: string
streamId?: string
@@ -88,8 +89,17 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
expectedProjectRole?: StreamRoles
}) => {
const { shouldHaveAccess, userId, workspaceId, streamId } = params
const shouldHaveWorkspaceAccess = isBoolean(shouldHaveAccess)
? shouldHaveAccess
: shouldHaveAccess.workspace
const shouldHaveProjectAccess = isBoolean(shouldHaveAccess)
? shouldHaveAccess
: shouldHaveAccess.project
const wrapAccessCheck = async (fn: () => MaybeAsync<unknown>) => {
const wrapAccessCheck = async (
fn: () => MaybeAsync<unknown>,
shouldHaveAccess: boolean
) => {
if (shouldHaveAccess) {
await fn()
} else {
@@ -113,7 +123,7 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
`Unexpected workspace role! Expected: ${params.expectedWorkspaceRole}, real: ${workspace.role}`
)
}
})
}, shouldHaveWorkspaceAccess)
if (streamId?.length) {
await wrapAccessCheck(async () => {
@@ -133,7 +143,7 @@ export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }
`Unexpected project role! Expected: ${params.expectedProjectRole}, real: ${project?.role}`
)
}
})
}, shouldHaveProjectAccess)
}
}
@@ -67,6 +67,7 @@ import {
} from '@/modules/workspaces/tests/helpers/invites'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
enum InviteByTarget {
Email = 'email',
@@ -1017,7 +1018,14 @@ describe('Workspaces Invites GQL', () => {
name: 'My Invite Target Workspace Stream 1',
id: '',
ownerId: '',
isPublic: false
visibility: ProjectRecordVisibility.Workspace
}
const myInviteTargetPrivateWorkspaceStream1: BasicTestStream = {
name: 'My Invite Target Private Workspace Stream 1',
id: '',
ownerId: '',
visibility: ProjectRecordVisibility.Private
}
const processableWorkspaceInvite = {
@@ -1050,15 +1058,16 @@ describe('Workspaces Invites GQL', () => {
}
const validateResourceAccess = async (params: {
shouldHaveAccess: boolean
shouldHaveAccess: boolean | { workspace: boolean; project: boolean }
expectedWorkspaceRole?: WorkspaceRoles
expectedProjectRole?: StreamRoles
streamId: string
}) => {
return gqlHelpers.validateResourceAccess({
...params,
userId: otherGuy.id,
workspaceId: myInviteTargetWorkspace.id,
streamId: myInviteTargetWorkspaceStream1.id
streamId: params.streamId
})
}
@@ -1067,7 +1076,11 @@ describe('Workspaces Invites GQL', () => {
await createTestWorkspaces([[myInviteTargetWorkspace, me]])
myInviteTargetWorkspaceStream1.workspaceId = myInviteTargetWorkspace.id
await createTestStreams([[myInviteTargetWorkspaceStream1, me]])
myInviteTargetPrivateWorkspaceStream1.workspaceId = myInviteTargetWorkspace.id
await createTestStreams([
[myInviteTargetWorkspaceStream1, me],
[myInviteTargetPrivateWorkspaceStream1, me]
])
})
beforeEach(async () => {
@@ -1392,7 +1405,15 @@ describe('Workspaces Invites GQL', () => {
})
expect(invite).to.be.not.ok
await validateResourceAccess({ shouldHaveAccess: accept })
// Should have access to workspace visibility stream, not the other one
await validateResourceAccess({
shouldHaveAccess: accept,
streamId: myInviteTargetWorkspaceStream1.id
})
await validateResourceAccess({
shouldHaveAccess: { workspace: accept, project: false },
streamId: myInviteTargetPrivateWorkspaceStream1.id
})
}
)
@@ -1440,7 +1461,10 @@ describe('Workspaces Invites GQL', () => {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspaceMutations.invites.use).to.be.ok
await validateResourceAccess({ shouldHaveAccess: accept })
await validateResourceAccess({
shouldHaveAccess: accept,
streamId: myInviteTargetWorkspaceStream1.id
})
const verifiedEmails = await findVerifiedEmailsByUserIdFactory({
db
@@ -1487,7 +1511,8 @@ describe('Workspaces Invites GQL', () => {
await validateResourceAccess({
shouldHaveAccess: true,
expectedWorkspaceRole: Roles.Workspace.Member
expectedWorkspaceRole: Roles.Workspace.Member,
streamId: myInviteTargetWorkspaceStream1.id
})
const targetInvite = roleChanged
@@ -1516,7 +1541,8 @@ describe('Workspaces Invites GQL', () => {
shouldHaveAccess: true,
expectedWorkspaceRole: roleChanged
? Roles.Workspace.Admin
: Roles.Workspace.Member
: Roles.Workspace.Member,
streamId: myInviteTargetWorkspaceStream1.id
})
const email = targetInvite.email
@@ -1610,7 +1636,8 @@ describe('Workspaces Invites GQL', () => {
await validateResourceAccess({
shouldHaveAccess: true,
expectedWorkspaceRole: Roles.Workspace.Guest,
expectedProjectRole: Roles.Stream.Reviewer
expectedProjectRole: Roles.Stream.Reviewer,
streamId: myInviteTargetWorkspaceStream1.id
})
})
@@ -1666,7 +1693,14 @@ describe('Workspaces Invites GQL', () => {
expectedWorkspaceRole: withRole
? Roles.Workspace.Admin
: Roles.Workspace.Guest,
expectedProjectRole: withRole ? Roles.Stream.Owner : Roles.Stream.Reviewer
expectedProjectRole: withRole ? Roles.Stream.Owner : Roles.Stream.Reviewer,
streamId: myInviteTargetWorkspaceStream1.id
})
// ws admin will have access to everything
await validateResourceAccess({
shouldHaveAccess: { workspace: true, project: withRole ? true : false },
streamId: myInviteTargetPrivateWorkspaceStream1.id
})
}
)
@@ -1,4 +1,6 @@
import { db } from '@/db/knex'
import { StreamAcl } from '@/modules/core/dbSchema'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import { WorkspaceSeatType } from '@/modules/gatekeeper/domain/billing'
import { getWorkspaceUserSeatsFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
@@ -10,16 +12,25 @@ import {
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { describeEach, itEach } from '@/test/assertionHelper'
import { BasicTestUser, createTestUser, createTestUsers } from '@/test/authHelper'
import {
BasicTestUser,
createTestUser,
createTestUsers,
login
} from '@/test/authHelper'
import {
ActiveUserProjectsDocument,
ActiveUserProjectsWorkspaceDocument,
CreateProjectDocument,
CreateWorkspaceProjectDocument,
GetProjectDocument,
GetWorkspaceDocument,
GetWorkspaceProjectsDocument,
GetWorkspaceProjectsQuery,
GetWorkspaceTeamDocument,
MoveProjectToWorkspaceDocument,
ProjectUpdateRoleInput,
ProjectVisibility,
UpdateProjectRoleDocument,
UpdateWorkspaceProjectRoleDocument
} from '@/test/graphql/generated/graphql'
@@ -41,7 +52,8 @@ import {
Nullable,
Optional,
PaidWorkspacePlans,
Roles
Roles,
WorkspacePlans
} from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -97,6 +109,48 @@ describe('Workspace project GQL CRUD', () => {
)
})
describe('when creating project', () => {
it('should have workspace visibility by default', async () => {
const res = await apollo.execute(
CreateWorkspaceProjectDocument,
{
input: {
name: 'Test Default Project',
workspaceId: workspace.id
}
},
{ assertNoErrors: true }
)
const project = res.data?.workspaceMutations?.projects.create
expect(project).to.be.ok
expect(project?.visibility).to.equal(ProjectVisibility.Workspace)
})
it('should create the project in that workspace', async () => {
const projectName = cryptoRandomString({ length: 6 })
const createRes = await apollo.execute(CreateWorkspaceProjectDocument, {
input: {
name: projectName,
workspaceId: workspace.id
}
})
const getRes = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id
})
const workspaceProject = getRes.data?.workspace.projects.items.find(
(project) => project.name === projectName
)
expect(createRes).to.not.haveGraphQLErrors()
expect(getRes).to.not.haveGraphQLErrors()
expect(workspaceProject).to.exist
})
})
describe('when changing workspace project roles', () => {
const workspaceGuest: BasicTestUser = {
id: '',
@@ -240,31 +294,6 @@ describe('Workspace project GQL CRUD', () => {
})
})
describe('when specifying a workspace id during project creation', () => {
it('should create the project in that workspace', async () => {
const projectName = cryptoRandomString({ length: 6 })
const createRes = await apollo.execute(CreateWorkspaceProjectDocument, {
input: {
name: projectName,
workspaceId: workspace.id
}
})
const getRes = await apollo.execute(GetWorkspaceProjectsDocument, {
id: workspace.id
})
const workspaceProject = getRes.data?.workspace.projects.items.find(
(project) => project.name === projectName
)
expect(createRes).to.not.haveGraphQLErrors()
expect(getRes).to.not.haveGraphQLErrors()
expect(workspaceProject).to.exist
})
})
describe('when querying projects', () => {
const PAGE_SIZE = 5
const PAGE_COUNT = 3
@@ -285,18 +314,36 @@ describe('Workspace project GQL CRUD', () => {
email: '',
name: 'Query Workspace Guest'
}
const workspaceAdmin = serverMemberUser
const workspaceAdmin2: BasicTestUser = {
id: '',
email: '',
name: 'Query Workspace Admin 2'
}
const workspaceMember: BasicTestUser = {
id: '',
email: '',
name: 'Query Workspace Member'
}
const workspaceMemberNoExplicitRoles: BasicTestUser = {
id: '',
email: '',
name: 'Query Workspace Member w/ No Explicit Project Roles'
}
let wsProjects: BasicTestStream[]
let nonWorkspaceProjects: BasicTestStream[]
let apollo: TestApolloServer
before(async () => {
await createTestUsers([workspaceGuest, workspaceMember])
await createTestUsers([
workspaceGuest,
workspaceMember,
workspaceAdmin2,
workspaceMemberNoExplicitRoles
])
await createTestWorkspace(queryWorkspace, workspaceAdmin, {
addPlan: { name: 'team', status: 'valid' }
})
@@ -312,6 +359,18 @@ describe('Workspace project GQL CRUD', () => {
workspaceMember,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
],
[
queryWorkspace,
workspaceAdmin2,
Roles.Workspace.Admin,
WorkspaceSeatType.Editor
],
[
queryWorkspace,
workspaceMemberNoExplicitRoles,
Roles.Workspace.Member,
WorkspaceSeatType.Editor
]
])
wsProjects = times(
@@ -320,7 +379,11 @@ describe('Workspace project GQL CRUD', () => {
id: '',
ownerId: '',
name: `Query Workspace Project - #${i}`,
isPublic: false, // have to be private for tests below
// Make all except the very last one workspace visibility
visibility:
i === TOTAL_WS_PROJECT_COUNT - 1
? ProjectRecordVisibility.Private
: ProjectRecordVisibility.Workspace,
workspaceId: queryWorkspace.id
})
)
@@ -330,7 +393,7 @@ describe('Workspace project GQL CRUD', () => {
id: '',
ownerId: '',
name: `Non Workspace Project - #${i}`,
isPublic: false
visibility: ProjectRecordVisibility.Private
})
)
@@ -351,8 +414,9 @@ describe('Workspace project GQL CRUD', () => {
)
await Promise.all([
// Add explicit single assignment to workspaceMember to 1st non-workspace project
// Add explicit single assignment to workspaceMember & workspaceAdmin to 1st non-workspace project
addToStream(nonWorkspaceProjects[0], workspaceMember, Roles.Stream.Contributor),
addToStream(nonWorkspaceProjects[0], workspaceAdmin, Roles.Stream.Contributor),
// Add explicit single assignment to workspaceMember to 1st workspace project
addToStream(wsProjects[0], workspaceMember, Roles.Stream.Contributor)
])
@@ -362,12 +426,18 @@ describe('Workspace project GQL CRUD', () => {
})
})
// projects at the end have no explicit project assignments (and very last one is fully private),
// and first X ones are explicitly assigned to guest user
const implicitPrivateProject = () => wsProjects.at(-1)!
const implicitWorkspaceVisibilityProject = () => wsProjects.at(-2)!
const explicitGuestProject = () => wsProjects.at(0)!
afterEach(async () => {
adminOverrideMock.disable()
})
describe('through Workspace.projects', () => {
it('should return all projects for workspace members', async () => {
it('should return all projects for workspace admin', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 999 // get everything
@@ -443,6 +513,24 @@ describe('Workspace project GQL CRUD', () => {
expect(collection?.totalCount).to.equal(GUEST_PROJECT_COUNT)
})
it('should return all non-private for members who may not even have any explicit project roles', async () => {
const apollo = await testApolloServer({
authUserId: workspaceMemberNoExplicitRoles.id
})
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
limit: 999 // get everything
})
const nonPrivateCount = TOTAL_WS_PROJECT_COUNT - 1 // -1 for the fully private one
expect(res).to.not.haveGraphQLErrors()
const collection = res.data?.workspace.projects
expect(collection?.items.length).to.equal(nonPrivateCount)
expect(collection?.cursor).to.be.ok
expect(collection?.totalCount).to.equal(nonPrivateCount)
})
it('should respect limits', async () => {
const res = await apollo.execute(GetWorkspaceProjectsDocument, {
id: queryWorkspace.id,
@@ -542,17 +630,36 @@ describe('Workspace project GQL CRUD', () => {
await createTestUser(randomServerGuy)
})
// projects at the end have no explicit project assignments,
// and first X ones are explicitly assigned to guest user
const implicitProject = () => wsProjects.at(-1)!
const explicitGuestProject = () => wsProjects.at(0)!
it('it should be accessible to workspace member', async () => {
it('workspace visibility should be accessible to workspace member', async () => {
const apollo = await testApolloServer({
authUserId: workspaceMember.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
id: implicitWorkspaceVisibilityProject().id
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.project.id).to.be.ok
})
it('private visibility should not be accessible to workspace member w/o explicit role', async () => {
const apollo = await testApolloServer({
authUserId: workspaceMember.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitPrivateProject().id
})
expect(res).to.haveGraphQLErrors()
expect(res.data?.project).to.not.be.ok
})
it('private visibility should be accessible to workspace admin w/o explicit role', async () => {
const apollo = await testApolloServer({
authUserId: workspaceAdmin2.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitPrivateProject().id
})
expect(res).to.not.haveGraphQLErrors()
@@ -564,7 +671,7 @@ describe('Workspace project GQL CRUD', () => {
authUserId: randomServerGuy.id
})
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
id: implicitWorkspaceVisibilityProject().id
})
expect(res).to.haveGraphQLErrors()
@@ -582,7 +689,9 @@ describe('Workspace project GQL CRUD', () => {
authUserId: workspaceGuest.id
})
const res = await apollo.execute(GetProjectDocument, {
id: explicit ? explicitGuestProject().id : implicitProject().id
id: explicit
? explicitGuestProject().id
: implicitWorkspaceVisibilityProject().id
})
if (explicit) {
@@ -599,8 +708,8 @@ describe('Workspace project GQL CRUD', () => {
[{ 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',
? 'it should return fully private project for server admins if override enabled'
: 'it should not return fully private project for server admins if override disabled',
async ({ adminOverrideEnabled }) => {
const apollo = await testApolloServer({
authUserId: serverAdminUser.id
@@ -608,7 +717,7 @@ describe('Workspace project GQL CRUD', () => {
adminOverrideMock.enable(adminOverrideEnabled)
const res = await apollo.execute(GetProjectDocument, {
id: implicitProject().id
id: implicitPrivateProject().id
})
if (adminOverrideEnabled) {
@@ -671,28 +780,41 @@ describe('Workspace project GQL CRUD', () => {
]).to.deep.equalInAnyOrder([nonWorkspaceProjects[0].id, wsProjects[0].id])
})
it('should return all projects user is explicitly or implicitly assigned to, if flag set', async () => {
const apolloMember = await testApolloServer({
authUserId: workspaceMember.id
})
const memberRes = await apolloMember.execute(
ActiveUserProjectsWorkspaceDocument,
{ limit: 999, filter: { includeImplicitAccess: true } },
{ assertNoErrors: true }
)
const memberCollection = memberRes.data?.activeUser?.projects
itEach(
[{ admin: true }, { admin: false }],
({ admin }) =>
`should return all projects ${
admin ? 'ws admin' : 'ws member'
} is explicitly or implicitly assigned to, if flag set`,
async ({ admin }) => {
const apollo = await testApolloServer({
authUserId: admin ? workspaceAdmin.id : workspaceMember.id
})
const res = await apollo.execute(
ActiveUserProjectsWorkspaceDocument,
{ limit: 999, filter: { includeImplicitAccess: true } },
{ assertNoErrors: true }
)
const projects = res.data?.activeUser?.projects
// 1 non-workspace assignment + all workspace projects
const expectedMemberCount = TOTAL_WS_PROJECT_COUNT + 1
// 1 non-workspace assignment + all workspace projects
// (except the last one thats fully private, if not admin)
let expectedCount = TOTAL_WS_PROJECT_COUNT + 1
if (!admin) {
expectedCount -= 1
}
expect(memberCollection).to.be.ok
expect(memberCollection!.totalCount).to.equal(expectedMemberCount)
expect(memberCollection!.items.length).to.equal(expectedMemberCount)
expect(memberCollection!.items.map((i) => i.id)).to.deep.equalInAnyOrder([
nonWorkspaceProjects[0].id,
...wsProjects.map((p) => p.id)
])
})
expect(projects).to.be.ok
expect(projects!.totalCount).to.equal(expectedCount)
expect(projects!.items.length).to.equal(expectedCount)
expect(projects!.items.map((i) => i.id)).to.deep.equalInAnyOrder([
nonWorkspaceProjects[0].id,
...wsProjects
.filter((p) => (admin ? true : p.id !== implicitPrivateProject().id))
.map((p) => p.id)
])
}
)
it('should only return workspace projects if filter set', async () => {
const res = await apollo.execute(ActiveUserProjectsWorkspaceDocument, {
@@ -739,7 +861,7 @@ describe('Workspace project GQL CRUD', () => {
id: '',
ownerId: '',
name: 'Test Project',
isPublic: false
visibility: ProjectRecordVisibility.Private
}
const targetWorkspace: BasicTestWorkspace = {
@@ -750,7 +872,9 @@ describe('Workspace project GQL CRUD', () => {
}
before(async () => {
await createTestWorkspace(targetWorkspace, serverAdminUser)
await createTestWorkspace(targetWorkspace, serverAdminUser, {
addPlan: WorkspacePlans.Unlimited
})
})
beforeEach(async () => {
@@ -762,17 +886,38 @@ describe('Workspace project GQL CRUD', () => {
})
})
it('should move the project to the target workspace', async () => {
it('should move the project to the target workspace and update visibility', async () => {
const res = await apollo.execute(MoveProjectToWorkspaceDocument, {
projectId: testProject.id,
workspaceId: targetWorkspace.id
})
const { workspaceId } =
res.data?.workspaceMutations.projects.moveToWorkspace ?? {}
const project = res.data?.workspaceMutations.projects.moveToWorkspace
expect(res).to.not.haveGraphQLErrors()
expect(workspaceId).to.equal(targetWorkspace.id)
expect(project?.workspaceId).to.equal(targetWorkspace.id)
expect(project?.visibility).to.equal(ProjectVisibility.Workspace)
})
it('should move a public project to the target workspace and keep same visibility', async () => {
const publicProject: BasicTestStream = {
id: '',
ownerId: '',
name: 'Test Public Project',
visibility: ProjectRecordVisibility.Public
}
await createTestStream(publicProject, serverAdminUser)
const res = await apollo.execute(MoveProjectToWorkspaceDocument, {
projectId: publicProject.id,
workspaceId: targetWorkspace.id
})
const project = res.data?.workspaceMutations.projects.moveToWorkspace
expect(res).to.not.haveGraphQLErrors()
expect(project?.workspaceId).to.equal(targetWorkspace.id)
expect(project?.visibility).to.equal(ProjectVisibility.Public)
})
it('should preserve project roles for project members with editor seats', async () => {
@@ -839,4 +984,224 @@ describe('Workspace project GQL CRUD', () => {
expect(adminWorkspaceRole?.role).to.equal(Roles.Workspace.Admin)
})
})
// moved over Alessandro's tests from core to here, since they are all related to workspaces
// they're kind of a mess and need to be cleaned up
describe('query user.projects', () => {
it('should return projects not in a workspace', async () => {
const testAdminUser: BasicTestUser = {
id: '',
name: 'test',
email: '',
role: Roles.Server.Admin,
verified: true
}
await createTestUser(testAdminUser)
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser)
const session = await login(testAdminUser)
const getWorkspaceRes = await session.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(getWorkspaceRes).to.not.haveGraphQLErrors()
const workspaceId = getWorkspaceRes.data!.workspace.id
const createProjectInWorkspaceRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project', workspaceId } }
)
expect(createProjectInWorkspaceRes).to.not.haveGraphQLErrors()
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
const projectNonInWorkspace =
createProjectNonInWorkspaceRes.data!.projectMutations.create
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: { personalOnly: true }
})
expect(userProjectsRes).to.not.haveGraphQLErrors()
const projects = userProjectsRes.data!.activeUser!.projects.items
expect(projects).to.have.length(1)
expect(projects[0].id).to.eq(projectNonInWorkspace.id)
})
it('should return projects in workspace', async () => {
const testAdminUser: BasicTestUser = {
id: '',
name: 'test',
email: '',
role: Roles.Server.Admin,
verified: true
}
await createTestUser(testAdminUser)
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser)
const session = await login(testAdminUser)
const getWorkspaceRes = await session.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(getWorkspaceRes).to.not.haveGraphQLErrors()
const workspaceId = getWorkspaceRes.data!.workspace.id
const createProjectInWorkspaceRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project', workspaceId } }
)
expect(createProjectInWorkspaceRes).to.not.haveGraphQLErrors()
const projectInWorkspace =
createProjectInWorkspaceRes.data!.workspaceMutations.projects.create
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: { workspaceId }
})
expect(userProjectsRes).to.not.haveGraphQLErrors()
const projects = userProjectsRes.data!.activeUser!.projects.items
expect(projects).to.have.length(1)
expect(projects[0].id).to.eq(projectInWorkspace.id)
})
it('should return all user projects', async () => {
const testAdminUser: BasicTestUser = {
id: '',
name: 'test',
email: '',
role: Roles.Server.Admin,
verified: true
}
await createTestUser(testAdminUser)
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser)
const session = await login(testAdminUser)
const getWorkspaceRes = await session.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(getWorkspaceRes).to.not.haveGraphQLErrors()
const workspaceId = getWorkspaceRes.data!.workspace.id
const createProjectInWorkspaceRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project', workspaceId } }
)
expect(createProjectInWorkspaceRes).to.not.haveGraphQLErrors()
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: {}
})
expect(userProjectsRes).to.not.haveGraphQLErrors()
const projects = userProjectsRes.data!.activeUser!.projects.items
expect(projects).to.have.length(2)
})
it('should return all user projects sorted by user role', async () => {
const testAdminUser: BasicTestUser = {
id: '',
name: 'test',
email: '',
role: Roles.Server.Admin,
verified: true
}
await createTestUser(testAdminUser)
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testAdminUser)
const session = await login(testAdminUser)
const getWorkspaceRes = await session.execute(GetWorkspaceDocument, {
workspaceId: workspace.id
})
expect(getWorkspaceRes).to.not.haveGraphQLErrors()
const workspaceId = getWorkspaceRes.data!.workspace.id
const createProjectInWorkspaceAsOwnerRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project', workspaceId } }
)
expect(createProjectInWorkspaceAsOwnerRes).to.not.haveGraphQLErrors()
const createProjectInWorkspaceAsContributorRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project 2', workspaceId } }
)
expect(createProjectInWorkspaceAsContributorRes).to.not.haveGraphQLErrors()
const projectContributorId =
createProjectInWorkspaceAsContributorRes.data?.workspaceMutations.projects
.create.id
await db(StreamAcl.name)
.update({ role: Roles.Stream.Contributor })
.where({ userId: testAdminUser.id, resourceId: projectContributorId })
const createProjectInWorkspaceAsReviewerRes = await session.execute(
CreateWorkspaceProjectDocument,
{ input: { name: 'project 3', workspaceId } }
)
expect(createProjectInWorkspaceAsReviewerRes).to.not.haveGraphQLErrors()
const projectReviewerId =
createProjectInWorkspaceAsReviewerRes.data?.workspaceMutations.projects.create
.id
await db(StreamAcl.name)
.update({ role: Roles.Stream.Reviewer })
.where({ userId: testAdminUser.id, resourceId: projectReviewerId })
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: {},
sortBy: ['role']
})
expect(userProjectsRes).to.not.haveGraphQLErrors()
const projects = userProjectsRes.data!.activeUser!.projects.items
expect(projects).to.have.length(3)
expect(projects[0].id).to.eq(
createProjectInWorkspaceAsOwnerRes.data?.workspaceMutations.projects.create.id
)
expect(projects[1].id).to.eq(projectContributorId)
expect(projects[2].id).to.eq(projectReviewerId)
})
})
})
@@ -1,5 +1,6 @@
import { Streams } from '@/modules/core/dbSchema'
import { AllScopes } from '@/modules/core/helpers/mainConstants'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import {
assignToWorkspace,
BasicTestWorkspace,
@@ -283,7 +284,6 @@ describe('Workspaces Roles/Seats GQL', () => {
})
})
// TODO: Viewer vs Editor
describe('in a workspace with projects', () => {
const workspace: BasicTestWorkspace = {
id: '',
@@ -326,35 +326,43 @@ describe('Workspaces Roles/Seats GQL', () => {
id: '',
ownerId: '',
name: 'Project A',
isPublic: false
visibility: ProjectRecordVisibility.Workspace
}
const workspaceProjectB: BasicTestStream = {
id: '',
ownerId: '',
name: 'Project B',
isPublic: false
visibility: ProjectRecordVisibility.Workspace
}
const workspaceProjectC: BasicTestStream = {
id: '',
ownerId: '',
name: 'Project C',
isPublic: false
visibility: ProjectRecordVisibility.Workspace
}
const workspaceProjectD: BasicTestStream = {
id: '',
ownerId: '',
name: 'Project D',
isPublic: false
visibility: ProjectRecordVisibility.Workspace
}
const workspaceProjectE: BasicTestStream = {
id: '',
ownerId: '',
name: 'Project E (Fully private)',
visibility: ProjectRecordVisibility.Private
}
const workspaceProjects = [
workspaceProjectA,
workspaceProjectB,
workspaceProjectC,
workspaceProjectD
workspaceProjectD,
workspaceProjectE
]
before(async () => {
@@ -424,13 +432,13 @@ describe('Workspaces Roles/Seats GQL', () => {
*
* Initial explicit workspace project roles:
*
* | | Project A | Project B | Project C | Project D |
* |---------------------------|-------------|-------------|-----------|-----------|
* | workspaceAdminUser | Owner | None | None | None |
* | workspaceMemberUser | Owner | Contributor | Reviewer | None |
* | workspaceGuestUser | Contributor | Reviewer | None | None |
* | workspaceMemberViewerUser | Reviewer | None | None | None |
* | workspaceGuestViewerUser | None | Reviewer | None | None |
* | | Project A | Project B | Project C | Project D | Project E (private) |
* |---------------------------|-------------|-------------|-----------|-----------|---------------------|
* | workspaceAdminUser | Owner | None | None | None | None
* | workspaceMemberUser | Owner | Contributor | Reviewer | None | None
* | workspaceGuestUser | Contributor | Reviewer | None | None | Reviewer
* | workspaceMemberViewerUser | Reviewer | None | None | None | Reviewer
* | workspaceGuestViewerUser | None | Reviewer | None | None | Reviewer
*/
await Promise.all([
@@ -448,7 +456,15 @@ describe('Workspaces Roles/Seats GQL', () => {
addToStream(workspaceProjectB, workspaceGuestUser, Roles.Stream.Reviewer),
addToStream(workspaceProjectB, workspaceGuestViewerUser, Roles.Stream.Reviewer),
// C
addToStream(workspaceProjectC, workspaceMemberUser, Roles.Stream.Reviewer)
addToStream(workspaceProjectC, workspaceMemberUser, Roles.Stream.Reviewer),
// E
addToStream(workspaceProjectE, workspaceGuestUser, Roles.Stream.Reviewer),
addToStream(
workspaceProjectE,
workspaceMemberViewerUser,
Roles.Stream.Reviewer
),
addToStream(workspaceProjectE, workspaceGuestViewerUser, Roles.Stream.Reviewer)
])
})
@@ -470,15 +486,16 @@ describe('Workspaces Roles/Seats GQL', () => {
user: workspaceAdminUser
})
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isOwner)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).hasExplicitRole).to.be.not.ok
})
it('workspaceMemberUser is implicit reviewer in all of them, and also has explicit roles in some', async () => {
it('workspaceMemberUser is implicit reviewer in all of them, except E, and also has explicit roles in some', async () => {
const { projects, checkAllProjects, checkProject } = await getProjects({
user: workspaceMemberUser
})
@@ -489,42 +506,46 @@ describe('Workspaces Roles/Seats GQL', () => {
expect(checkProject(workspaceProjectB).isExplicitContributor).to.be.ok
expect(checkProject(workspaceProjectC).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).hasAccess).to.be.not.ok
})
it('workspaceGuestUser only has explicit roles in 2 projects', async () => {
it('workspaceGuestUser only has explicit roles in 3 projects', async () => {
const { projects, checkProject } = await getProjects({
user: workspaceGuestUser
})
expect(projects.length).to.eq(2)
expect(projects.length).to.eq(3)
expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok
expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectC).hasAccess).to.be.not.ok
expect(checkProject(workspaceProjectD).hasAccess).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
it('workspaceMemberViewerUser is only explicit reviewer in 1 project, and has implicit roles elsewhere', async () => {
it('workspaceMemberViewerUser is only explicit reviewer in 2 projects, and has implicit roles elsewhere', async () => {
const { projects, checkAllProjects, checkProject } = await getProjects({
user: workspaceMemberViewerUser
})
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isReviewer)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
it('workspaceGuestViewerUser is only explicit reviewer in 1 project', async () => {
it('workspaceGuestViewerUser is only explicit reviewer in 2 projects', async () => {
const { projects, checkProject } = await getProjects({
user: workspaceGuestViewerUser
})
expect(projects.length).to.eq(1)
expect(projects.length).to.eq(2)
expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectA).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
})
@@ -582,9 +603,10 @@ describe('Workspaces Roles/Seats GQL', () => {
user: workspaceGuestUser
})
expect(projects.length).to.eq(2)
expect(projects.length).to.eq(3)
expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
})
@@ -605,17 +627,18 @@ describe('Workspaces Roles/Seats GQL', () => {
)
})
it('should still remain explicit owner and be implicit reviewer elsewhere', async () => {
it('should still remain explicit owner and be implicit reviewer elsewhere, except private E', async () => {
const { projects, checkAllProjects, checkProject } = await getProjects({
user: workspaceAdminUser
})
expect(projects.length).to.eq(4)
expect(checkAllProjects((p) => p.isReviewer)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectB).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkAllProjects((p) => p.isReviewer)).to.be.ok
expect(checkProject(workspaceProjectE).hasAccess).to.be.not.ok
})
})
@@ -670,12 +693,13 @@ describe('Workspaces Roles/Seats GQL', () => {
user: workspaceMemberUser
})
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isOwner)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectC).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectE).hasExplicitRole).to.not.be.ok
})
})
@@ -704,6 +728,7 @@ describe('Workspaces Roles/Seats GQL', () => {
expect(checkProject(workspaceProjectB).isExplicitContributor).to.be.ok
expect(checkProject(workspaceProjectC).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).hasExplicitRole).to.be.not.ok
})
})
})
@@ -729,12 +754,13 @@ describe('Workspaces Roles/Seats GQL', () => {
user: workspaceGuestUser
})
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isOwner)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitOwner).to.be.ok
})
})
@@ -758,12 +784,13 @@ describe('Workspaces Roles/Seats GQL', () => {
user: workspaceGuestUser
})
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isReviewer)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitContributor).to.be.ok
expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
})
})
@@ -791,12 +818,13 @@ describe('Workspaces Roles/Seats GQL', () => {
})
expect(workspace.seatType).to.eq(WorkspaceSeatType.Editor)
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isOwner)).to.be.ok
expect(checkProject(workspaceProjectA).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectB).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectE).isExplicitOwner).to.be.ok
})
})
@@ -821,8 +849,9 @@ describe('Workspaces Roles/Seats GQL', () => {
})
expect(workspace.seatType).to.eq(WorkspaceSeatType.Viewer)
expect(projects.length).to.eq(1)
expect(projects.length).to.eq(2)
expect(checkProject(workspaceProjectA).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
})
})
@@ -850,12 +879,13 @@ describe('Workspaces Roles/Seats GQL', () => {
})
expect(workspace.seatType).to.eq(WorkspaceSeatType.Editor)
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isOwner)).to.be.ok
expect(checkProject(workspaceProjectA).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectB).isExplicitOwner).to.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.not.be.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitOwner).to.be.ok
})
})
@@ -874,19 +904,20 @@ describe('Workspaces Roles/Seats GQL', () => {
)
})
it('should retain viewer seat, same explicit access and get full implicit acccess', async () => {
it('should retain viewer seat, same explicit access and get full workspace visibility implicit acccess', async () => {
const { workspace, projects, checkProject, checkAllProjects } =
await getProjects({
user: workspaceGuestViewerUser
})
expect(workspace.seatType).to.eq(WorkspaceSeatType.Viewer)
expect(projects.length).to.eq(4)
expect(projects.length).to.eq(5)
expect(checkAllProjects((p) => p.isReviewer)).to.be.ok
expect(checkProject(workspaceProjectA).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectB).isExplicitReviewer).to.be.ok
expect(checkProject(workspaceProjectC).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectD).hasExplicitRole).to.be.not.ok
expect(checkProject(workspaceProjectE).isExplicitReviewer).to.be.ok
})
})
})
@@ -1,3 +1,4 @@
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import {
assignToWorkspaces,
@@ -110,9 +111,9 @@ const { FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags()
const testProject: BasicTestStream = {
id: '',
ownerId: '',
isPublic: false,
name: 'Workspace Project',
workspaceId: testWorkspaceWithSso.id
workspaceId: testWorkspaceWithSso.id,
visibility: ProjectRecordVisibility.Workspace
}
await createTestStream(testProject, workspaceAdmin)