diff --git a/frontend/common/utils/__tests__/utils.test.ts b/frontend/common/utils/__tests__/utils.test.ts index ff969dd8a3c4..40bd17e4c6a2 100644 --- a/frontend/common/utils/__tests__/utils.test.ts +++ b/frontend/common/utils/__tests__/utils.test.ts @@ -3,7 +3,9 @@ // mocked out to allow `common/utils/utils` to load for real. jest.mock('common/stores/account-store', () => ({})) jest.mock('common/stores/project-store', () => ({})) -jest.mock('common/store', () => ({ getStore: () => ({}) })) +jest.mock('common/store', () => ({ + getStore: () => ({ getState: () => ({}) }), +})) jest.mock('@flagsmith/flagsmith', () => ({ getValue: (_key: string, opts: { fallback: unknown }) => opts.fallback, })) @@ -96,3 +98,67 @@ describe('validateRule', () => { expect(Utils.validateRule(rule)).toBe(false) }) }) + +describe('getPlanPermission', () => { + beforeEach(() => { + jest.spyOn(Utils, 'isEnterpriseImage').mockReturnValue(false) + jest.spyOn(Utils, 'isSaas').mockReturnValue(true) + }) + afterEach(() => jest.restoreAllMocks()) + + it.each([ + ['scale-up', 'AUDIT', true], + ['enterprise', 'AUDIT', true], + ['start-up', 'AUDIT', false], + ['free', 'AUDIT', false], + ['enterprise', 'SCIM', true], + ['scale-up', 'SCIM', false], + ] as const)( + 'hardcoded features: plan=%s feature=%s => %s', + (plan, feature, expected) => { + expect(Utils.getPlanPermission(plan, feature)).toBe(expected) + }, + ) + + describe('WAREHOUSE with legacy array value', () => { + it('allows free plan when value includes "free"', () => { + jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue(['free']) + expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(true) + }) + + it('blocks free plan when value is empty', () => { + jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue([]) + expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(false) + }) + + it('allows scale-up when value includes "scale-up"', () => { + jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue(['scale-up']) + expect(Utils.getPlanPermission('scale-up', 'WAREHOUSE')).toBe(true) + }) + }) + + describe('WAREHOUSE with object value', () => { + it('allows free plan when allowed_plans includes "free"', () => { + jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue({ + allowed_plans: ['free'], + auto_connect_warehouse: true, + }) + expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(true) + }) + + it('blocks free plan when allowed_plans does not include "free"', () => { + jest + .spyOn(Utils, 'getFlagsmithJSONValue') + .mockReturnValue({ allowed_plans: ['scale-up'] }) + expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(false) + }) + + it('falls back to enterprise with malformed value', () => { + jest + .spyOn(Utils, 'getFlagsmithJSONValue') + .mockReturnValue({ allowed_plans: 'not-an-array' }) + expect(Utils.getPlanPermission('enterprise', 'WAREHOUSE')).toBe(true) + expect(Utils.getPlanPermission('scale-up', 'WAREHOUSE')).toBe(false) + }) + }) +}) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 30df0c6165ae..0bdd7f58cb4f 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -61,7 +61,7 @@ export type PaidFeature = export type AppFeature = PaidFeature | 'FEATURE_HEALTH' // Define a type for plan categories -type Plan = 'start-up' | 'scale-up' | 'enterprise' | null +type Plan = 'free' | 'start-up' | 'scale-up' | 'enterprise' | null export const planNames = { enterprise: 'Enterprise', @@ -180,8 +180,6 @@ const Utils = Object.assign({}, BaseUtils, { } return null }, - // Delegates to the standalone, Flux-free module so callers that can't import - // this file (e.g. unit-tested hooks) can use the same logic directly. featureStateToValue, findOperator( operator: SegmentCondition['operator'], @@ -479,16 +477,19 @@ const Utils = Object.assign({}, BaseUtils, { getPlanPermission: (plan: string, feature: PaidFeature) => { const planName = Utils.getPlanName(plan) - if (!plan || planName === planNames.free) { - return false - } + if (!plan) return false + + const requiredPlan = Utils.getRequiredPlan(feature) + if (requiredPlan === 'free') return true + + if (planName === planNames.free) return false + const isScaleupOrGreater = planName !== planNames.startup const isEnterprise = planName === planNames.enterprise if (feature === 'AUTO_SEATS') { return isScaleupOrGreater && !isEnterprise } - const requiredPlan = Utils.getRequiredPlan(feature) if (requiredPlan === 'enterprise') { return isEnterprise } else if (requiredPlan === 'scale-up') { @@ -542,15 +543,23 @@ const Utils = Object.assign({}, BaseUtils, { break } case 'WAREHOUSE': { - const remotePlansValue = Utils.getFlagsmithJSONValue( + const remoteValue = Utils.getFlagsmithJSONValue( 'experimentation_warehouse_connection', [], ) - const remotePlans: string[] = Array.isArray(remotePlansValue) - ? remotePlansValue - : [] + let remotePlans: string[] = [] + if (Array.isArray(remoteValue)) { + remotePlans = remoteValue + } else if (Array.isArray(remoteValue?.allowed_plans)) { + remotePlans = remoteValue.allowed_plans + } const allowedPlans = [...remotePlans, 'enterprise'] - const planHierarchy: Plan[] = ['start-up', 'scale-up', 'enterprise'] + const planHierarchy: Plan[] = [ + 'free', + 'start-up', + 'scale-up', + 'enterprise', + ] plan = planHierarchy.find((p) => allowedPlans.includes(p)) || 'enterprise' break