diff --git a/packages/server/assets/core/typedefs/modelsAndVersions.graphql b/packages/server/assets/core/typedefs/modelsAndVersions.graphql index 92dc0a362..dc3a477c6 100644 --- a/packages/server/assets/core/typedefs/modelsAndVersions.graphql +++ b/packages/server/assets/core/typedefs/modelsAndVersions.graphql @@ -344,12 +344,19 @@ extend type Subscription { Subscribe to changes to a project's models. Optionally specify modelIds to track. """ projectModelsUpdated(id: String!, modelIds: [String!]): ProjectModelsUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") + """ Subscribe to changes to a project's versions. """ projectVersionsUpdated(id: String!): ProjectVersionsUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") """ Subscribe to when a project's versions get their preview image fully generated. """ projectVersionsPreviewGenerated(id: String!): ProjectVersionsPreviewGeneratedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") } diff --git a/packages/server/assets/core/typedefs/projects.graphql b/packages/server/assets/core/typedefs/projects.graphql index 6ff502f8b..d52b300ee 100644 --- a/packages/server/assets/core/typedefs/projects.graphql +++ b/packages/server/assets/core/typedefs/projects.graphql @@ -245,10 +245,15 @@ extend type Subscription { Track newly added or deleted projects owned by the active user """ userProjectsUpdated: UserProjectsUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "profile:read") + """ Track updates to a specific project """ projectUpdated(id: String!): ProjectUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "streams:read") } extend type User { diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index de95bbfc2..ce5027e4b 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -551,10 +551,17 @@ extend type Subscription { workspaceId: String workspaceSlug: String ): WorkspaceProjectsUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScopes(scopes: ["workspace:read", "streams:read"]) """ Track updates to a specific workspace. Either slug or id must be set. """ - workspaceUpdated(workspaceId: String, workspaceSlug: String): WorkspaceUpdatedMessage! + workspaceUpdated( + workspaceId: String + workspaceSlug: String + ): WorkspaceUpdatedMessage! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "workspace:read") } diff --git a/packages/server/modules/core/services/branch/management.ts b/packages/server/modules/core/services/branch/management.ts index b0335193e..c2822ec53 100644 --- a/packages/server/modules/core/services/branch/management.ts +++ b/packages/server/modules/core/services/branch/management.ts @@ -1,4 +1,4 @@ -import { Roles, isNullOrUndefined } from '@speckle/shared' +import { Roles, ensureError, isNullOrUndefined } from '@speckle/shared' import { BranchCreateError, BranchDeleteError, @@ -95,7 +95,21 @@ export const updateBranchAndNotifyFactory = throw new BranchUpdateError('Please specify a property to update') } - const newBranch = await deps.updateBranch(input.id, updates) + let newBranch: BranchRecord + try { + newBranch = await deps.updateBranch(input.id, updates) + } catch (e) { + if (ensureError(e).message.includes('branches_streamid_name_unique')) { + throw new BranchUpdateError( + 'A branch with this name already exists in the parent stream', + { + info: { ...input, userId } + } + ) + } else { + throw e + } + } if (newBranch) { await deps.addBranchUpdatedActivity({ diff --git a/packages/server/modules/core/tests/graphSubs.spec.js b/packages/server/modules/core/tests/graphSubs.spec.js deleted file mode 100644 index 0432f9de4..000000000 --- a/packages/server/modules/core/tests/graphSubs.spec.js +++ /dev/null @@ -1,872 +0,0 @@ -const expect = require('chai').expect -const request = require('supertest') -const { gql } = require('graphql-tag') -const { WebSocketLink } = require('@apollo/client/link/ws') -const { execute } = require('@apollo/client/core') - -const { SubscriptionClient } = require('subscriptions-transport-ws') -const ws = require('ws') - -const { beforeEachContext } = require(`@/test/hooks`) - -const { sleep, noErrors } = require('@/test/helpers') -const { packageRoot } = require('@/bootstrap') -const { Roles, Scopes } = require('@speckle/shared') -const { getFreeServerPort } = require('@/test/serverHelper') -const { saveActivityFactory } = require('@/modules/activitystream/repositories') -const { db } = require('@/db/knex') -const { - validateStreamAccessFactory, - addOrUpdateStreamCollaboratorFactory -} = require('@/modules/core/services/streams/access') -const { authorizeResolver } = require('@/modules/shared') -const { grantStreamPermissionsFactory } = require('@/modules/core/repositories/streams') -const { - addStreamInviteAcceptedActivityFactory, - addStreamPermissionsAddedActivityFactory -} = require('@/modules/activitystream/services/streamActivity') -const { publish } = require('@/modules/shared/utils/subscriptions') -const { - getUserFactory, - storeUserFactory, - countAdminUsersFactory, - storeUserAclFactory -} = require('@/modules/core/repositories/users') -const { - findEmailFactory, - createUserEmailFactory, - ensureNoPrimaryEmailForUserFactory -} = require('@/modules/core/repositories/userEmails') -const { - requestNewEmailVerificationFactory -} = require('@/modules/emails/services/verification/request') -const { - deleteOldAndInsertNewVerificationFactory -} = require('@/modules/emails/repositories') -const { renderEmail } = require('@/modules/emails/services/emailRendering') -const { sendEmail } = require('@/modules/emails/services/sending') -const { createUserFactory } = require('@/modules/core/services/users/management') -const { - validateAndCreateUserEmailFactory -} = require('@/modules/core/services/userEmails') -const { - finalizeInvitedServerRegistrationFactory -} = require('@/modules/serverinvites/services/processing') -const { - deleteServerOnlyInvitesFactory, - updateAllInviteTargetsFactory -} = require('@/modules/serverinvites/repositories/serverInvites') -const { UsersEmitter } = require('@/modules/core/events/usersEmitter') -const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens') -const { - storeApiTokenFactory, - storeTokenScopesFactory, - storeTokenResourceAccessDefinitionsFactory, - storePersonalApiTokenFactory -} = require('@/modules/core/repositories/tokens') -const { getServerInfoFactory } = require('@/modules/core/repositories/server') - -const saveActivity = saveActivityFactory({ db }) -const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) - -const getUser = getUserFactory({ db }) -const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ - validateStreamAccess, - getUser, - grantStreamPermissions: grantStreamPermissionsFactory({ db }), - addStreamInviteAcceptedActivity: addStreamInviteAcceptedActivityFactory({ - saveActivity, - publish - }), - addStreamPermissionsAddedActivity: addStreamPermissionsAddedActivityFactory({ - saveActivity, - publish - }) -}) - -const getServerInfo = getServerInfoFactory({ db }) -const findEmail = findEmailFactory({ db }) -const requestNewEmailVerification = requestNewEmailVerificationFactory({ - findEmail, - getUser: getUserFactory({ db }), - getServerInfo, - deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), - renderEmail, - sendEmail -}) -const createUser = createUserFactory({ - getServerInfo, - findEmail, - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail, - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification - }), - usersEventsEmitter: UsersEmitter.emit -}) - -const createPersonalAccessToken = createPersonalAccessTokenFactory({ - storeApiToken: storeApiTokenFactory({ db }), - storeTokenScopes: storeTokenScopesFactory({ db }), - storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({ - db - }), - storePersonalApiToken: storePersonalApiTokenFactory({ db }) -}) - -let addr -let wsAddr -let childPort = null - -describe('GraphQL API Subscriptions @gql-subscriptions', () => { - const userA = { - name: 'd1', - email: 'd.1@speckle.systems', - password: 'wow8charsplease' - } - const userB = { - name: 'd2', - email: 'd.2@speckle.systems', - password: 'wow8charsplease' - } - const userC = { - name: 'd3', - email: 'd.3@speckle.systems', - password: 'wow8charsplease' - } - - /** @type {import('child_process').ChildProcessWithoutNullStreams} */ - let serverProcess - - const getWsClient = (wsurl, authToken) => { - const client = new SubscriptionClient( - wsurl, - { - reconnect: true, - connectionParams: { headers: { Authorization: authToken } } - }, - ws - ) - return client - } - - const createSubscriptionObservable = (wsurl, authToken, query, variables) => { - authToken = authToken || userA.token - const link = new WebSocketLink(getWsClient(wsurl, authToken)) - return execute(link, { query, variables }) - } - - // set up app & two basic users to ping pong permissions around - before(async function () { - this.timeout(15000) // we need to wait for the server to start in the child process! - - await beforeEachContext() - - const childProcess = require('child_process') - console.log(' Starting server... this may take a while.') - - childPort = await getFreeServerPort() - addr = `http://127.0.0.1:${childPort}/graphql` - wsAddr = `ws://127.0.0.1:${childPort}/graphql` - - // if u want to see full child process output, change LOG_LEVEL to info for dev:server:test in package.json - serverProcess = childProcess.spawn( - /^win/.test(process.platform) ? 'npm.cmd' : 'npm', - ['run', 'dev:server:test'], - { cwd: packageRoot, env: { ...process.env, PORT: childPort }, stdio: 'inherit' } - ) - serverProcess.on('error', (err) => { - console.error(err) - }) - - console.log(` Waiting on child server to be started at PORT ${childPort} `) - // lets wait for the server is starting up - - while (true) { - try { - const res = await sendRequest('', { - query: `query {serverInfo{version}}` - }) - if (res.status === 200) { - break - } - } catch { - //continue - } - await sleep(1000) - } - - userA.id = await createUser(userA) - const token = await createPersonalAccessToken(userA.id, 'test token user A', [ - Scopes.Streams.Read, - Scopes.Streams.Write, - Scopes.Users.Read, - Scopes.Users.Email, - Scopes.Tokens.Write, - Scopes.Tokens.Read, - Scopes.Profile.Read, - Scopes.Profile.Email - ]) - userA.token = `Bearer ${token}` - - userB.id = await createUser(userB) - userB.token = `Bearer ${await createPersonalAccessToken( - userB.id, - 'test token user B', - [ - Scopes.Streams.Read, - Scopes.Streams.Write, - Scopes.Users.Read, - Scopes.Users.Email, - Scopes.Tokens.Write, - Scopes.Tokens.Read, - Scopes.Profile.Read, - Scopes.Profile.Email - ] - )}` - - userC.id = await createUser(userC) - userC.token = `Bearer ${await createPersonalAccessToken( - userC.id, - 'test token user B', - [Scopes.Streams.Read, Scopes.Streams.Write, Scopes.Users.Read, Scopes.Users.Email] - )}` - }) - - after(async () => { - serverProcess.kill(9) // force killing with SIGKILL - }) - - describe('Streams', () => { - it('A user (me) should be notified when a stream is created', async () => { - let eventNum = 0 - const query = gql` - subscription mySub { - userStreamAdded - } - ` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamAdded).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sleep(2500) // we need to wait up a second here - expect(eventNum).to.equal(2) - - consumer.unsubscribe() - }).timeout(5000) - - it('A user (me) should be notified when a stream is deleted', async () => { - const sc1 = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const sc2 = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - - const sid1 = sc1.body.data.streamCreate - const sid2 = sc2.body.data.streamCreate - - let eventNum = 0 - const query = gql` - subscription userStreamRemoved { - userStreamRemoved - } - ` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamRemoved).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { streamDelete(id: "${sid1}" ) }` - }) - .expect(200) - .expect(noErrors) - - await sendRequest(userA.token, { - query: `mutation { streamDelete(id: "${sid2}" ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it('A user (me) should be notified when stream permission is granted', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql` - subscription permissionGranted { - userStreamAdded - } - ` - const client = createSubscriptionObservable(wsAddr, userB.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamAdded).to.exist - expect(eventData.data.userStreamAdded.sharedBy).to.exist - eventNum++ - }) - - await sleep(500) - - // Add stream permission directly - await addOrUpdateStreamCollaborator( - streamId, - userB.id, - Roles.Stream.Contributor, - userA.id - ) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(1) - consumer.unsubscribe() - }).timeout(5000) - - it('A user (me) should be notified when stream permission is revoked', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql` - subscription permissionRevoked { - userStreamRemoved - } - ` - const client = createSubscriptionObservable(wsAddr, userB.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamRemoved).to.exist - expect(eventData.data.userStreamRemoved.revokedBy).to.exist - eventNum++ - }) - - await sleep(500) - - // Add stream permission directly - await addOrUpdateStreamCollaborator( - streamId, - userB.id, - Roles.Stream.Contributor, - userA.id - ) - - await sendRequest(userA.token, { - query: `mutation { streamRevokePermission( permissionParams: {streamId: "${streamId}", userId: "${userB.id}"} ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(1) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a stream is updated', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql`subscription streamUpdated { streamUpdated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.streamUpdated).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { streamUpdate(stream: { id: "${streamId}", description: "updated this stream" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { streamUpdate(stream: { id: "${streamId}", description: "updated this stream... again!" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sendRequest(userA.token, { - query: `mutation { streamUpdate(stream: { id: "${streamId}", description: "updated this stream... again!" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(3) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a stream is deleted', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql`subscription streamDeleted { streamDeleted( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.streamDeleted).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { streamDelete( id: "${streamId}" ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(1) - consumer.unsubscribe() - }).timeout(5000) - - it('Should *not* be notified of stream creation if invalid token', async () => { - let eventNum = 0 - const query = gql` - subscription mySub { - userStreamAdded - } - ` - const client = createSubscriptionObservable(wsAddr, 'faketoken123', query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data).to.not.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(0) - consumer.unsubscribe() - }).timeout(5000) - - it('Should *not* be notified of another user stream created', async () => { - const query = gql` - subscription mySub { - userStreamAdded - } - ` - const client = createSubscriptionObservable(wsAddr, userB.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamAdded).to.not.exist - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - consumer.unsubscribe() - }) - - it('Should *not* allow subscribing to stream creation without profile:read scope', async () => { - let eventNum = 0 - const query = gql` - subscription mySub { - userStreamAdded - } - ` - const client = createSubscriptionObservable(wsAddr, userC.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.userStreamAdded).to.not.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userC.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sendRequest(userC.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - // unlike with `validateResolver` and `withFilter` within the subscription resolver, this is controlled with a - // directive which wraps the entire resolver. it seems that in this case the resolver fully executes and does ping - // the subscriber and increment the eventNum, but ofc does not return a payload if you don't satisfy the directive - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - }) - - describe('Branches', () => { - it('Should be notified when a branch is created', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql`subscription { branchCreated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.branchCreated).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "new branch 🌿", description: "this is a test branch 🌳" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "another branch 🥬", description: "this is a test branch 🌳" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a branch is updated', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const bc1 = await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "new branch 🌿", description: "this is a test branch 🌳" } ) }` - }) - const branchId = bc1.body.data.branchCreate - - let eventNum = 0 - const query = gql`subscription { branchUpdated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.branchUpdated).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { branchUpdate ( branch: { streamId: "${streamId}", id: "${branchId}", description: "updating this branch" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { branchUpdate ( branch: { streamId: "${streamId}", id: "${branchId}", description: "updating this branch v2" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a branch is deleted', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const bc1 = await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "new branch 🌿", description: "this is a test branch 🌳" } ) }` - }) - const bc2 = await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "another branch 🥬", description: "this is a test branch 🌳" } ) }` - }) - const bid1 = bc1.body.data.branchCreate - const bid2 = bc2.body.data.branchCreate - - let eventNum = 0 - const query = gql`subscription { branchDeleted( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.branchDeleted).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { branchDelete ( branch: { streamId: "${streamId}", id: "${bid1}" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { branchDelete ( branch: { streamId: "${streamId}", id: "${bid2}" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it("Should *not* be notified when a branch is created for a stream you're not authorised for", async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - - let eventNum = 0 - const query = gql`subscription { branchCreated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userB.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.branchCreated).to.not.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { branchCreate ( branch: { streamId: "${streamId}", name: "new branch 🌿", description: "this is a test branch 🌳" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(0) - consumer.unsubscribe() - }).timeout(5000) - }) - - describe('Commits', () => { - it('Should be notified when a commit is created', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const resOC1 = await sendRequest(userA.token, { - query: `mutation { objectCreate( objectInput: {streamId: "${streamId}", objects: {hello: "goodbye 🌊"}} ) }` - }) - const resOC2 = await sendRequest(userA.token, { - query: `mutation { objectCreate( objectInput: {streamId: "${streamId}", objects: {wow: "cool 🐟"}} ) }` - }) - const objId1 = resOC1.body.data.objectCreate - const objId2 = resOC2.body.data.objectCreate - - let eventNum = 0 - const query = gql`subscription { commitCreated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.commitCreated).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { commitCreate ( commit: { streamId: "${streamId}", branchName: "main", objectId: "${objId1}" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { commitCreate ( commit: { streamId: "${streamId}", branchName: "main", objectId: "${objId2}" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a commit is updated', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const resOC = await sendRequest(userA.token, { - query: `mutation { objectCreate( objectInput: {streamId: "${streamId}", objects: {hello: "goodbye 🌊"}} ) }` - }) - const objId = resOC.body.data.objectCreate - const resCC = await sendRequest(userA.token, { - query: `mutation { commitCreate ( commit: { streamId: "${streamId}", branchName: "main", objectId: "${objId}" } ) }` - }) - const commitId = resCC.body.data.commitCreate - - let eventNum = 0 - const query = gql`subscription { commitUpdated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.commitUpdated).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { commitUpdate ( commit: { streamId: "${streamId}", id: "${commitId}", message: "updating this commit" } ) }` - }) - .expect(200) - .expect(noErrors) - await sendRequest(userA.token, { - query: `mutation { commitUpdate ( commit: { streamId: "${streamId}", id: "${commitId}", message: "updating this commit v2" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(2) - consumer.unsubscribe() - }).timeout(5000) - - it('Should be notified when a commit is deleted', async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const resOC = await sendRequest(userA.token, { - query: `mutation { objectCreate( objectInput: {streamId: "${streamId}", objects: {hello: "goodbye 🌊"}} ) }` - }) - const objId = resOC.body.data.objectCreate - const resCC = await sendRequest(userA.token, { - query: `mutation { commitCreate ( commit: { streamId: "${streamId}", branchName: "main", objectId: "${objId}" } ) }` - }) - const commitId = resCC.body.data.commitCreate - - let eventNum = 0 - const query = gql`subscription { commitDeleted( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userA.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.commitDeleted).to.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { commitDelete ( commit: { streamId: "${streamId}", id: "${commitId}" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(1) - consumer.unsubscribe() - }).timeout(5000) - - it("Should *not* be notified when a commit is created on a stream you're not authorised for", async () => { - const resSC = await sendRequest(userA.token, { - query: - 'mutation { streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } ) }' - }) - const streamId = resSC.body.data.streamCreate - const resOC = await sendRequest(userA.token, { - query: `mutation { objectCreate( objectInput: {streamId: "${streamId}", objects: {hello: "goodbye 🌊"}} ) }` - }) - const objId = resOC.body.data.objectCreate - - let eventNum = 0 - const query = gql`subscription { commitCreated( streamId: "${streamId}" ) }` - const client = createSubscriptionObservable(wsAddr, userB.token, query) - const consumer = client.subscribe((eventData) => { - expect(eventData.data.commitCreated).to.not.exist - eventNum++ - }) - - await sleep(500) - - await sendRequest(userA.token, { - query: `mutation { commitCreate ( commit: { streamId: "${streamId}", branchName: "main", objectId: "${objId}" } ) }` - }) - .expect(200) - .expect(noErrors) - - await sleep(1000) // we need to wait up a second here - expect(eventNum).to.equal(0) - consumer.unsubscribe() - }).timeout(5000) - }) -}) - -/** - * Sends a graphql request. Convenience wrapper. - * @param {string} auth the user's token - * @param {string} obj the query/mutation to send - * @return {Promise} the awaitable request - */ -function sendRequest(auth, obj, address = addr) { - return request(address) - .post('/graphql') - .set({ Authorization: auth, Accept: 'application/json' }) - .send(obj) -} diff --git a/packages/server/modules/core/tests/helpers/graphql.ts b/packages/server/modules/core/tests/helpers/graphql.ts index f501cfe70..d5597f8b0 100644 --- a/packages/server/modules/core/tests/helpers/graphql.ts +++ b/packages/server/modules/core/tests/helpers/graphql.ts @@ -25,12 +25,37 @@ export const onUserProjectsUpdatedSubscription = gql` } ` +export const onProjectUpdatedSubscription = gql` + subscription OnProjectUpdated($projectId: String!) { + projectUpdated(id: $projectId) { + id + type + project { + id + name + } + } + } +` + export const onUserStreamAddedSubscription = gql` subscription OnUserStreamAdded { userStreamAdded } ` +export const onUserStreamRemovedSubscription = gql` + subscription OnUserStreamRemoved { + userStreamRemoved + } +` + +export const onStreamUpdatedSubscription = gql` + subscription OnStreamUpdated($streamId: String!) { + streamUpdated(streamId: $streamId) + } +` + export const onUserProjectVersionsUpdatedSubscription = gql` subscription OnUserProjectVersionsUpdated($projectId: String!) { projectVersionsUpdated(id: $projectId) { @@ -50,3 +75,46 @@ export const onUserStreamCommitCreatedSubscription = gql` commitCreated(streamId: $streamId) } ` + +export const onUserStreamCommitDeletedSubscription = gql` + subscription OnUserStreamCommitDeleted($streamId: String!) { + commitDeleted(streamId: $streamId) + } +` + +export const onUserStreamCommitUpdatedSubscription = gql` + subscription OnUserStreamCommitUpdated($streamId: String!, $commitId: String) { + commitUpdated(streamId: $streamId, commitId: $commitId) + } +` + +export const onProjectModelsUpdatedSubscription = gql` + subscription OnProjectModelsUpdated($projectId: String!, $modelIds: [String!]) { + projectModelsUpdated(id: $projectId, modelIds: $modelIds) { + id + type + model { + id + name + } + } + } +` + +export const onBranchCreatedSubscription = gql` + subscription OnBranchCreated($streamId: String!) { + branchCreated(streamId: $streamId) + } +` + +export const onBranchUpdatedSubscription = gql` + subscription OnBranchUpdated($streamId: String!, $branchId: String) { + branchUpdated(streamId: $streamId, branchId: $branchId) + } +` + +export const onBranchDeletedSubscription = gql` + subscription OnBranchDeleted($streamId: String!) { + branchDeleted(streamId: $streamId) + } +` diff --git a/packages/server/modules/core/tests/integration/subs.graph.spec.ts b/packages/server/modules/core/tests/integration/subs.graph.spec.ts index 047f8b555..115c6de1b 100644 --- a/packages/server/modules/core/tests/integration/subs.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/subs.graph.spec.ts @@ -1,13 +1,94 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { db } from '@/db/knex' +import { saveActivityFactory } from '@/modules/activitystream/repositories' +import { + addBranchDeletedActivityFactory, + addBranchUpdatedActivityFactory +} from '@/modules/activitystream/services/branchActivity' +import { + addCommitDeletedActivityFactory, + addCommitUpdatedActivityFactory +} from '@/modules/activitystream/services/commitActivity' +import { + addStreamDeletedActivityFactory, + addStreamInviteAcceptedActivityFactory, + addStreamPermissionsAddedActivityFactory, + addStreamPermissionsRevokedActivityFactory, + addStreamUpdatedActivityFactory +} from '@/modules/activitystream/services/streamActivity' +import { ModelsEmitter } from '@/modules/core/events/modelsEmitter' +import { AllScopes } from '@/modules/core/helpers/mainConstants' +import { + deleteBranchByIdFactory, + getBranchByIdFactory, + getStreamBranchByNameFactory, + markCommitBranchUpdatedFactory, + updateBranchFactory +} from '@/modules/core/repositories/branches' +import { + deleteCommitsFactory, + getCommitBranchFactory, + getCommitFactory, + getCommitsFactory, + switchCommitBranchFactory, + updateCommitFactory +} from '@/modules/core/repositories/commits' +import { + deleteStreamFactory, + getCommitStreamFactory, + getStreamCollaboratorsFactory, + getStreamFactory, + getStreamsFactory, + grantStreamPermissionsFactory, + markBranchStreamUpdatedFactory, + markCommitStreamUpdatedFactory, + revokeStreamPermissionsFactory, + updateStreamFactory +} from '@/modules/core/repositories/streams' +import { getUserFactory } from '@/modules/core/repositories/users' +import { + deleteBranchAndNotifyFactory, + updateBranchAndNotifyFactory +} from '@/modules/core/services/branch/management' +import { batchDeleteCommitsFactory } from '@/modules/core/services/commit/batchCommitActions' +import { updateCommitAndNotifyFactory } from '@/modules/core/services/commit/management' +import { + addOrUpdateStreamCollaboratorFactory, + isStreamCollaboratorFactory, + removeStreamCollaboratorFactory, + validateStreamAccessFactory +} from '@/modules/core/services/streams/access' +import { + deleteStreamAndNotifyFactory, + updateStreamAndNotifyFactory +} from '@/modules/core/services/streams/management' +import { getProjectDbClient } from '@/modules/multiregion/dbSelector' +import { deleteAllResourceInvitesFactory } from '@/modules/serverinvites/repositories/serverInvites' +import { authorizeResolver } from '@/modules/shared' +import { publish } from '@/modules/shared/utils/subscriptions' import { BasicTestWorkspace, createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import { itEach } from '@/test/assertionHelper' import { BasicTestUser, createTestUser } from '@/test/authHelper' import { + OnBranchCreatedDocument, + OnBranchDeletedDocument, + OnBranchUpdatedDocument, + OnProjectModelsUpdatedDocument, + OnProjectUpdatedDocument, + OnStreamUpdatedDocument, OnUserProjectsUpdatedDocument, OnUserProjectVersionsUpdatedDocument, OnUserStreamAddedDocument, OnUserStreamCommitCreatedDocument, + OnUserStreamCommitDeletedDocument, + OnUserStreamCommitUpdatedDocument, + OnUserStreamRemovedDocument, + ProjectModelsUpdatedMessageType, + ProjectUpdatedMessageType, + ProjectVersionsUpdatedMessageType, UserProjectsUpdatedMessageType } from '@/test/graphql/generated/graphql' import { @@ -16,14 +97,158 @@ import { TestApolloSubscriptionServer } from '@/test/graphqlHelper' import { beforeEachContext, getMainTestRegionKey } from '@/test/hooks' +import { + BasicTestBranch, + createTestBranch, + createTestBranches +} from '@/test/speckle-helpers/branchHelper' import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commitHelper' +import { TestError } from '@/test/speckle-helpers/error' import { isMultiRegionTestMode, waitForRegionUser } from '@/test/speckle-helpers/regions' import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' +import { faker } from '@faker-js/faker' +import { Optional, Roles, Scopes, ServerScope } from '@speckle/shared' import { expect } from 'chai' +const saveActivity = saveActivityFactory({ db }) +const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) +const isStreamCollaborator = isStreamCollaboratorFactory({ + getStream: getStreamFactory({ db }) +}) + +const buildDeleteProject = async (params: { projectId: string; ownerId: string }) => { + const { projectId, ownerId } = params + const projectDb = await getProjectDbClient({ projectId }) + const deleteStreamAndNotify = deleteStreamAndNotifyFactory({ + deleteStream: deleteStreamFactory({ + db: projectDb + }), + authorizeResolver, + addStreamDeletedActivity: addStreamDeletedActivityFactory({ + saveActivity, + publish, + getStreamCollaborators: getStreamCollaboratorsFactory({ db }) + }), + deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }), + getStream: getStreamFactory({ db: projectDb }) + }) + return async () => deleteStreamAndNotify(projectId, ownerId, null) +} + +const buildUpdateProject = async (params: { projectId: string }) => { + const { projectId } = params + const projectDB = await getProjectDbClient({ projectId }) + const updateStreamAndNotify = updateStreamAndNotifyFactory({ + authorizeResolver, + getStream: getStreamFactory({ db: projectDB }), + updateStream: updateStreamFactory({ db: projectDB }), + addStreamUpdatedActivity: addStreamUpdatedActivityFactory({ + saveActivity, + publish + }) + }) + return updateStreamAndNotify +} + +const buildUpdateModel = async (params: { projectId: string }) => { + const { projectId } = params + const projectDB = await getProjectDbClient({ projectId }) + const updateBranchAndNotify = updateBranchAndNotifyFactory({ + getBranchById: getBranchByIdFactory({ db: projectDB }), + updateBranch: updateBranchFactory({ db: projectDB }), + addBranchUpdatedActivity: addBranchUpdatedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish + }) + }) + return updateBranchAndNotify +} + +const buildDeleteModel = async (params: { projectId: string }) => { + const { projectId } = params + const projectDB = await getProjectDbClient({ projectId }) + const markBranchStreamUpdated = markBranchStreamUpdatedFactory({ + db: projectDB + }) + const getStream = getStreamFactory({ db }) + const deleteBranchAndNotify = deleteBranchAndNotifyFactory({ + getStream, + getBranchById: getBranchByIdFactory({ db: projectDB }), + modelsEventsEmitter: ModelsEmitter.emit, + markBranchStreamUpdated, + addBranchDeletedActivity: addBranchDeletedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish + }), + deleteBranchById: deleteBranchByIdFactory({ db: projectDB }) + }) + return deleteBranchAndNotify +} + +const buildDeleteVersion = async (params: { projectId: string }) => { + const { projectId } = params + const projectDb = await getProjectDbClient({ projectId }) + + const batchDeleteCommits = batchDeleteCommitsFactory({ + getCommits: getCommitsFactory({ db: projectDb }), + getStreams: getStreamsFactory({ db: projectDb }), + deleteCommits: deleteCommitsFactory({ db: projectDb }), + addCommitDeletedActivity: addCommitDeletedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish + }) + }) + return batchDeleteCommits +} + +const buildUpdateVersion = async (params: { projectId: string }) => { + const { projectId } = params + const projectDb = await getProjectDbClient({ projectId }) + const updateCommitAndNotify = updateCommitAndNotifyFactory({ + getCommit: getCommitFactory({ db: projectDb }), + getStream: getStreamFactory({ db: projectDb }), + getCommitStream: getCommitStreamFactory({ db: projectDb }), + getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }), + getCommitBranch: getCommitBranchFactory({ db: projectDb }), + switchCommitBranch: switchCommitBranchFactory({ db: projectDb }), + updateCommit: updateCommitFactory({ db: projectDb }), + addCommitUpdatedActivity: addCommitUpdatedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish + }), + markCommitStreamUpdated: markCommitStreamUpdatedFactory({ db: projectDb }), + markCommitBranchUpdated: markCommitBranchUpdatedFactory({ db: projectDb }) + }) + return updateCommitAndNotify +} + +const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ + validateStreamAccess, + getUser: getUserFactory({ db }), + grantStreamPermissions: grantStreamPermissionsFactory({ db }), + addStreamInviteAcceptedActivity: addStreamInviteAcceptedActivityFactory({ + saveActivity, + publish + }), + addStreamPermissionsAddedActivity: addStreamPermissionsAddedActivityFactory({ + saveActivity, + publish + }) +}) + +const removeStreamCollaborator = removeStreamCollaboratorFactory({ + validateStreamAccess, + isStreamCollaborator, + revokeStreamPermissions: revokeStreamPermissionsFactory({ db }), + addStreamPermissionsRevokedActivity: addStreamPermissionsRevokedActivityFactory({ + saveActivity, + publish + }) +}) + describe('Core GraphQL Subscriptions (New)', () => { let me: BasicTestUser let otherGuy: BasicTestUser @@ -55,11 +280,23 @@ describe('Core GraphQL Subscriptions (New)', () => { slug: '', name: 'My Main Workspace' } + const otherGuysWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + slug: '', + name: 'Other Guys Workspace' + } before(async () => { - await createTestWorkspace(myMainWorkspace, me, { - regionKey: isMultiRegion ? getMainTestRegionKey() : undefined - }) + await Promise.all([ + createTestWorkspace(myMainWorkspace, me, { + regionKey: isMultiRegion ? getMainTestRegionKey() : undefined + }), + createTestWorkspace(otherGuysWorkspace, otherGuy, { + regionKey: isMultiRegion ? getMainTestRegionKey() : undefined + }) + ]) + if (isMultiRegion) { await Promise.all([ waitForRegionUser({ userId: me.id }), @@ -69,6 +306,125 @@ describe('Core GraphQL Subscriptions (New)', () => { }) describe('Project Subs', () => { + describe('scope tests', () => { + const randomProject: BasicTestStream = { + name: 'Scope test project', + id: '', + ownerId: '', + isPublic: true + } + let testClient: Optional = undefined + + before(async () => { + randomProject.workspaceId = myMainWorkspace.id + await createTestStreams([[randomProject, me]]) + }) + + afterEach(async () => { + testClient?.quit() + }) + + type ScopeTest = { + title: string + withoutScope: ServerScope + sub: () => { + query: any + variables: any + } + triggerMessage: () => Promise + } + + const triggerProjectUpdate = async () => { + const projectId = randomProject.id + const updateProject = await buildUpdateProject({ projectId }) + await updateProject( + { id: projectId, name: new Date().toISOString() }, + me.id, + null + ) + } + + const scopeTests: ScopeTest[] = [ + { + title: 'streamUpdated()', + withoutScope: Scopes.Streams.Read, + sub: () => ({ + query: OnStreamUpdatedDocument, + variables: { streamId: randomProject.id } + }), + triggerMessage: triggerProjectUpdate + }, + { + title: 'projectUpdated()', + withoutScope: Scopes.Streams.Read, + sub: () => ({ + query: OnProjectUpdatedDocument, + variables: { projectId: randomProject.id } + }), + triggerMessage: triggerProjectUpdate + }, + { + title: 'userProjectsUpdated()', + withoutScope: Scopes.Profile.Read, + sub: () => ({ + query: OnUserProjectsUpdatedDocument, + variables: {} + }), + triggerMessage: async () => { + // Create a new project + const newProject: BasicTestStream = { + name: 'New Scope Test Project', + id: '', + ownerId: me.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + + await createTestStreams([[newProject, me]]) + } + } + ] + + scopeTests.forEach(({ title, withoutScope, sub, triggerMessage }) => { + itEach( + [{ allow: false }, { allow: true }], + ({ allow }) => + `should ${allow ? '' : 'not '} allow ${title} sub with${ + !allow ? 'out' : '' + } ${withoutScope} scope`, + async ({ allow }) => { + testClient = await subServer.buildClient({ + authUserId: me.id, + scopes: allow + ? AllScopes + : AllScopes.filter((s) => s !== withoutScope) + }) + + const { query, variables } = sub() + const onMessage = await testClient.subscribe( + query, + variables, + (res) => { + if (allow) { + expect(res).to.not.haveGraphQLErrors() + } else { + expect(res).to.haveGraphQLErrors( + 'Your auth token does not have the required scope' + ) + } + } + ) + await testClient.waitForReadiness() + + await triggerMessage() + await onMessage.waitForMessage() + + expect(onMessage.getMessages()).to.have.length(1) + } + ) + }) + }) + it('should notify me of a new project (userProjectsUpdated/userStreamAdded)', async () => { const onUserProjectsUpdated = await meSubClient.subscribe( OnUserProjectsUpdatedDocument, @@ -103,7 +459,7 @@ describe('Core GraphQL Subscriptions (New)', () => { id: '', ownerId: otherGuy.id, isPublic: true, - workspaceId: myMainWorkspace.id + workspaceId: otherGuysWorkspace.id } await createTestStreams([ [myProj, me], @@ -117,6 +473,241 @@ describe('Core GraphQL Subscriptions (New)', () => { expect(onUserProjectsUpdated.getMessages()).to.have.length(1) expect(onUserStreamAdded.getMessages()).to.have.length(1) }) + + it('should notify me of a project ive just been added to (userProjectsUpdated/userStreamAdded)', async () => { + const otherGuysProj: BasicTestStream = { + name: 'Other Guys Project #1', + id: '', + ownerId: otherGuy.id, + isPublic: true, + workspaceId: otherGuysWorkspace.id + } + await createTestStreams([[otherGuysProj, otherGuy]]) + + const onUserProjectsUpdated = await meSubClient.subscribe( + OnUserProjectsUpdatedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userProjectsUpdated.type).to.equal( + UserProjectsUpdatedMessageType.Added + ) + expect(res.data?.userProjectsUpdated.project?.id).to.equal( + otherGuysProj.id + ) + } + ) + const onUserStreamAdded = await meSubClient.subscribe( + OnUserStreamAddedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userStreamAdded?.id).to.equal(otherGuysProj.id) + } + ) + await meSubClient.waitForReadiness() + await meSubClient.waitForReadiness() + await meSubClient.waitForReadiness() + await meSubClient.waitForReadiness() + + await addOrUpdateStreamCollaborator( + otherGuysProj.id, + me.id, + Roles.Stream.Contributor, + otherGuy.id + ) + + await Promise.all([ + onUserProjectsUpdated.waitForMessage(), + onUserStreamAdded.waitForMessage() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamAdded.getMessages()).to.have.length(1) + }) + + it('should notify me of a removed project (userProjectsUpdated/userStreamRemoved)', async () => { + const myProj: BasicTestStream = { + name: 'My New Test2 Project', + id: '', + ownerId: me.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + await createTestStreams([[myProj, me]]) + const deleteProject = await buildDeleteProject({ + projectId: myProj.id, + ownerId: me.id + }) + + const onUserProjectsUpdated = await meSubClient.subscribe( + OnUserProjectsUpdatedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userProjectsUpdated.type).to.equal( + UserProjectsUpdatedMessageType.Removed + ) + expect(res.data?.userProjectsUpdated.id).to.equal(myProj.id) + } + ) + const onUserStreamRemoved = await meSubClient.subscribe( + OnUserStreamRemovedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userStreamRemoved?.id).to.equal(myProj.id) + } + ) + await meSubClient.waitForReadiness() + await deleteProject() + + await Promise.all([ + onUserProjectsUpdated.waitForMessage(), + onUserStreamRemoved.waitForMessage() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamRemoved.getMessages()).to.have.length(1) + }) + + it('should notify me of a project ive just been removed from (userProjectsUpdated/userStreamRemoved)', async () => { + const otherGuysProj: BasicTestStream = { + name: 'Other Guys Project #2', + id: '', + ownerId: otherGuy.id, + isPublic: true, + workspaceId: otherGuysWorkspace.id + } + await createTestStreams([[otherGuysProj, otherGuy]]) + await addOrUpdateStreamCollaborator( + otherGuysProj.id, + me.id, + Roles.Stream.Contributor, + otherGuy.id + ) + + const onUserProjectsUpdated = await meSubClient.subscribe( + OnUserProjectsUpdatedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userProjectsUpdated.type).to.equal( + UserProjectsUpdatedMessageType.Removed + ) + expect(res.data?.userProjectsUpdated.id).to.equal(otherGuysProj.id) + } + ) + const onUserStreamRemoved = await meSubClient.subscribe( + OnUserStreamRemovedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userStreamRemoved?.id).to.equal(otherGuysProj.id) + } + ) + await meSubClient.waitForReadiness() + await removeStreamCollaborator(otherGuysProj.id, me.id, otherGuy.id, null) + + await Promise.all([ + onUserProjectsUpdated.waitForMessage(), + onUserStreamRemoved.waitForMessage() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamRemoved.getMessages()).to.have.length(1) + }) + + it('should notify me of a project update (projectUpdated/streamUpdate)', async () => { + const myProj: BasicTestStream = { + name: 'My New Test3 Project', + id: '', + ownerId: me.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + await createTestStreams([[myProj, me]]) + const updateProject = await buildUpdateProject({ projectId: myProj.id }) + + const onUserProjectsUpdated = await meSubClient.subscribe( + OnProjectUpdatedDocument, + { projectId: myProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectUpdated.type).to.equal( + ProjectUpdatedMessageType.Updated + ) + expect(res.data?.projectUpdated.project?.id).to.equal(myProj.id) + } + ) + const onStreamUpdated = await meSubClient.subscribe( + OnStreamUpdatedDocument, + { streamId: myProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.streamUpdated?.id).to.equal(myProj.id) + } + ) + await meSubClient.waitForReadiness() + await updateProject( + { id: myProj.id, name: 'Updated Project Name' }, + me.id, + null + ) + + await Promise.all([ + onUserProjectsUpdated.waitForMessage(), + onStreamUpdated.waitForMessage() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(1) + expect(onStreamUpdated.getMessages()).to.have.length(1) + }) + + it('should not notify me of a project update for a different project', async () => { + const myProj: BasicTestStream = { + name: 'My New Test4 Project', + id: '', + ownerId: me.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + await createTestStreams([[myProj, me]]) + const updateProject = await buildUpdateProject({ projectId: myProj.id }) + + const onUserProjectsUpdated = await meSubClient.subscribe( + OnProjectUpdatedDocument, + { projectId: 'aaa' }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + const onStreamUpdated = await meSubClient.subscribe( + OnStreamUpdatedDocument, + { streamId: 'bbb' }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + await meSubClient.waitForReadiness() + await updateProject( + { id: myProj.id, name: 'Updated Project Name' }, + me.id, + null + ) + + await Promise.all([ + onUserProjectsUpdated.waitForTimeout(), + onStreamUpdated.waitForTimeout() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(0) + expect(onStreamUpdated.getMessages()).to.have.length(0) + }) }) describe('Version Subs', () => { @@ -139,6 +730,9 @@ describe('Core GraphQL Subscriptions (New)', () => { { projectId: myVersionProj.id }, (res) => { expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectVersionsUpdated.type).to.equal( + ProjectVersionsUpdatedMessageType.Created + ) expect(res.data?.projectVersionsUpdated.version?.message).to.equal( message ) @@ -173,6 +767,413 @@ describe('Core GraphQL Subscriptions (New)', () => { expect(onUserProjectVersionsUpdated.getMessages()).to.have.length(1) expect(onUserStreamCommitCreated.getMessages()).to.have.length(1) }) + + it('should notify me when a version is deleted (projectVersionsUpdated/commitDeleted)', async () => { + const commitToDelete: BasicTestCommit = { + streamId: '', + objectId: '', + id: '', + authorId: '', + message: 'Commit to Delete' + } + await createTestCommits([commitToDelete], { + owner: me, + stream: myVersionProj + }) + const deleteVersion = await buildDeleteVersion({ + projectId: myVersionProj.id + }) + + const onUserProjectVersionsUpdated = await meSubClient.subscribe( + OnUserProjectVersionsUpdatedDocument, + { projectId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectVersionsUpdated.type).to.equal( + ProjectVersionsUpdatedMessageType.Deleted + ) + expect(res.data?.projectVersionsUpdated.id).to.equal(commitToDelete.id) + } + ) + const onUserStreamCommitDeleted = await meSubClient.subscribe( + OnUserStreamCommitDeletedDocument, + { streamId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.commitDeleted?.id).to.equal(commitToDelete.id) + } + ) + await meSubClient.waitForReadiness() + await deleteVersion( + { + versionIds: [commitToDelete.id], + projectId: myVersionProj.id + }, + me.id + ) + + await Promise.all([ + onUserProjectVersionsUpdated.waitForMessage(), + onUserStreamCommitDeleted.waitForMessage() + ]) + + expect(onUserProjectVersionsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamCommitDeleted.getMessages()).to.have.length(1) + }) + + it('should notify me when a version is updated (projectVersionsUpdated/commitUpdated)', async () => { + const commitToUpdate: BasicTestCommit = { + streamId: '', + objectId: '', + id: '', + authorId: '', + message: 'Commit to Update' + } + await createTestCommits([commitToUpdate], { + owner: me, + stream: myVersionProj + }) + const updateVersion = await buildUpdateVersion({ + projectId: myVersionProj.id + }) + + const onUserProjectVersionsUpdated = await meSubClient.subscribe( + OnUserProjectVersionsUpdatedDocument, + { projectId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectVersionsUpdated.type).to.equal( + ProjectVersionsUpdatedMessageType.Updated + ) + expect(res.data?.projectVersionsUpdated.version?.id).to.equal( + commitToUpdate.id + ) + } + ) + const onUserStreamCommitCreated = await meSubClient.subscribe( + OnUserStreamCommitUpdatedDocument, + { streamId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.commitUpdated?.id).to.equal(commitToUpdate.id) + } + ) + await meSubClient.waitForReadiness() + await updateVersion( + { + versionId: commitToUpdate.id, + message: 'Updated Message', + projectId: myVersionProj.id + }, + me.id + ) + + await Promise.all([ + onUserProjectVersionsUpdated.waitForMessage(), + onUserStreamCommitCreated.waitForMessage() + ]) + + expect(onUserProjectVersionsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamCommitCreated.getMessages()).to.have.length(1) + }) + + it('should not notify me when version is created for stream im not authorized for', async () => { + const otherGuysProj: BasicTestStream = { + name: 'Other Guys Project #3', + id: '', + ownerId: otherGuy.id, + isPublic: false, + workspaceId: otherGuysWorkspace.id + } + await createTestStreams([[otherGuysProj, otherGuy]]) + + const onUserProjectVersionsUpdated = await meSubClient.subscribe( + OnUserProjectVersionsUpdatedDocument, + { projectId: otherGuysProj.id }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + const onUserStreamCommitCreated = await meSubClient.subscribe( + OnUserStreamCommitCreatedDocument, + { streamId: otherGuysProj.id }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + await meSubClient.waitForReadiness() + + const commit: BasicTestCommit = { + streamId: otherGuysProj.id, + objectId: '', + id: '', + authorId: '', + message: 'Random Commit' + } + await createTestCommits([commit], { + owner: otherGuy, + stream: otherGuysProj + }) + + await Promise.all([ + onUserProjectVersionsUpdated.waitForTimeout(), + onUserStreamCommitCreated.waitForTimeout() + ]) + + expect(onUserProjectVersionsUpdated.getMessages()).to.have.length(0) + expect(onUserStreamCommitCreated.getMessages()).to.have.length(0) + }) + }) + + describe('Model Subs', () => { + const myModelProj: BasicTestStream = { + name: 'My New Model Project #1', + id: '', + ownerId: '', + isPublic: true + } + + before(async () => { + myModelProj.workspaceId = myMainWorkspace.id + await createTestStreams([[myModelProj, me]]) + }) + + it(`should notify me of a new model (projectModelsUpdated/branchCreated)`, async () => { + const newModel: BasicTestBranch = { + name: 'Some New Fangled kind of Model', + streamId: '', + authorId: '', + id: '' + } + + const onProjectModelsUpdated = await meSubClient.subscribe( + OnProjectModelsUpdatedDocument, + { projectId: myModelProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + + // name should be lowercaseified + expect(res.data?.projectModelsUpdated.model?.name).to.equal( + newModel.name.toLowerCase() + ) + expect(res.data?.projectModelsUpdated.type).to.equal( + ProjectModelsUpdatedMessageType.Created + ) + } + ) + const onBranchCreated = await meSubClient.subscribe( + OnBranchCreatedDocument, + { streamId: myModelProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.branchCreated?.name).to.equal( + newModel.name.toLowerCase() + ) + } + ) + await meSubClient.waitForReadiness() + + await createTestBranch({ branch: newModel, stream: myModelProj, owner: me }) + await Promise.all([ + onProjectModelsUpdated.waitForMessage(), + onBranchCreated.waitForMessage() + ]) + + expect(onProjectModelsUpdated.getMessages()).to.have.length(1) + expect(onBranchCreated.getMessages()).to.have.length(1) + }) + + itEach( + [{ any: false }, { any: true }], + ({ any }) => + `should notify me of ${ + any ? 'any ' : '' + }updated model (projectModelsUpdated/branchUpdated)`, + async ({ any }) => { + // Create 2 models + const firstModel: BasicTestBranch = { + name: 'First Model ' + faker.number.int(), + streamId: '', + authorId: '', + id: '' + } + const secondModel: BasicTestBranch = { + name: 'Second Model ' + faker.number.int(), + streamId: '', + authorId: '', + id: '' + } + await createTestBranches([ + { branch: firstModel, stream: myModelProj, owner: me }, + { branch: secondModel, stream: myModelProj, owner: me } + ]) + const updateModel = await buildUpdateModel({ projectId: myModelProj.id }) + + // Sub + const onProjectModelsUpdated = await meSubClient.subscribe( + OnProjectModelsUpdatedDocument, + { + projectId: myModelProj.id, + ...(!any ? { modelIds: [firstModel.id] } : {}) + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + + const modelId = res.data?.projectModelsUpdated.model?.id + expect([firstModel.id, ...(any ? [secondModel.id] : [])]).to.include( + modelId + ) + expect(res.data?.projectModelsUpdated.type).to.equal( + ProjectModelsUpdatedMessageType.Updated + ) + } + ) + const onBranchUpdated = await meSubClient.subscribe( + OnBranchUpdatedDocument, + { + streamId: myModelProj.id, + branchId: !any ? firstModel.id : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + const modelId = res.data?.branchUpdated?.id + expect([firstModel.id, ...(any ? [secondModel.id] : [])]).to.include( + modelId + ) + } + ) + await meSubClient.waitForReadiness() + + // Update both models + await Promise.all([ + updateModel( + { + id: firstModel.id, + name: 'First Model New Name' + faker.number.int(), + projectId: myModelProj.id + }, + me.id + ), + updateModel( + { + id: secondModel.id, + name: 'Second Model New Name' + faker.number.int(), + projectId: myModelProj.id + }, + me.id + ) + ]) + + await Promise.all([ + onProjectModelsUpdated.waitForMessage(), + onBranchUpdated.waitForMessage() + ]) + + expect(onProjectModelsUpdated.getMessages()).to.have.length(any ? 2 : 1) + expect(onBranchUpdated.getMessages()).to.have.length(any ? 2 : 1) + } + ) + + it('should notify me of model delete (projectModelsUpdated/branchDeleted)', async () => { + const modelToDelete: BasicTestBranch = { + name: 'Model to Delete', + streamId: '', + authorId: '', + id: '' + } + await createTestBranch({ + branch: modelToDelete, + stream: myModelProj, + owner: me + }) + const deleteModel = await buildDeleteModel({ projectId: myModelProj.id }) + + const onProjectModelsUpdated = await meSubClient.subscribe( + OnProjectModelsUpdatedDocument, + { projectId: myModelProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectModelsUpdated.type).to.equal( + ProjectModelsUpdatedMessageType.Deleted + ) + expect(res.data?.projectModelsUpdated.id).to.equal(modelToDelete.id) + } + ) + const onBranchDeleted = await meSubClient.subscribe( + OnBranchDeletedDocument, + { streamId: myModelProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.branchDeleted?.id).to.equal(modelToDelete.id) + } + ) + await meSubClient.waitForReadiness() + await deleteModel({ id: modelToDelete.id, projectId: myModelProj.id }, me.id) + + await Promise.all([ + onProjectModelsUpdated.waitForMessage(), + onBranchDeleted.waitForMessage() + ]) + + expect(onProjectModelsUpdated.getMessages()).to.have.length(1) + expect(onBranchDeleted.getMessages()).to.have.length(1) + }) + + it('should not notify me when model is created for stream im not authorized for', async () => { + const otherGuysProj: BasicTestStream = { + name: 'Other Guys Project #3', + id: '', + ownerId: otherGuy.id, + isPublic: false, + workspaceId: otherGuysWorkspace.id + } + await createTestStreams([[otherGuysProj, otherGuy]]) + + const newModel: BasicTestBranch = { + name: 'Some New Fangled kind of Model', + streamId: '', + authorId: '', + id: '' + } + + const onProjectModelsUpdated = await meSubClient.subscribe( + OnProjectModelsUpdatedDocument, + { projectId: otherGuysProj.id }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + const onBranchCreated = await meSubClient.subscribe( + OnBranchCreatedDocument, + { streamId: otherGuysProj.id }, + (res) => { + throw new TestError('Message received for wrong project', { + info: { res } + }) + } + ) + await meSubClient.waitForReadiness() + + await createTestBranch({ + branch: newModel, + stream: otherGuysProj, + owner: otherGuy + }) + + await Promise.all([ + onProjectModelsUpdated.waitForTimeout(), + onBranchCreated.waitForTimeout() + ]) + + expect(onProjectModelsUpdated.getMessages()).to.have.length(0) + expect(onBranchCreated.getMessages()).to.have.length(0) + }) }) }) }) diff --git a/packages/server/modules/serverinvites/domain/events.ts b/packages/server/modules/serverinvites/domain/events.ts index e9bc58c77..08447c10c 100644 --- a/packages/server/modules/serverinvites/domain/events.ts +++ b/packages/server/modules/serverinvites/domain/events.ts @@ -6,7 +6,8 @@ const prefix = `${serverinvitesEventNamespace}.` as const export const ServerInvitesEvents = { Created: `${prefix}created`, - Finalized: `${prefix}finalized` + Finalized: `${prefix}finalized`, + Canceled: `${prefix}canceled` } as const export type ServerInvitesEventsKeys = @@ -21,4 +22,8 @@ export type ServerInvitesEventsPayloads = { finalizerUserId: string accept: boolean } + [ServerInvitesEvents.Canceled]: { + invite: ServerInviteRecord + cancelerUserId: string + } } diff --git a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts index 048395c57..b34f2847f 100644 --- a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts +++ b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts @@ -360,7 +360,8 @@ export = { deleteInvite: deleteInviteFromDbFactory({ db }), validateResourceAccess: validateProjectInviteBeforeFinalizationFactory({ getProject: getStream - }) + }), + emitEvent: getEventBus().emit }) await authorizeResolver(userId, streamId, Roles.Stream.Owner, resourceAccessRules) @@ -394,13 +395,14 @@ export = { return true }, - async inviteDelete(_parent, args) { + async inviteDelete(_parent, args, ctx) { const { inviteId } = args await deleteInviteFactory({ findInvite: findInviteFactory({ db }), - deleteInvite: deleteInviteFromDbFactory({ db }) - })(inviteId) + deleteInvite: deleteInviteFromDbFactory({ db }), + emitEvent: getEventBus().emit + })(inviteId, ctx.userId!) return true } @@ -510,7 +512,8 @@ export = { deleteInvite: deleteInviteFromDbFactory({ db }), validateResourceAccess: validateProjectInviteBeforeFinalizationFactory({ getProject: getStream - }) + }), + emitEvent: getEventBus().emit }) await cancelInvite({ diff --git a/packages/server/modules/serverinvites/services/processing.ts b/packages/server/modules/serverinvites/services/processing.ts index 9765ab8ac..29b2eb215 100644 --- a/packages/server/modules/serverinvites/services/processing.ts +++ b/packages/server/modules/serverinvites/services/processing.ts @@ -342,11 +342,16 @@ export const finalizeResourceInviteFactory = }) } +/** + * Cancel invite. The difference between this and declining is that this action is invoked + * by the invite creator, not by the invitee. + */ export const cancelResourceInviteFactory = (deps: { findInvite: FindInvite validateResourceAccess: ValidateResourceInviteBeforeFinalization deleteInvite: DeleteInvite + emitEvent: EventBusEmit }) => async (params: { inviteId: string @@ -355,7 +360,7 @@ export const cancelResourceInviteFactory = cancelerId: string cancelerResourceAccessLimits: MaybeNullOrUndefined }) => { - const { findInvite, validateResourceAccess, deleteInvite } = deps + const { findInvite, validateResourceAccess, deleteInvite, emitEvent } = deps const { inviteId, resourceId, @@ -386,24 +391,41 @@ export const cancelResourceInviteFactory = finalizerResourceAccessLimits: cancelerResourceAccessLimits }) await deleteInvite(invite.id) + await emitEvent({ + eventName: ServerInvitesEvents.Canceled, + payload: { + invite, + cancelerUserId: cancelerId + } + }) } /** * Delete pending invite - does no access checks! + * (Used for admin invite delete currently) */ export const deleteInviteFactory = ({ findInvite, - deleteInvite + deleteInvite, + emitEvent }: { findInvite: FindInvite deleteInvite: DeleteInvite + emitEvent: EventBusEmit }) => - async (inviteId: string) => { + async (inviteId: string, cancelerId: string) => { const invite = await findInvite({ inviteId }) if (!invite) { throw new InviteNotFoundError('Attempted to delete a nonexistant invite') } await deleteInvite(invite.id) + await emitEvent({ + eventName: ServerInvitesEvents.Canceled, + payload: { + invite, + cancelerUserId: cancelerId + } + }) } diff --git a/packages/server/modules/shared/services/eventBus.ts b/packages/server/modules/shared/services/eventBus.ts index 67449c29a..31351098a 100644 --- a/packages/server/modules/shared/services/eventBus.ts +++ b/packages/server/modules/shared/services/eventBus.ts @@ -11,6 +11,7 @@ import { ServerInvitesEventsPayloads } from '@/modules/serverinvites/domain/events' +type AllEventsWildcard = '**' type EventWildcard = '*' export const TestEvents = { @@ -39,7 +40,7 @@ type EventNamesByNamespace = { // generated type for a top level wildcard one level nested wildcards per namespace and each possible event type EventSubscriptionKey = - | EventWildcard + | AllEventsWildcard | `${keyof EventNamesByNamespace}.${EventWildcard}` | { [Namespace in keyof EventNamesByNamespace]: EventNamesByNamespace[Namespace] @@ -64,7 +65,7 @@ type EventPayloadsByNamespaceMap = { } } -export type EventPayload = T extends EventWildcard +export type EventPayload = T extends AllEventsWildcard ? // if event key is "*", get all events from the flat object EventPayloadsMap[keyof EventPayloadsMap] : // else if, the key is a "namespace.*" wildcard diff --git a/packages/server/modules/shared/utils/subscriptions.ts b/packages/server/modules/shared/utils/subscriptions.ts index c834aecf1..2bbb825c6 100644 --- a/packages/server/modules/shared/utils/subscriptions.ts +++ b/packages/server/modules/shared/utils/subscriptions.ts @@ -380,7 +380,7 @@ type SubscriptionTypeMap = { payload: { workspaceUpdated: Merge< WorkspaceUpdatedMessage, - { workspace: Nullable } + { workspace: WorkspaceGraphQLReturn } > } variables: SubscriptionWorkspaceUpdatedArgs diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 526d5f63d..614440316 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -61,6 +61,7 @@ import { } from '@/modules/workspaces/repositories/sso' import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace' import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions' +import { isWorkspaceResourceTarget } from '@/modules/workspaces/services/invites' export const onProjectCreatedFactory = ({ @@ -259,16 +260,8 @@ export const onWorkspaceRoleUpdatedFactory = } const emitWorkspaceGraphqlSubscriptionsFactory = - (deps: { getWorkspace: GetWorkspace }) => - async (params: EventPayload<'workspace.*'>) => { + (deps: { getWorkspace: GetWorkspace }) => async (params: EventPayload<'**'>) => { const { eventName, payload } = params - const eventWhitelist: string[] = [ - WorkspaceEvents.Updated, - WorkspaceEvents.RoleDeleted, - WorkspaceEvents.RoleUpdated - ] - if (!eventWhitelist.includes(eventName)) return - switch (eventName) { case WorkspaceEvents.Updated: await publish(WorkspaceSubscriptions.WorkspaceUpdated, { @@ -290,6 +283,26 @@ const emitWorkspaceGraphqlSubscriptionsFactory = } }) } + break + case ServerInvitesEvents.Created: + case ServerInvitesEvents.Canceled: + case ServerInvitesEvents.Finalized: + const { invite } = payload + if (!isWorkspaceResourceTarget(invite.resource)) return + + const res = invite.resource + const newInviteWorkspace = await deps.getWorkspace({ + workspaceId: res.resourceId + }) + if (newInviteWorkspace) { + await publish(WorkspaceSubscriptions.WorkspaceUpdated, { + workspaceUpdated: { + workspace: newInviteWorkspace, + id: newInviteWorkspace.id + } + }) + } + break } } @@ -360,8 +373,7 @@ export const initializeEventListenersFactory = }) await withTransaction(onWorkspaceRoleUpdated(payload), trx) }), - // Emit Updated subscription - eventBus.listen('workspace.*', emitWorkspaceGraphqlSubscriptions) + eventBus.listen('**', emitWorkspaceGraphqlSubscriptions) ] return () => quitCbs.forEach((quit) => quit()) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 3458fff52..e9abe5fa0 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -835,7 +835,8 @@ export = FF_WORKSPACES_MODULE_ENABLED deleteInvite: deleteInviteFactory({ db }), validateResourceAccess: validateWorkspaceInviteBeforeFinalizationFactory({ getWorkspace: getWorkspaceFactory({ db }) - }) + }), + emitEvent: getEventBus().emit }) await cancelInvite({ diff --git a/packages/server/modules/workspaces/services/invites.ts b/packages/server/modules/workspaces/services/invites.ts index 289e9dc02..d6bfb9ecc 100644 --- a/packages/server/modules/workspaces/services/invites.ts +++ b/packages/server/modules/workspaces/services/invites.ts @@ -73,7 +73,7 @@ import { import { GetStream } from '@/modules/core/domain/streams/operations' import { GetUser } from '@/modules/core/domain/users/operations' -const isWorkspaceResourceTarget = ( +export const isWorkspaceResourceTarget = ( target: InviteResourceTarget ): target is WorkspaceInviteResourceTarget => target.resourceType === WorkspaceInviteResourceType diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index c2c796733..6b0d31356 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -141,6 +141,7 @@ export const createTestWorkspace = async ( userResourceAccessLimits: null }) + workspace.slug = newWorkspace.slug workspace.id = newWorkspace.id workspace.ownerId = owner.id diff --git a/packages/server/modules/workspaces/tests/helpers/graphql.ts b/packages/server/modules/workspaces/tests/helpers/graphql.ts index 806b1728a..b146464a4 100644 --- a/packages/server/modules/workspaces/tests/helpers/graphql.ts +++ b/packages/server/modules/workspaces/tests/helpers/graphql.ts @@ -268,3 +268,49 @@ export const setDefaultRegionMutation = gql` } } ` + +export const onWorkspaceProjectsUpdatedSubscription = gql` + subscription OnWorkspaceProjectsUpdated( + $workspaceId: String + $workspaceSlug: String + ) { + workspaceProjectsUpdated(workspaceId: $workspaceId, workspaceSlug: $workspaceSlug) { + type + projectId + workspaceId + project { + id + name + } + } + } + + ${basicWorkspaceFragment} +` + +export const onWorkspaceUpdatedSubscription = gql` + subscription OnWorkspaceUpdated($workspaceId: String, $workspaceSlug: String) { + workspaceUpdated(workspaceId: $workspaceId, workspaceSlug: $workspaceSlug) { + id + workspace { + ...BasicWorkspace + team { + totalCount + items { + id + role + user { + id + name + } + } + } + invitedTeam { + ...BasicPendingWorkspaceCollaborator + } + } + } + } + + ${basicWorkspaceFragment} +` diff --git a/packages/server/modules/workspaces/tests/helpers/invites.ts b/packages/server/modules/workspaces/tests/helpers/invites.ts new file mode 100644 index 000000000..ed964f489 --- /dev/null +++ b/packages/server/modules/workspaces/tests/helpers/invites.ts @@ -0,0 +1,163 @@ +import { ExecuteOperationOptions, TestApolloServer } from '@/test/graphqlHelper' + +import { + BatchCreateWorkspaceInvitesDocument, + BatchCreateWorkspaceInvitesMutationVariables, + CancelWorkspaceInviteDocument, + CancelWorkspaceInviteMutationVariables, + CreateProjectInviteDocument, + CreateProjectInviteMutationVariables, + CreateWorkspaceInviteDocument, + CreateWorkspaceInviteMutationVariables, + CreateWorkspaceProjectInviteDocument, + CreateWorkspaceProjectInviteMutationVariables, + GetMyWorkspaceInvitesDocument, + GetWorkspaceInviteDocument, + GetWorkspaceInviteQueryVariables, + GetWorkspaceWithTeamDocument, + GetWorkspaceWithTeamQueryVariables, + ResendWorkspaceInviteDocument, + ResendWorkspaceInviteMutationVariables, + UseWorkspaceInviteDocument, + UseWorkspaceInviteMutationVariables, + UseWorkspaceProjectInviteDocument, + UseWorkspaceProjectInviteMutationVariables +} from '@/test/graphql/generated/graphql' +import { expect } from 'chai' + +import { MaybeAsync, StreamRoles, WorkspaceRoles } from '@speckle/shared' +import { expectToThrow } from '@/test/assertionHelper' + +import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' + +import { ForbiddenError } from '@/modules/shared/errors' +import { getStreamFactory } from '@/modules/core/repositories/streams' +import { db } from '@/db/knex' + +export const buildInvitesGraphqlOperations = (deps: { apollo: TestApolloServer }) => { + const { apollo } = deps + const getStream = getStreamFactory({ db }) + + const useInvite = async ( + args: UseWorkspaceInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(UseWorkspaceInviteDocument, args, options) + + const getInvite = async ( + args: GetWorkspaceInviteQueryVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(GetWorkspaceInviteDocument, args, options) + + const getMyInvites = async (options?: ExecuteOperationOptions) => + apollo.execute(GetMyWorkspaceInvitesDocument, {}, options) + + const createDefaultProjectInvite = ( + args: CreateProjectInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(CreateProjectInviteDocument, args, options) + + const createWorkspaceProjectInvite = ( + args: CreateWorkspaceProjectInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(CreateWorkspaceProjectInviteDocument, args, options) + + const resendWorkspaceInvite = ( + args: ResendWorkspaceInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(ResendWorkspaceInviteDocument, args, options) + + const useProjectInvite = async ( + args: UseWorkspaceProjectInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(UseWorkspaceProjectInviteDocument, args, options) + + const validateResourceAccess = async (params: { + shouldHaveAccess: boolean + userId: string + workspaceId: string + streamId?: string + expectedWorkspaceRole?: WorkspaceRoles + expectedProjectRole?: StreamRoles + }) => { + const { shouldHaveAccess, userId, workspaceId, streamId } = params + + const wrapAccessCheck = async (fn: () => MaybeAsync) => { + if (shouldHaveAccess) { + await fn() + } else { + const e = await expectToThrow(fn) + expect(e instanceof ForbiddenError).to.be.true + } + } + + await wrapAccessCheck(async () => { + const workspace = await getWorkspaceFactory({ db })({ workspaceId, userId }) + if (!workspace?.role) { + throw new ForbiddenError('Missing workspace role') + } + + if ( + params.expectedWorkspaceRole && + workspace.role !== params.expectedWorkspaceRole + ) { + throw new ForbiddenError( + `Unexpected workspace role! Expected: ${params.expectedWorkspaceRole}, real: ${workspace.role}` + ) + } + }) + + if (streamId?.length) { + await wrapAccessCheck(async () => { + const project = await getStream({ streamId, userId }) + if (!project?.role) { + throw new ForbiddenError('Missing project role') + } + + if (params.expectedProjectRole && project.role !== params.expectedProjectRole) { + throw new ForbiddenError( + `Unexpected project role! Expected: ${params.expectedProjectRole}, real: ${project.role}` + ) + } + }) + } + } + + const createInvite = ( + args: CreateWorkspaceInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(CreateWorkspaceInviteDocument, args, options) + + const batchCreateInvites = async ( + args: BatchCreateWorkspaceInvitesMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(BatchCreateWorkspaceInvitesDocument, args, options) + + const cancelInvite = async ( + args: CancelWorkspaceInviteMutationVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(CancelWorkspaceInviteDocument, args, options) + + const getWorkspaceWithTeam = async ( + args: GetWorkspaceWithTeamQueryVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(GetWorkspaceWithTeamDocument, args, options) + + return { + useInvite, + getMyInvites, + useProjectInvite, + validateResourceAccess, + getInvite, + createInvite, + batchCreateInvites, + cancelInvite, + getWorkspaceWithTeam, + createDefaultProjectInvite, + createWorkspaceProjectInvite, + resendWorkspaceInvite + } +} + +export type TestInvitesGraphQLOperations = ReturnType< + typeof buildInvitesGraphqlOperations +> diff --git a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts index ff5a30b97..8b7a767bd 100644 --- a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts @@ -8,44 +8,20 @@ import { import { BasicTestUser, createTestUsers } from '@/test/authHelper' import { createTestContext, - ExecuteOperationOptions, testApolloServer, TestApolloServer } from '@/test/graphqlHelper' import { beforeEachContext, truncateTables } from '@/test/hooks' import { describe } from 'mocha' import { EmailSendingServiceMock } from '@/test/mocks/global' -import { - BatchCreateWorkspaceInvitesDocument, - BatchCreateWorkspaceInvitesMutationVariables, - CancelWorkspaceInviteDocument, - CancelWorkspaceInviteMutationVariables, - CreateProjectInviteDocument, - CreateProjectInviteMutationVariables, - CreateWorkspaceInviteDocument, - CreateWorkspaceInviteMutationVariables, - CreateWorkspaceProjectInviteDocument, - CreateWorkspaceProjectInviteMutationVariables, - GetMyWorkspaceInvitesDocument, - GetWorkspaceInviteDocument, - GetWorkspaceInviteQueryVariables, - GetWorkspaceWithTeamDocument, - GetWorkspaceWithTeamQueryVariables, - ResendWorkspaceInviteDocument, - ResendWorkspaceInviteMutationVariables, - UseWorkspaceInviteDocument, - UseWorkspaceInviteMutationVariables, - UseWorkspaceProjectInviteDocument, - UseWorkspaceProjectInviteMutationVariables, - WorkspaceRole -} from '@/test/graphql/generated/graphql' +import { WorkspaceRole } from '@/test/graphql/generated/graphql' import { expect } from 'chai' import { captureCreatedInvite, validateInviteExistanceFromEmail } from '@/test/speckle-helpers/inviteHelper' -import { MaybeAsync, Roles, StreamRoles, WorkspaceRoles } from '@speckle/shared' -import { expectToThrow, itEach } from '@/test/assertionHelper' +import { Roles, StreamRoles, WorkspaceRoles } from '@speckle/shared' +import { itEach } from '@/test/assertionHelper' import { ServerInvites } from '@/modules/core/dbSchema' import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql' import { times } from 'lodash' @@ -64,7 +40,6 @@ import { } from '@/modules/auth/tests/helpers/registration' import type { Express } from 'express' import { AllScopes } from '@/modules/core/helpers/mainConstants' -import { getWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces' import { createUserEmailFactory, deleteUserEmailFactory, @@ -75,12 +50,8 @@ import { import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/emailVerification' import { createRandomPassword } from '@/modules/core/helpers/testHelpers' import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace' -import { ForbiddenError } from '@/modules/shared/errors' import cryptoRandomString from 'crypto-random-string' -import { - getStreamFactory, - grantStreamPermissionsFactory -} from '@/modules/core/repositories/streams' +import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams' import { saveActivityFactory } from '@/modules/activitystream/repositories' import { addOrUpdateStreamCollaboratorFactory, @@ -93,15 +64,16 @@ import { } from '@/modules/activitystream/services/streamActivity' import { publish } from '@/modules/shared/utils/subscriptions' import { getUserFactory } from '@/modules/core/repositories/users' +import { + TestInvitesGraphQLOperations, + buildInvitesGraphqlOperations +} from '@/modules/workspaces/tests/helpers/invites' enum InviteByTarget { Email = 'email', Id = 'id' } -type TestGraphQLOperations = ReturnType - -const getStream = getStreamFactory({ db }) const saveActivity = saveActivityFactory({ db }) const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) @@ -120,129 +92,6 @@ const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({ }) }) -const buildGraphqlOperations = (deps: { apollo: TestApolloServer }) => { - const { apollo } = deps - - const useInvite = async ( - args: UseWorkspaceInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(UseWorkspaceInviteDocument, args, options) - - const getInvite = async ( - args: GetWorkspaceInviteQueryVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(GetWorkspaceInviteDocument, args, options) - - const getMyInvites = async (options?: ExecuteOperationOptions) => - apollo.execute(GetMyWorkspaceInvitesDocument, {}, options) - - const createDefaultProjectInvite = ( - args: CreateProjectInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(CreateProjectInviteDocument, args, options) - - const createWorkspaceProjectInvite = ( - args: CreateWorkspaceProjectInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(CreateWorkspaceProjectInviteDocument, args, options) - - const resendWorkspaceInvite = ( - args: ResendWorkspaceInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(ResendWorkspaceInviteDocument, args, options) - - const useProjectInvite = async ( - args: UseWorkspaceProjectInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(UseWorkspaceProjectInviteDocument, args, options) - - const validateResourceAccess = async (params: { - shouldHaveAccess: boolean - userId: string - workspaceId: string - streamId?: string - expectedWorkspaceRole?: WorkspaceRoles - expectedProjectRole?: StreamRoles - }) => { - const { shouldHaveAccess, userId, workspaceId, streamId } = params - - const wrapAccessCheck = async (fn: () => MaybeAsync) => { - if (shouldHaveAccess) { - await fn() - } else { - const e = await expectToThrow(fn) - expect(e instanceof ForbiddenError).to.be.true - } - } - - await wrapAccessCheck(async () => { - const workspace = await getWorkspaceFactory({ db })({ workspaceId, userId }) - if (!workspace?.role) { - throw new ForbiddenError('Missing workspace role') - } - - if ( - params.expectedWorkspaceRole && - workspace.role !== params.expectedWorkspaceRole - ) { - throw new ForbiddenError( - `Unexpected workspace role! Expected: ${params.expectedWorkspaceRole}, real: ${workspace.role}` - ) - } - }) - - if (streamId?.length) { - await wrapAccessCheck(async () => { - const project = await getStream({ streamId, userId }) - if (!project?.role) { - throw new ForbiddenError('Missing project role') - } - - if (params.expectedProjectRole && project.role !== params.expectedProjectRole) { - throw new ForbiddenError( - `Unexpected project role! Expected: ${params.expectedProjectRole}, real: ${project.role}` - ) - } - }) - } - } - - const createInvite = ( - args: CreateWorkspaceInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(CreateWorkspaceInviteDocument, args, options) - - const batchCreateInvites = async ( - args: BatchCreateWorkspaceInvitesMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(BatchCreateWorkspaceInvitesDocument, args, options) - - const cancelInvite = async ( - args: CancelWorkspaceInviteMutationVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(CancelWorkspaceInviteDocument, args, options) - - const getWorkspaceWithTeam = async ( - args: GetWorkspaceWithTeamQueryVariables, - options?: ExecuteOperationOptions - ) => apollo.execute(GetWorkspaceWithTeamDocument, args, options) - - return { - useInvite, - getMyInvites, - useProjectInvite, - validateResourceAccess, - getInvite, - createInvite, - batchCreateInvites, - cancelInvite, - getWorkspaceWithTeam, - createDefaultProjectInvite, - createWorkspaceProjectInvite, - resendWorkspaceInvite - } -} - describe('Workspaces Invites GQL', () => { let app: Express @@ -324,7 +173,7 @@ describe('Workspaces Invites GQL', () => { describe('when authenticated', () => { let apollo: TestApolloServer - let gqlHelpers: TestGraphQLOperations + let gqlHelpers: TestInvitesGraphQLOperations before(async () => { apollo = await testApolloServer({ @@ -333,7 +182,7 @@ describe('Workspaces Invites GQL', () => { role: Roles.Server.User } }) - gqlHelpers = buildGraphqlOperations({ apollo }) + gqlHelpers = buildInvitesGraphqlOperations({ apollo }) }) describe('and inviting to workspace', () => { @@ -1616,7 +1465,7 @@ describe('Workspaces Invites GQL', () => { describe('when unauthenticated', () => { let registrationRestApi: LocalAuthRestApiHelpers let apollo: TestApolloServer - let gqlHelpers: TestGraphQLOperations + let gqlHelpers: TestInvitesGraphQLOperations const otherWorkspaceOwner: BasicTestUser = { name: 'Other Workspace Owner', @@ -1634,7 +1483,7 @@ describe('Workspaces Invites GQL', () => { before(async () => { apollo = await testApolloServer() registrationRestApi = localAuthRestApi({ express: app }) - gqlHelpers = buildGraphqlOperations({ apollo }) + gqlHelpers = buildInvitesGraphqlOperations({ apollo }) await createTestUsers([otherWorkspaceOwner]) await createTestWorkspaces([[otherWorkspace, otherWorkspaceOwner]]) diff --git a/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts new file mode 100644 index 000000000..af501e7af --- /dev/null +++ b/packages/server/modules/workspaces/tests/integration/subs.graph.spec.ts @@ -0,0 +1,383 @@ +import { db } from '@/db/knex' +import { ServerInvites } from '@/modules/core/dbSchema' +import { getEventBus } from '@/modules/shared/services/eventBus' +import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' +import { + getWorkspaceBySlugFactory, + getWorkspaceWithDomainsFactory, + upsertWorkspaceFactory +} from '@/modules/workspaces/repositories/workspaces' +import { + updateWorkspaceFactory, + validateSlugFactory +} from '@/modules/workspaces/services/management' +import { createWorkspaceProjectFactory } from '@/modules/workspaces/services/projects' +import { + BasicTestWorkspace, + createTestWorkspace, + unassignFromWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { + buildInvitesGraphqlOperations, + TestInvitesGraphQLOperations +} from '@/modules/workspaces/tests/helpers/invites' +import { itEach } from '@/test/assertionHelper' +import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { + OnWorkspaceProjectsUpdatedDocument, + OnWorkspaceUpdatedDocument, + WorkspaceProjectsUpdatedMessageType +} from '@/test/graphql/generated/graphql' +import { + testApolloServer, + TestApolloServer, + TestApolloSubscriptionClient, + testApolloSubscriptionServer, + TestApolloSubscriptionServer +} from '@/test/graphqlHelper' +import { beforeEachContext, truncateTables } from '@/test/hooks' +import { captureCreatedInvite } from '@/test/speckle-helpers/inviteHelper' +import { + getMainTestRegionKey, + isMultiRegionTestMode, + waitForRegionUser +} from '@/test/speckle-helpers/regions' +import { faker } from '@faker-js/faker' +import { expect } from 'chai' + +enum WorkspaceIdentification { + WithId = 'with id', + WithSlug = 'with slug' +} + +const createWorkspaceProject = createWorkspaceProjectFactory({ + getDefaultRegion: getDefaultRegionFactory({ db }) +}) + +const updateWorkspace = updateWorkspaceFactory({ + validateSlug: validateSlugFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) + }), + getWorkspace: getWorkspaceWithDomainsFactory({ db }), + upsertWorkspace: upsertWorkspaceFactory({ db }), + emitWorkspaceEvent: getEventBus().emit +}) + +describe('Workspace GQL Subscriptions', () => { + let me: BasicTestUser + let otherGuy: BasicTestUser + let subServer: TestApolloSubscriptionServer + let meSubClient: TestApolloSubscriptionClient + let apollo: TestApolloServer + let invitesHelpers: TestInvitesGraphQLOperations + + before(async () => { + await beforeEachContext() + me = await createTestUser() + otherGuy = await createTestUser() + subServer = await testApolloSubscriptionServer() + meSubClient = await subServer.buildClient({ authUserId: me.id }) + apollo = await testApolloServer({ authUserId: me.id }) + invitesHelpers = buildInvitesGraphqlOperations({ apollo }) + }) + + after(async () => { + subServer.quit() + }) + + const modes = [ + { isMultiRegion: false }, + ...(isMultiRegionTestMode() ? [{ isMultiRegion: true }] : []) + ] + + modes.forEach(({ isMultiRegion }) => { + describe(`W/${!isMultiRegion ? 'o' : ''} multiregion`, () => { + const myMainWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + slug: '', + name: 'My Main Workspace' + } + + before(async () => { + if (isMultiRegion) { + await Promise.all([ + waitForRegionUser({ userId: me.id }), + waitForRegionUser({ userId: otherGuy.id }) + ]) + } + + await createTestWorkspace(myMainWorkspace, me, { + regionKey: isMultiRegion ? getMainTestRegionKey() : undefined + }) + }) + + itEach( + [WorkspaceIdentification.WithId, WorkspaceIdentification.WithSlug], + (idType) => `sub ${idType} and notify when a project is added to the workspace`, + async (idType) => { + const sub = await meSubClient.subscribe( + OnWorkspaceProjectsUpdatedDocument, + { + workspaceId: + idType === WorkspaceIdentification.WithId + ? myMainWorkspace.id + : undefined, + workspaceSlug: + idType === WorkspaceIdentification.WithSlug + ? myMainWorkspace.slug + : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.workspaceProjectsUpdated.type).to.equal( + WorkspaceProjectsUpdatedMessageType.Added + ) + } + ) + await meSubClient.waitForReadiness() + + await createWorkspaceProject({ + input: { + workspaceId: myMainWorkspace.id, + name: 'New Workspace Project ' + faker.number.int() + }, + ownerId: me.id + }) + + await sub.waitForMessage() + expect(sub.getMessages()).to.have.length(1) + } + ) + + itEach( + [WorkspaceIdentification.WithId, WorkspaceIdentification.WithSlug], + (idType) => `sub ${idType} and notify when a workspace is updated`, + async (idType) => { + const sub = await meSubClient.subscribe( + OnWorkspaceUpdatedDocument, + { + workspaceId: + idType === WorkspaceIdentification.WithId + ? myMainWorkspace.id + : undefined, + workspaceSlug: + idType === WorkspaceIdentification.WithSlug + ? myMainWorkspace.slug + : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.workspaceUpdated.id).to.equal(myMainWorkspace.id) + expect(res.data?.workspaceUpdated.workspace.slug).to.equal( + myMainWorkspace.slug + ) + } + ) + await meSubClient.waitForReadiness() + + await updateWorkspace({ + workspaceId: myMainWorkspace.id, + workspaceInput: { + name: 'Updated Workspace Name' + } + }) + + await sub.waitForMessage() + expect(sub.getMessages()).to.have.length(1) + } + ) + + describe('team changes', () => { + const myTeamWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + slug: '', + name: 'My Team Workspace' + } + + before(async () => { + await createTestWorkspace(myTeamWorkspace, me, { + regionKey: isMultiRegion ? getMainTestRegionKey() : undefined + }) + }) + + afterEach(async () => { + await truncateTables([ServerInvites.name]) + await unassignFromWorkspace(myTeamWorkspace, otherGuy) + }) + + itEach( + [WorkspaceIdentification.WithId, WorkspaceIdentification.WithSlug], + (idType) => `sub ${idType} and notify when a workspace has a new invite`, + async (idType) => { + const sub = await meSubClient.subscribe( + OnWorkspaceUpdatedDocument, + { + workspaceId: + idType === WorkspaceIdentification.WithId + ? myMainWorkspace.id + : undefined, + workspaceSlug: + idType === WorkspaceIdentification.WithSlug + ? myMainWorkspace.slug + : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.workspaceUpdated.id).to.equal(myMainWorkspace.id) + expect(res.data?.workspaceUpdated.workspace.slug).to.equal( + myMainWorkspace.slug + ) + + const invite = res.data?.workspaceUpdated.workspace.invitedTeam?.[0] + expect(invite?.user?.id).to.equal(otherGuy.id) + } + ) + await meSubClient.waitForReadiness() + + // Invite user to workspace + await invitesHelpers.createInvite( + { + workspaceId: myMainWorkspace.id, + input: { + userId: otherGuy.id + } + }, + { assertNoErrors: true } + ) + + await sub.waitForMessage() + expect(sub.getMessages()).to.have.length(1) + } + ) + + itEach( + [WorkspaceIdentification.WithId, WorkspaceIdentification.WithSlug], + (idType) => `sub ${idType} and notify when a workspace invite is canceled`, + async (idType) => { + const { id: inviteId } = await captureCreatedInvite( + async () => + await invitesHelpers.createInvite( + { + workspaceId: myTeamWorkspace.id, + input: { + userId: otherGuy.id + } + }, + { assertNoErrors: true } + ) + ) + + const sub = await meSubClient.subscribe( + OnWorkspaceUpdatedDocument, + { + workspaceId: + idType === WorkspaceIdentification.WithId + ? myTeamWorkspace.id + : undefined, + workspaceSlug: + idType === WorkspaceIdentification.WithSlug + ? myTeamWorkspace.slug + : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.workspaceUpdated.id).to.equal(myTeamWorkspace.id) + expect(res.data?.workspaceUpdated.workspace.slug).to.equal( + myTeamWorkspace.slug + ) + expect( + res.data?.workspaceUpdated.workspace.invitedTeam || [] + ).to.have.length(0) + } + ) + await meSubClient.waitForReadiness() + + // Cancel invite + await invitesHelpers.cancelInvite( + { + workspaceId: myTeamWorkspace.id, + inviteId + }, + { assertNoErrors: true } + ) + + await sub.waitForMessage() + expect(sub.getMessages()).to.have.length(1) + } + ) + + itEach( + [ + { idType: WorkspaceIdentification.WithId, accept: true }, + { idType: WorkspaceIdentification.WithSlug, accept: true }, + { idType: WorkspaceIdentification.WithId, accept: false }, + { idType: WorkspaceIdentification.WithSlug, accept: false } + ], + ({ idType, accept }) => + `sub ${idType} and notify when a workspace invite is ${ + accept ? 'accepted' : 'declined' + }`, + async ({ idType, accept }) => { + const { token } = await captureCreatedInvite( + async () => + await invitesHelpers.createInvite( + { + workspaceId: myTeamWorkspace.id, + input: { + userId: otherGuy.id + } + }, + { assertNoErrors: true } + ) + ) + + const sub = await meSubClient.subscribe( + OnWorkspaceUpdatedDocument, + { + workspaceId: + idType === WorkspaceIdentification.WithId + ? myTeamWorkspace.id + : undefined, + workspaceSlug: + idType === WorkspaceIdentification.WithSlug + ? myTeamWorkspace.slug + : undefined + }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.workspaceUpdated.id).to.equal(myTeamWorkspace.id) + expect(res.data?.workspaceUpdated.workspace.slug).to.equal( + myTeamWorkspace.slug + ) + expect( + res.data?.workspaceUpdated.workspace.invitedTeam || [] + ).to.have.length(0) + + const team = res.data?.workspaceUpdated.workspace.team.items || [] + const newTeammateAdded = !!team.find((t) => t.user.id === otherGuy.id) + expect(newTeammateAdded).to.equal(accept) + } + ) + await meSubClient.waitForReadiness() + + // Accept invite + await invitesHelpers.useInvite( + { + input: { + token, + accept + } + }, + { assertNoErrors: true, authUserId: otherGuy.id } + ) + + await sub.waitForMessage() + expect(sub.getMessages()).to.have.length(1) + } + ) + }) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index 928ffb178..d13008f38 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -732,14 +732,19 @@ describe('Workspaces GQL CRUD', () => { }) describe('mutation workspaceMutations.update', () => { - const workspace = { + const workspace: BasicTestWorkspace = { id: '', + slug: '', ownerId: '', name: cryptoRandomString({ length: 6 }), description: cryptoRandomString({ length: 12 }) } beforeEach(async () => { + // we want a new workspace for each test + workspace.id = '' + workspace.slug = '' + await createTestWorkspace(workspace, testAdminUser) }) diff --git a/packages/server/test/assertionHelper.ts b/packages/server/test/assertionHelper.ts index 401aaa6c8..5eddb0784 100644 --- a/packages/server/test/assertionHelper.ts +++ b/packages/server/test/assertionHelper.ts @@ -19,9 +19,16 @@ export const expectToThrow = async (fn: () => MaybeAsync) => { export const itEach = ( testCases: Array | ReadonlyArray, name: (test: T) => string, - testHandler: (test: T) => MaybeAsync + testHandler: (test: T) => MaybeAsync, + options?: Partial<{ + /** + * Mark tests as sklipped + */ + skip: boolean + }> ) => { testCases.forEach((testCase) => { - it(name(testCase), testHandler.bind(null, testCase)) + const itFn = options?.skip ? it.skip : it + itFn(name(testCase), testHandler.bind(null, testCase)) }) } diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 38853af42..2b61ea456 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -4552,11 +4552,30 @@ export type OnUserProjectsUpdatedSubscriptionVariables = Exact<{ [key: string]: export type OnUserProjectsUpdatedSubscription = { __typename?: 'Subscription', userProjectsUpdated: { __typename?: 'UserProjectsUpdatedMessage', id: string, type: UserProjectsUpdatedMessageType, project?: { __typename?: 'Project', id: string, name: string } | null } }; +export type OnProjectUpdatedSubscriptionVariables = Exact<{ + projectId: Scalars['String']['input']; +}>; + + +export type OnProjectUpdatedSubscription = { __typename?: 'Subscription', projectUpdated: { __typename?: 'ProjectUpdatedMessage', id: string, type: ProjectUpdatedMessageType, project?: { __typename?: 'Project', id: string, name: string } | null } }; + export type OnUserStreamAddedSubscriptionVariables = Exact<{ [key: string]: never; }>; export type OnUserStreamAddedSubscription = { __typename?: 'Subscription', userStreamAdded?: Record | null }; +export type OnUserStreamRemovedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type OnUserStreamRemovedSubscription = { __typename?: 'Subscription', userStreamRemoved?: Record | null }; + +export type OnStreamUpdatedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; +}>; + + +export type OnStreamUpdatedSubscription = { __typename?: 'Subscription', streamUpdated?: Record | null }; + export type OnUserProjectVersionsUpdatedSubscriptionVariables = Exact<{ projectId: Scalars['String']['input']; }>; @@ -4571,6 +4590,51 @@ export type OnUserStreamCommitCreatedSubscriptionVariables = Exact<{ export type OnUserStreamCommitCreatedSubscription = { __typename?: 'Subscription', commitCreated?: Record | null }; +export type OnUserStreamCommitDeletedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; +}>; + + +export type OnUserStreamCommitDeletedSubscription = { __typename?: 'Subscription', commitDeleted?: Record | null }; + +export type OnUserStreamCommitUpdatedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; + commitId?: InputMaybe; +}>; + + +export type OnUserStreamCommitUpdatedSubscription = { __typename?: 'Subscription', commitUpdated?: Record | null }; + +export type OnProjectModelsUpdatedSubscriptionVariables = Exact<{ + projectId: Scalars['String']['input']; + modelIds?: InputMaybe | Scalars['String']['input']>; +}>; + + +export type OnProjectModelsUpdatedSubscription = { __typename?: 'Subscription', projectModelsUpdated: { __typename?: 'ProjectModelsUpdatedMessage', id: string, type: ProjectModelsUpdatedMessageType, model?: { __typename?: 'Model', id: string, name: string } | null } }; + +export type OnBranchCreatedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; +}>; + + +export type OnBranchCreatedSubscription = { __typename?: 'Subscription', branchCreated?: Record | null }; + +export type OnBranchUpdatedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; + branchId?: InputMaybe; +}>; + + +export type OnBranchUpdatedSubscription = { __typename?: 'Subscription', branchUpdated?: Record | null }; + +export type OnBranchDeletedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; +}>; + + +export type OnBranchDeletedSubscription = { __typename?: 'Subscription', branchDeleted?: Record | null }; + export type BasicWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null }; export type BasicPendingWorkspaceCollaboratorFragment = { __typename?: 'PendingWorkspaceCollaborator', id: string, inviteId: string, workspaceId: string, workspaceName: string, title: string, role: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string }, user?: { __typename?: 'LimitedUser', id: string, name: string } | null }; @@ -4692,6 +4756,22 @@ export type SetWorkspaceDefaultRegionMutationVariables = Exact<{ export type SetWorkspaceDefaultRegionMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', setDefaultRegion: { __typename?: 'Workspace', id: string, defaultRegion?: { __typename?: 'ServerRegionItem', id: string, key: string, name: string } | null } } }; +export type OnWorkspaceProjectsUpdatedSubscriptionVariables = Exact<{ + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}>; + + +export type OnWorkspaceProjectsUpdatedSubscription = { __typename?: 'Subscription', workspaceProjectsUpdated: { __typename?: 'WorkspaceProjectsUpdatedMessage', type: WorkspaceProjectsUpdatedMessageType, projectId: string, workspaceId: string, project?: { __typename?: 'Project', id: string, name: string } | null } }; + +export type OnWorkspaceUpdatedSubscriptionVariables = Exact<{ + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}>; + + +export type OnWorkspaceUpdatedSubscription = { __typename?: 'Subscription', workspaceUpdated: { __typename?: 'WorkspaceUpdatedMessage', id: string, workspace: { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, team: { __typename?: 'WorkspaceCollaboratorCollection', totalCount: number, items: Array<{ __typename?: 'WorkspaceCollaborator', id: string, role: string, user: { __typename?: 'LimitedUser', id: string, name: string } }> }, invitedTeam?: Array<{ __typename?: 'PendingWorkspaceCollaborator', id: string, inviteId: string, workspaceId: string, workspaceName: string, title: string, role: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string }, user?: { __typename?: 'LimitedUser', id: string, name: string } | null }> | null } } }; + export type BasicStreamAccessRequestFieldsFragment = { __typename?: 'StreamAccessRequest', id: string, requesterId: string, streamId: string, createdAt: string, requester: { __typename?: 'LimitedUser', id: string, name: string } }; export type CreateStreamAccessRequestMutationVariables = Exact<{ @@ -5363,9 +5443,18 @@ export const TestWorkspaceProjectFragmentDoc = {"kind":"Document","definitions": export const CreateObjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateObject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ObjectCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objectCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"objectInput"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const PingPongDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PingPong"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ping"}}]}}]} as unknown as DocumentNode; export const OnUserProjectsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserProjectsUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userProjectsUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const OnProjectUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnProjectUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const OnUserStreamAddedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserStreamAdded"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userStreamAdded"}}]}}]} as unknown as DocumentNode; +export const OnUserStreamRemovedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserStreamRemoved"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userStreamRemoved"}}]}}]} as unknown as DocumentNode; +export const OnStreamUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnStreamUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const OnUserProjectVersionsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserProjectVersionsUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectVersionsUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"version"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}}]}}]}}]} as unknown as DocumentNode; export const OnUserStreamCommitCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserStreamCommitCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; +export const OnUserStreamCommitDeletedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserStreamCommitDeleted"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitDeleted"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; +export const OnUserStreamCommitUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnUserStreamCommitUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"commitId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commitUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"commitId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"commitId"}}}]}]}}]} as unknown as DocumentNode; +export const OnProjectModelsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnProjectModelsUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectModelsUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"model"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const OnBranchCreatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchCreated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchCreated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; +export const OnBranchUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"branchId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"branchId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"branchId"}}}]}]}}]} as unknown as DocumentNode; +export const OnBranchDeletedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnBranchDeleted"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"branchDeleted"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}]}]}}]} as unknown as DocumentNode; export const CreateWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; export const BatchCreateWorkspaceInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchCreateWorkspaceInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; export const GetWorkspaceWithTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithTeam"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; @@ -5382,6 +5471,8 @@ export const DeleteWorkspaceDomainDocument = {"kind":"Document","definitions":[{ export const GetAvailableRegionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAvailableRegions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"multiRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetWorkspaceDefaultRegionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceDefaultRegion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const SetWorkspaceDefaultRegionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetWorkspaceDefaultRegion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"regionKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setDefaultRegion"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"regionKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"regionKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"defaultRegion"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const OnWorkspaceProjectsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnWorkspaceProjectsUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceProjectsUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceSlug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const OnWorkspaceUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"OnWorkspaceUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceSlug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode; export const CreateStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequestCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; export const GetFullStreamAccessRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFullStreamAccessRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"streamAccessRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"streamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"}},{"kind":"Field","name":{"kind":"Name","value":"stream"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicStreamAccessRequestFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"StreamAccessRequest"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"requester"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"requesterId"}},{"kind":"Field","name":{"kind":"Name","value":"streamId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/test/graphqlHelper.ts b/packages/server/test/graphqlHelper.ts index 5ab609fd9..00420dbca 100644 --- a/packages/server/test/graphqlHelper.ts +++ b/packages/server/test/graphqlHelper.ts @@ -8,17 +8,18 @@ import { Roles } from '@/modules/core/helpers/mainConstants' import { AllScopes, buildManualPromise, - ManualPromise, + ensureError, MaybeAsync, MaybeNullOrUndefined, Optional, + ServerScope, timeoutAt } from '@speckle/shared' import { expect } from 'chai' import { ApolloServer, GraphQLResponse } from '@apollo/server' import { getUserFactory } from '@/modules/core/repositories/users' import { db } from '@/db/knex' -import { pick, set } from 'lodash' +import { get, pick, set } from 'lodash' import { isTestEnv } from '@/modules/shared/helpers/envHelper' import { publish, TestSubscriptions } from '@/modules/shared/utils/subscriptions' import cryptoRandomString from 'crypto-random-string' @@ -29,6 +30,9 @@ import { SubscriptionClient } from 'subscriptions-transport-ws' import { WebSocketLink } from '@apollo/client/link/ws' import { execute } from '@apollo/client/core' import { PingPongDocument } from '@/test/graphql/generated/graphql' +import { BaseError } from '@/modules/shared/errors' +import EventEmitter from 'eventemitter2' +import { expectToThrow } from '@/test/assertionHelper' type TypedGraphqlResponse> = GraphQLResponse @@ -238,12 +242,11 @@ export const testApolloServer = async (params?: { }, { contextValue: ctx } )) as TypedGraphqlResponse - + const results = getResponseResults(res) if (options?.assertNoErrors) { - expect(res).to.not.haveGraphQLErrors() + expect(results).to.not.haveGraphQLErrors() } - const results = getResponseResults(res) return { ...results, res @@ -271,6 +274,11 @@ export const startEmittingTestSubs = async () => { return () => clearInterval(interval) } +export class TestApolloSubscriptionError extends BaseError { + static code = 'TEST_APOLLO_SUBSCRIPTION_ERROR' + static defaultMessage = 'Unexpected issue occurred during test subscriptions' +} + /** * Utilities for quickly/easily testing GQL subscriptions without having to build real network servers & connections */ @@ -292,12 +300,19 @@ export const testApolloSubscriptionServer = async () => { */ const buildClient = async (params?: { /** - * Real user id to auth the connection with. If unset, will be unauthenticated + * Real user id to auth the connection with. If unset, will be unauthenticated. + * Token will be given all scopes, unless overridden */ authUserId?: string + /** + * Optionally provide the scopes you want the token to have + */ + scopes?: ServerScope[] }) => { - const { authUserId } = params || {} - const token = authUserId ? await createAuthTokenForUser(authUserId) : undefined + const { authUserId, scopes } = params || {} + const token = authUserId + ? await createAuthTokenForUser(authUserId, scopes) + : undefined const wsClient = new SubscriptionClient( serverUrl, { @@ -320,31 +335,49 @@ export const testApolloSubscriptionServer = async () => { variables: V, handler: (res: FormattedExecutionResult) => MaybeAsync ) => { - let msgFlaggedPromise: Optional> = undefined + const name = getOperationName(query) + const buildLogMsg = (msg: string) => (name ? `[${name}] ${msg}` : msg) + + let processingErrors: unknown[] = [] const messages: Array> = [] + const eventBus = new EventEmitter() + const errHandler = (e: unknown) => { + processingErrors.push(e) + } + eventBus.on('uncaughtException', errHandler) + eventBus.on('error', errHandler) + const observable = execute(clientLink, { query, variables }) - const sub = observable.subscribe((eventData) => { + const sub = observable.subscribe(async (eventData) => { const res = eventData as FormattedExecutionResult + const asyncHandler = async () => handler(res) // Invoke handler - messages.push(res) - handler(res) + try { + await asyncHandler() + } catch (e) { + // If we throw here, this will be an unhandled rejection, lets throw in waitForMsg instead + eventBus.emit('error', e) + } // Mark msg received - if (!msgFlaggedPromise) { - msgFlaggedPromise = buildManualPromise() + try { + messages.push(res) + await eventBus.emitAsync('message', res) + } catch (e) { + eventBus.emit('error', e) } - msgFlaggedPromise.resolve() }) /** * Unsubscribe from the subscription */ const unsub = () => { + eventBus.removeAllListeners() sub.unsubscribe() } @@ -355,22 +388,98 @@ export const testApolloSubscriptionServer = async () => { const waitForMessage = async ( options?: Partial<{ /** - * Max time to wait for the messag + * Max time to wait for the message * Defaults to: 200 */ timeout: number + + /** + * Whether to consider messages that have already arrived before the invocation of this function. + * This is useful cause sometimes the message might arrive before we even start waiting for it. + * Defaults to: true + */ + allowPreviousMessages: boolean + + /** + * Optionally wait for a specific kind of message + */ + predicate: (msg: FormattedExecutionResult) => boolean }> - ) => { - const { timeout = 200 } = options || {} - if (!msgFlaggedPromise) { - msgFlaggedPromise = buildManualPromise() + ): Promise> => { + const { timeout = 200, allowPreviousMessages = true, predicate } = options || {} + + // First check for previous errors + if (processingErrors.length) { + const firstErr = processingErrors[0] + processingErrors = [] + + throw firstErr + } + + // Then lets check previous messages + if (allowPreviousMessages) { + const found = messages.find((msg) => !predicate || predicate(msg)) + if (found) return found // Found it! + } + + // Now lets wait for incoming ones + const retPromise = buildManualPromise>() + const unlisten = () => { + eventBus.removeListener('message', onMessage) + eventBus.removeListener('error', onError) + } + const onMessage = async (msg: FormattedExecutionResult) => { + if (!predicate || predicate(msg)) { + retPromise.resolve(msg) + unlisten() + } + } + const onError = (e: unknown) => { + retPromise.reject(e) + unlisten() + processingErrors = [] + } + + eventBus.on('message', onMessage) + eventBus.on('error', onError) + + try { + return await Promise.race([retPromise.promise, timeoutAt(timeout)]) + } catch (e) { + throw new TestApolloSubscriptionError( + buildLogMsg('waitForMessage() failed'), + { + cause: ensureError(e) + } + ) } - await Promise.race([msgFlaggedPromise.promise, timeoutAt(timeout)]) } - const getMessages = () => messages.slice() + /** + * Wrapper over waitForMessage() that does the inverse and expects a timeout + * to happen instead (no message should arrive) + */ + const waitForTimeout = async (...params: Parameters) => { + const e = await expectToThrow(() => waitForMessage(...params)) + if (!e.message.includes('timeout')) { + throw e + } + } - return { unsub, waitForMessage, getMessages } + const getMessages = ( + options?: Partial<{ + /** + * Optionally check for a specific kind of message + */ + predicate: (msg: FormattedExecutionResult) => boolean + }> + ) => { + const { predicate } = options || {} + const msgs = messages.slice() + return predicate ? msgs.filter(predicate) : msgs + } + + return { unsub, waitForMessage, getMessages, waitForTimeout } } /** @@ -380,14 +489,14 @@ export const testApolloSubscriptionServer = async () => { return new Promise(async (resolve, reject) => { const { unsub } = await subscribe(PingPongDocument, {}, (res) => { if (!res.data?.ping) { - return reject(new Error('Unexpected ping error')) + return reject(new TestApolloSubscriptionError('Unexpected ping error')) } unsub() resolve() }) - timeoutAt(5000).catch(reject) + timeoutAt(5000, 'waitForReadiness() timed out').catch(reject) }) } @@ -425,3 +534,13 @@ export type TestApolloSubscriptionServer = Awaited< export type TestApolloSubscriptionClient = Awaited< ReturnType > + +const getOperationName = (query: DocumentNode) => { + const operation = query.definitions.find((def) => def.kind === 'OperationDefinition') + + // doing this w/ a get() because of some weird Ts typing issues + const name = ( + operation ? get(operation, 'name.value') : undefined + ) as Optional + return name +} diff --git a/packages/server/test/hooks.ts b/packages/server/test/hooks.ts index 92820c441..1e7f07ac6 100644 --- a/packages/server/test/hooks.ts +++ b/packages/server/test/hooks.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ // eslint-disable-next-line no-restricted-imports import '../bootstrap' @@ -17,7 +18,7 @@ import type http from 'http' import type express from 'express' import type net from 'net' import { MaybeAsync, MaybeNullOrUndefined, Optional, wait } from '@speckle/shared' -import type mocha from 'mocha' +import * as mocha from 'mocha' import { getAvailableRegionKeysFactory, getFreeRegionKeysFactory @@ -40,6 +41,8 @@ import { isMultiRegionEnabled } from '@/modules/multiregion/helpers' import { GraphQLContext } from '@/modules/shared/helpers/typeHelper' import { ApolloServer } from '@apollo/server' import { ReadinessHandler } from '@/healthchecks/health' +import { set } from 'lodash' +import { fixStackTrace } from '@/test/speckle-helpers/error' // why is server config only created once!???? // because its done in a migration, to not override existing configs @@ -53,6 +56,18 @@ chai.use(chaiHttp) chai.use(deepEqualInAnyOrder) chai.use(graphqlChaiPlugin) +// Please forgive me god for what I'm about to do, but Mocha's ancient API sucks ass +// and there's NO OTHER WAY to format errors across all reporters +const originalMochaRun = mocha.default.prototype.run +set(mocha.default.prototype, 'run', function (this: any, ...args: any) { + const runner = originalMochaRun.apply(this, args) + runner.prependListener(mocha.Runner.constants.EVENT_TEST_FAIL, (_test, err) => { + fixStackTrace(err) + }) + + return runner +}) + export const getMainTestRegionKey = () => { const key = Object.keys(regionClients)[0] if (!key) { @@ -233,13 +248,26 @@ const resetSchemaFactory = (deps: { db: Knex }) => async () => { await deps.db.migrate.latest() } -export const truncateTables = async (tableNames?: string[]) => { +export const truncateTables = async ( + tableNames?: string[], + options?: Partial<{ + /** + * Whether to also reset pubsub before truncate. Pubsub only gets re-initialized on app + * init so don't do this if not needed! + * Defaults to: false + */ + resetPubSub: boolean + }> +) => { + const { resetPubSub = false } = options || {} const dbs = [mainDb, ...Object.values(regionClients)] - // First reset pubsubs - for (const db of dbs) { - const resetPubSub = resetPubSubFactory({ db }) - await resetPubSub() + // First reset pubsubs, if needed + if (resetPubSub) { + for (const db of dbs) { + const resetPubSub = resetPubSubFactory({ db }) + await resetPubSub() + } } // Now truncate @@ -314,6 +342,6 @@ export const buildApp = async () => { } export const beforeEachContext = async () => { - await truncateTables() + await truncateTables(undefined, { resetPubSub: true }) return await buildApp() } diff --git a/packages/server/test/speckle-helpers/branchHelper.ts b/packages/server/test/speckle-helpers/branchHelper.ts index 895a8e18b..8a1a22099 100644 --- a/packages/server/test/speckle-helpers/branchHelper.ts +++ b/packages/server/test/speckle-helpers/branchHelper.ts @@ -1,5 +1,13 @@ import { db } from '@/db/knex' -import { createBranchFactory } from '@/modules/core/repositories/branches' +import { saveActivityFactory } from '@/modules/activitystream/repositories' +import { addBranchCreatedActivityFactory } from '@/modules/activitystream/services/branchActivity' +import { + createBranchFactory, + getStreamBranchByNameFactory +} from '@/modules/core/repositories/branches' +import { createBranchAndNotifyFactory } from '@/modules/core/services/branch/management' +import { getProjectDbClient } from '@/modules/multiregion/dbSelector' +import { publish } from '@/modules/shared/utils/subscriptions' import { BasicTestUser } from '@/test/authHelper' import { BasicTestStream } from '@/test/speckle-helpers/streamHelper' import { omit } from 'lodash' @@ -31,12 +39,25 @@ export async function createTestBranch(params: { branch.streamId = stream.id branch.authorId = owner.id - const createBranch = createBranchFactory({ db }) - const id = ( - await createBranch({ - ...omit(branch, ['id']), - description: branch.description || null + const projectDb = await getProjectDbClient({ projectId: stream.id }) + + const createBranchAndNotify = createBranchAndNotifyFactory({ + getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }), + createBranch: createBranchFactory({ db: projectDb }), + addBranchCreatedActivity: addBranchCreatedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish }) + }) + + const id = ( + await createBranchAndNotify( + { + ...omit(branch, ['id']), + description: branch.description || null + }, + owner.id + ) ).id branch.id = id } diff --git a/packages/server/test/speckle-helpers/error.ts b/packages/server/test/speckle-helpers/error.ts new file mode 100644 index 000000000..1778f2a03 --- /dev/null +++ b/packages/server/test/speckle-helpers/error.ts @@ -0,0 +1,24 @@ +import { BaseError } from '@/modules/shared/errors' +import { VError } from 'verror' + +/** + * Generic VError-enhanced error for usage in tests + */ +export class TestError extends BaseError { + static code = 'TEST_ERROR' + static message = 'Error occurred in a test' +} + +/** + * Ensure VError.cause & info are reported properly in stack trace + */ +export const fixStackTrace = (err: unknown) => { + if (err instanceof BaseError) { + const info = VError.info(err) + const hasInfo = Object.keys(info).length > 0 + + err.stack = `${VError.fullStack(err)}${ + hasInfo ? '\nInfo:\n' + JSON.stringify(info, undefined, 4) + '\n' : '' + }` + } +} diff --git a/packages/ui-components/src/components/form/select/Base.vue b/packages/ui-components/src/components/form/select/Base.vue index 4bfd6a3c3..e8516d3db 100644 --- a/packages/ui-components/src/components/form/select/Base.vue +++ b/packages/ui-components/src/components/form/select/Base.vue @@ -12,7 +12,7 @@ 'md:flex md:items-center md:space-x-2 md:justify-between': isLeftLabelPosition }" > -
+