Files
speckle-server/packages/server/modules/accessrequests/tests/streamAccessRequests.spec.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

468 lines
16 KiB
TypeScript

import { buildApolloServer } from '@/app'
import { db } from '@/db/knex'
import {
createNewRequestFactory,
deleteRequestByIdFactory,
getPendingAccessRequestFactory,
getUsersPendingAccessRequestFactory
} from '@/modules/accessrequests/repositories'
import {
getUserProjectAccessRequestFactory,
getUserStreamAccessRequestFactory,
requestProjectAccessFactory,
requestStreamAccessFactory
} from '@/modules/accessrequests/services/stream'
import { StreamActionTypes } from '@/modules/activitystream/helpers/types'
import { getActivitiesFactory } from '@/modules/activitystream/repositories/index'
import {
Activity,
ServerAccessRequests,
StreamActivity,
Streams,
Users
} from '@/modules/core/dbSchema'
import { StreamAccessUpdateError } from '@/modules/core/errors/stream'
import { mapStreamRoleToValue } from '@/modules/core/helpers/graphTypes'
import { Roles } from '@/modules/core/helpers/mainConstants'
import {
getStreamCollaboratorsFactory,
getStreamFactory,
getStreamRolesFactory,
grantStreamPermissionsFactory,
revokeStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import {
addOrUpdateStreamCollaboratorFactory,
isStreamCollaboratorFactory,
removeStreamCollaboratorFactory,
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { NotificationType } from '@/modules/notifications/helpers/types'
import { authorizeResolver } from '@/modules/shared'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import {
createStreamAccessRequest,
getFullStreamAccessRequest,
getPendingStreamAccessRequests,
getStreamAccessRequest,
useStreamAccessRequest
} from '@/test/graphql/accessRequests'
import { StreamRole } from '@/test/graphql/generated/graphql'
import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper'
import { truncateTables } from '@/test/hooks'
import {
buildNotificationsStateTracker,
NotificationsStateManager
} from '@/test/notificationsHelper'
import { getStreamActivities } from '@/test/speckle-helpers/activityStreamHelper'
import { createEmailListener, TestEmailListener } from '@/test/speckle-helpers/email'
import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper'
import { expect } from 'chai'
import { noop } from 'lodash-es'
const getUser = getUserFactory({ db })
const getStreamCollaborators = getStreamCollaboratorsFactory({ db })
const getStream = getStreamFactory({ db })
const requestStreamAccess = requestStreamAccessFactory({
requestProjectAccess: requestProjectAccessFactory({
getUserStreamAccessRequest: getUserStreamAccessRequestFactory({
getUserProjectAccessRequest: getUserProjectAccessRequestFactory({
getUsersPendingAccessRequest: getUsersPendingAccessRequestFactory({ db })
})
}),
getStream,
createNewRequest: createNewRequestFactory({ db }),
emitEvent: getEventBus().emit
})
})
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
const isStreamCollaborator = isStreamCollaboratorFactory({
getStream
})
const removeStreamCollaborator = removeStreamCollaboratorFactory({
validateStreamAccess,
isStreamCollaborator,
revokeStreamPermissions: revokeStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const getActivities = getActivitiesFactory({ db })
const isNotCollaboratorError = (e: unknown) =>
e instanceof StreamAccessUpdateError &&
e.message.includes('User is not a stream collaborator')
const createReqAndGetId = async (userId: string, streamId: string) => {
const createReqRes = await requestStreamAccess(userId, streamId)
return createReqRes.id
}
const cleanup = async () => {
await truncateTables([Streams.name, ServerAccessRequests.name, Users.name])
}
describe('Stream access requests', () => {
let apollo: ServerAndContext
let notificationsStateManager: NotificationsStateManager
const me: BasicTestUser = {
name: 'hello itsa me',
email: '',
id: ''
}
const otherGuy: BasicTestUser = {
name: 'and im the other guy, hi!',
email: '',
id: ''
}
const anotherGuy: BasicTestUser = {
name: 'and im another guy lol',
email: '',
id: ''
}
const otherGuysPrivateStream: BasicTestStream = {
name: 'other guys test stream #1',
isPublic: false,
ownerId: '',
id: ''
}
const otherGuysPublicStream: BasicTestStream = {
name: 'other guys public test stream #2',
isPublic: true,
ownerId: '',
id: ''
}
const myPrivateStream: BasicTestStream = {
name: 'this is my private stream #1',
isPublic: false,
ownerId: '',
id: ''
}
let emailListener: TestEmailListener
before(async () => {
await cleanup()
await createTestUsers([me, otherGuy, anotherGuy])
await createTestStreams([
[otherGuysPrivateStream, otherGuy],
[otherGuysPublicStream, otherGuy],
[myPrivateStream, me]
])
apollo = {
apollo: await buildApolloServer(),
context: await createAuthedTestContext(me.id)
}
notificationsStateManager = buildNotificationsStateTracker()
emailListener = await createEmailListener()
})
after(async () => {
notificationsStateManager.destroy()
await emailListener.destroy()
})
afterEach(async () => {
emailListener.reset()
})
const createReq = (streamId: string) =>
createStreamAccessRequest(apollo, { streamId })
const getReq = (streamId: string) => getStreamAccessRequest(apollo, { streamId })
const getStreamReqs = (streamId: string) =>
getPendingStreamAccessRequests(apollo, { streamId })
const useReq = (
requestId: string,
accept: boolean,
role: StreamRole = StreamRole.StreamContributor
) => useStreamAccessRequest(apollo, { requestId, accept, role })
describe('when being created', () => {
beforeEach(async () => {
await truncateTables([ServerAccessRequests.name, StreamActivity.name])
})
afterEach(async () => {
// Ensure me doesnt have any roles on stream1
await removeStreamCollaborator(otherGuysPrivateStream.id, me.id, me.id).catch(
(e) => {
if (!isNotCollaboratorError(e)) throw e
}
)
})
it('operation succeeds', async () => {
const { getSends } = emailListener.listen({ times: 1 })
const waitForAck = notificationsStateManager.waitForAck(
(e) => e.result?.type === NotificationType.NewStreamAccessRequest
)
const results = await createReq(otherGuysPrivateStream.id)
// req gets created
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.streamAccessRequestCreate.id).to.be.ok
expect(results.data?.streamAccessRequestCreate?.createdAt).to.be.ok
expect(results.data?.streamAccessRequestCreate?.requesterId).to.be.ok
expect(results.data?.streamAccessRequestCreate?.requester.id).to.eq(
results.data?.streamAccessRequestCreate?.requesterId
)
expect(results.data?.streamAccessRequestCreate.streamId).to.be.ok
await waitForAck
// email gets sent out
const sentEmails = getSends()
expect(sentEmails.length).to.eq(1)
const emailParams = sentEmails[0]
expect(emailParams.subject).to.contain('A user requested access to your project')
expect(emailParams.html).to.be.ok
expect(emailParams.text).to.be.ok
expect(emailParams.to).to.eq(otherGuy.email)
// activity stream item inserted
const streamActivity = await getStreamActivities(otherGuysPrivateStream.id, {
actionType: StreamActionTypes.Stream.AccessRequestSent,
userId: me.id
})
expect(streamActivity).to.have.lengthOf(1)
})
it('operation fails if request already exists', async () => {
const firstResults = await createReq(otherGuysPrivateStream.id)
expect(firstResults).to.not.haveGraphQLErrors()
expect(firstResults.data?.streamAccessRequestCreate.id).to.be.ok
const secondResults = await createReq(otherGuysPrivateStream.id)
expect(secondResults).to.haveGraphQLErrors('already has a pending access request')
expect(secondResults.data?.streamAccessRequestCreate.id).to.be.not.ok
})
it('operation fails if stream is nonexistant', async () => {
const secondResults = await createReq('abcdef123')
expect(secondResults).to.haveGraphQLErrors('non-existant resource')
expect(secondResults.data?.streamAccessRequestCreate.id).to.be.not.ok
})
it('operation fails if user already has a role on the stream', async () => {
await addOrUpdateStreamCollaborator(
otherGuysPrivateStream.id,
me.id,
Roles.Stream.Contributor,
otherGuy.id
)
const secondResults = await createReq(otherGuysPrivateStream.id)
expect(secondResults).to.haveGraphQLErrors('user already has access')
expect(secondResults.data?.streamAccessRequestCreate.id).to.be.not.ok
})
})
describe('when being read', () => {
let myRequestId: string
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let myPublicReqId: string
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let anotherGuysRequestId: string
beforeEach(async () => {
await truncateTables([ServerAccessRequests.name])
const [myNewReqId, anotherGuysNewReqId, myNewPublicReqId] = await Promise.all([
createReqAndGetId(me.id, otherGuysPrivateStream.id),
createReqAndGetId(anotherGuy.id, otherGuysPrivateStream.id),
createReqAndGetId(me.id, otherGuysPublicStream.id)
])
myRequestId = myNewReqId
anotherGuysRequestId = anotherGuysNewReqId
myPublicReqId = myNewPublicReqId
})
it('returns the request correctly', async () => {
const results = await getReq(otherGuysPrivateStream.id)
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.streamAccessRequest?.id).to.eq(myRequestId)
expect(results.data?.streamAccessRequest?.createdAt).to.be.ok
expect(results.data?.streamAccessRequest?.requesterId).to.be.ok
expect(results.data?.streamAccessRequest?.requester.id).to.eq(
results.data?.streamAccessRequest?.requesterId
)
expect(results.data?.streamAccessRequest?.streamId).to.be.ok
})
it('returns null if no req found', async () => {
await deleteRequestByIdFactory({ db })(myRequestId)
const results = await getReq(otherGuysPrivateStream.id)
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.streamAccessRequest).to.eq(null)
})
it('throws error if attempting to read private stream metadata before has access to it', async () => {
const results = await getFullStreamAccessRequest(apollo, {
streamId: otherGuysPrivateStream.id
})
expect(results).to.haveGraphQLErrors(
'User does not have required access to stream'
)
})
it('doesnt throw if attempting to read stream metadata on accessible stream', async () => {
const results = await getFullStreamAccessRequest(apollo, {
streamId: otherGuysPublicStream.id
})
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.streamAccessRequest?.stream.id).to.be.ok
})
})
describe('when being read from a stream', () => {
before(async () => {
await truncateTables([ServerAccessRequests.name])
await addOrUpdateStreamCollaborator(
otherGuysPublicStream.id,
me.id,
Roles.Stream.Contributor,
otherGuy.id
)
await Promise.all([
createReqAndGetId(otherGuy.id, myPrivateStream.id),
createReqAndGetId(anotherGuy.id, myPrivateStream.id)
])
})
after(async () => {
await removeStreamCollaborator(otherGuysPublicStream.id, me.id, me.id).catch(noop)
})
it(`operation fails if reading from a non-owned stream`, async () => {
const results = await getStreamReqs(otherGuysPublicStream.id)
expect(results).to.haveGraphQLErrors('not authorized')
expect(results.data?.stream?.pendingAccessRequests).to.be.not.ok
expect(results.data?.stream?.id).to.be.ok
})
it('operation succeeds', async () => {
const results = await getStreamReqs(myPrivateStream.id)
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.stream?.pendingAccessRequests).to.have.lengthOf(2)
for (const pendingReq of results.data!.stream!.pendingAccessRequests!) {
expect(pendingReq.id).to.be.ok
expect(pendingReq.createdAt).to.be.ok
expect(pendingReq.requesterId).to.be.ok
expect(pendingReq.streamId).to.be.ok
expect(pendingReq.stream.id).to.eq(results.data!.stream!.id)
expect(pendingReq.requester.id).to.eq(pendingReq.requesterId)
expect([otherGuy.id, anotherGuy.id].includes(pendingReq.requesterId)).to.be.true
}
})
})
describe('when being processed', () => {
let validReqId: string
beforeEach(async () => {
await truncateTables([
ServerAccessRequests.name,
StreamActivity.name,
Activity.name
])
await removeStreamCollaborator(
myPrivateStream.id,
otherGuy.id,
otherGuy.id
).catch((e) => {
if (!isNotCollaboratorError(e)) throw e
})
validReqId = await createReqAndGetId(otherGuy.id, myPrivateStream.id)
})
it('processing fails when pointing to nonexistant req', async () => {
const results = await useReq('abcd', true)
expect(results).to.haveGraphQLErrors('no request with this id exists')
expect(results.data?.streamAccessRequestUse).to.be.not.ok
})
it('processing fails when pointing to a req the user doesnt have access to', async () => {
const inaccessibleReqId = await createReqAndGetId(
anotherGuy.id,
otherGuysPrivateStream.id
)
const results = await useReq(inaccessibleReqId, true)
expect(results).to.haveGraphQLErrors('you must own the stream')
expect(results.data?.streamAccessRequestUse).to.be.not.ok
})
const validProcessingDataSet = [
{ display: 'declining', accept: false },
{ display: 'approving', accept: true },
{
display: 'approving with custom role',
accept: true,
role: StreamRole.StreamReviewer
}
]
validProcessingDataSet.forEach(({ display, accept, role }) => {
it(`${display} works`, async () => {
const results = await useReq(validReqId, accept, role)
expect(results).to.not.haveGraphQLErrors()
expect(results.data?.streamAccessRequestUse).to.be.ok
// req should be deleted
const req = await getPendingAccessRequestFactory({ db })(validReqId)
expect(req).to.not.be.ok
// activity stream item should be inserted
if (accept) {
const streamActivity = await getActivities({
projectId: myPrivateStream.id,
userId: me.id,
eventType: 'project_role_updated'
})
expect(streamActivity).to.have.lengthOf(1)
const collaborators = await getStreamCollaborators(myPrivateStream.id)
const newCollaborator = collaborators.find((c) => c.id === otherGuy.id)
expect(newCollaborator).to.be.ok
expect(newCollaborator?.streamRole).to.eq(
role ? mapStreamRoleToValue(role) : Roles.Stream.Contributor
)
} else {
const streamActivity = await getStreamActivities(myPrivateStream.id, {
actionType: StreamActionTypes.Stream.AccessRequestDeclined,
userId: me.id
})
expect(streamActivity).to.have.lengthOf(1)
}
})
})
})
})