diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a27e23dd..2009c2a20 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -577,13 +577,7 @@ jobs: REDIS_URL: 'redis://127.0.0.1:6379' S3_REGION: '' # optional, defaults to 'us-east-1' AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' - FF_AUTOMATE_MODULE_ENABLED: 'false' # Disable all FFs - FF_WORKSPACES_MODULE_ENABLED: 'false' - FF_WORKSPACES_SSO_ENABLED: 'false' - FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false' - FF_GENDOAI_MODULE_ENABLED: 'false' - FF_GATEKEEPER_MODULE_ENABLED: 'false' - FF_BILLING_INTEGRATION_ENABLED: 'false' + DISABLE_ALL_FFS: 'true' test-server-multiregion: <<: *test-server-job diff --git a/packages/frontend-2/components/projects/Dashboard.vue b/packages/frontend-2/components/projects/Dashboard.vue index 6eed0e439..fb5d78a60 100644 --- a/packages/frontend-2/components/projects/Dashboard.vue +++ b/packages/frontend-2/components/projects/Dashboard.vue @@ -108,7 +108,8 @@ const { filter: { search: (search.value || '').trim() || null, onlyWithRoles: selectedRoles.value?.length ? selectedRoles.value : null - } + }, + cursor: null as Nullable })) const { result: workspacesResult } = useQuery( diff --git a/packages/frontend-2/components/workspace/ProjectList.vue b/packages/frontend-2/components/workspace/ProjectList.vue index 8429db43e..595d179c6 100644 --- a/packages/frontend-2/components/workspace/ProjectList.vue +++ b/packages/frontend-2/components/workspace/ProjectList.vue @@ -111,7 +111,7 @@ diff --git a/packages/server/.mocharc.js b/packages/server/.mocharc.js index 5ce9d9f53..20fda1bd7 100644 --- a/packages/server/.mocharc.js +++ b/packages/server/.mocharc.js @@ -13,7 +13,7 @@ const ignore = [ /** @type {import("mocha").MochaOptions} */ const config = { - spec: ['modules/**/*.spec.js', 'modules/**/*.spec.ts', 'logging/**/*.spec.js'], + spec: ['modules/**/*.spec.js', 'modules/**/*.spec.ts', 'logging/**/*.spec.ts'], require: ['ts-node/register', 'test/hooks.ts'], ...(ignore.length ? { ignore } : {}), slow: 0, diff --git a/packages/server/app.ts b/packages/server/app.ts index 52417d7cf..3f9e2923e 100644 --- a/packages/server/app.ts +++ b/packages/server/app.ts @@ -75,12 +75,20 @@ import { shouldLogAsInfoLevel } from '@/logging/graphqlError' import { getUserFactory } from '@/modules/core/repositories/users' import { initFactory as healthchecksInitFactory } from '@/healthchecks' import type { ReadinessHandler } from '@/healthchecks/health' +import type ws from 'ws' +import type { Server as MockWsServer } from 'mock-socket' +import { SetOptional } from 'type-fest' const GRAPHQL_PATH = '/graphql' // eslint-disable-next-line @typescript-eslint/no-explicit-any type SubscriptionResponse = { errors?: GraphQLError[]; data?: any } +/** + * In mocked Ws connections, request will be undefined + */ +type PossiblyMockedConnectionContext = SetOptional + function logSubscriptionOperation(params: { ctx: GraphQLContext execParams: ExecutionParams @@ -118,12 +126,23 @@ function logSubscriptionOperation(params: { } } +const isWsServer = (server: http.Server | MockWsServer): server is MockWsServer => { + return 'on' in server && 'clients' in server +} + /** * TODO: subscriptions-transport-ws is no longer maintained, we should migrate to graphql-ws insted. The problem * is that graphql-ws uses an entirely different protocol, so the client-side has to change as well, and so old clients * will be unable to use any WebSocket/subscriptions functionality with the updated server */ -function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer { +export function buildApolloSubscriptionServer( + server: http.Server | MockWsServer +): SubscriptionServer { + const httpServer = isWsServer(server) ? undefined : server + const mockServer = isWsServer(server) ? server : undefined + + // we have to break the type here, cause its a mock + const wsServer = mockServer ? (mockServer as unknown as ws.Server) : undefined const schema = ModulesSetup.graphSchema() // Init metrics @@ -156,6 +175,20 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer labelNames: ['subscriptionType', 'status'] as const }) + const getHeaders = (params: { + connContext?: PossiblyMockedConnectionContext + connectionParams?: Record + }) => { + const { connContext, connectionParams } = params + const connCtxHeaders = connContext?.request?.headers || {} + const paramsHeaders = connectionParams?.headers || {} + + return { + ...connCtxHeaders, + ...paramsHeaders + } as Record + } + return SubscriptionServer.create( { schema, @@ -164,12 +197,12 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer onConnect: async ( connectionParams: Record, webSocket: WebSocket, - connContext: ConnectionContext + connContext: PossiblyMockedConnectionContext ) => { metricConnectCounter.inc() metricConnectedClients.inc() - const logger = connContext.request.log || subscriptionLogger + const logger = connContext.request?.log || subscriptionLogger const possiblePaths = [ 'Authorization', @@ -181,9 +214,10 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer // Resolve token let token: string try { - const requestId = get(connectionParams, 'headers.x-request-id') as string + const headers = getHeaders({ connContext, connectionParams }) + const requestId = headers['x-request-id'] || '' logger.debug( - { requestId, headers: sanitizeHeaders(connContext.request.headers) }, + { requestId, headers: sanitizeHeaders(headers) }, 'New websocket connection' ) let header: Optional @@ -210,6 +244,7 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer // Build context (Apollo Server v3 no longer triggers context building automatically // for subscriptions) try { + const headers = getHeaders({ connContext, connectionParams }) const buildCtx = await buildContext({ req: null, token, @@ -220,7 +255,7 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer userId: buildCtx.userId, ws_protocol: webSocket.protocol, ws_url: webSocket.url, - headers: sanitizeHeaders(connContext.request.headers) + headers: sanitizeHeaders(headers) }, 'Websocket connected and subscription context built.' ) @@ -229,13 +264,17 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer throw new ForbiddenError('Subscription context build failed') } }, - onDisconnect: (webSocket: WebSocket, connContext: ConnectionContext) => { - const logger = connContext.request.log || subscriptionLogger + onDisconnect: ( + webSocket: WebSocket, + connContext: PossiblyMockedConnectionContext + ) => { + const logger = connContext.request?.log || subscriptionLogger + const headers = getHeaders({ connContext }) logger.debug( { ws_protocol: webSocket.protocol, ws_url: webSocket.url, - headers: sanitizeHeaders(connContext.request.headers) + headers: sanitizeHeaders(headers) }, 'Websocket disconnected.' ) @@ -286,8 +325,8 @@ function buildApolloSubscriptionServer(server: http.Server): SubscriptionServer }, keepAlive: 30000 //milliseconds. Loadbalancers may close the connection after inactivity. e.g. nginx default is 60000ms. }, - { - server, + wsServer || { + server: httpServer!, path: GRAPHQL_PATH } ) @@ -451,9 +490,9 @@ export async function init() { } export async function shutdown(params: { - graphqlServer: ApolloServer + graphqlServer: Optional> }): Promise { - await params.graphqlServer.stop() + await params.graphqlServer?.stop() await ModulesSetup.shutdown() } diff --git a/packages/server/assets/core/typedefs/test.graphql b/packages/server/assets/core/typedefs/test.graphql new file mode 100644 index 000000000..aeb1b0246 --- /dev/null +++ b/packages/server/assets/core/typedefs/test.graphql @@ -0,0 +1,7 @@ +extend type Subscription { + """ + Cyclically sends a message to the client, used for testing + Note: Only works in test environment + """ + ping: String! +} diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index fc40cec30..29566c5fa 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -485,3 +485,55 @@ extend type LimitedUser { # if workspaceId is undefined | null, just return undefined # this can be implemented by the workspaceCore resolver too, to avoid frontend component duplication } + +enum WorkspaceProjectsUpdatedMessageType { + ADDED + REMOVED +} + +type WorkspaceProjectsUpdatedMessage { + """ + Message type + """ + type: WorkspaceProjectsUpdatedMessageType! + """ + Project ID + """ + projectId: String! + """ + Workspace ID + """ + workspaceId: String! + """ + Project entity, null if project was deleted + """ + project: Project +} + +type WorkspaceUpdatedMessage { + """ + Workspace ID + """ + id: String! + """ + Workspace itself + """ + workspace: Workspace! +} + +extend type Subscription { + """ + Track newly added or deleted projects in a specific workspace. + Either slug or id must be set. + """ + workspaceProjectsUpdated( + workspaceId: String + workspaceSlug: String + ): WorkspaceProjectsUpdatedMessage! + + """ + Track updates to a specific workspace. + Either slug or id must be set. + """ + workspaceUpdated(workspaceId: String, workspaceSlug: String): WorkspaceUpdatedMessage! +} diff --git a/packages/server/modules/activitystream/domain/operations.ts b/packages/server/modules/activitystream/domain/operations.ts index 067a299b7..64626bee2 100644 --- a/packages/server/modules/activitystream/domain/operations.ts +++ b/packages/server/modules/activitystream/domain/operations.ts @@ -28,6 +28,7 @@ import { StreamAclRecord, StreamRecord } from '@/modules/core/helpers/types' +import { Nullable } from '@speckle/shared' export type GetActivity = ( streamId: string, @@ -193,6 +194,7 @@ export type AddStreamInviteSentOutActivity = (params: { export type AddStreamDeletedActivity = (params: { streamId: string deleterId: string + workspaceId: Nullable }) => Promise export type AddStreamUpdatedActivity = (params: { diff --git a/packages/server/modules/activitystream/services/streamActivity.ts b/packages/server/modules/activitystream/services/streamActivity.ts index b30b2016b..a216ab8a1 100644 --- a/packages/server/modules/activitystream/services/streamActivity.ts +++ b/packages/server/modules/activitystream/services/streamActivity.ts @@ -2,9 +2,9 @@ import { ActionTypes, ResourceTypes } from '@/modules/activitystream/helpers/typ import { StreamRoles } from '@/modules/core/helpers/mainConstants' import { PublishSubscription, - StreamSubscriptions as StreamPubsubEvents + StreamSubscriptions as StreamPubsubEvents, + WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions' -import { StreamCreateInput } from '@/test/graphql/generated/graphql' import { Knex } from 'knex' import { chunk, flatten } from 'lodash' import { StreamRecord } from '@/modules/core/helpers/types' @@ -12,8 +12,10 @@ import { ProjectCreateInput, ProjectUpdatedMessageType, ProjectUpdateInput, + StreamCreateInput, StreamUpdateInput, - UserProjectsUpdatedMessageType + UserProjectsUpdatedMessageType, + WorkspaceProjectsUpdatedMessageType } from '@/modules/core/graph/generated/graphql' import { ProjectSubscriptions, @@ -88,10 +90,10 @@ export const addStreamDeletedActivityFactory = saveActivity: SaveActivity publish: PublishSubscription }): AddStreamDeletedActivity => - async (params: { streamId: string; deleterId: string }) => { - const { streamId, deleterId } = params + async (params) => { + const { streamId, deleterId, workspaceId } = params - // Notify any listeners on streamId + // Notify any listeners on streamId/workspaceId await Promise.all([ publish(StreamPubsubEvents.StreamDeleted, { streamDeleted: { streamId }, @@ -103,7 +105,20 @@ export const addStreamDeletedActivityFactory = type: ProjectUpdatedMessageType.Deleted, project: null } - }) + }), + ...(workspaceId + ? [ + publish(WorkspaceSubscriptions.WorkspaceProjectsUpdated, { + workspaceProjectsUpdated: { + projectId: streamId, + type: WorkspaceProjectsUpdatedMessageType.Removed, + project: null, + workspaceId + }, + workspaceId + }) + ] + : []) ]) // Notify all stream users @@ -165,14 +180,29 @@ export const addStreamClonedActivityFactory = const newStreamId = newStream.id const publishSubscriptions = async () => - publish(UserSubscriptions.UserProjectsUpdated, { - userProjectsUpdated: { - id: newStreamId, - type: UserProjectsUpdatedMessageType.Added, - project: newStream - }, - ownerId: clonerId - }) + await Promise.all([ + publish(UserSubscriptions.UserProjectsUpdated, { + userProjectsUpdated: { + id: newStreamId, + type: UserProjectsUpdatedMessageType.Added, + project: newStream + }, + ownerId: clonerId + }), + ...(newStream.workspaceId + ? [ + publish(WorkspaceSubscriptions.WorkspaceProjectsUpdated, { + workspaceProjectsUpdated: { + projectId: newStreamId, + type: WorkspaceProjectsUpdatedMessageType.Added, + project: newStream, + workspaceId: newStream.workspaceId + }, + workspaceId: newStream.workspaceId + }) + ] + : []) + ]) await Promise.all([ saveActivity({ @@ -233,7 +263,20 @@ export const addStreamCreatedActivityFactory = project: stream }, ownerId: creatorId - }) + }), + ...(stream.workspaceId + ? [ + publish(WorkspaceSubscriptions.WorkspaceProjectsUpdated, { + workspaceProjectsUpdated: { + projectId: streamId, + type: WorkspaceProjectsUpdatedMessageType.Added, + project: stream, + workspaceId: stream.workspaceId + }, + workspaceId: stream.workspaceId + }) + ] + : []) ]) } diff --git a/packages/server/modules/cli/commands/db/seed/commits.ts b/packages/server/modules/cli/commands/db/seed/commits.ts index 54002a24e..37733566a 100644 --- a/packages/server/modules/cli/commands/db/seed/commits.ts +++ b/packages/server/modules/cli/commands/db/seed/commits.ts @@ -2,11 +2,7 @@ import { db } from '@/db/knex' import { cliLogger } from '@/logging/logging' import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' -import { getProjectDbClient } from '@/modules/multiregion/dbSelector' -import { - BasicTestCommit, - createTestCommitsFactory -} from '@/test/speckle-helpers/commitHelper' +import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commitHelper' import dayjs from 'dayjs' import { times } from 'lodash' import { CommandModule } from 'yargs' @@ -40,8 +36,6 @@ const command: CommandModule< const streamId = argv.streamId const authorId = argv.authorId const date = dayjs().toISOString() - const projectDb = await getProjectDbClient({ projectId: streamId }) - const createTestCommits = createTestCommitsFactory({ db: projectDb }) const user = await getUser(authorId) if (!user?.id) { diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 2829184aa..34d08817a 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -3267,6 +3267,11 @@ export type Subscription = { * @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead. */ commitUpdated?: Maybe; + /** + * Cyclically sends a message to the client, used for testing + * Note: Only works in test environment + */ + ping: Scalars['String']['output']; /** Subscribe to updates to automations in the project */ projectAutomationsUpdated: ProjectAutomationsUpdatedMessage; /** @@ -3326,6 +3331,16 @@ export type Subscription = { userViewerActivity?: Maybe; /** Track user activities in the viewer relating to the specified resources */ viewerUserActivityBroadcasted: ViewerUserActivityMessage; + /** + * Track newly added or deleted projects in a specific workspace. + * Either slug or id must be set. + */ + workspaceProjectsUpdated: WorkspaceProjectsUpdatedMessage; + /** + * Track updates to a specific workspace. + * Either slug or id must be set. + */ + workspaceUpdated: WorkspaceUpdatedMessage; }; @@ -3457,6 +3472,18 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; + +export type SubscriptionWorkspaceProjectsUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + + +export type SubscriptionWorkspaceUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + export type TestAutomationRun = { __typename?: 'TestAutomationRun'; automationRunId: Scalars['String']['output']; @@ -4367,6 +4394,23 @@ export type WorkspaceProjectsFilter = { search?: InputMaybe; }; +export type WorkspaceProjectsUpdatedMessage = { + __typename?: 'WorkspaceProjectsUpdatedMessage'; + /** Project entity, null if project was deleted */ + project?: Maybe; + /** Project ID */ + projectId: Scalars['String']['output']; + /** Message type */ + type: WorkspaceProjectsUpdatedMessageType; + /** Workspace ID */ + workspaceId: Scalars['String']['output']; +}; + +export enum WorkspaceProjectsUpdatedMessageType { + Added = 'ADDED', + Removed = 'REMOVED' +} + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -4434,6 +4478,14 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdatedMessage = { + __typename?: 'WorkspaceUpdatedMessage'; + /** Workspace ID */ + id: Scalars['String']['output']; + /** Workspace itself */ + workspace: Workspace; +}; + export type ResolverTypeWrapper = Promise | T; @@ -4773,6 +4825,8 @@ export type ResolversTypes = { WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput; WorkspaceProjectMutations: ResolverTypeWrapper; WorkspaceProjectsFilter: WorkspaceProjectsFilter; + WorkspaceProjectsUpdatedMessage: ResolverTypeWrapper & { project?: Maybe }>; + WorkspaceProjectsUpdatedMessageType: WorkspaceProjectsUpdatedMessageType; WorkspaceRole: WorkspaceRole; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; @@ -4782,6 +4836,7 @@ export type ResolversTypes = { WorkspaceSubscription: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; + WorkspaceUpdatedMessage: ResolverTypeWrapper & { workspace: ResolversTypes['Workspace'] }>; }; /** Mapping between all available schema types and the resolvers parents */ @@ -5027,6 +5082,7 @@ export type ResolversParentTypes = { WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput; WorkspaceProjectMutations: WorkspaceProjectMutationsGraphQLReturn; WorkspaceProjectsFilter: WorkspaceProjectsFilter; + WorkspaceProjectsUpdatedMessage: Omit & { project?: Maybe }; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; WorkspaceSso: WorkspaceSsoGraphQLReturn; @@ -5035,6 +5091,7 @@ export type ResolversParentTypes = { WorkspaceSubscription: WorkspaceSubscription; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; + WorkspaceUpdatedMessage: Omit & { workspace: ResolversParentTypes['Workspace'] }; }; export type HasScopeDirectiveArgs = { @@ -6183,6 +6240,7 @@ export type SubscriptionResolvers, "commitCreated", ParentType, ContextType, RequireFields>; commitDeleted?: SubscriptionResolver, "commitDeleted", ParentType, ContextType, RequireFields>; commitUpdated?: SubscriptionResolver, "commitUpdated", ParentType, ContextType, RequireFields>; + ping?: SubscriptionResolver; projectAutomationsUpdated?: SubscriptionResolver>; projectCommentsUpdated?: SubscriptionResolver>; projectFileImportUpdated?: SubscriptionResolver>; @@ -6202,6 +6260,8 @@ export type SubscriptionResolvers, "userStreamRemoved", ParentType, ContextType>; userViewerActivity?: SubscriptionResolver, "userViewerActivity", ParentType, ContextType, RequireFields>; viewerUserActivityBroadcasted?: SubscriptionResolver>; + workspaceProjectsUpdated?: SubscriptionResolver>; + workspaceUpdated?: SubscriptionResolver>; }; export type TestAutomationRunResolvers = { @@ -6524,6 +6584,14 @@ export type WorkspaceProjectMutationsResolvers; }; +export type WorkspaceProjectsUpdatedMessageResolvers = { + project?: Resolver, ParentType, ContextType>; + projectId?: Resolver; + type?: Resolver; + workspaceId?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceSsoResolvers = { provider?: Resolver, ParentType, ContextType>; session?: Resolver, ParentType, ContextType>; @@ -6552,6 +6620,12 @@ export type WorkspaceSubscriptionResolvers; }; +export type WorkspaceUpdatedMessageResolvers = { + id?: Resolver; + workspace?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { ActiveUserMutations?: ActiveUserMutationsResolvers; Activity?: ActivityResolvers; @@ -6696,10 +6770,12 @@ export type Resolvers = { WorkspaceMutations?: WorkspaceMutationsResolvers; WorkspacePlan?: WorkspacePlanResolvers; WorkspaceProjectMutations?: WorkspaceProjectMutationsResolvers; + WorkspaceProjectsUpdatedMessage?: WorkspaceProjectsUpdatedMessageResolvers; WorkspaceSso?: WorkspaceSsoResolvers; WorkspaceSsoProvider?: WorkspaceSsoProviderResolvers; WorkspaceSsoSession?: WorkspaceSsoSessionResolvers; WorkspaceSubscription?: WorkspaceSubscriptionResolvers; + WorkspaceUpdatedMessage?: WorkspaceUpdatedMessageResolvers; }; export type DirectiveResolvers = { diff --git a/packages/server/modules/core/graph/resolvers/base.ts b/packages/server/modules/core/graph/resolvers/base.ts new file mode 100644 index 000000000..8dae2f5c8 --- /dev/null +++ b/packages/server/modules/core/graph/resolvers/base.ts @@ -0,0 +1,18 @@ +import { Resolvers } from '@/modules/core/graph/generated/graphql' +import { + filteredSubscribe, + TestSubscriptions +} from '@/modules/shared/utils/subscriptions' + +export default { + Query: { + async _() { + return `Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn.` + } + }, + Subscription: { + ping: { + subscribe: filteredSubscribe(TestSubscriptions.Ping, () => true) + } + } +} as Resolvers diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index 7fef240db..b4876f0e4 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -223,9 +223,10 @@ export = { async batchDelete(_parent, args, ctx) { const results = await Promise.all( args.ids.map(async (id) => { + const projectDb = await getProjectDbClient({ projectId: id }) const deleteStreamAndNotify = deleteStreamAndNotifyFactory({ deleteStream: deleteStreamFactory({ - db: await getProjectDbClient({ projectId: id }) + db: projectDb }), authorizeResolver, addStreamDeletedActivity: addStreamDeletedActivityFactory({ @@ -233,7 +234,8 @@ export = { publish, getStreamCollaborators: getStreamCollaboratorsFactory({ db }) }), - deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }) + deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }), + getStream: getStreamFactory({ db: projectDb }) }) return deleteStreamAndNotify(id, ctx.userId!, ctx.resourceAccessRules, { skipAccessChecks: true @@ -243,9 +245,10 @@ export = { return results.every((res) => res === true) }, async delete(_parent, { id }, { userId, resourceAccessRules }) { + const projectDb = await getProjectDbClient({ projectId: id }) const deleteStreamAndNotify = deleteStreamAndNotifyFactory({ deleteStream: deleteStreamFactory({ - db: await getProjectDbClient({ projectId: id }) + db: projectDb }), authorizeResolver, addStreamDeletedActivity: addStreamDeletedActivityFactory({ @@ -253,7 +256,8 @@ export = { publish, getStreamCollaborators: getStreamCollaboratorsFactory({ db }) }), - deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }) + deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }), + getStream: getStreamFactory({ db: projectDb }) }) return await deleteStreamAndNotify(id, userId!, resourceAccessRules) }, diff --git a/packages/server/modules/core/graph/resolvers/streams.ts b/packages/server/modules/core/graph/resolvers/streams.ts index 1cbe2f70a..a2878cd5e 100644 --- a/packages/server/modules/core/graph/resolvers/streams.ts +++ b/packages/server/modules/core/graph/resolvers/streams.ts @@ -130,7 +130,8 @@ const deleteStreamAndNotify = deleteStreamAndNotifyFactory({ saveActivity: saveActivityFactory({ db }), getStreamCollaborators: getStreamCollaboratorsFactory({ db }) }), - deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }) + deleteAllResourceInvites: deleteAllResourceInvitesFactory({ db }), + getStream }) const updateStreamAndNotify = updateStreamAndNotifyFactory({ authorizeResolver, diff --git a/packages/server/modules/core/graph/resolvers/users.ts b/packages/server/modules/core/graph/resolvers/users.ts index 62b569511..4eafc84c4 100644 --- a/packages/server/modules/core/graph/resolvers/users.ts +++ b/packages/server/modules/core/graph/resolvers/users.ts @@ -78,9 +78,6 @@ const getAdminUsersListCollection = getAdminUsersListCollectionFactory({ export = { Query: { - async _() { - return `Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn.` - }, async activeUser(_parent, _args, context) { const activeUserId = context.userId if (!activeUserId) return null diff --git a/packages/server/modules/core/index.ts b/packages/server/modules/core/index.ts index 0796fbad1..cd63f94be 100644 --- a/packages/server/modules/core/index.ts +++ b/packages/server/modules/core/index.ts @@ -17,6 +17,9 @@ import { getGenericRedis } from '@/modules/shared/redis/redis' import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes' import db from '@/db/knex' import { registerOrUpdateRole } from '@/modules/shared/repositories/roles' +import { isTestEnv } from '@/modules/shared/helpers/envHelper' + +let stopTestSubs: (() => void) | undefined = undefined const coreModule: SpeckleModule = { async init(app, isInitial) { @@ -52,13 +55,17 @@ const coreModule: SpeckleModule = { // Init mp mp.initialize() - // Generic redis client + // Setup test subs + if (isTestEnv()) { + const { startEmittingTestSubs } = await import('@/test/graphqlHelper') + stopTestSubs = await startEmittingTestSubs() + } } }, async shutdown() { await shutdownResultListener() - await getGenericRedis().quit() + stopTestSubs?.() } } diff --git a/packages/server/modules/core/repositories/users.ts b/packages/server/modules/core/repositories/users.ts index 6a49a51e3..104814293 100644 --- a/packages/server/modules/core/repositories/users.ts +++ b/packages/server/modules/core/repositories/users.ts @@ -80,8 +80,8 @@ export const getUsersFactory = userIds = isArray(userIds) ? userIds : [userIds] const q = tables.users(deps.db).whereIn(Users.col.id, userIds) - q.leftJoin(UserEmails.name, UserEmails.col.userId, Users.col.id).where({ - [UserEmails.col.primary]: true + q.leftJoin(UserEmails.name, (j1) => { + j1.on(UserEmails.col.userId, Users.col.id).andOnVal(UserEmails.col.primary, true) }) const columns: (Knex.Raw | string)[] = [ diff --git a/packages/server/modules/core/services/streams/management.ts b/packages/server/modules/core/services/streams/management.ts index f9209edae..93ca7376a 100644 --- a/packages/server/modules/core/services/streams/management.ts +++ b/packages/server/modules/core/services/streams/management.ts @@ -49,6 +49,7 @@ import { AddStreamDeletedActivity, AddStreamUpdatedActivity } from '@/modules/activitystream/domain/operations' +import { LogicError } from '@/modules/shared/errors' export const createStreamReturnRecordFactory = (deps: { @@ -119,6 +120,7 @@ export const deleteStreamAndNotifyFactory = authorizeResolver: AuthorizeResolver addStreamDeletedActivity: AddStreamDeletedActivity deleteAllResourceInvites: DeleteAllResourceInvites + getStream: GetStream }): DeleteStream => async ( streamId: string, @@ -139,7 +141,15 @@ export const deleteStreamAndNotifyFactory = ) } - await deps.addStreamDeletedActivity({ streamId, deleterId }) + const stream = await deps.getStream({ streamId }) + if (!stream) + throw new LogicError('Unexpectedly stream that should exist is not found...') + + await deps.addStreamDeletedActivity({ + streamId, + deleterId, + workspaceId: stream.workspaceId + }) // TODO: this has been around since before my time, we should get rid of it... // delay deletion by a bit so we can do auth checks diff --git a/packages/server/modules/core/tests/helpers/graphql.ts b/packages/server/modules/core/tests/helpers/graphql.ts index 8114b13e4..f501cfe70 100644 --- a/packages/server/modules/core/tests/helpers/graphql.ts +++ b/packages/server/modules/core/tests/helpers/graphql.ts @@ -5,3 +5,48 @@ export const createObjectMutation = gql` objectCreate(objectInput: $input) } ` + +export const pingPongSubscription = gql` + subscription PingPong { + ping + } +` + +export const onUserProjectsUpdatedSubscription = gql` + subscription OnUserProjectsUpdated { + userProjectsUpdated { + id + type + project { + id + name + } + } + } +` + +export const onUserStreamAddedSubscription = gql` + subscription OnUserStreamAdded { + userStreamAdded + } +` + +export const onUserProjectVersionsUpdatedSubscription = gql` + subscription OnUserProjectVersionsUpdated($projectId: String!) { + projectVersionsUpdated(id: $projectId) { + id + type + version { + id + message + } + modelId + } + } +` + +export const onUserStreamCommitCreatedSubscription = gql` + subscription OnUserStreamCommitCreated($streamId: String!) { + commitCreated(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 new file mode 100644 index 000000000..047f8b555 --- /dev/null +++ b/packages/server/modules/core/tests/integration/subs.graph.spec.ts @@ -0,0 +1,179 @@ +import { + BasicTestWorkspace, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { + OnUserProjectsUpdatedDocument, + OnUserProjectVersionsUpdatedDocument, + OnUserStreamAddedDocument, + OnUserStreamCommitCreatedDocument, + UserProjectsUpdatedMessageType +} from '@/test/graphql/generated/graphql' +import { + TestApolloSubscriptionClient, + testApolloSubscriptionServer, + TestApolloSubscriptionServer +} from '@/test/graphqlHelper' +import { beforeEachContext, getMainTestRegionKey } from '@/test/hooks' +import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commitHelper' +import { + isMultiRegionTestMode, + waitForRegionUser +} from '@/test/speckle-helpers/regions' +import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' +import { expect } from 'chai' + +describe('Core GraphQL Subscriptions (New)', () => { + let me: BasicTestUser + let otherGuy: BasicTestUser + let subServer: TestApolloSubscriptionServer + let meSubClient: TestApolloSubscriptionClient + + before(async () => { + await beforeEachContext() + me = await createTestUser() + otherGuy = await createTestUser() + subServer = await testApolloSubscriptionServer() + meSubClient = await subServer.buildClient({ authUserId: me.id }) + }) + + 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 () => { + await createTestWorkspace(myMainWorkspace, me, { + regionKey: isMultiRegion ? getMainTestRegionKey() : undefined + }) + if (isMultiRegion) { + await Promise.all([ + waitForRegionUser({ userId: me.id }), + waitForRegionUser({ userId: otherGuy.id }) + ]) + } + }) + + describe('Project Subs', () => { + it('should notify me of a new project (userProjectsUpdated/userStreamAdded)', async () => { + 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?.name).to.equal(myProj.name) + } + ) + const onUserStreamAdded = await meSubClient.subscribe( + OnUserStreamAddedDocument, + {}, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.userStreamAdded?.name).to.equal(myProj.name) + } + ) + await meSubClient.waitForReadiness() + + const myProj: BasicTestStream = { + name: 'My New Test1 Project', + id: '', + ownerId: me.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + const otherGuysProj: BasicTestStream = { + name: 'Other Guys Project', + id: '', + ownerId: otherGuy.id, + isPublic: true, + workspaceId: myMainWorkspace.id + } + await createTestStreams([ + [myProj, me], + [otherGuysProj, otherGuy] + ]) + await Promise.all([ + onUserProjectsUpdated.waitForMessage(), + onUserStreamAdded.waitForMessage() + ]) + + expect(onUserProjectsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamAdded.getMessages()).to.have.length(1) + }) + }) + + describe('Version Subs', () => { + const myVersionProj: BasicTestStream = { + name: 'My New Version Project #1', + id: '', + ownerId: '', + isPublic: true + } + + before(async () => { + myVersionProj.workspaceId = myMainWorkspace.id + await createTestStreams([[myVersionProj, me]]) + }) + + it(`should notify me of a new version (projectVersionsUpdated/commitCreated)`, async () => { + const message = 'ayyyooo' + const onUserProjectVersionsUpdated = await meSubClient.subscribe( + OnUserProjectVersionsUpdatedDocument, + { projectId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.projectVersionsUpdated.version?.message).to.equal( + message + ) + } + ) + const onUserStreamCommitCreated = await meSubClient.subscribe( + OnUserStreamCommitCreatedDocument, + { streamId: myVersionProj.id }, + (res) => { + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.commitCreated?.message).to.equal(message) + } + ) + await meSubClient.waitForReadiness() + + // Create test commit + const commit: BasicTestCommit = { + streamId: '', + objectId: '', + id: '', + authorId: '', + message + } + + await createTestCommits([commit], { owner: me, stream: myVersionProj }) + + await Promise.all([ + onUserProjectVersionsUpdated.waitForMessage(), + onUserStreamCommitCreated.waitForMessage() + ]) + + expect(onUserProjectVersionsUpdated.getMessages()).to.have.length(1) + expect(onUserStreamCommitCreated.getMessages()).to.have.length(1) + }) + }) + }) + }) +}) diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index c290c4192..0f4a1b63b 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -3248,6 +3248,11 @@ export type Subscription = { * @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead. */ commitUpdated?: Maybe; + /** + * Cyclically sends a message to the client, used for testing + * Note: Only works in test environment + */ + ping: Scalars['String']['output']; /** Subscribe to updates to automations in the project */ projectAutomationsUpdated: ProjectAutomationsUpdatedMessage; /** @@ -3307,6 +3312,16 @@ export type Subscription = { userViewerActivity?: Maybe; /** Track user activities in the viewer relating to the specified resources */ viewerUserActivityBroadcasted: ViewerUserActivityMessage; + /** + * Track newly added or deleted projects in a specific workspace. + * Either slug or id must be set. + */ + workspaceProjectsUpdated: WorkspaceProjectsUpdatedMessage; + /** + * Track updates to a specific workspace. + * Either slug or id must be set. + */ + workspaceUpdated: WorkspaceUpdatedMessage; }; @@ -3438,6 +3453,18 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; + +export type SubscriptionWorkspaceProjectsUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + + +export type SubscriptionWorkspaceUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + export type TestAutomationRun = { __typename?: 'TestAutomationRun'; automationRunId: Scalars['String']['output']; @@ -4348,6 +4375,23 @@ export type WorkspaceProjectsFilter = { search?: InputMaybe; }; +export type WorkspaceProjectsUpdatedMessage = { + __typename?: 'WorkspaceProjectsUpdatedMessage'; + /** Project entity, null if project was deleted */ + project?: Maybe; + /** Project ID */ + projectId: Scalars['String']['output']; + /** Message type */ + type: WorkspaceProjectsUpdatedMessageType; + /** Workspace ID */ + workspaceId: Scalars['String']['output']; +}; + +export enum WorkspaceProjectsUpdatedMessageType { + Added = 'ADDED', + Removed = 'REMOVED' +} + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -4415,6 +4459,14 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdatedMessage = { + __typename?: 'WorkspaceUpdatedMessage'; + /** Workspace ID */ + id: Scalars['String']['output']; + /** Workspace itself */ + workspace: Workspace; +}; + export type CrossSyncCommitBranchMetadataQueryVariables = Exact<{ streamId: Scalars['String']['input']; commitId: Scalars['String']['input']; diff --git a/packages/server/modules/gatekeeper/events/eventListener.ts b/packages/server/modules/gatekeeper/events/eventListener.ts index c9b102904..15c6cf94b 100644 --- a/packages/server/modules/gatekeeper/events/eventListener.ts +++ b/packages/server/modules/gatekeeper/events/eventListener.ts @@ -40,7 +40,7 @@ export const initializeEventListenersFactory = workspacePlan: { name: 'starter', status: 'trial', - workspaceId: payload.id, + workspaceId: payload.workspace.id, createdAt: new Date() } }) diff --git a/packages/server/modules/shared/services/auth.ts b/packages/server/modules/shared/services/auth.ts index 16ffeb81f..ecede6af1 100644 --- a/packages/server/modules/shared/services/auth.ts +++ b/packages/server/modules/shared/services/auth.ts @@ -14,6 +14,7 @@ import { GetRoles } from '@/modules/shared/domain/rolesAndScopes/operations' import { ForbiddenError } from '@/modules/shared/errors' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' import { EventBusEmit } from '@/modules/shared/services/eventBus' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' import { isNullOrUndefined, Roles } from '@speckle/shared' /** @@ -111,7 +112,7 @@ export const authorizeResolverFactory = if (!isNullOrUndefined(targetWorkspaceId)) { await deps.emitWorkspaceEvent({ - eventName: 'workspace.authorized', + eventName: WorkspaceEvents.Authorized, payload: { workspaceId: targetWorkspaceId, userId diff --git a/packages/server/modules/shared/services/eventBus.ts b/packages/server/modules/shared/services/eventBus.ts index 36f839373..67449c29a 100644 --- a/packages/server/modules/shared/services/eventBus.ts +++ b/packages/server/modules/shared/services/eventBus.ts @@ -13,14 +13,19 @@ import { type EventWildcard = '*' -type TestEvents = { - ['test.string']: string - ['test.number']: number +export const TestEvents = { + String: 'test.string', + Number: 'test.number' +} as const + +type TestEventsPayloads = { + [TestEvents.String]: string + [TestEvents.Number]: number } // we should only ever extend this type, other helper types will be derived from this type EventsByNamespace = { - test: TestEvents + test: TestEventsPayloads [workspaceEventNamespace]: WorkspaceEventsPayloads [serverinvitesEventNamespace]: ServerInvitesEventsPayloads } @@ -59,7 +64,7 @@ type EventPayloadsByNamespaceMap = { } } -type EventPayload = T extends EventWildcard +export type EventPayload = T extends EventWildcard ? // 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/test/unit/eventBus.spec.ts b/packages/server/modules/shared/test/unit/eventBus.spec.ts index fe6ade070..831a3cc4b 100644 --- a/packages/server/modules/shared/test/unit/eventBus.spec.ts +++ b/packages/server/modules/shared/test/unit/eventBus.spec.ts @@ -1,4 +1,8 @@ -import { getEventBus, initializeEventBus } from '@/modules/shared/services/eventBus' +import { + getEventBus, + initializeEventBus, + TestEvents +} from '@/modules/shared/services/eventBus' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' @@ -7,18 +11,18 @@ describe('Event Bus', () => { it('calls back all the listeners', async () => { const testEventBus = initializeEventBus() const eventNames: string[] = [] - testEventBus.listen('test.string', ({ eventName }) => { + testEventBus.listen(TestEvents.String, ({ eventName }) => { eventNames.push(eventName) }) - testEventBus.listen('test.string', ({ eventName }) => { + testEventBus.listen(TestEvents.String, ({ eventName }) => { eventNames.push(eventName) }) - await testEventBus.emit({ eventName: 'test.number', payload: 1 }) + await testEventBus.emit({ eventName: TestEvents.Number, payload: 1 }) expect(eventNames.length).to.equal(0) - const eventName = 'test.string' as const + const eventName = TestEvents.String await testEventBus.emit({ eventName, payload: 'fake event' }) expect(eventNames.length).to.equal(2) @@ -27,32 +31,35 @@ describe('Event Bus', () => { it('can removes listeners from itself', async () => { const testEventBus = initializeEventBus() const eventNumbers: number[] = [] - testEventBus.listen('test.string', () => { + testEventBus.listen(TestEvents.String, () => { eventNumbers.push(1) }) - const listenerOff = testEventBus.listen('test.string', () => { + const listenerOff = testEventBus.listen(TestEvents.String, () => { eventNumbers.push(2) }) - await testEventBus.emit({ eventName: 'test.string', payload: 'fake event' }) + await testEventBus.emit({ eventName: TestEvents.String, payload: 'fake event' }) expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) listenerOff() - await testEventBus.emit({ eventName: 'test.string', payload: 'fake event' }) + await testEventBus.emit({ eventName: TestEvents.String, payload: 'fake event' }) expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 1, 2]) }) it('bubbles up listener exceptions to emitter', async () => { const testEventBus = initializeEventBus() - testEventBus.listen('test.string', ({ payload }) => { + testEventBus.listen(TestEvents.String, ({ payload }) => { throw new Error(payload) }) const lookWhatHappened = 'kabumm' try { - await testEventBus.emit({ eventName: 'test.string', payload: lookWhatHappened }) + await testEventBus.emit({ + eventName: TestEvents.String, + payload: lookWhatHappened + }) throw new Error('this should have thrown by now') } catch (error) { if (error instanceof Error) { @@ -65,20 +72,20 @@ describe('Event Bus', () => { it('can be destroyed, removing all listeners', async () => { const testEventBus = initializeEventBus() const eventNumbers: number[] = [] - testEventBus.listen('test.string', () => { + testEventBus.listen(TestEvents.String, () => { eventNumbers.push(1) }) - testEventBus.listen('test.string', () => { + testEventBus.listen(TestEvents.String, () => { eventNumbers.push(2) }) - await testEventBus.emit({ eventName: 'test.string', payload: 'test' }) + await testEventBus.emit({ eventName: TestEvents.String, payload: 'test' }) expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) testEventBus.destroy() - await testEventBus.emit({ eventName: 'test.string', payload: 'test' }) + await testEventBus.emit({ eventName: TestEvents.String, payload: 'test' }) expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) }) }) @@ -89,18 +96,18 @@ describe('Event Bus', () => { const payloads: string[] = [] - bus1.listen('test.string', ({ payload }) => { + bus1.listen(TestEvents.String, ({ payload }) => { payloads.push(payload) }) - bus2.listen('test.string', ({ payload }) => { + bus2.listen(TestEvents.String, ({ payload }) => { payloads.push(payload) }) const payload = cryptoRandomString({ length: 1 }) await bus1.emit({ - eventName: 'test.string', + eventName: TestEvents.String, payload }) @@ -114,10 +121,10 @@ describe('Event Bus', () => { eventBus.listen('test.*', ({ payload, eventName }) => { switch (eventName) { - case 'test.string': + case TestEvents.String: events.push(payload) break - case 'test.number': + case TestEvents.Number: events.push(`${payload}`) break } @@ -126,12 +133,12 @@ describe('Event Bus', () => { const stringPayload = cryptoRandomString({ length: 10 }) await eventBus.emit({ - eventName: 'test.string', + eventName: TestEvents.String, payload: stringPayload }) await eventBus.emit({ - eventName: 'test.number', + eventName: TestEvents.Number, payload: 999 }) diff --git a/packages/server/modules/shared/utils/subscriptions.ts b/packages/server/modules/shared/utils/subscriptions.ts index a2c7f3e54..c834aecf1 100644 --- a/packages/server/modules/shared/utils/subscriptions.ts +++ b/packages/server/modules/shared/utils/subscriptions.ts @@ -50,7 +50,11 @@ import { SubscriptionCommitCreatedArgs, CommitCreateInput, SubscriptionCommitUpdatedArgs, - CommitUpdateInput + CommitUpdateInput, + SubscriptionWorkspaceProjectsUpdatedArgs, + WorkspaceProjectsUpdatedMessage, + SubscriptionWorkspaceUpdatedArgs, + WorkspaceUpdatedMessage } from '@/modules/core/graph/generated/graphql' import { Merge } from 'type-fest' import { @@ -67,6 +71,7 @@ import { import { CommentRecord } from '@/modules/comments/helpers/types' import { CommitRecord } from '@/modules/core/helpers/types' import { BranchRecord } from '@/modules/core/helpers/types' +import { WorkspaceGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes' /** * GraphQL Subscription PubSub instance @@ -134,6 +139,15 @@ export enum FileImportSubscriptions { ProjectFileImportUpdated = 'PROJECT_FILE_IMPORT_UPDATED' } +export enum TestSubscriptions { + Ping = 'PING' +} + +export enum WorkspaceSubscriptions { + WorkspaceProjectsUpdated = 'WORKSPACE_PROJECTS_UPDATED', + WorkspaceUpdated = 'WORKSPACE_UPDATED' +} + type NoVariables = Record // Add mappings between expected event constant, its payload and variables @@ -348,6 +362,29 @@ type SubscriptionTypeMap = { } variables: SubscriptionCommitUpdatedArgs } + [TestSubscriptions.Ping]: { + payload: { ping: string } + variables: NoVariables + } + [WorkspaceSubscriptions.WorkspaceProjectsUpdated]: { + payload: { + workspaceProjectsUpdated: Merge< + WorkspaceProjectsUpdatedMessage, + { project: Nullable } + > + workspaceId: string + } + variables: SubscriptionWorkspaceProjectsUpdatedArgs + } + [WorkspaceSubscriptions.WorkspaceUpdated]: { + payload: { + workspaceUpdated: Merge< + WorkspaceUpdatedMessage, + { workspace: Nullable } + > + } + variables: SubscriptionWorkspaceUpdatedArgs + } } & { [k in SubscriptionEvent]: { payload: unknown; variables: unknown } } type SubscriptionEvent = @@ -359,6 +396,8 @@ type SubscriptionEvent = | UserSubscriptions | ViewerSubscriptions | BranchSubscriptions + | TestSubscriptions + | WorkspaceSubscriptions /** * Publish a GQL subscription event diff --git a/packages/server/modules/workspaces/events/eventListener.ts b/packages/server/modules/workspaces/events/eventListener.ts index 5c123afe9..6430136d7 100644 --- a/packages/server/modules/workspaces/events/eventListener.ts +++ b/packages/server/modules/workspaces/events/eventListener.ts @@ -26,7 +26,7 @@ import { } from '@/modules/serverinvites/helpers/core' import { logger, moduleLogger } from '@/logging/logging' import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management' -import { getEventBus } from '@/modules/shared/services/eventBus' +import { EventPayload, getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspaces/domain/constants' import { Roles, WorkspaceRoles } from '@speckle/shared' import { @@ -60,6 +60,7 @@ import { getWorkspaceSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace' +import { publish, WorkspaceSubscriptions } from '@/modules/shared/utils/subscriptions' export const onProjectCreatedFactory = ({ @@ -256,17 +257,58 @@ export const onWorkspaceRoleUpdatedFactory = } } +const emitWorkspaceGraphqlSubscriptionsFactory = + (deps: { getWorkspace: GetWorkspace }) => + async (params: EventPayload<'workspace.*'>) => { + 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, { + workspaceUpdated: { + workspace: payload.workspace, + id: payload.workspace.id + } + }) + break + case WorkspaceEvents.RoleDeleted: + case WorkspaceEvents.RoleUpdated: + const { workspaceId } = payload + const foundWorkspace = await deps.getWorkspace({ workspaceId }) + if (foundWorkspace) { + await publish(WorkspaceSubscriptions.WorkspaceUpdated, { + workspaceUpdated: { + workspace: foundWorkspace, + id: foundWorkspace.id + } + }) + } + break + } + } + export const initializeEventListenersFactory = ({ db }: { db: Knex }) => () => { const eventBus = getEventBus() const getStreams = legacyGetStreamsFactory({ db }) + const getWorkspace = getWorkspaceFactory({ db }) + const emitWorkspaceGraphqlSubscriptions = emitWorkspaceGraphqlSubscriptionsFactory({ + getWorkspace + }) + const quitCbs = [ ProjectsEmitter.listen(ProjectEvents.Created, async (payload) => { const onProjectCreated = onProjectCreatedFactory({ getWorkspaceRoleToDefaultProjectRoleMapping: getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace: getWorkspaceFactory({ db }) + getWorkspace }), upsertProjectRole: upsertProjectRoleFactory({ db }), getWorkspaceRoles: getWorkspaceRolesFactory({ db }) @@ -289,7 +331,7 @@ export const initializeEventListenersFactory = }), eventBus.listen(WorkspaceEvents.Authorized, async ({ payload }) => { const onWorkspaceAuthorized = onWorkspaceAuthorizedFactory({ - getWorkspace: getWorkspaceFactory({ db }), + getWorkspace, getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }), getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderRecordFactory({ db }), getUserSsoSession: getUserSsoSessionFactory({ db }) @@ -309,14 +351,16 @@ export const initializeEventListenersFactory = const onWorkspaceRoleUpdated = onWorkspaceRoleUpdatedFactory({ getWorkspaceRoleToDefaultProjectRoleMapping: getWorkspaceRoleToDefaultProjectRoleMappingFactory({ - getWorkspace: getWorkspaceFactory({ db: trx }) + getWorkspace }), queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ getStreams }), deleteProjectRole: deleteProjectRoleFactory({ db: trx }), upsertProjectRole: upsertProjectRoleFactory({ db: trx }) }) await withTransaction(onWorkspaceRoleUpdated(payload), trx) - }) + }), + // Emit Updated subscription + eventBus.listen('workspace.*', 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 e76da8883..94712da4e 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -94,6 +94,7 @@ import { validateSlugFactory } from '@/modules/workspaces/services/management' import { + createWorkspaceProjectFactory, getWorkspaceProjectsFactory, getWorkspaceRoleToDefaultProjectRoleMappingFactory, moveProjectToWorkspaceFactory, @@ -145,7 +146,11 @@ import { addStreamPermissionsAddedActivityFactory, addStreamPermissionsRevokedActivityFactory } from '@/modules/activitystream/services/streamActivity' -import { publish } from '@/modules/shared/utils/subscriptions' +import { + filteredSubscribe, + publish, + WorkspaceSubscriptions +} from '@/modules/shared/utils/subscriptions' import { updateStreamRoleAndNotifyFactory } from '@/modules/core/services/streams/management' import { getUserByEmailFactory, @@ -160,14 +165,7 @@ import { isRateLimitBreached } from '@/modules/core/services/ratelimiter' import { RateLimitError } from '@/modules/core/errors/ratelimit' -import { ProjectsEmitter } from '@/modules/core/events/projectsEmitter' -import { getDb, getRegionDb } from '@/modules/multiregion/dbSelector' -import { createNewProjectFactory } from '@/modules/core/services/projects' -import { - deleteProjectFactory, - storeProjectFactory, - storeProjectRoleFactory -} from '@/modules/core/repositories/projects' +import { getRegionDb } from '@/modules/multiregion/dbSelector' import { listUserExpiredSsoSessionsFactory, listWorkspaceSsoMembershipsByUserEmailFactory @@ -182,7 +180,6 @@ import { } from '@/modules/workspaces/repositories/sso' import { getDecryptor } from '@/modules/workspaces/helpers/sso' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' -import { storeModelFactory } from '@/modules/core/repositories/models' import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { Knex } from 'knex' @@ -844,28 +841,11 @@ export = FF_WORKSPACES_MODULE_ENABLED context.resourceAccessRules ) - // TODO: get workspace's region here - const workspaceDefaultRegion = await getDefaultRegionFactory({ db })({ - workspaceId: args.input.workspaceId + const createWorkspaceProject = createWorkspaceProjectFactory({ + getDefaultRegion: getDefaultRegionFactory({ db }) }) - const regionKey = workspaceDefaultRegion?.key - - const projectDb = await getDb({ regionKey }) - - // todo, use the command factory here, but for that, we need to migrate to the event bus - const createNewProject = createNewProjectFactory({ - storeProject: storeProjectFactory({ db: projectDb }), - getProject: getProjectFactory({ db }), - deleteProject: deleteProjectFactory({ db: projectDb }), - storeModel: storeModelFactory({ db: projectDb }), - // THIS MUST GO TO THE MAIN DB - storeProjectRole: storeProjectRoleFactory({ db }), - projectsEventsEmitter: ProjectsEmitter.emit - }) - - const project = await createNewProject({ - ...args.input, - regionKey, + const project = await createWorkspaceProject({ + input: args.input, ownerId: context.userId! }) @@ -983,7 +963,10 @@ export = FF_WORKSPACES_MODULE_ENABLED return { items, cursor, - totalCount: await getUserStreamsCount(filter) + totalCount: await getUserStreamsCount({ + ...filter, + searchQuery: filter.search || undefined + }) } }, domains: async (parent) => { @@ -1203,6 +1186,62 @@ export = FF_WORKSPACES_MODULE_ENABLED }, ServerWorkspacesInfo: { workspacesEnabled: () => true + }, + Subscription: { + workspaceProjectsUpdated: { + subscribe: filteredSubscribe( + WorkspaceSubscriptions.WorkspaceProjectsUpdated, + async (payload, vars, ctx) => { + const { workspaceId, workspaceSlug } = vars + if (!workspaceId && !workspaceSlug) return false + + const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db }) + const requestedWorkspaceId = + workspaceId || + (await getWorkspaceBySlug({ workspaceSlug: workspaceSlug! }))?.id + if (!requestedWorkspaceId) return false + + if (payload.workspaceId !== requestedWorkspaceId) return false + await authorizeResolver( + ctx.userId!, + payload.workspaceId, + Roles.Workspace.Guest, + ctx.resourceAccessRules + ) + + return true + } + ) + }, + workspaceUpdated: { + subscribe: filteredSubscribe( + WorkspaceSubscriptions.WorkspaceUpdated, + async (payload, vars, ctx) => { + const { workspaceId, workspaceSlug } = vars + if (!workspaceId && !workspaceSlug) return false + + const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db }) + const requestedWorkspaceId = + workspaceId || + ( + await getWorkspaceBySlug({ + workspaceSlug: workspaceSlug! + }) + )?.id + if (!requestedWorkspaceId) return false + + if (payload.workspaceUpdated.id !== requestedWorkspaceId) return false + await authorizeResolver( + ctx.userId!, + payload.workspaceUpdated.id, + Roles.Workspace.Guest, + ctx.resourceAccessRules + ) + + return true + } + ) + } } } as Resolvers) : {} diff --git a/packages/server/modules/workspaces/services/management.ts b/packages/server/modules/workspaces/services/management.ts index 6ed9968fc..98c2c3f9b 100644 --- a/packages/server/modules/workspaces/services/management.ts +++ b/packages/server/modules/workspaces/services/management.ts @@ -175,7 +175,7 @@ export const createWorkspaceFactory = // emit a workspace created event await emitWorkspaceEvent({ eventName: WorkspaceEvents.Created, - payload: { ...workspace, createdByUserId: userId } + payload: { workspace, createdByUserId: userId } }) return { ...workspace } @@ -262,7 +262,10 @@ export const updateWorkspaceFactory = } await upsertWorkspace({ workspace }) - await emitWorkspaceEvent({ eventName: WorkspaceEvents.Updated, payload: workspace }) + await emitWorkspaceEvent({ + eventName: WorkspaceEvents.Updated, + payload: { workspace } + }) return workspace } @@ -538,6 +541,6 @@ export const addDomainToWorkspaceFactory = await emitWorkspaceEvent({ eventName: WorkspaceEvents.Updated, - payload: workspace + payload: { workspace } }) } diff --git a/packages/server/modules/workspaces/services/projects.ts b/packages/server/modules/workspaces/services/projects.ts index 7c5d65270..2d7282fc5 100644 --- a/packages/server/modules/workspaces/services/projects.ts +++ b/packages/server/modules/workspaces/services/projects.ts @@ -1,5 +1,6 @@ import { StreamRecord } from '@/modules/core/helpers/types' import { + GetDefaultRegion, GetWorkspace, GetWorkspaceRoleForUser, GetWorkspaceRoles, @@ -32,6 +33,18 @@ import { UpdateStreamRole } from '@/modules/core/domain/streams/operations' import { ProjectNotFoundError } from '@/modules/core/errors/projects' +import { WorkspaceProjectCreateInput } from '@/test/graphql/generated/graphql' +import { getDb } from '@/modules/multiregion/dbSelector' +import { createNewProjectFactory } from '@/modules/core/services/projects' +import { + deleteProjectFactory, + storeProjectFactory, + storeProjectRoleFactory +} from '@/modules/core/repositories/projects' +import { mainDb } from '@/db/knex' +import { storeModelFactory } from '@/modules/core/repositories/models' +import { ProjectsEmitter } from '@/modules/core/events/projectsEmitter' +import { getProjectFactory } from '@/modules/core/repositories/streams' export const queryAllWorkspaceProjectsFactory = ({ getStreams @@ -244,3 +257,35 @@ export const updateWorkspaceProjectRoleFactory = updater.resourceAccessRules ) } + +export const createWorkspaceProjectFactory = + (deps: { getDefaultRegion: GetDefaultRegion }) => + async (params: { input: WorkspaceProjectCreateInput; ownerId: string }) => { + const { input, ownerId } = params + const workspaceDefaultRegion = await deps.getDefaultRegion({ + workspaceId: input.workspaceId + }) + const regionKey = workspaceDefaultRegion?.key + const projectDb = await getDb({ regionKey }) + const db = mainDb + + // todo, use the command factory here, but for that, we need to migrate to the event bus + // deps not injected to ensure proper DB injection + const createNewProject = createNewProjectFactory({ + storeProject: storeProjectFactory({ db: projectDb }), + getProject: getProjectFactory({ db }), + deleteProject: deleteProjectFactory({ db: projectDb }), + storeModel: storeModelFactory({ db: projectDb }), + // THIS MUST GO TO THE MAIN DB + storeProjectRole: storeProjectRoleFactory({ db }), + projectsEventsEmitter: ProjectsEmitter.emit + }) + + const project = await createNewProject({ + ...input, + regionKey, + ownerId + }) + + return project + } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index a685b7d50..c2c796733 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -55,14 +55,32 @@ import { } from '@/modules/workspaces/repositories/sso' import { getEncryptor } from '@/modules/workspaces/helpers/sso' import { OidcProvider } from '@/modules/workspaces/domain/sso/types' -import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' +import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' -import { upsertPaidWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' +import { + getWorkspacePlanFactory, + upsertPaidWorkspacePlanFactory +} from '@/modules/gatekeeper/repositories/billing' import { SetOptional } from 'type-fest' +import { isMultiRegionTestMode } from '@/test/speckle-helpers/regions' +import { + assignRegionFactory, + getAvailableRegionsFactory +} from '@/modules/workspaces/services/regions' +import { getRegionsFactory } from '@/modules/multiregion/repositories' +import { canWorkspaceUseRegionsFactory } from '@/modules/gatekeeper/services/featureAuthorization' +import { + getDefaultRegionFactory, + upsertRegionAssignmentFactory +} from '@/modules/workspaces/repositories/regions' +import { getDb } from '@/modules/multiregion/dbSelector' + +const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() export type BasicTestWorkspace = { /** * Leave empty, will be filled on creation + * Note: Will be set to undefined if tests running with workspaces disabled entirely cause workspaces can't be created! */ id: string /** @@ -84,9 +102,19 @@ export type BasicTestWorkspace = { export const createTestWorkspace = async ( workspace: SetOptional, owner: BasicTestUser, - options?: { domain?: string; addPlan?: boolean } + options?: { domain?: string; addPlan?: boolean; regionKey?: string } ) => { - const { domain, addPlan = true } = options || {} + const { domain, addPlan = true, regionKey } = options || {} + const useRegion = isMultiRegionTestMode() && regionKey + + if (!FF_WORKSPACES_MODULE_ENABLED) { + // Just skip creation and set id to undefined - this allows this to be invoked the same way if FFs are on or off + // When BasicTestStream.workspaceId is set to this workspaces id, it will end up just being undefined, making the stream + // be created as if it was not assigned to a workspace, allowing tests to still work + // (Surely if you explicitly invoke createTestWorkspace with FFs off, you know what you're doing) + workspace.id = undefined as unknown as string + return + } const upsertWorkspacePlan = upsertPaidWorkspacePlanFactory({ db }) const createWorkspace = createWorkspaceFactory({ @@ -131,7 +159,7 @@ export const createTestWorkspace = async ( }) } - if (addPlan) { + if (addPlan || useRegion) { await upsertWorkspacePlan({ workspacePlan: { createdAt: new Date(), @@ -142,6 +170,26 @@ export const createTestWorkspace = async ( }) } + if (useRegion) { + const regionDb = await getDb({ regionKey }) + const assignRegion = assignRegionFactory({ + getAvailableRegions: getAvailableRegionsFactory({ + getRegions: getRegionsFactory({ db }), + canWorkspaceUseRegions: canWorkspaceUseRegionsFactory({ + getWorkspacePlan: getWorkspacePlanFactory({ db }) + }) + }), + upsertRegionAssignment: upsertRegionAssignmentFactory({ db }), + getDefaultRegion: getDefaultRegionFactory({ db }), + getWorkspace: getWorkspaceFactory({ db }), + insertRegionWorkspace: upsertWorkspaceFactory({ db: regionDb }) + }) + await assignRegion({ + workspaceId: newWorkspace.id, + regionKey + }) + } + const updateWorkspace = updateWorkspaceFactory({ validateSlug: validateSlugFactory({ getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }) diff --git a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts index 5d22a8f3a..767f3f211 100644 --- a/packages/server/modules/workspaces/tests/unit/services/management.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/management.spec.ts @@ -241,7 +241,7 @@ describe('Workspace services', () => { expect(context.eventData.isCalled).to.equal(true) expect(context.eventData.eventName).to.equal(WorkspaceEvents.Created) expect(context.eventData.payload).to.deep.equal({ - ...workspace, + workspace, createdByUserId: userId }) }) @@ -527,9 +527,9 @@ const buildDeleteWorkspaceRoleAndTestContext = ( context.eventData.payload = payload switch (eventName) { - case 'workspace.role-deleted': { + case WorkspaceEvents.RoleDeleted: { const { userId } = - payload as WorkspaceEventsPayloads['workspace.role-deleted'] + payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] for (const project of context.workspaceProjects) { context.workspaceProjectRoles = context.workspaceProjectRoles.filter( (role) => role.resourceId !== project.id && role.userId !== userId @@ -573,9 +573,9 @@ const buildUpdateWorkspaceRoleAndTestContext = ( context.eventData.payload = payload switch (eventName) { - case 'workspace.role-deleted': { + case WorkspaceEvents.RoleDeleted: { const { userId } = - payload as WorkspaceEventsPayloads['workspace.role-deleted'] + payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleDeleted] for (const project of context.workspaceProjects) { context.workspaceProjectRoles = context.workspaceProjectRoles.filter( (role) => role.resourceId !== project.id && role.userId !== userId @@ -583,9 +583,9 @@ const buildUpdateWorkspaceRoleAndTestContext = ( } break } - case 'workspace.role-updated': { + case WorkspaceEvents.RoleUpdated: { const workspaceRole = - payload as WorkspaceEventsPayloads['workspace.role-updated'] + payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleUpdated] const mapping = { [Roles.Workspace.Guest]: null, [Roles.Workspace.Member]: @@ -749,7 +749,7 @@ describe('Workspace role services', () => { const payload = { ...(context.eventData - .payload as WorkspaceEventsPayloads['workspace.role-updated']) + .payload as WorkspaceEventsPayloads[typeof WorkspaceEvents.RoleUpdated]) } delete payload.flags diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 0aefc6086..2ae6b2eea 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -20,10 +20,11 @@ type WorkspaceAuthorizedPayload = { userId: string | null workspaceId: string } -type WorkspaceCreatedPayload = Workspace & { +type WorkspaceCreatedPayload = { + workspace: Workspace createdByUserId: string } -type WorkspaceUpdatedPayload = Workspace +type WorkspaceUpdatedPayload = { workspace: Workspace } type WorkspaceRoleDeletedPayload = Pick type WorkspaceRoleUpdatedPayload = Pick< WorkspaceAcl, diff --git a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts index d19d20194..977bedb37 100644 --- a/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts +++ b/packages/server/modules/workspacesCore/graph/resolvers/workspacesCore.ts @@ -1,6 +1,10 @@ import { WorkspacesModuleDisabledError } from '@/modules/core/errors/workspaces' import { Resolvers } from '@/modules/core/graph/generated/graphql' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { + filteredSubscribe, + WorkspaceSubscriptions +} from '@/modules/shared/utils/subscriptions' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -115,6 +119,20 @@ export = !FF_WORKSPACES_MODULE_ENABLED }, ServerWorkspacesInfo: { workspacesEnabled: () => false + }, + Subscription: { + workspaceProjectsUpdated: { + subscribe: filteredSubscribe( + WorkspaceSubscriptions.WorkspaceProjectsUpdated, + () => false + ) + }, + workspaceUpdated: { + subscribe: filteredSubscribe( + WorkspaceSubscriptions.WorkspaceUpdated, + () => false + ) + } } } as Resolvers) : {} diff --git a/packages/server/package.json b/packages/server/package.json index 6a3f0d5f1..89b6f99aa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,6 +24,7 @@ "dev:server:test": "cross-env DISABLE_NOTIFICATIONS_CONSUMPTION=true NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true node ./bin/ts-www", "test": "cross-env NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true mocha", "test:multiregion": "cross-env RUN_TESTS_IN_MULTIREGION_MODE=true FF_WORKSPACES_MODULE_ENABLED=true FF_WORKSPACES_MULTI_REGION_ENABLED=true yarn test", + "test:no-ff": "cross-env DISABLE_ALL_FFS=true yarn test", "test:coverage": "cross-env NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true nyc --reporter lcov mocha", "test:report": "yarn test:coverage -- --reporter mocha-junit-reporter --reporter-options mochaFile=reports/test-results.xml", "lint": "yarn lint:tsc && yarn lint:eslint", @@ -195,6 +196,7 @@ "mocha": "^10.1.0", "mocha-junit-reporter": "^2.0.2", "mock-require": "^3.0.3", + "mock-socket": "^9.3.1", "node-mocks-http": "^1.12.1", "nodemon": "^2.0.20", "nyc": "^15.0.1", diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 8b8f3f0d8..6d81bcf42 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -3249,6 +3249,11 @@ export type Subscription = { * @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead. */ commitUpdated?: Maybe; + /** + * Cyclically sends a message to the client, used for testing + * Note: Only works in test environment + */ + ping: Scalars['String']['output']; /** Subscribe to updates to automations in the project */ projectAutomationsUpdated: ProjectAutomationsUpdatedMessage; /** @@ -3308,6 +3313,16 @@ export type Subscription = { userViewerActivity?: Maybe; /** Track user activities in the viewer relating to the specified resources */ viewerUserActivityBroadcasted: ViewerUserActivityMessage; + /** + * Track newly added or deleted projects in a specific workspace. + * Either slug or id must be set. + */ + workspaceProjectsUpdated: WorkspaceProjectsUpdatedMessage; + /** + * Track updates to a specific workspace. + * Either slug or id must be set. + */ + workspaceUpdated: WorkspaceUpdatedMessage; }; @@ -3439,6 +3454,18 @@ export type SubscriptionViewerUserActivityBroadcastedArgs = { target: ViewerUpdateTrackingTarget; }; + +export type SubscriptionWorkspaceProjectsUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + + +export type SubscriptionWorkspaceUpdatedArgs = { + workspaceId?: InputMaybe; + workspaceSlug?: InputMaybe; +}; + export type TestAutomationRun = { __typename?: 'TestAutomationRun'; automationRunId: Scalars['String']['output']; @@ -4349,6 +4376,23 @@ export type WorkspaceProjectsFilter = { search?: InputMaybe; }; +export type WorkspaceProjectsUpdatedMessage = { + __typename?: 'WorkspaceProjectsUpdatedMessage'; + /** Project entity, null if project was deleted */ + project?: Maybe; + /** Project ID */ + projectId: Scalars['String']['output']; + /** Message type */ + type: WorkspaceProjectsUpdatedMessageType; + /** Workspace ID */ + workspaceId: Scalars['String']['output']; +}; + +export enum WorkspaceProjectsUpdatedMessageType { + Added = 'ADDED', + Removed = 'REMOVED' +} + export enum WorkspaceRole { Admin = 'ADMIN', Guest = 'GUEST', @@ -4416,6 +4460,14 @@ export type WorkspaceUpdateInput = { slug?: InputMaybe; }; +export type WorkspaceUpdatedMessage = { + __typename?: 'WorkspaceUpdatedMessage'; + /** Workspace ID */ + id: Scalars['String']['output']; + /** Workspace itself */ + workspace: Workspace; +}; + export type CreateObjectMutationVariables = Exact<{ input: ObjectCreateInput; }>; @@ -4423,6 +4475,35 @@ export type CreateObjectMutationVariables = Exact<{ export type CreateObjectMutation = { __typename?: 'Mutation', objectCreate: Array }; +export type PingPongSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type PingPongSubscription = { __typename?: 'Subscription', ping: string }; + +export type OnUserProjectsUpdatedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type OnUserProjectsUpdatedSubscription = { __typename?: 'Subscription', userProjectsUpdated: { __typename?: 'UserProjectsUpdatedMessage', id: string, type: UserProjectsUpdatedMessageType, 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 OnUserProjectVersionsUpdatedSubscriptionVariables = Exact<{ + projectId: Scalars['String']['input']; +}>; + + +export type OnUserProjectVersionsUpdatedSubscription = { __typename?: 'Subscription', projectVersionsUpdated: { __typename?: 'ProjectVersionsUpdatedMessage', id: string, type: ProjectVersionsUpdatedMessageType, modelId?: string | null, version?: { __typename?: 'Version', id: string, message?: string | null } | null } }; + +export type OnUserStreamCommitCreatedSubscriptionVariables = Exact<{ + streamId: Scalars['String']['input']; +}>; + + +export type OnUserStreamCommitCreatedSubscription = { __typename?: 'Subscription', commitCreated?: 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 }; @@ -5215,6 +5296,11 @@ export const TestWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind export const TestWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceCollaborator"}},"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":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"projectRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"role"}},{"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 TestWorkspaceProjectFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TestWorkspaceProject"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; 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 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 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 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; diff --git a/packages/server/test/graphqlHelper.ts b/packages/server/test/graphqlHelper.ts index 0835ffb46..5ab609fd9 100644 --- a/packages/server/test/graphqlHelper.ts +++ b/packages/server/test/graphqlHelper.ts @@ -1,16 +1,34 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DocumentNode } from 'graphql' +import { DocumentNode, FormattedExecutionResult } from 'graphql' import { GraphQLContext } from '@/modules/shared/helpers/typeHelper' import { TypedDocumentNode } from '@graphql-typed-document-node/core' -import { buildApolloServer } from '@/app' +import { buildApolloServer, buildApolloSubscriptionServer } from '@/app' import { addLoadersToCtx } from '@/modules/shared/middleware' import { Roles } from '@/modules/core/helpers/mainConstants' -import { AllScopes, MaybeNullOrUndefined } from '@speckle/shared' +import { + AllScopes, + buildManualPromise, + ManualPromise, + MaybeAsync, + MaybeNullOrUndefined, + Optional, + 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 } from 'lodash' +import { 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' +import * as MockSocket from 'mock-socket' +import type ws from 'ws' +import { createAuthTokenForUser } from '@/test/authHelper' +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' type TypedGraphqlResponse> = GraphQLResponse @@ -237,3 +255,173 @@ export const testApolloServer = async (params?: { export type TestApolloServer = Awaited> export type ExecuteOperationOptions = Parameters[2] + +/** + * In test env we use a ping sub as a readiness signal for other subscriptions + * (there's no better way, no "is ready" event or anything) + */ +export const startEmittingTestSubs = async () => { + if (!isTestEnv()) return undefined + + const intervalMs = 100 + const interval = setInterval(async () => { + await publish(TestSubscriptions.Ping, { ping: new Date().toISOString() }) + }, intervalMs) + + return () => clearInterval(interval) +} + +/** + * Utilities for quickly/easily testing GQL subscriptions without having to build real network servers & connections + */ +export const testApolloSubscriptionServer = async () => { + const serverId = cryptoRandomString({ length: 16, type: 'url-safe' }) + const serverUrl = `ws://${serverId}.fakeWsServer:1234/graphql` + + const mockWsServer = new MockSocket.Server(serverUrl) + set(mockWsServer, 'removeListener', mockWsServer.off.bind(mockWsServer)) // backwards compat w/ subscriptions-transport-ws + + const mockWs = MockSocket.WebSocket as unknown as ws.WebSocket + const apolloSubServer = buildApolloSubscriptionServer(mockWsServer) + + // weakRef to ensure we dont prevent garbage collection + const clients: WeakRef[] = [] + + /** + * Build subscription client. One per user is ideal. + */ + const buildClient = async (params?: { + /** + * Real user id to auth the connection with. If unset, will be unauthenticated + */ + authUserId?: string + }) => { + const { authUserId } = params || {} + const token = authUserId ? await createAuthTokenForUser(authUserId) : undefined + const wsClient = new SubscriptionClient( + serverUrl, + { + reconnect: true, + connectionParams: { headers: token ? { Authorization: `Bearer ${token}` } : {} } + }, + mockWs + ) + clients.push(new WeakRef(wsClient)) + const clientLink = new WebSocketLink(wsClient) + + /** + * Subscribe and return a fn for unsubscribing + */ + const subscribe = async < + R extends Record = Record, + V extends Record = Record + >( + query: TypedDocumentNode, + variables: V, + handler: (res: FormattedExecutionResult) => MaybeAsync + ) => { + let msgFlaggedPromise: Optional> = undefined + const messages: Array> = [] + + const observable = execute(clientLink, { + query, + variables + }) + const sub = observable.subscribe((eventData) => { + const res = eventData as FormattedExecutionResult + + // Invoke handler + messages.push(res) + handler(res) + + // Mark msg received + if (!msgFlaggedPromise) { + msgFlaggedPromise = buildManualPromise() + } + msgFlaggedPromise.resolve() + }) + + /** + * Unsubscribe from the subscription + */ + const unsub = () => { + sub.unsubscribe() + } + + /** + * Wait for a message to come in - it should be near instantenous, but it sometimes might occur in next ticks + * due to the async nature of subscriptions + */ + const waitForMessage = async ( + options?: Partial<{ + /** + * Max time to wait for the messag + * Defaults to: 200 + */ + timeout: number + }> + ) => { + const { timeout = 200 } = options || {} + if (!msgFlaggedPromise) { + msgFlaggedPromise = buildManualPromise() + } + await Promise.race([msgFlaggedPromise.promise, timeoutAt(timeout)]) + } + + const getMessages = () => messages.slice() + + return { unsub, waitForMessage, getMessages } + } + + /** + * Invoke this after subscribe() calls to ensure that your subscriptions are ready + */ + const waitForReadiness = async () => { + return new Promise(async (resolve, reject) => { + const { unsub } = await subscribe(PingPongDocument, {}, (res) => { + if (!res.data?.ping) { + return reject(new Error('Unexpected ping error')) + } + + unsub() + resolve() + }) + + timeoutAt(5000).catch(reject) + }) + } + + /** + * Close down the client + */ + const quit = () => { + wsClient.close() + } + + return { subscribe, waitForReadiness, quit } + } + + /** + * Close down server and all clients + */ + const quit = () => { + for (const client of clients) { + client.deref()?.close() + } + mockWsServer.close() + apolloSubServer.close() + } + + return { + buildClient, + quit + } +} + +export type TestApolloSubscriptionServer = Awaited< + ReturnType +> + +export type TestApolloSubscriptionClient = Awaited< + ReturnType +> diff --git a/packages/server/test/hooks.ts b/packages/server/test/hooks.ts index 970c25554..92820c441 100644 --- a/packages/server/test/hooks.ts +++ b/packages/server/test/hooks.ts @@ -16,7 +16,7 @@ import { once } from 'events' import type http from 'http' import type express from 'express' import type net from 'net' -import { MaybeAsync, MaybeNullOrUndefined, wait } from '@speckle/shared' +import { MaybeAsync, MaybeNullOrUndefined, Optional, wait } from '@speckle/shared' import type mocha from 'mocha' import { getAvailableRegionKeysFactory, @@ -53,6 +53,25 @@ chai.use(chaiHttp) chai.use(deepEqualInAnyOrder) chai.use(graphqlChaiPlugin) +export const getMainTestRegionKey = () => { + const key = Object.keys(regionClients)[0] + if (!key) { + throw new Error('No registered region client found') + } + + return key +} + +export const getMainTestRegionClient = () => { + const key = getMainTestRegionKey() + const client = regionClients[key] + if (!client) { + throw new Error('No registered region client found') + } + + return client +} + const inEachDb = async (fn: (db: Knex) => MaybeAsync) => { await fn(mainDb) for (const regionClient of Object.values(regionClients)) { @@ -64,8 +83,12 @@ const ensureAivenExtrasFactory = (deps: { db: Knex }) => async () => { await deps.db.raw('CREATE EXTENSION IF NOT EXISTS "aiven_extras";') } -const setupMultiregionMode = async () => { +const setupDatabases = async () => { + // First reset main db const db = mainDb + const resetMainDb = resetSchemaFactory({ db }) + await resetMainDb() + const getAvailableRegionKeys = getAvailableRegionKeysFactory({ getAvailableRegionConfig }) @@ -94,9 +117,9 @@ const setupMultiregionMode = async () => { // Store active region clients regionClients = await getRegisteredRegionClients() - // Reset each DB client (re-run all migrations and setup) - for (const [, regionClient] of Object.entries(regionClients)) { - const reset = resetSchemaFactory({ db: regionClient }) + // Reset each region DB client (re-run all migrations and setup) + for (const client of Object.values(regionClients)) { + const reset = resetSchemaFactory({ db: client }) await reset() } @@ -199,9 +222,11 @@ const truncateTablesFactory = (deps: { db: Knex }) => async (tableNames?: string const resetSchemaFactory = (deps: { db: Knex }) => async () => { const resetPubSub = resetPubSubFactory(deps) + const truncate = truncateTablesFactory(deps) await unlockFactory(deps)() await resetPubSub() + await truncate() // otherwise some rollbacks will fail // Reset schema await deps.db.migrate.rollback() @@ -259,7 +284,7 @@ export const initializeTestServer = async (params: { } } -let graphqlServer: ApolloServer +let graphqlServer: Optional> = undefined export const mochaHooks: mocha.RootHookObject = { beforeAll: async () => { @@ -269,12 +294,8 @@ export const mochaHooks: mocha.RootHookObject = { logger.info('running before all') - // Init main db - const reset = resetSchemaFactory({ db: mainDb }) - await reset() - - // Init (or cleanup) multi-region mode - await setupMultiregionMode() + // Init (or cleanup) test databases + await setupDatabases() // Init app ;({ graphqlServer } = await init()) diff --git a/packages/server/test/speckle-helpers/commitHelper.ts b/packages/server/test/speckle-helpers/commitHelper.ts index 65346a070..33789b3b4 100644 --- a/packages/server/test/speckle-helpers/commitHelper.ts +++ b/packages/server/test/speckle-helpers/commitHelper.ts @@ -1,4 +1,4 @@ -import { mainDb } from '@/db/knex' +import { db } from '@/db/knex' import { saveActivityFactory } from '@/modules/activitystream/repositories' import { addCommitCreatedActivityFactory } from '@/modules/activitystream/services/commitActivity' import { VersionsEmitter } from '@/modules/core/events/versionsEmitter' @@ -23,10 +23,10 @@ import { createCommitByBranchNameFactory } from '@/modules/core/services/commit/management' import { createObjectFactory } from '@/modules/core/services/objects/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 { Knex } from 'knex' export type BasicTestCommit = { /** @@ -61,10 +61,12 @@ export type BasicTestCommit = { } export async function createTestObject(params: { projectId: string }) { - const db = mainDb + const projectDb = await getProjectDbClient(params) const createObject = createObjectFactory({ - storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }), - storeClosuresIfNotFound: storeClosuresIfNotFoundFactory({ db }) + storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ + db: projectDb + }), + storeClosuresIfNotFound: storeClosuresIfNotFoundFactory({ db: projectDb }) }) return await createObject({ @@ -73,84 +75,83 @@ export async function createTestObject(params: { projectId: string }) { }) } -const ensureObjectsFactory = - (deps: { db: Knex }) => async (commits: BasicTestCommit[]) => { - const { db } = deps - const createObject = createObjectFactory({ - storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db }), - storeClosuresIfNotFound: storeClosuresIfNotFoundFactory({ db }) - }) - - const commitsWithoutObjects = commits.filter((c) => !c.objectId) - await Promise.all( - commitsWithoutObjects.map((c) => - createObject({ - streamId: c.streamId, - object: { foo: 'bar' } - }).then((oid) => (c.objectId = oid)) - ) - ) - } - /** - * Create test commits + * Ensure all commits have objectId set */ -export const createTestCommitsFactory = - (deps: { db: Knex }) => - async ( - commits: BasicTestCommit[], - options?: Partial<{ owner: BasicTestUser; stream: BasicTestStream }> - ) => { - const { db } = deps - const { owner, stream } = options || {} - - const createCommitByBranchId = createCommitByBranchIdFactory({ - createCommit: createCommitFactory({ db }), - getObject: getObjectFactory({ db }), - getBranchById: getBranchByIdFactory({ db }), - insertStreamCommits: insertStreamCommitsFactory({ db }), - insertBranchCommits: insertBranchCommitsFactory({ db }), - markCommitStreamUpdated: markCommitStreamUpdatedFactory({ db }), - markCommitBranchUpdated: markCommitBranchUpdatedFactory({ db }), - versionsEventEmitter: VersionsEmitter.emit, - addCommitCreatedActivity: addCommitCreatedActivityFactory({ - saveActivity: saveActivityFactory({ db: mainDb }), - publish +async function ensureObjects(commits: BasicTestCommit[]) { + const commitsWithoutObjects = commits.filter((c) => !c.objectId) + await Promise.all( + commitsWithoutObjects.map(async (c) => { + const projectDb = await getProjectDbClient({ projectId: c.streamId }) + const createObject = createObjectFactory({ + storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ + db: projectDb + }), + storeClosuresIfNotFound: storeClosuresIfNotFoundFactory({ db: projectDb }) }) - }) - const createCommitByBranchName = createCommitByBranchNameFactory({ - createCommitByBranchId, - getStreamBranchByName: getStreamBranchByNameFactory({ db }), - getBranchById: getBranchByIdFactory({ db }) + return createObject({ + streamId: c.streamId, + object: { foo: 'bar' } + }).then((oid) => (c.objectId = oid)) }) - - commits.forEach((c) => { - if (owner) c.authorId = owner.id - if (stream) c.streamId = stream.id - }) - - await ensureObjectsFactory(deps)(commits) - await Promise.all( - commits.map((c) => - createCommitByBranchName({ - streamId: c.streamId, - branchName: c.branchName || 'main', - message: c.message || 'this message is auto generated', - sourceApplication: 'tests', - objectId: c.objectId, - authorId: c.authorId, - totalChildrenCount: 0, - parents: c.parents || [] - }).then((newCommit) => (c.id = newCommit.id)) - ) - ) - } + ) +} /** * Create test commits */ -export const createTestCommits = createTestCommitsFactory({ db: mainDb }) +export async function createTestCommits( + commits: BasicTestCommit[], + options?: Partial<{ owner: BasicTestUser; stream: BasicTestStream }> +) { + const { owner, stream } = options || {} + + commits.forEach((c) => { + if (owner) c.authorId = owner.id + if (stream) c.streamId = stream.id + }) + + await ensureObjects(commits) + await Promise.all( + commits.map(async (c) => { + const projectDb = await getProjectDbClient({ projectId: c.streamId }) + const markCommitStreamUpdated = markCommitStreamUpdatedFactory({ db: projectDb }) + const getObject = getObjectFactory({ db: projectDb }) + const createCommitByBranchId = createCommitByBranchIdFactory({ + createCommit: createCommitFactory({ db: projectDb }), + getObject, + getBranchById: getBranchByIdFactory({ db: projectDb }), + insertStreamCommits: insertStreamCommitsFactory({ db: projectDb }), + insertBranchCommits: insertBranchCommitsFactory({ db: projectDb }), + markCommitStreamUpdated, + markCommitBranchUpdated: markCommitBranchUpdatedFactory({ db: projectDb }), + versionsEventEmitter: VersionsEmitter.emit, + addCommitCreatedActivity: addCommitCreatedActivityFactory({ + saveActivity: saveActivityFactory({ db }), + publish + }) + }) + + const createCommitByBranchName = createCommitByBranchNameFactory({ + createCommitByBranchId, + getStreamBranchByName: getStreamBranchByNameFactory({ db: projectDb }), + getBranchById: getBranchByIdFactory({ db: projectDb }) + }) + + return createCommitByBranchName({ + streamId: c.streamId, + branchName: c.branchName || 'main', + message: c.message || 'this message is auto generated', + sourceApplication: 'tests', + objectId: c.objectId, + authorId: c.authorId, + totalChildrenCount: 0, + parents: c.parents || [] + }).then((newCommit) => (c.id = newCommit.id)) + }) + ) +} export async function createTestCommit( commit: BasicTestCommit, diff --git a/packages/server/test/speckle-helpers/regions.ts b/packages/server/test/speckle-helpers/regions.ts index 6a1169789..065b6232d 100644 --- a/packages/server/test/speckle-helpers/regions.ts +++ b/packages/server/test/speckle-helpers/regions.ts @@ -1,11 +1,17 @@ import { db } from '@/db/knex' +import { getUserFactory } from '@/modules/core/repositories/users' import { isMultiRegionEnabled } from '@/modules/multiregion/helpers' import { Regions } from '@/modules/multiregion/repositories' import { isTestEnv, shouldRunTestsInMultiregionMode } from '@/modules/shared/helpers/envHelper' -import { getRegionKeys } from '@/test/hooks' +import { + getRegionKeys, + getMainTestRegionClient, + getMainTestRegionKey +} from '@/test/hooks' +import { wait } from '@speckle/shared' /** * Delete all regions entries that are not part of the main multi region mode @@ -17,3 +23,38 @@ export const truncateRegionsSafely = async () => { export const isMultiRegionTestMode = () => isMultiRegionEnabled() && isTestEnv() && shouldRunTestsInMultiregionMode() + +const waitForPredicate = async (params: { + predicate: () => Promise + timeout?: number + delay?: number + errMsg?: string +}) => { + const { predicate, timeout = 5000, delay = 100, errMsg } = params + const start = Date.now() + + while (Date.now() - start < timeout) { + if (await predicate()) return + await wait(delay) + } + + throw new Error(errMsg || 'Timeout waiting for predicate') +} + +/** + * Wait for user to exist in region db + */ +export const waitForRegionUser = async (params: { userId: string }) => { + const client = getMainTestRegionClient() + const getUser = getUserFactory({ db: client }) + + await waitForPredicate({ + predicate: async () => { + const user = await getUser(params.userId) + return !!user + }, + errMsg: `User ${params.userId} not found in region db` + }) +} + +export { getMainTestRegionClient, getMainTestRegionKey } diff --git a/packages/server/test/speckle-helpers/streamHelper.ts b/packages/server/test/speckle-helpers/streamHelper.ts index 957422350..2e452629b 100644 --- a/packages/server/test/speckle-helpers/streamHelper.ts +++ b/packages/server/test/speckle-helpers/streamHelper.ts @@ -33,7 +33,11 @@ import { authorizeResolver } from '@/modules/shared' import { Nullable } from '@/modules/shared/helpers/typeHelper' import { getEventBus } from '@/modules/shared/services/eventBus' import { publish } from '@/modules/shared/utils/subscriptions' +import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' +import { createWorkspaceProjectFactory } from '@/modules/workspaces/services/projects' import { BasicTestUser } from '@/test/authHelper' +import { ProjectVisibility } from '@/test/graphql/generated/graphql' +import { faker } from '@faker-js/faker' import { ensureError } from '@speckle/shared' import { omit } from 'lodash' @@ -113,10 +117,30 @@ export async function createTestStream( streamObj: BasicTestStream, owner: BasicTestUser ) { - const id = await createStream({ - ...omit(streamObj, ['id', 'ownerId']), - ownerId: owner.id - }) + let id: string + if (streamObj.workspaceId) { + const createWorkspaceProject = createWorkspaceProjectFactory({ + getDefaultRegion: getDefaultRegionFactory({ db }) + }) + const newProject = await createWorkspaceProject({ + input: { + name: streamObj.name || faker.commerce.productName(), + description: streamObj.description, + visibility: streamObj.isPublic + ? ProjectVisibility.Public + : ProjectVisibility.Private, + workspaceId: streamObj.workspaceId + }, + ownerId: owner.id + }) + id = newProject.id + } else { + id = await createStream({ + ...omit(streamObj, ['id', 'ownerId']), + ownerId: owner.id + }) + } + streamObj.id = id streamObj.ownerId = owner.id } diff --git a/packages/shared/src/core/helpers/utility.ts b/packages/shared/src/core/helpers/utility.ts index 917202fbe..526b45b7b 100644 --- a/packages/shared/src/core/helpers/utility.ts +++ b/packages/shared/src/core/helpers/utility.ts @@ -27,6 +27,8 @@ export const buildManualPromise = () => { return { promise, resolve: resolveWrapper, reject: rejectWrapper } } +export type ManualPromise = ReturnType> + export const isNullOrUndefined = (val: unknown): val is null | undefined => isNull(val) || isUndefined(val) diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index bf87f2677..b77b8e315 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -1,10 +1,13 @@ import { parseEnv } from 'znv' import { z } from 'zod' -function parseFeatureFlags() { +const isDisableAllFFsMode = () => + ['true', '1'].includes(process.env.DISABLE_ALL_FFS || '') + +const parseFeatureFlags = () => { //INFO // As a convention all feature flags should be prefixed with a FF_ - return parseEnv(process.env, { + const res = parseEnv(process.env, { // Enables the automate module. FF_AUTOMATE_MODULE_ENABLED: { schema: z.boolean(), @@ -49,6 +52,15 @@ function parseFeatureFlags() { defaults: { production: false, _: false } } }) + + // Can be used to disable all feature flags for testing purposes + if (isDisableAllFFsMode()) { + for (const key of Object.keys(res)) { + ;(res as Record)[key] = false + } + } + + return res } let parsedFlags: ReturnType | undefined diff --git a/yarn.lock b/yarn.lock index ccaa2322c..86ef2104f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17198,6 +17198,7 @@ __metadata: mocha: "npm:^10.1.0" mocha-junit-reporter: "npm:^2.0.2" mock-require: "npm:^3.0.3" + mock-socket: "npm:^9.3.1" module-alias: "npm:^2.2.2" netmask: "npm:^2.0.2" node-cron: "npm:^3.0.2" @@ -40607,6 +40608,13 @@ __metadata: languageName: node linkType: hard +"mock-socket@npm:^9.3.1": + version: 9.3.1 + resolution: "mock-socket@npm:9.3.1" + checksum: 10/c5c07568f2859db6926d79cb61580c07e67958b5cd6b52d1270fdfa17ae066d7f74a18a4208fc4386092eea4e1ee001aa23f015c88a1774265994e4fae34d18e + languageName: node + linkType: hard + "module-alias@npm:^2.2.2": version: 2.2.2 resolution: "module-alias@npm:2.2.2"