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:
committed by
GitHub
parent
e3d3c1446b
commit
820a1e2ebf
@@ -241,6 +241,51 @@ export type GetWorkspacesProjectsCounts = (params: {
|
||||
[workspaceId: string]: number
|
||||
}>
|
||||
|
||||
export type GetPaginatedWorkspaceProjectsArgs = {
|
||||
workspaceId: string
|
||||
/**
|
||||
* If set, will take the user's workspace role into account when fetching projects.
|
||||
* E.g. guests will only see projects they have explicit access to.
|
||||
*/
|
||||
userId?: string
|
||||
cursor?: MaybeNullOrUndefined<string>
|
||||
/**
|
||||
* Defaults to 25, if unset
|
||||
*/
|
||||
limit?: MaybeNullOrUndefined<number>
|
||||
filter?: MaybeNullOrUndefined<
|
||||
Partial<{
|
||||
/**
|
||||
* Search for projects by name
|
||||
*/
|
||||
search: MaybeNullOrUndefined<string>
|
||||
/**
|
||||
* Only get projects that the active user has an explicit role in
|
||||
*/
|
||||
withProjectRoleOnly: MaybeNullOrUndefined<boolean>
|
||||
}>
|
||||
>
|
||||
}
|
||||
|
||||
export type GetPaginatedWorkspaceProjectsItems = (
|
||||
params: GetPaginatedWorkspaceProjectsArgs
|
||||
) => Promise<{
|
||||
items: Stream[]
|
||||
cursor: string | null
|
||||
}>
|
||||
|
||||
export type GetPaginatedWorkspaceProjectsTotalCount = (
|
||||
params: Omit<GetPaginatedWorkspaceProjectsArgs, 'cursor' | 'limit'>
|
||||
) => Promise<number>
|
||||
|
||||
export type GetPaginatedWorkspaceProjects = (
|
||||
params: GetPaginatedWorkspaceProjectsArgs
|
||||
) => Promise<{
|
||||
cursor: string | null
|
||||
items: Stream[]
|
||||
totalCount: number
|
||||
}>
|
||||
|
||||
/** Workspace Project Roles */
|
||||
|
||||
type GrantWorkspaceProjectRolesArgs = {
|
||||
|
||||
@@ -499,7 +499,7 @@ export const workspaceTrackingFactory =
|
||||
break
|
||||
case 'gatekeeper.workspace-trial-expired':
|
||||
break
|
||||
case 'workspace.authorized':
|
||||
case WorkspaceEvents.Authorizing:
|
||||
break
|
||||
case 'workspace.created':
|
||||
// we're setting workspace props and attributing to speckle users
|
||||
@@ -732,7 +732,7 @@ export const initializeEventListenersFactory =
|
||||
getWorkspaceSubscription: getWorkspaceSubscriptionFactory({ db })
|
||||
})(payload)
|
||||
}),
|
||||
eventBus.listen(WorkspaceEvents.Authorized, async ({ payload }) => {
|
||||
eventBus.listen(WorkspaceEvents.Authorizing, async ({ payload }) => {
|
||||
const onWorkspaceAuthorized = onWorkspaceAuthorizedFactory({
|
||||
getWorkspace,
|
||||
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
revokeStreamPermissionsFactory,
|
||||
grantStreamPermissionsFactory,
|
||||
legacyGetStreamsFactory,
|
||||
getUserStreamsPageFactory,
|
||||
getUserStreamsCountFactory,
|
||||
getStreamCollaboratorsFactory
|
||||
} from '@/modules/core/repositories/streams'
|
||||
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
|
||||
@@ -74,7 +72,8 @@ import {
|
||||
upsertWorkspaceCreationStateFactory,
|
||||
queryWorkspacesFactory,
|
||||
countWorkspacesFactory,
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory,
|
||||
getPaginatedWorkspaceProjectsFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import {
|
||||
buildWorkspaceInviteEmailContentsFactory,
|
||||
@@ -98,7 +97,6 @@ import {
|
||||
} from '@/modules/workspaces/services/management'
|
||||
import {
|
||||
createWorkspaceProjectFactory,
|
||||
getWorkspaceProjectsFactory,
|
||||
getWorkspaceRoleToDefaultProjectRoleMappingFactory,
|
||||
moveProjectToWorkspaceFactory,
|
||||
queryAllWorkspaceProjectsFactory
|
||||
@@ -288,8 +286,6 @@ const updateStreamRoleAndNotify = updateStreamRoleAndNotifyFactory({
|
||||
}),
|
||||
removeStreamCollaborator
|
||||
})
|
||||
const getUserStreams = getUserStreamsPageFactory({ db })
|
||||
const getUserStreamsCount = getUserStreamsCountFactory({ db })
|
||||
|
||||
const { FF_WORKSPACES_MODULE_ENABLED, FF_MOVE_PROJECT_REGION_ENABLED } =
|
||||
getFeatureFlags()
|
||||
@@ -1207,33 +1203,12 @@ export = FF_WORKSPACES_MODULE_ENABLED
|
||||
return await getPendingTeam({ workspaceId: parent.id, filter: args.filter })
|
||||
},
|
||||
projects: async (parent, args, ctx) => {
|
||||
if (!ctx.userId) return []
|
||||
const getWorkspaceProjects = getWorkspaceProjectsFactory({
|
||||
getStreams: getUserStreams
|
||||
const getWorkspaceProjects = getPaginatedWorkspaceProjectsFactory({ db })
|
||||
return await getWorkspaceProjects({
|
||||
workspaceId: parent.id,
|
||||
userId: ctx.userId!,
|
||||
...args
|
||||
})
|
||||
const filter = {
|
||||
...(args.filter || {}),
|
||||
userId: ctx.userId,
|
||||
workspaceId: parent.id
|
||||
}
|
||||
const { items, cursor } = await getWorkspaceProjects(
|
||||
{
|
||||
workspaceId: parent.id
|
||||
},
|
||||
{
|
||||
limit: args.limit || 25,
|
||||
cursor: args.cursor || null,
|
||||
filter
|
||||
}
|
||||
)
|
||||
return {
|
||||
items,
|
||||
cursor,
|
||||
totalCount: await getUserStreamsCount({
|
||||
...filter,
|
||||
searchQuery: filter.search || undefined
|
||||
})
|
||||
}
|
||||
},
|
||||
automateFunctions: async (parent, args, context) => {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
DeleteWorkspace,
|
||||
DeleteWorkspaceDomain,
|
||||
DeleteWorkspaceRole,
|
||||
GetPaginatedWorkspaceProjects,
|
||||
GetPaginatedWorkspaceProjectsArgs,
|
||||
GetPaginatedWorkspaceProjectsItems,
|
||||
GetPaginatedWorkspaceProjectsTotalCount,
|
||||
GetUserDiscoverableWorkspaces,
|
||||
GetUserIdsWithRoleInWorkspace,
|
||||
GetWorkspace,
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
import { Knex } from 'knex'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import {
|
||||
ServerAclRecord,
|
||||
BranchRecord,
|
||||
StreamAclRecord,
|
||||
StreamRecord
|
||||
@@ -60,16 +65,22 @@ import {
|
||||
InvitesRetrievalValidityFilter
|
||||
} from '@/modules/serverinvites/repositories/serverInvites'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import { clamp } from 'lodash'
|
||||
import { clamp, has, isObjectLike } from 'lodash'
|
||||
import {
|
||||
WorkspaceCreationState,
|
||||
WorkspaceTeamMember
|
||||
} from '@/modules/workspaces/domain/types'
|
||||
import {
|
||||
decodeCompositeCursor,
|
||||
encodeCompositeCursor
|
||||
} from '@/modules/shared/helpers/graphqlHelper'
|
||||
import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper'
|
||||
|
||||
const tables = {
|
||||
branches: (db: Knex) => db<BranchRecord>('branches'),
|
||||
streams: (db: Knex) => db<StreamRecord>('streams'),
|
||||
streamAcl: (db: Knex) => db<StreamAclRecord>('stream_acl'),
|
||||
serverAcl: (db: Knex) => db<ServerAclRecord>(ServerAcl.name),
|
||||
workspaces: (db: Knex) => db<Workspace>('workspaces'),
|
||||
workspaceDomains: (db: Knex) => db<WorkspaceDomain>('workspace_domains'),
|
||||
workspacesAcl: (db: Knex) => db<WorkspaceAcl>('workspace_acl'),
|
||||
@@ -542,3 +553,152 @@ export const getWorkspacesProjectsCountsFactory =
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
const getPaginatedWorkspaceProjectsBaseQueryFactory =
|
||||
(deps: { db: Knex }) =>
|
||||
(params: Omit<GetPaginatedWorkspaceProjectsArgs, 'cursor' | 'limit'>) => {
|
||||
const { workspaceId, userId, filter } = params
|
||||
const { search, withProjectRoleOnly } = filter || {}
|
||||
|
||||
const query = tables
|
||||
.streams(deps.db)
|
||||
.where(Streams.col.workspaceId, workspaceId)
|
||||
.select<StreamRecord[]>(Streams.cols)
|
||||
|
||||
/**
|
||||
* 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 withProjectRoleOnly is set: Require project role always
|
||||
*/
|
||||
if (userId) {
|
||||
query
|
||||
.leftJoin(DbWorkspaceAcl.name, (j) => {
|
||||
j.on(DbWorkspaceAcl.col.workspaceId, Streams.col.workspaceId).andOnVal(
|
||||
DbWorkspaceAcl.col.userId,
|
||||
userId
|
||||
)
|
||||
})
|
||||
.andWhere((w) => {
|
||||
// Check server_acl exist first, so subsequent checks can be optimized away
|
||||
if (adminOverrideEnabled() && !withProjectRoleOnly) {
|
||||
w.whereExists(
|
||||
tables
|
||||
.serverAcl(deps.db)
|
||||
.select('*')
|
||||
.where(ServerAcl.col.userId, userId)
|
||||
.andWhere(ServerAcl.col.role, Roles.Server.Admin)
|
||||
)
|
||||
}
|
||||
|
||||
w.orWhere((w2) => {
|
||||
// Ensure workspace role exists and its not guest or the user has explicit stream roles
|
||||
w2.whereNotNull(DbWorkspaceAcl.col.role).andWhere((w3) => {
|
||||
if (!withProjectRoleOnly) {
|
||||
w3.whereNot(DbWorkspaceAcl.col.role, Roles.Workspace.Guest)
|
||||
}
|
||||
|
||||
w3.orWhereExists(
|
||||
tables
|
||||
.streamAcl(deps.db)
|
||||
.select('*')
|
||||
.where(StreamAcl.col.userId, userId)
|
||||
.andWhere(StreamAcl.col.resourceId, knex.ref(Streams.col.id))
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (search?.length) {
|
||||
query.andWhere((w) => {
|
||||
w.where(Streams.col.name, 'ILIKE', `%${search}%`).orWhere(
|
||||
Streams.col.description,
|
||||
'ILIKE',
|
||||
`%${search}%`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export const getPaginatedWorkspaceProjectsItemsFactory =
|
||||
(deps: { db: Knex }): GetPaginatedWorkspaceProjectsItems =>
|
||||
async (params) => {
|
||||
type CursorType = { updatedAt: string; id: string }
|
||||
const query = getPaginatedWorkspaceProjectsBaseQueryFactory(deps)(params)
|
||||
|
||||
const limit = clamp(params.limit || 25, 1, 50)
|
||||
const cursor = decodeCompositeCursor<CursorType>(
|
||||
params.cursor,
|
||||
(c) => isObjectLike(c) && has(c, 'id') && has(c, 'updatedAt')
|
||||
)
|
||||
|
||||
if (cursor) {
|
||||
// filter by date, and if there's duplicate dates, filter by id too
|
||||
query.andWhereRaw('(??, ??) < (?, ?)', [
|
||||
Streams.col.updatedAt,
|
||||
Streams.col.id,
|
||||
cursor.updatedAt,
|
||||
cursor.id
|
||||
])
|
||||
}
|
||||
|
||||
query
|
||||
.orderBy([
|
||||
{ column: Streams.col.updatedAt, order: 'desc' },
|
||||
{ column: Streams.col.id, order: 'desc' }
|
||||
])
|
||||
.limit(limit)
|
||||
|
||||
const rows = await query
|
||||
const newCursorRow = rows.at(-1)
|
||||
const newCursor = newCursorRow
|
||||
? encodeCompositeCursor<CursorType>({
|
||||
updatedAt: newCursorRow.updatedAt.toISOString(),
|
||||
id: newCursorRow.id
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
items: rows,
|
||||
cursor: newCursor
|
||||
}
|
||||
}
|
||||
|
||||
export const getPaginatedWorkspaceProjectsTotalCountFactory =
|
||||
(deps: { db: Knex }): GetPaginatedWorkspaceProjectsTotalCount =>
|
||||
async (params) => {
|
||||
const query = getPaginatedWorkspaceProjectsBaseQueryFactory(deps)(params)
|
||||
const [res] = await query.clearSelect().count()
|
||||
const count = parseInt(res.count.toString())
|
||||
return count
|
||||
}
|
||||
|
||||
export const getPaginatedWorkspaceProjectsFactory =
|
||||
(deps: { db: Knex }): GetPaginatedWorkspaceProjects =>
|
||||
async (params) => {
|
||||
const getItems = getPaginatedWorkspaceProjectsItemsFactory(deps)
|
||||
const getTotalCount = getPaginatedWorkspaceProjectsTotalCountFactory(deps)
|
||||
|
||||
const [items, totalCount] = await Promise.all([
|
||||
params.limit !== 0 ? getItems(params) : undefined,
|
||||
getTotalCount(params)
|
||||
])
|
||||
|
||||
if (!items) {
|
||||
return {
|
||||
items: [],
|
||||
cursor: null,
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...items,
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { orderByWeight } from '@/modules/shared/domain/rolesAndScopes/logic'
|
||||
import coreUserRoles from '@/modules/core/roles'
|
||||
import {
|
||||
GetStreamCollaborators,
|
||||
GetUserStreamsPage,
|
||||
LegacyGetStreams
|
||||
} from '@/modules/core/domain/streams/operations'
|
||||
import { ProjectNotFoundError } from '@/modules/core/errors/projects'
|
||||
@@ -87,44 +86,6 @@ export const queryAllWorkspaceProjectsFactory = ({
|
||||
} while (!!cursor)
|
||||
}
|
||||
|
||||
type GetWorkspaceProjectsArgs = {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
type GetWorkspaceProjectsOptions = {
|
||||
limit: number | null
|
||||
cursor: string | null
|
||||
filter: {
|
||||
search?: string | null
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
type GetWorkspaceProjectsReturnValue = {
|
||||
items: StreamRecord[]
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
export const getWorkspaceProjectsFactory =
|
||||
({ getStreams }: { getStreams: GetUserStreamsPage }) =>
|
||||
async (
|
||||
args: GetWorkspaceProjectsArgs,
|
||||
opts: GetWorkspaceProjectsOptions
|
||||
): Promise<GetWorkspaceProjectsReturnValue> => {
|
||||
const { streams, cursor } = await getStreams({
|
||||
cursor: opts.cursor,
|
||||
limit: opts.limit || 25,
|
||||
searchQuery: opts.filter?.search || undefined,
|
||||
workspaceId: args.workspaceId,
|
||||
userId: opts.filter.userId
|
||||
})
|
||||
|
||||
return {
|
||||
items: streams,
|
||||
cursor
|
||||
}
|
||||
}
|
||||
|
||||
type MoveProjectToWorkspaceArgs = {
|
||||
projectId: string
|
||||
workspaceId: string
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user