feat(gatekeeper): add per workspace feature flags (#5303)
* feat(gatekeeper): add per workspace feature flags * feat(workspaces): add admin api for granting and removing access to workspace features * fix(workspaces): use the correct constant name * fix(workspaces): more test type fixes * fix(shared): fix tests and types * fix(workspaces): properly use exhaustive switch statement * fix(workspaces): add new workspace plan feature to switch * fix(workspaces): use regular integer, its fine for now... * fix(workspaces): feature flag retention post checkout * fix(gatekeeper): fix upsert plan tests
This commit is contained in:
+1
-1
@@ -34,7 +34,7 @@
|
||||
"console": "integratedTerminal",
|
||||
"program": "test",
|
||||
"runtimeExecutable": "yarn",
|
||||
"args": ["-t", "forbids creation for users eligible"],
|
||||
"args": [],
|
||||
"type": "pwa-node",
|
||||
"cwd": "$ZED_WORKTREE_ROOT/packages/shared",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
|
||||
@@ -135,6 +135,11 @@ export type AddDomainToWorkspaceInput = {
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AdminAccessToWorkspaceFeatureInput = {
|
||||
featureFlagName: WorkspaceFeatureFlagName;
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AdminInviteList = {
|
||||
__typename?: 'AdminInviteList';
|
||||
cursor?: Maybe<Scalars['String']['output']>;
|
||||
@@ -144,10 +149,22 @@ export type AdminInviteList = {
|
||||
|
||||
export type AdminMutations = {
|
||||
__typename?: 'AdminMutations';
|
||||
giveAccessToWorkspaceFeature: Scalars['Boolean']['output'];
|
||||
removeAccessToWorkspaceFeature: Scalars['Boolean']['output'];
|
||||
updateWorkspacePlan: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = {
|
||||
input: AdminAccessToWorkspaceFeatureInput;
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = {
|
||||
input: AdminAccessToWorkspaceFeatureInput;
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsUpdateWorkspacePlanArgs = {
|
||||
input: AdminUpdateWorkspacePlanInput;
|
||||
};
|
||||
@@ -5273,8 +5290,15 @@ export type WorkspaceEmbedOptions = {
|
||||
hideSpeckleBranding: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export const WorkspaceFeatureFlagName = {
|
||||
AccIntegration: 'accIntegration',
|
||||
Dashboards: 'dashboards'
|
||||
} as const;
|
||||
|
||||
export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName];
|
||||
export const WorkspaceFeatureName = {
|
||||
AccIntegration: 'accIntegration',
|
||||
Dashboards: 'dashboards',
|
||||
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
|
||||
ExclusiveMembership: 'exclusiveMembership',
|
||||
HideSpeckleBranding: 'hideSpeckleBranding',
|
||||
@@ -8872,6 +8896,8 @@ export type AdminInviteListFieldArgs = {
|
||||
totalCount: {},
|
||||
}
|
||||
export type AdminMutationsFieldArgs = {
|
||||
giveAccessToWorkspaceFeature: AdminMutationsGiveAccessToWorkspaceFeatureArgs,
|
||||
removeAccessToWorkspaceFeature: AdminMutationsRemoveAccessToWorkspaceFeatureArgs,
|
||||
updateWorkspacePlan: AdminMutationsUpdateWorkspacePlanArgs,
|
||||
}
|
||||
export type AdminQueriesFieldArgs = {
|
||||
|
||||
@@ -154,7 +154,8 @@ extend type Project {
|
||||
}
|
||||
|
||||
enum WorkspaceFeatureName {
|
||||
accIntegration
|
||||
accIntegration # will be moved to a per workspace feature
|
||||
dashboards
|
||||
domainBasedSecurityPolicies
|
||||
oidcSso
|
||||
hideSpeckleBranding
|
||||
|
||||
@@ -684,8 +684,20 @@ input AdminUpdateWorkspacePlanInput {
|
||||
status: WorkspacePlanStatuses!
|
||||
}
|
||||
|
||||
enum WorkspaceFeatureFlagName {
|
||||
dashboards
|
||||
accIntegration
|
||||
}
|
||||
|
||||
input AdminAccessToWorkspaceFeatureInput {
|
||||
workspaceId: ID!
|
||||
featureFlagName: WorkspaceFeatureFlagName!
|
||||
}
|
||||
|
||||
type AdminMutations {
|
||||
updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean!
|
||||
giveAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean!
|
||||
removeAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean!
|
||||
}
|
||||
|
||||
extend type ActiveUserMutations {
|
||||
|
||||
@@ -156,6 +156,11 @@ export type AddDomainToWorkspaceInput = {
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AdminAccessToWorkspaceFeatureInput = {
|
||||
featureFlagName: WorkspaceFeatureFlagName;
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AdminInviteList = {
|
||||
__typename?: 'AdminInviteList';
|
||||
cursor?: Maybe<Scalars['String']['output']>;
|
||||
@@ -165,10 +170,22 @@ export type AdminInviteList = {
|
||||
|
||||
export type AdminMutations = {
|
||||
__typename?: 'AdminMutations';
|
||||
giveAccessToWorkspaceFeature: Scalars['Boolean']['output'];
|
||||
removeAccessToWorkspaceFeature: Scalars['Boolean']['output'];
|
||||
updateWorkspacePlan: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = {
|
||||
input: AdminAccessToWorkspaceFeatureInput;
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = {
|
||||
input: AdminAccessToWorkspaceFeatureInput;
|
||||
};
|
||||
|
||||
|
||||
export type AdminMutationsUpdateWorkspacePlanArgs = {
|
||||
input: AdminUpdateWorkspacePlanInput;
|
||||
};
|
||||
@@ -5299,8 +5316,15 @@ export type WorkspaceEmbedOptions = {
|
||||
hideSpeckleBranding: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export const WorkspaceFeatureFlagName = {
|
||||
AccIntegration: 'accIntegration',
|
||||
Dashboards: 'dashboards'
|
||||
} as const;
|
||||
|
||||
export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName];
|
||||
export const WorkspaceFeatureName = {
|
||||
AccIntegration: 'accIntegration',
|
||||
Dashboards: 'dashboards',
|
||||
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
|
||||
ExclusiveMembership: 'exclusiveMembership',
|
||||
HideSpeckleBranding: 'hideSpeckleBranding',
|
||||
@@ -5896,6 +5920,7 @@ export type ResolversTypes = {
|
||||
Activity: ResolverTypeWrapper<Activity>;
|
||||
ActivityCollection: ResolverTypeWrapper<ActivityCollectionGraphQLReturn>;
|
||||
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
|
||||
AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput;
|
||||
AdminInviteList: ResolverTypeWrapper<Omit<AdminInviteList, 'items'> & { items: Array<ResolversTypes['ServerInvite']> }>;
|
||||
AdminMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
|
||||
AdminQueries: ResolverTypeWrapper<GraphQLEmptyReturn>;
|
||||
@@ -6223,6 +6248,7 @@ export type ResolversTypes = {
|
||||
WorkspaceDomain: ResolverTypeWrapper<WorkspaceDomain>;
|
||||
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
|
||||
WorkspaceEmbedOptions: ResolverTypeWrapper<WorkspaceEmbedOptions>;
|
||||
WorkspaceFeatureFlagName: WorkspaceFeatureFlagName;
|
||||
WorkspaceFeatureName: WorkspaceFeatureName;
|
||||
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
|
||||
WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions;
|
||||
@@ -6280,6 +6306,7 @@ export type ResolversParentTypes = {
|
||||
Activity: Activity;
|
||||
ActivityCollection: ActivityCollectionGraphQLReturn;
|
||||
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
|
||||
AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput;
|
||||
AdminInviteList: Omit<AdminInviteList, 'items'> & { items: Array<ResolversParentTypes['ServerInvite']> };
|
||||
AdminMutations: MutationsObjectGraphQLReturn;
|
||||
AdminQueries: GraphQLEmptyReturn;
|
||||
@@ -6726,6 +6753,8 @@ export type AdminInviteListResolvers<ContextType = GraphQLContext, ParentType ex
|
||||
};
|
||||
|
||||
export type AdminMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminMutations'] = ResolversParentTypes['AdminMutations']> = {
|
||||
giveAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsGiveAccessToWorkspaceFeatureArgs, 'input'>>;
|
||||
removeAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsRemoveAccessToWorkspaceFeatureArgs, 'input'>>;
|
||||
updateWorkspacePlan?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsUpdateWorkspacePlanArgs, 'input'>>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
import type {
|
||||
Optional,
|
||||
WorkspacePlan,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspaceFeatures,
|
||||
WorkspacePlans,
|
||||
WorkspacePlanStatuses,
|
||||
WorkspaceRoles
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
|
||||
export type CanWorkspaceAccessFeature = (args: {
|
||||
workspaceId: string
|
||||
workspaceFeature: WorkspacePlanFeatures
|
||||
workspaceFeature: WorkspaceFeatures
|
||||
}) => Promise<boolean>
|
||||
|
||||
export type WorkspaceFeatureAccessFunction = (args: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authorizeResolver } from '@/modules/shared'
|
||||
import {
|
||||
Roles,
|
||||
throwUncoveredError,
|
||||
WorkspaceFeatureFlags,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspacePlans
|
||||
} from '@speckle/shared'
|
||||
@@ -124,11 +125,28 @@ export default FF_GATEKEEPER_MODULE_ENABLED
|
||||
})
|
||||
},
|
||||
hasAccessToFeature: async (parent, args) => {
|
||||
const workspaceFeature = (() => {
|
||||
switch (args.featureName) {
|
||||
case 'dashboards':
|
||||
return WorkspaceFeatureFlags.dashboards
|
||||
case 'accIntegration':
|
||||
// TODO: move this to be a feature flag, once the feature flags have rolled out.
|
||||
case WorkspacePlanFeatures.DomainSecurity:
|
||||
case WorkspacePlanFeatures.ExclusiveMembership:
|
||||
case WorkspacePlanFeatures.HideSpeckleBranding:
|
||||
case WorkspacePlanFeatures.SSO:
|
||||
case WorkspacePlanFeatures.CustomDataRegion:
|
||||
case WorkspacePlanFeatures.SavedViews:
|
||||
return args.featureName
|
||||
default:
|
||||
throwUncoveredError(args.featureName)
|
||||
}
|
||||
})()
|
||||
const hasAccess = await canWorkspaceAccessFeatureFactory({
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db })
|
||||
})({
|
||||
workspaceId: parent.id,
|
||||
workspaceFeature: args.featureName
|
||||
workspaceFeature
|
||||
})
|
||||
return hasAccess
|
||||
},
|
||||
|
||||
@@ -35,7 +35,8 @@ const WorkspacePlans = buildTableHelper('workspace_plans', [
|
||||
'name',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
'updatedAt',
|
||||
'featureFlags'
|
||||
])
|
||||
const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [
|
||||
'workspaceId',
|
||||
@@ -107,7 +108,7 @@ export const upsertWorkspacePlanFactory =
|
||||
.workspacePlans(db)
|
||||
.insert(workspacePlan)
|
||||
.onConflict('workspaceId')
|
||||
.merge(['name', 'status', 'updatedAt'])
|
||||
.merge(['name', 'status', 'updatedAt', 'featureFlags'])
|
||||
}
|
||||
|
||||
// this is a typed rebrand of the generic workspace plan upsert
|
||||
|
||||
@@ -79,7 +79,8 @@ export const completeCheckoutSessionFactory =
|
||||
updatedAt: new Date(),
|
||||
workspaceId: checkoutSession.workspaceId,
|
||||
name: checkoutSession.workspacePlan,
|
||||
status: 'valid'
|
||||
status: 'valid',
|
||||
featureFlags: previousWorkspacePlan.featureFlags
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
workspacePlan
|
||||
|
||||
@@ -4,7 +4,12 @@ import type {
|
||||
WorkspaceFeatureAccessFunction
|
||||
} from '@/modules/gatekeeper/domain/operations'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { throwUncoveredError, workspacePlanHasAccessToFeature } from '@speckle/shared'
|
||||
import {
|
||||
throwUncoveredError,
|
||||
workspacePlanHasAccessToFeature,
|
||||
isPlanFeature,
|
||||
isWorkspaceFeatureFlagOn
|
||||
} from '@speckle/shared'
|
||||
|
||||
export const canWorkspaceAccessFeatureFactory =
|
||||
({
|
||||
@@ -25,12 +30,17 @@ export const canWorkspaceAccessFeatureFactory =
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
if (isPlanFeature(workspaceFeature))
|
||||
return workspacePlanHasAccessToFeature({
|
||||
plan: workspacePlan.name,
|
||||
feature: workspaceFeature,
|
||||
featureFlags: getFeatureFlags()
|
||||
})
|
||||
const isFlagOn = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags: workspacePlan.featureFlags,
|
||||
feature: workspaceFeature
|
||||
})
|
||||
return isFlagOn
|
||||
}
|
||||
|
||||
export const canWorkspaceUseOidcSsoFactory =
|
||||
|
||||
@@ -59,7 +59,14 @@ export const updateWorkspacePlanFactory =
|
||||
case 'cancelationScheduled':
|
||||
case 'canceled':
|
||||
case 'paymentFailed':
|
||||
workspacePlan = { workspaceId, status, name, createdAt, updatedAt }
|
||||
workspacePlan = {
|
||||
workspaceId,
|
||||
status,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
featureFlags: previousWorkspacePlan.featureFlags
|
||||
}
|
||||
await upsertWorkspacePlan({ workspacePlan })
|
||||
break
|
||||
default:
|
||||
@@ -77,7 +84,14 @@ export const updateWorkspacePlanFactory =
|
||||
case 'valid':
|
||||
if (workspaceSubscription) throw new InvalidWorkspacePlanStatus()
|
||||
|
||||
workspacePlan = { workspaceId, status, name, createdAt, updatedAt }
|
||||
workspacePlan = {
|
||||
workspaceId,
|
||||
status,
|
||||
name,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
featureFlags: previousWorkspacePlan.featureFlags
|
||||
}
|
||||
await upsertWorkspacePlan({ workspacePlan })
|
||||
break
|
||||
case 'cancelationScheduled':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspacePlan } from '@speckle/shared'
|
||||
import { WorkspaceFeatureFlags, type WorkspacePlan } from '@speckle/shared'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { assign } from 'lodash-es'
|
||||
import type {
|
||||
@@ -16,7 +16,8 @@ export const buildTestWorkspacePlan = (
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'free',
|
||||
status: 'valid'
|
||||
status: 'valid',
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
},
|
||||
overrides
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces'
|
||||
import { truncateTables } from '@/test/hooks'
|
||||
import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces'
|
||||
import { PaidWorkspacePlans } from '@speckle/shared'
|
||||
import { PaidWorkspacePlans, WorkspaceFeatureFlags } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
|
||||
@@ -61,7 +61,8 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
workspacePlan
|
||||
@@ -78,6 +79,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -105,6 +107,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: createdAt1,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace1.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -118,6 +121,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: createdAt2,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace2.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -144,6 +148,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: createdAt1,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace1.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -157,6 +162,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: createdAt2,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace2.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -180,6 +186,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'paymentFailed',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: createdAt1,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace1.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -193,6 +200,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'valid',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: createdAt2,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace2.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -216,6 +224,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'valid',
|
||||
createdAt: createdAt1,
|
||||
updatedAt: createdAt1,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace1.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -229,6 +238,7 @@ describe('billing repositories @gatekeeper', () => {
|
||||
status: 'valid',
|
||||
createdAt: createdAt2,
|
||||
updatedAt: createdAt2,
|
||||
featureFlags: WorkspaceFeatureFlags.none,
|
||||
workspaceId: workspace2.id
|
||||
} as const
|
||||
await upsertPaidWorkspacePlan({
|
||||
@@ -462,7 +472,8 @@ describe('billing repositories @gatekeeper', () => {
|
||||
name: PaidWorkspacePlans.Team,
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}
|
||||
})
|
||||
await upsertWorkspaceSubscription({
|
||||
|
||||
+30
-26
@@ -14,6 +14,7 @@ import { createTestUser } from '@/test/authHelper'
|
||||
import type { WorkspacePlan } from '@speckle/shared'
|
||||
import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
|
||||
|
||||
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
|
||||
|
||||
@@ -25,7 +26,6 @@ describe('Module @gatekeeper', () => {
|
||||
'Repositories WorkspacePlan',
|
||||
() => {
|
||||
let user: BasicTestUser
|
||||
let now: Date
|
||||
let in5days: Date
|
||||
let workspace1: BasicTestWorkspace
|
||||
let workspace2: BasicTestWorkspace
|
||||
@@ -34,7 +34,6 @@ describe('Module @gatekeeper', () => {
|
||||
let plan2: WorkspacePlan
|
||||
|
||||
before(async () => {
|
||||
now = new Date()
|
||||
user = await createTestUser()
|
||||
workspace1 = buildBasicTestWorkspace({ ownerId: user.id })
|
||||
workspace2 = buildBasicTestWorkspace({ ownerId: user.id })
|
||||
@@ -43,21 +42,8 @@ describe('Module @gatekeeper', () => {
|
||||
await createTestWorkspace(workspace2, user)
|
||||
await createTestWorkspace(workspaceWithoutPlan, user)
|
||||
|
||||
plan1 = {
|
||||
workspaceId: workspace1.id,
|
||||
name: PaidWorkspacePlans.Team,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: PaidWorkspacePlanStatuses.Valid
|
||||
}
|
||||
|
||||
plan2 = {
|
||||
workspaceId: workspace2.id,
|
||||
name: PaidWorkspacePlans.Team,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: PaidWorkspacePlanStatuses.Valid
|
||||
}
|
||||
plan1 = buildTestWorkspacePlan({ workspaceId: workspace1.id })
|
||||
plan2 = buildTestWorkspacePlan({ workspaceId: workspace2.id })
|
||||
|
||||
await upsertWorkspacePlan({
|
||||
workspacePlan: plan1
|
||||
@@ -86,13 +72,10 @@ describe('Module @gatekeeper', () => {
|
||||
describe('upsertWorkspacePlan should return a function, that', () => {
|
||||
it('inserts a workspace plan if it does not exist', async () => {
|
||||
await upsertWorkspacePlan({
|
||||
workspacePlan: {
|
||||
workspacePlan: buildTestWorkspacePlan({
|
||||
workspaceId: workspaceWithoutPlan.id,
|
||||
name: PaidWorkspacePlans.Team,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: PaidWorkspacePlanStatuses.Valid
|
||||
}
|
||||
name: PaidWorkspacePlans.Team
|
||||
})
|
||||
})
|
||||
|
||||
const result = (
|
||||
@@ -109,17 +92,38 @@ describe('Module @gatekeeper', () => {
|
||||
in5days = new Date()
|
||||
in5days.setDate(in5days.getDate() + 7)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const workspacePlan = buildTestWorkspacePlan({
|
||||
workspaceId: workspace2.id,
|
||||
name: PaidWorkspacePlans.Pro,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await upsertWorkspacePlan({
|
||||
workspacePlan
|
||||
})
|
||||
|
||||
let result = (
|
||||
await getWorkspacePlansByWorkspaceId({
|
||||
workspaceIds: [workspace2.id]
|
||||
})
|
||||
)[workspace2.id]
|
||||
|
||||
expect(result.workspaceId).to.equal(workspace2.id)
|
||||
expect(result.name).to.equal(PaidWorkspacePlans.Pro)
|
||||
expect(result.updatedAt).to.be.deep.eq(now)
|
||||
|
||||
await upsertWorkspacePlan({
|
||||
workspacePlan: {
|
||||
workspaceId: workspace2.id,
|
||||
...workspacePlan,
|
||||
name: PaidWorkspacePlans.ProUnlimited,
|
||||
createdAt: in5days,
|
||||
updatedAt: in5days,
|
||||
status: PaidWorkspacePlanStatuses.Canceled
|
||||
}
|
||||
})
|
||||
|
||||
const result = (
|
||||
result = (
|
||||
await getWorkspacePlansByWorkspaceId({
|
||||
workspaceIds: [workspace2.id]
|
||||
})
|
||||
@@ -158,7 +158,8 @@ describe('checkout @gatekeeper', () => {
|
||||
expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal({
|
||||
workspaceId,
|
||||
name: storedCheckoutSession.workspacePlan,
|
||||
status: 'valid'
|
||||
status: 'valid',
|
||||
featureFlags: 0
|
||||
})
|
||||
expect(emittedEventName).to.equal('gatekeeper.workspace-subscription-updated')
|
||||
expect(emittedEventPayload).to.nested.include({
|
||||
@@ -236,11 +237,10 @@ describe('checkout @gatekeeper', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspaceCheckoutSession: () => {
|
||||
@@ -275,11 +275,10 @@ describe('checkout @gatekeeper', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
status: 'paymentFailed',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspaceCheckoutSession: () => {
|
||||
@@ -314,11 +313,10 @@ describe('checkout @gatekeeper', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const err = await expectToThrow(() =>
|
||||
startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'free',
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspaceCheckoutSession: async () => ({
|
||||
@@ -380,11 +378,10 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
let storedCheckoutSession: CheckoutSession | undefined = undefined
|
||||
const createdCheckoutSession = await startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
name: 'free',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceCheckoutSession: async () => null,
|
||||
@@ -440,12 +437,11 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
let storedCheckoutSession: CheckoutSession | undefined = undefined
|
||||
const createdCheckoutSession = await startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
name: 'free',
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
|
||||
countSeatsByTypeInWorkspace: async () => 1,
|
||||
@@ -489,11 +485,10 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
const err = await expectToThrow(async () => {
|
||||
await startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
name: 'free',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
|
||||
@@ -549,11 +544,10 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
let storedCheckoutSession: CheckoutSession | undefined = undefined
|
||||
const createdCheckoutSession = await startCheckoutSessionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'canceled'
|
||||
}),
|
||||
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/
|
||||
import { PaidWorkspacePlans, WorkspacePlanFeatures } from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
|
||||
|
||||
describe('featureAuthorization @gatekeeper', () => {
|
||||
describe('canWorkspaceAccessFeatureFactory creates a function, that', () => {
|
||||
@@ -34,12 +35,11 @@ describe('featureAuthorization @gatekeeper', () => {
|
||||
it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: plan,
|
||||
status,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
workspaceId
|
||||
})
|
||||
})
|
||||
const result = await canWorkspaceAccessFeature({
|
||||
|
||||
@@ -32,6 +32,7 @@ import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/servic
|
||||
import type { EventBusEmit } from '@/modules/shared/services/eventBus'
|
||||
import { testLogger } from '@/observability/logging'
|
||||
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
|
||||
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
|
||||
|
||||
describe('subscriptions @gatekeeper', () => {
|
||||
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
|
||||
@@ -106,11 +107,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
}),
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async () => {
|
||||
@@ -149,11 +149,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
@@ -210,11 +209,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'paymentFailed'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
@@ -290,11 +288,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'paymentFailed'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
@@ -350,11 +347,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
@@ -410,11 +406,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
|
||||
@@ -459,11 +454,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
upsertWorkspaceSubscription: async () => {
|
||||
@@ -514,11 +508,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const updatedByUserId = cryptoRandomString({ length: 10 })
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'free',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceSubscription: async () => null,
|
||||
@@ -551,11 +544,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'free',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
getWorkspaceSubscription: async () => workspaceSubscription,
|
||||
@@ -590,11 +582,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
addWorkspaceSubscriptionSeatIfNeededFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'canceled'
|
||||
}),
|
||||
getWorkspaceSubscription: async () => workspaceSubscription,
|
||||
@@ -624,13 +615,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}
|
||||
})
|
||||
const priceId = cryptoRandomString({ length: 10 })
|
||||
const productId = cryptoRandomString({ length: 10 })
|
||||
const roleCount = 10
|
||||
@@ -699,13 +688,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}
|
||||
})
|
||||
const roleCount = 10
|
||||
|
||||
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
||||
@@ -769,13 +756,11 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId,
|
||||
subscriptionData
|
||||
})
|
||||
const workspacePlan: WorkspacePlan = {
|
||||
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
|
||||
name: 'team',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}
|
||||
})
|
||||
const count = 1
|
||||
|
||||
const addWorkspaceSubscriptionSeatIfNeeded =
|
||||
@@ -931,11 +916,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -961,11 +945,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: PaidWorkspacePlans.Team,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'canceled'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -998,11 +981,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
const workspacePlanName = PaidWorkspacePlans.Team
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async ({ type }) => {
|
||||
@@ -1043,11 +1025,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
let reconciledSub: SubscriptionDataInput | undefined = undefined
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async ({ type }) => {
|
||||
@@ -1098,11 +1079,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -1128,11 +1108,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: 'pro',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'canceled'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -1165,11 +1144,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
const workspacePlanName = 'pro'
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -1211,11 +1189,10 @@ describe('subscriptions @gatekeeper', () => {
|
||||
|
||||
let reconciledSub: SubscriptionDataInput | undefined = undefined
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
@@ -1280,9 +1257,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
name: plan,
|
||||
status: 'valid',
|
||||
workspaceId
|
||||
@@ -1325,10 +1301,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: plan,
|
||||
status
|
||||
}),
|
||||
@@ -1369,10 +1344,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team',
|
||||
status: 'valid'
|
||||
}),
|
||||
@@ -1412,10 +1386,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const userId = cryptoRandomString({ length: 10 })
|
||||
const workspaceSubscription = createTestWorkspaceSubscription()
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'pro',
|
||||
status: 'valid'
|
||||
}),
|
||||
@@ -1457,10 +1430,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
billingInterval: 'yearly'
|
||||
})
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team',
|
||||
status: 'valid'
|
||||
}),
|
||||
@@ -1501,10 +1473,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
billingInterval: 'monthly'
|
||||
})
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team',
|
||||
status: 'valid'
|
||||
}),
|
||||
@@ -1553,10 +1524,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
subscriptionData
|
||||
})
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team',
|
||||
status: 'valid'
|
||||
}),
|
||||
@@ -1616,10 +1586,9 @@ describe('subscriptions @gatekeeper', () => {
|
||||
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
|
||||
getWorkspacePlan: async () => ({
|
||||
getWorkspacePlan: async () =>
|
||||
buildTestWorkspacePlan({
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team',
|
||||
status: 'valid'
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PaidWorkspacePlans,
|
||||
PaidWorkspacePlanStatuses,
|
||||
UnpaidWorkspacePlans,
|
||||
WorkspaceFeatureFlags,
|
||||
WorkspacePlans
|
||||
} from '@speckle/shared'
|
||||
import { expect } from 'chai'
|
||||
@@ -215,7 +216,12 @@ describe('workspacePlan services @gatekeeper', () => {
|
||||
//@ts-expect-error we need to test the runtime error checks too
|
||||
status
|
||||
})
|
||||
const expectedPlan = { workspaceId, name: planName, status }
|
||||
const expectedPlan = {
|
||||
workspaceId,
|
||||
name: planName,
|
||||
status,
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}
|
||||
expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal(
|
||||
expectedPlan
|
||||
)
|
||||
|
||||
@@ -33,7 +33,12 @@ import type { EventBusEmit, EventPayload } from '@/modules/shared/services/event
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
|
||||
import type { MaybeNullOrUndefined, StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import { isPaidPlan, Roles, throwUncoveredError } from '@speckle/shared'
|
||||
import {
|
||||
isPaidPlan,
|
||||
Roles,
|
||||
throwUncoveredError,
|
||||
WorkspaceFeatureFlags
|
||||
} from '@speckle/shared'
|
||||
import type {
|
||||
QueryAllProjects,
|
||||
UpsertProjectRole
|
||||
@@ -965,7 +970,8 @@ export const initializeEventListenersFactory =
|
||||
status: WorkspacePlanStatuses.Valid,
|
||||
workspaceId: payload.workspace.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}
|
||||
await upsertUnpaidWorkspacePlanFactory({ db })({ workspacePlan })
|
||||
await eventBus.emit({
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
import type { WorkspaceRoles } from '@speckle/shared'
|
||||
import {
|
||||
Roles,
|
||||
WorkspaceFeatureFlags,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspacePlans,
|
||||
removeNullOrUndefinedKeys,
|
||||
@@ -229,6 +230,7 @@ import {
|
||||
import { WorkspaceInvitesLimit } from '@/modules/workspaces/domain/constants'
|
||||
import { copyWorkspaceFactory } from '@/modules/workspaces/repositories/projectRegions'
|
||||
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
|
||||
import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing'
|
||||
|
||||
const eventBus = getEventBus()
|
||||
const getServerInfo = getServerInfoFactory({ db })
|
||||
@@ -607,6 +609,42 @@ export default FF_WORKSPACES_MODULE_ENABLED
|
||||
operationDescription: 'Update workspace plan'
|
||||
}
|
||||
)
|
||||
return true
|
||||
},
|
||||
giveAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
|
||||
const { workspaceId, featureFlagName } = input
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
|
||||
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
|
||||
|
||||
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
workspacePlan.featureFlags |= featureFlag
|
||||
// not updating updatedAt here deliberately. Feature flags are internal for now
|
||||
await upsertWorkspacePlanFactory({ db })({
|
||||
workspacePlan
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
removeAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
|
||||
const { workspaceId, featureFlagName } = input
|
||||
const userId = ctx.userId
|
||||
if (!userId) throw new UnauthorizedError()
|
||||
|
||||
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
|
||||
|
||||
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
workspacePlan.featureFlags ^= featureFlag
|
||||
// not updating updatedAt here deliberately. Feature flags are internal for now
|
||||
await upsertWorkspacePlanFactory({ db })({
|
||||
workspacePlan
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type Knex } from 'knex'
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspace_plans', (table) => {
|
||||
table.integer('featureFlags').notNullable().defaultTo(0)
|
||||
})
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('workspace_plans', (table) => {
|
||||
table.dropColumn('featureFlags')
|
||||
})
|
||||
}
|
||||
@@ -233,15 +233,7 @@ describe('ensureUserIsWorkspaceAdminFragment', () => {
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited' as const,
|
||||
status: 'valid' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
WorkspaceProjectMoveInvalidError,
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import {
|
||||
getProjectFake,
|
||||
getWorkspaceFake,
|
||||
getWorkspacePlanFake
|
||||
} from '../../../tests/fakes.js'
|
||||
|
||||
const buildCanMoveToWorkspace = (
|
||||
overrides?: Partial<Parameters<typeof canMoveToWorkspacePolicy>[0]>
|
||||
@@ -42,15 +46,7 @@ const buildCanMoveToWorkspace = (
|
||||
getWorkspaceSsoSession: async () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
status: 'valid',
|
||||
workspaceId: 'workspace-id',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: 'team'
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId: 'workspace-id' }),
|
||||
getWorkspaceLimits: async () => {
|
||||
return {
|
||||
modelCount: 5,
|
||||
|
||||
@@ -3,13 +3,18 @@ import { Roles } from '../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { OverridesOf } from '../../../tests/helpers/types.js'
|
||||
import { canReadAccIntegrationSettingsPolicy } from './canReadAccIntegrationSettings.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import {
|
||||
getProjectFake,
|
||||
getWorkspaceFake,
|
||||
getWorkspacePlanFake
|
||||
} from '../../../tests/fakes.js'
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AccIntegrationNotEnabledError,
|
||||
ProjectNoAccessError,
|
||||
WorkspacePlanNoFeatureAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { WorkspaceFeatureFlags } from '../../../workspaces/index.js'
|
||||
|
||||
const buildSUT = (
|
||||
overrides?: OverridesOf<typeof canReadAccIntegrationSettingsPolicy>
|
||||
@@ -43,15 +48,7 @@ const buildSUT = (
|
||||
getWorkspaceSsoSession: async () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
status: 'valid',
|
||||
workspaceId,
|
||||
name: 'enterprise',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'enterprise' }),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
@@ -108,7 +105,8 @@ describe('canReadAccIntegrationSettings returns a function, that', () => {
|
||||
workspaceId: cryptoRandomString({ length: 9 }),
|
||||
name: 'free',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}
|
||||
}
|
||||
})(buildArgs())
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { Roles } from '../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import {
|
||||
getProjectFake,
|
||||
getWorkspaceFake,
|
||||
getWorkspacePlanFake
|
||||
} from '../../../tests/fakes.js'
|
||||
import { canUpdateEmbedTokensPolicy } from './canUpdateEmbedTokens.js'
|
||||
import { assert, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
@@ -10,6 +14,7 @@ import {
|
||||
WorkspacePlanNoFeatureAccessError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { OverridesOf } from '../../../tests/helpers/types.js'
|
||||
import { WorkspaceFeatureFlags } from '../../../workspaces/index.js'
|
||||
|
||||
const buildCanUpdateEmbedTokens = (
|
||||
overrides?: OverridesOf<typeof canUpdateEmbedTokensPolicy>
|
||||
@@ -40,15 +45,7 @@ const buildCanUpdateEmbedTokens = (
|
||||
getWorkspaceSsoSession: async () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
status: 'valid',
|
||||
workspaceId,
|
||||
name: 'unlimited',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
@@ -118,7 +115,8 @@ describe('canUpdateEmbedTokensArgs returns a function, that', () => {
|
||||
workspaceId: 'foo',
|
||||
name: 'free',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}
|
||||
}
|
||||
})(canUpdateEmbedTokensArgs())
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WorkspacesNotEnabledError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { canReadMemberEmailPolicy } from './canReadMemberEmail.js'
|
||||
import { getWorkspaceFake } from '../../../tests/fakes.js'
|
||||
import { getWorkspaceFake, getWorkspacePlanFake } from '../../../tests/fakes.js'
|
||||
|
||||
describe('canReadMemberEmailPolicy', () => {
|
||||
const workspaceId = cryptoRandomString({ length: 9 })
|
||||
@@ -32,15 +32,7 @@ describe('canReadMemberEmailPolicy', () => {
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited' as const,
|
||||
status: 'valid' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Roles } from '../../../core/constants.js'
|
||||
import { parseFeatureFlags } from '../../../environment/index.js'
|
||||
import { Workspace } from '../../domain/workspaces/types.js'
|
||||
import { canUseWorkspacePlanFeature } from './canUseWorkspacePlanFeature.js'
|
||||
import { WorkspacePlanFeatures } from '../../../workspaces/index.js'
|
||||
import {
|
||||
WorkspaceFeatureFlags,
|
||||
WorkspacePlanFeatures
|
||||
} from '../../../workspaces/index.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ServerNoAccessError,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
WorkspaceNotEnoughPermissionsError,
|
||||
WorkspaceReadOnlyError
|
||||
} from '../../domain/authErrors.js'
|
||||
import { getWorkspacePlanFake } from '../../../tests/fakes.js'
|
||||
|
||||
const buildSUT = (
|
||||
overrides?: Partial<Parameters<typeof canUseWorkspacePlanFeature>[0]>
|
||||
@@ -35,15 +39,7 @@ const buildSUT = (
|
||||
getWorkspaceRole: async () => Roles.Workspace.Admin,
|
||||
getWorkspaceSsoProvider: async () => null,
|
||||
getWorkspaceSsoSession: async () => null,
|
||||
getWorkspacePlan: async () => {
|
||||
return {
|
||||
workspaceId,
|
||||
name: 'unlimited',
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'unlimited' }),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
@@ -123,7 +119,8 @@ describe('canUseFeature', () => {
|
||||
name: 'proUnlimited',
|
||||
status: 'canceled',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,7 +138,8 @@ describe('canUseFeature', () => {
|
||||
name: 'free',
|
||||
status: 'valid',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../authz/domain/workspaces/types.js'
|
||||
import { parseFeatureFlags } from '../environment/index.js'
|
||||
import { mapValues } from 'lodash'
|
||||
import { WorkspacePlan } from '../workspaces/index.js'
|
||||
import { WorkspaceFeatureFlags, WorkspacePlan } from '../workspaces/index.js'
|
||||
import { TIME_MS } from '../core/index.js'
|
||||
import {
|
||||
SavedView,
|
||||
@@ -49,7 +49,8 @@ export const getWorkspacePlanFake = fakeGetFactory<WorkspacePlan>(() => ({
|
||||
status: 'valid',
|
||||
workspaceId: nanoid(10),
|
||||
createdAt: new Date(Date.now() - TIME_MS.day),
|
||||
updatedAt: new Date(Date.now() - TIME_MS.day)
|
||||
updatedAt: new Date(Date.now() - TIME_MS.day),
|
||||
featureFlags: WorkspaceFeatureFlags.none
|
||||
}))
|
||||
|
||||
export const getWorkspaceSsoProviderFake = fakeGetFactory<WorkspaceSsoProvider>(() => ({
|
||||
|
||||
@@ -2,14 +2,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
workspacePlanHasAccessToFeature,
|
||||
WorkspacePlanFeatures,
|
||||
WorkspacePlanConfigs
|
||||
WorkspacePlanConfigs,
|
||||
isWorkspaceFeatureFlagOn,
|
||||
WorkspaceFeatureFlags
|
||||
} from './features.js'
|
||||
import { WorkspacePlans } from './plans.js'
|
||||
|
||||
describe('workspacePlanHasAccessToFeature', () => {
|
||||
describe('workspace features', () => {
|
||||
describe('workspacePlanHasAccessToFeature', () => {
|
||||
describe('Comprehensive feature coverage', () => {
|
||||
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
|
||||
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
|
||||
const allFeatures = Object.values(
|
||||
WorkspacePlanFeatures
|
||||
) as WorkspacePlanFeatures[]
|
||||
|
||||
describe.each(allPlans)('should work for %s plan', (plan) => {
|
||||
it.each(allFeatures)('%s feature combination', (feature) => {
|
||||
@@ -21,7 +26,6 @@ describe('workspacePlanHasAccessToFeature', () => {
|
||||
feature,
|
||||
featureFlags: undefined
|
||||
})
|
||||
|
||||
expect(
|
||||
actualResult,
|
||||
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
|
||||
@@ -29,4 +33,67 @@ describe('workspacePlanHasAccessToFeature', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('isWorkspaceFeatureFlagOn', () => {
|
||||
it('returns false if no flags are on', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.none
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false if the currently tested flag is off', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.accIntegration
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true if the currently tested flag is on', () => {
|
||||
const workspaceFeatureFlags = WorkspaceFeatureFlags.dashboards
|
||||
const feature = WorkspaceFeatureFlags.dashboards
|
||||
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true if the currently tested flag is on in a combo flag', () => {
|
||||
const workspaceFeatureFlags =
|
||||
WorkspaceFeatureFlags.dashboards | WorkspaceFeatureFlags.accIntegration
|
||||
let result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.accIntegration
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
it('feature flag can be turned on and off', () => {
|
||||
let workspaceFeatureFlags = WorkspaceFeatureFlags.none
|
||||
let result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
|
||||
workspaceFeatureFlags |= WorkspaceFeatureFlags.dashboards
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
|
||||
workspaceFeatureFlags ^= WorkspaceFeatureFlags.dashboards
|
||||
|
||||
result = isWorkspaceFeatureFlagOn({
|
||||
workspaceFeatureFlags,
|
||||
feature: WorkspaceFeatureFlags.dashboards
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,13 +24,37 @@ export const WorkspacePlanFeatures = <const>{
|
||||
HideSpeckleBranding: 'hideSpeckleBranding',
|
||||
ExclusiveMembership: 'exclusiveMembership',
|
||||
EmbedPrivateProjects: 'embedPrivateProjects',
|
||||
AccIntegration: 'accIntegration',
|
||||
AccIntegration: 'accIntegration', // TODO: this should be moved to a workspace addon
|
||||
SavedViews: 'savedViews'
|
||||
}
|
||||
|
||||
export type WorkspacePlanFeatures =
|
||||
(typeof WorkspacePlanFeatures)[keyof typeof WorkspacePlanFeatures]
|
||||
|
||||
// this const will be used as a bitwise flag for a per workspace feature access controller
|
||||
// IMPORTANT: always use powers of 2 as the value of the object
|
||||
// read more https://www.hendrik-erz.de/post/bitwise-flags-are-beautiful-and-heres-why
|
||||
// this will make its way to the pricing plan and info setup at some point
|
||||
// but for now its an internal only control
|
||||
export const WorkspaceFeatureFlags = <const>{
|
||||
none: 0,
|
||||
dashboards: 1,
|
||||
accIntegration: 2
|
||||
}
|
||||
|
||||
export type WorkspaceFeatureFlags =
|
||||
(typeof WorkspaceFeatureFlags)[keyof typeof WorkspaceFeatureFlags]
|
||||
|
||||
export const isWorkspaceFeatureFlagOn = ({
|
||||
workspaceFeatureFlags,
|
||||
feature
|
||||
}: {
|
||||
workspaceFeatureFlags: number
|
||||
feature: WorkspaceFeatureFlags
|
||||
}): boolean => (workspaceFeatureFlags & feature) === feature
|
||||
|
||||
export type WorkspaceFeatures = WorkspacePlanFeatures | WorkspaceFeatureFlags
|
||||
|
||||
export const WorkspacePlanFeaturesMetadata = (<const>{
|
||||
[WorkspacePlanFeatures.AutomateBeta]: {
|
||||
displayName: 'Automate beta access',
|
||||
@@ -314,3 +338,12 @@ export const workspacePlanHasAccessToFeature = ({
|
||||
const hasAccess = planConfig.features.includes(feature)
|
||||
return hasAccess
|
||||
}
|
||||
|
||||
export const isPlanFeature = (
|
||||
feature: WorkspaceFeatures
|
||||
): feature is WorkspacePlanFeatures => {
|
||||
if (typeof feature === 'number') {
|
||||
return false
|
||||
}
|
||||
return Object.values(WorkspacePlanFeatures).includes(feature)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ type BaseWorkspacePlan = {
|
||||
workspaceId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
featureFlags: number // this will be a bitwise flag number
|
||||
}
|
||||
|
||||
export type PaidWorkspacePlan = BaseWorkspacePlan & {
|
||||
|
||||
Reference in New Issue
Block a user