diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index b44de5b36..8e951477b 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4152,6 +4152,8 @@ export type Workspace = { name: Scalars['String']['output']; plan?: Maybe; projects: ProjectCollection; + /** A Workspace is marked as readOnly if its trial period is finished or a paid plan is subscribed but payment has failed */ + readOnly: Scalars['Boolean']['output']; /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; @@ -7994,6 +7996,7 @@ export type WorkspaceFieldArgs = { name: {}, plan: {}, projects: WorkspaceProjectsArgs, + readOnly: {}, role: {}, slug: {}, sso: {}, diff --git a/packages/server/modules/core/graph/resolvers/objects.ts b/packages/server/modules/core/graph/resolvers/objects.ts index abd21400d..48ed2fdc0 100644 --- a/packages/server/modules/core/graph/resolvers/objects.ts +++ b/packages/server/modules/core/graph/resolvers/objects.ts @@ -10,6 +10,7 @@ import { } from '@/modules/core/repositories/objects' import { createObjectsFactory } from '@/modules/core/services/objects/management' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import coreModule from '@/modules/core' type GetObjectChildrenQueryParams = Parameters< ReturnType @@ -100,6 +101,10 @@ export = { context.resourceAccessRules ) + await coreModule.executeHooks?.('onCreateObjectRequest', { + projectId: args.objectInput.streamId + }) + const projectDB = await getProjectDbClient({ projectId: args.objectInput.streamId }) diff --git a/packages/server/modules/core/rest/upload.ts b/packages/server/modules/core/rest/upload.ts index 23a689002..fde210afa 100644 --- a/packages/server/modules/core/rest/upload.ts +++ b/packages/server/modules/core/rest/upload.ts @@ -20,11 +20,12 @@ import { import { validatePermissionsWriteStreamFactory } from '@/modules/core/services/streams/auth' import { authorizeResolver, validateScopes } from '@/modules/shared' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import { ExecuteHooks } from '@/modules/core/hooks' const MAX_FILE_SIZE = maximumObjectUploadFileSizeMb() * 1024 * 1024 const { FF_NO_CLOSURE_WRITES } = getFeatureFlags() -export default (app: Router) => { +export default (app: Router, { executeHooks }: { executeHooks: ExecuteHooks }) => { const validatePermissionsWriteStream = validatePermissionsWriteStreamFactory({ validateScopes, authorizeResolver @@ -63,6 +64,10 @@ export default (app: Router) => { return res.status(hasStreamAccess.status).end() } + await executeHooks('onCreateObjectRequest', { + projectId: req.params.streamId + }) + const projectDb = await getProjectDbClient({ projectId: req.params.streamId }) const objectInsertionService = FF_NO_CLOSURE_WRITES diff --git a/packages/server/modules/core/tests/graph.spec.js b/packages/server/modules/core/tests/graph.spec.js index c0a00cb61..633bb679c 100644 --- a/packages/server/modules/core/tests/graph.spec.js +++ b/packages/server/modules/core/tests/graph.spec.js @@ -363,7 +363,10 @@ describe('GraphQL API Core @core-api', () => { const res = await sendRequest(userA.token, { query: 'mutation($user:UserUpdateInput!) { userUpdate( user: $user) } ', variables: { - user: { name: 'MiticÄ', bio: 'He never really knows what he is doing.' } + user: { + name: 'test user updated', + bio: 'He never really knows what he is doing.' + } } }) expect(res).to.be.json @@ -1215,7 +1218,7 @@ describe('GraphQL API Core @core-api', () => { expect(res).to.be.json expect(res.body.errors).to.not.exist expect(res.body.data).to.have.property('user') - expect(res.body.data.user.name).to.equal('MiticÄ') + expect(res.body.data.user.name).to.equal('test user updated') expect(res.body.data.user.email).to.equal('d.1@speckle.systems') expect(res.body.data.user.role).to.equal(Roles.Server.Admin) }) diff --git a/packages/server/modules/core/tests/integration/objects.graph.spec.ts b/packages/server/modules/core/tests/integration/objects.graph.spec.ts new file mode 100644 index 000000000..c05e0d043 --- /dev/null +++ b/packages/server/modules/core/tests/integration/objects.graph.spec.ts @@ -0,0 +1,140 @@ +import { beforeEachContext } from '@/test/hooks' +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { + createRandomEmail, + createRandomPassword +} from '@/modules/core/helpers/testHelpers' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { db } from '@/db/knex' +import { testApolloServer } from '@/test/graphqlHelper' +import { + CreateObjectDocument, + CreateWorkspaceDocument, + CreateWorkspaceProjectDocument +} from '@/test/graphql/generated/graphql' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' +import { sendEmail } from '@/modules/emails/services/sending' +import { + countAdminUsersFactory, + legacyGetUserFactory, + storeUserAclFactory, + storeUserFactory +} from '@/modules/core/repositories/users' +import { createUserFactory } from '@/modules/core/services/users/management' +import { UsersEmitter } from '@/modules/core/events/usersEmitter' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' + +const getServerInfo = getServerInfoFactory({ db }) +const getUser = legacyGetUserFactory({ db }) +const requestNewEmailVerification = requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), + renderEmail, + sendEmail +}) + +const createUserEmail = validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification +}) + +const findEmail = findEmailFactory({ db }) +const createUser = createUserFactory({ + getServerInfo, + findEmail, + storeUser: storeUserFactory({ db }), + countAdminUsers: countAdminUsersFactory({ db }), + storeUserAcl: storeUserAclFactory({ db }), + validateAndCreateUserEmail: createUserEmail, + usersEventsEmitter: UsersEmitter.emit +}) + +describe('Objects graphql @core', () => { + before(async () => { + await beforeEachContext() + }) + + describe('objectCreate mutation', () => { + it('should return error if project is read-only', async () => { + const userId = await createUser({ + name: 'emails user', + email: createRandomEmail(), + password: createRandomPassword() + }) + + const apollo = await testApolloServer({ authUserId: userId }) + + const workspaceCreateRes = await apollo.execute(CreateWorkspaceDocument, { + input: { name: 'test ws' } + }) + expect(workspaceCreateRes).to.not.haveGraphQLErrors() + + const workspace = workspaceCreateRes.data?.workspaceMutations.create + + const projectCreateRes = await apollo.execute(CreateWorkspaceProjectDocument, { + input: { workspaceId: workspace!.id, name: 'test project' } + }) + expect(projectCreateRes).to.not.haveGraphQLErrors() + const project = projectCreateRes.data?.workspaceMutations.projects.create + + // Make the project read-only + await db('workspace_plans') + .update({ status: 'expired' }) + .where({ workspaceId: workspace!.id }) + + const objectCreateRes = await apollo.execute(CreateObjectDocument, { + input: { + streamId: project!.id, + objects: [ + { + id: 'e5262a6fb51540974e6d07ac60b7fe5c', + name: 'Rhino Model', + elements: [ + { + referencedId: '581a822cdaa5c2972783510d57617f73', + /* eslint-disable camelcase */ + speckle_type: 'reference' + } + ], + __closure: { + '0086c072ee1fd70ac0a68c067a37e0eb': 3 + }, + speckleType: 'Speckle.Core.Models.Collection', + speckle_type: 'Speckle.Core.Models.Collection', + applicationId: null, + collectionType: 'rhino model', + totalChildrenCount: 610 + } + ] + } + }) + expect(objectCreateRes).to.haveGraphQLErrors() + expect(objectCreateRes.errors).to.have.length(1) + expect(objectCreateRes.errors![0].message).to.eq( + new WorkspaceReadOnlyError().message + ) + }) + }) +}) diff --git a/packages/server/modules/core/tests/integration/objects.rest.spec.ts b/packages/server/modules/core/tests/integration/objects.rest.spec.ts new file mode 100644 index 000000000..2e07d5b43 --- /dev/null +++ b/packages/server/modules/core/tests/integration/objects.rest.spec.ts @@ -0,0 +1,165 @@ +import { db } from '@/db/knex' +import { UsersEmitter } from '@/modules/core/events/usersEmitter' +import { + createRandomEmail, + createRandomPassword +} from '@/modules/core/helpers/testHelpers' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory +} from '@/modules/core/repositories/userEmails' +import { + countAdminUsersFactory, + legacyGetUserFactory, + storeUserAclFactory, + storeUserFactory +} from '@/modules/core/repositories/users' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { createUserFactory } from '@/modules/core/services/users/management' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { renderEmail } from '@/modules/emails/services/emailRendering' +import { sendEmail } from '@/modules/emails/services/sending' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { + deleteServerOnlyInvitesFactory, + updateAllInviteTargetsFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { createTestWorkspace } from '@/modules/workspaces/tests/helpers/creation' +import { beforeEachContext } from '@/test/hooks' +import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper' +import request from 'supertest' +import { Express } from 'express' +import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens' +import { + storeApiTokenFactory, + storePersonalApiTokenFactory, + storeTokenResourceAccessDefinitionsFactory, + storeTokenScopesFactory +} from '@/modules/core/repositories/tokens' +import { Scopes } from '@speckle/shared' +import { expect } from 'chai' + +const getServerInfo = getServerInfoFactory({ db }) +const getUser = legacyGetUserFactory({ db }) +const requestNewEmailVerification = requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db }), + getUser, + getServerInfo, + deleteOldAndInsertNewVerification: deleteOldAndInsertNewVerificationFactory({ db }), + renderEmail, + sendEmail +}) + +const createUserEmail = validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), + findEmail: findEmailFactory({ db }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) + }), + requestNewEmailVerification +}) + +const findEmail = findEmailFactory({ db }) +const createUser = createUserFactory({ + getServerInfo, + findEmail, + storeUser: storeUserFactory({ db }), + countAdminUsers: countAdminUsersFactory({ db }), + storeUserAcl: storeUserAclFactory({ db }), + validateAndCreateUserEmail: createUserEmail, + usersEventsEmitter: UsersEmitter.emit +}) + +const createPersonalAccessToken = createPersonalAccessTokenFactory({ + storeApiToken: storeApiTokenFactory({ db }), + storeTokenScopes: storeTokenScopesFactory({ db }), + storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({ + db + }), + storePersonalApiToken: storePersonalApiTokenFactory({ db }) +}) + +describe('Objects REST @core', () => { + let app: Express + before(async () => { + ;({ app } = await beforeEachContext()) + }) + + it('should return an error if the project is read-only', async () => { + const userId = await createUser({ + name: 'emails user', + email: createRandomEmail(), + password: createRandomPassword() + }) + const user = await getUser(userId) + const workspace = { + name: 'Test Workspace #1', + ownerId: userId, + id: '', + slug: '' + } + await createTestWorkspace(workspace, user, { + addPlan: { name: 'business', status: 'expired' } + }) + + const project = { + id: '', + name: 'test project', + ownerId: userId, + workspaceId: workspace.id + } + await createTestStream(project as unknown as BasicTestStream, user) + + const token = `Bearer ${await createPersonalAccessToken( + user.id, + 'test token user A', + [ + Scopes.Streams.Read, + Scopes.Streams.Write, + Scopes.Users.Read, + Scopes.Users.Email, + Scopes.Tokens.Write, + Scopes.Tokens.Read, + Scopes.Profile.Read, + Scopes.Profile.Email + ] + )}` + + const res = await request(app) + .post(`/objects/${project.id}`) + .set('Authorization', token) + .set('Content-type', 'multipart/form-data') + .attach( + 'batch1', + Buffer.from( + JSON.stringify({ + id: 'e5262a6fb51540974e6d07ac60b7fe5c', + name: 'Rhino Model', + elements: [ + { + referencedId: '581a822cdaa5c2972783510d57617f73', + /* eslint-disable camelcase */ + speckle_type: 'reference' + } + ], + __closure: { + '0086c072ee1fd70ac0a68c067a37e0eb': 3 + }, + speckleType: 'Speckle.Core.Models.Collection', + speckle_type: 'Speckle.Core.Models.Collection', + applicationId: null, + collectionType: 'rhino model', + totalChildrenCount: 610 + }), + 'utf8' + ) + ) + + expect(res).to.have.status(403) + }) +}) diff --git a/packages/server/modules/gatekeeper/domain/operations.ts b/packages/server/modules/gatekeeper/domain/operations.ts index c4da4dc7d..a761a6096 100644 --- a/packages/server/modules/gatekeeper/domain/operations.ts +++ b/packages/server/modules/gatekeeper/domain/operations.ts @@ -13,3 +13,9 @@ export type WorkspaceFeatureAccessFunction = (args: { export type ChangeExpiredTrialWorkspacePlanStatuses = (args: { numberOfDays: number }) => Promise + +export type GetWorkspacePlanByProjectId = ({ + projectId +}: { + projectId: string +}) => Promise diff --git a/packages/server/modules/gatekeeper/errors/billing.ts b/packages/server/modules/gatekeeper/errors/billing.ts index 297fcfc90..811ffe233 100644 --- a/packages/server/modules/gatekeeper/errors/billing.ts +++ b/packages/server/modules/gatekeeper/errors/billing.ts @@ -53,3 +53,9 @@ export class WorkspacePlanDowngradeError extends BaseError { static code = 'WORKSPACE_PLAN_DOWNGRADE_ERROR' static statusCode = 400 } + +export class WorkspaceReadOnlyError extends BaseError { + static defaultMessage = 'Workspace is read-only' + static code = 'WORKSPACE_READ_ONLY_ERROR' + static statusCode = 403 +} diff --git a/packages/server/modules/gatekeeper/index.ts b/packages/server/modules/gatekeeper/index.ts index a49430039..101531ea6 100644 --- a/packages/server/modules/gatekeeper/index.ts +++ b/packages/server/modules/gatekeeper/index.ts @@ -20,6 +20,7 @@ import { } from '@/modules/gatekeeper/services/subscriptions' import { changeExpiredTrialWorkspacePlanStatusesFactory, + getWorkspacePlanByProjectIdFactory, getWorkspacePlanFactory, getWorkspaceSubscriptionsPastBillingCycleEndFactory, upsertWorkspaceSubscriptionFactory @@ -28,6 +29,9 @@ import { countWorkspaceRoleWithOptionalProjectRoleFactory } from '@/modules/work import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe' import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations' import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus' +import coreModule from '@/modules/core/index' +import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' +import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing' const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } = getFeatureFlags() @@ -166,6 +170,19 @@ const gatekeeperModule: SpeckleModule = { scheduledTasks.forEach((task) => { task.stop() }) + }, + async finalize() { + coreModule.addHook( + 'onCreateObjectRequest', + async function isProjectReadOnly({ projectId }) { + const readOnly = await isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId: getWorkspacePlanByProjectIdFactory({ + db + }) + })({ projectId }) + if (readOnly) throw new WorkspaceReadOnlyError() + } + ) } } export = gatekeeperModule diff --git a/packages/server/modules/gatekeeper/repositories/billing.ts b/packages/server/modules/gatekeeper/repositories/billing.ts index d46dce8da..c71cda8a5 100644 --- a/packages/server/modules/gatekeeper/repositories/billing.ts +++ b/packages/server/modules/gatekeeper/repositories/billing.ts @@ -1,3 +1,4 @@ +import { Streams } from '@/modules/core/dbSchema' import { CheckoutSession, GetCheckoutSession, @@ -17,7 +18,11 @@ import { UpsertTrialWorkspacePlan, UpsertUnpaidWorkspacePlan } from '@/modules/gatekeeper/domain/billing' -import { ChangeExpiredTrialWorkspacePlanStatuses } from '@/modules/gatekeeper/domain/operations' +import { + ChangeExpiredTrialWorkspacePlanStatuses, + GetWorkspacePlanByProjectId +} from '@/modules/gatekeeper/domain/operations' +import { Workspaces } from '@/modules/workspacesCore/helpers/db' import { Knex } from 'knex' const tables = { @@ -165,3 +170,20 @@ export const getWorkspaceSubscriptionsPastBillingCycleEndFactory = .select() .where('currentBillingCycleEnd', '<', cycleEnd) } + +export const getWorkspacePlanByProjectIdFactory = + ({ db }: { db: Knex }): GetWorkspacePlanByProjectId => + async ({ projectId }) => { + return await tables + .workspacePlans(db) + .select([ + 'workspace_plans.workspaceId', + 'workspace_plans.status', + 'workspace_plans.name', + 'workspace_plans.createdAt' + ]) + .innerJoin(Workspaces.name, Workspaces.col.id, 'workspace_plans.workspaceId') + .innerJoin(Streams.name, Streams.col.workspaceId, Workspaces.col.id) + .where({ [Streams.col.id]: projectId }) + .first() + } diff --git a/packages/server/modules/gatekeeper/services/readOnly.ts b/packages/server/modules/gatekeeper/services/readOnly.ts index 9c4936574..eda0359e2 100644 --- a/packages/server/modules/gatekeeper/services/readOnly.ts +++ b/packages/server/modules/gatekeeper/services/readOnly.ts @@ -1,24 +1,40 @@ -import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { GetWorkspacePlan, WorkspacePlan } from '@/modules/gatekeeper/domain/billing' +import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations' import { Workspace } from '@/modules/workspacesCore/domain/types' import { throwUncoveredError } from '@speckle/shared' +const isWorkspacePlanStatusReadOnly = (status: WorkspacePlan['status']) => { + switch (status) { + case 'cancelationScheduled': + case 'valid': + case 'trial': + case 'paymentFailed': + return false + case 'expired': + case 'canceled': + return true + default: + throwUncoveredError(status) + } +} + export const isWorkspaceReadOnlyFactory = ({ getWorkspacePlan }: { getWorkspacePlan: GetWorkspacePlan }) => async ({ workspaceId }: { workspaceId: Workspace['id'] }) => { const workspacePlan = await getWorkspacePlan({ workspaceId }) // Should never happen if (!workspacePlan) return true - - switch (workspacePlan.status) { - case 'cancelationScheduled': - case 'valid': - case 'trial': - case 'paymentFailed': - return false - case 'expired': - case 'canceled': - return true - default: - throwUncoveredError(workspacePlan) - } + return isWorkspacePlanStatusReadOnly(workspacePlan.status) + } + +export const isProjectReadOnlyFactory = + ({ + getWorkspacePlanByProjectId + }: { + getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId + }) => + async ({ projectId }: { projectId: string }) => { + const workspacePlan = await getWorkspacePlanByProjectId({ projectId }) + if (!workspacePlan) return false // The project is not in a workspace + return isWorkspacePlanStatusReadOnly(workspacePlan.status) } diff --git a/packages/server/modules/gatekeeper/tests/unit/readOnly.spec.ts b/packages/server/modules/gatekeeper/tests/unit/readOnly.spec.ts index ad924ac98..66b030732 100644 --- a/packages/server/modules/gatekeeper/tests/unit/readOnly.spec.ts +++ b/packages/server/modules/gatekeeper/tests/unit/readOnly.spec.ts @@ -1,5 +1,9 @@ import { GetWorkspacePlan } from '@/modules/gatekeeper/domain/billing' -import { isWorkspaceReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly' +import { GetWorkspacePlanByProjectId } from '@/modules/gatekeeper/domain/operations' +import { + isProjectReadOnlyFactory, + isWorkspaceReadOnlyFactory +} from '@/modules/gatekeeper/services/readOnly' import { expect } from 'chai' describe('@gatekeeper readOnly', () => { @@ -53,4 +57,88 @@ describe('@gatekeeper readOnly', () => { expect(await isWorkspaceReadOnly({ workspaceId: '' })).to.be.false }) }) + + describe('isProjectReadOnlyFactory returns a function that', () => { + it('returns false if project is not in a workspace', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => null + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.false + }) + it('returns true if workspace plan status is expired', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'expired' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.true + }) + it('returns true if workspace plan status is paymentFailed', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'paymentFailed' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.false + }) + it('returns true if workspace plan status is canceled', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'canceled' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.true + }) + it('returns false if workspace plan status is trial', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'trial' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.false + }) + it('returns false if workspace plan status is valid', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'valid' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.false + }) + it('returns false if workspace plan status is cancelationScheduled', async () => { + const getWorkspacePlanByProjectId: GetWorkspacePlanByProjectId = async () => + ({ + status: 'cancelationScheduled' + } as unknown as ReturnType) + + const isProjectReadOnly = isProjectReadOnlyFactory({ + getWorkspacePlanByProjectId + }) + + expect(await isProjectReadOnly({ projectId: '' })).to.be.false + }) + }) })