From 94055b12793d1349ff05222c49047e29324082e6 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 26 Aug 2026 10:30:28 +0200 Subject: [PATCH 1/3] feat: flag-driven warehouse plan gating for free orgs --- frontend/common/utils/utils.tsx | 53 ++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 30df0c6165ae..fa5861faffbf 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -4,6 +4,7 @@ import Project from 'common/project' import { ContentType, FeatureState, + FeatureStateValue, FlagsmithValue, MultivariateFeatureStateValue, MultivariateOption, @@ -21,7 +22,6 @@ import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import Constants from 'common/constants' import { getDefaultVariantKey } from './multivariate' -import { featureStateToValue } from './featureStateToValue' import { defaultFlags } from 'common/stores/default-flags' import Color from 'color' import { selectBuildVersion } from 'common/services/useBuildVersion' @@ -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,9 +180,23 @@ 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, + featureStateToValue(featureState: FeatureStateValue) { + if (!featureState) { + return null + } + + //@ts-ignore value_type is the type key on core traits + switch (featureState.value_type || featureState.type) { + case 'bool': + return featureState.boolean_value + case 'float': + return featureState.float_value + case 'int': + return featureState.integer_value + default: + return featureState.string_value + } + }, findOperator( operator: SegmentCondition['operator'], value: string, @@ -479,16 +493,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 +559,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 From 8162b9c84949cb6e82a5207f66bb4d2689ff727a Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 26 Aug 2026 13:51:52 +0200 Subject: [PATCH 2/3] test: add coverage for getPlanPermission with WAREHOUSE flag-driven gating --- frontend/common/utils/__tests__/utils.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) 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) + }) + }) +}) From 53c85b230f6948560bc6c4a9fbb31f3af6b46970 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 27 Aug 2026 10:31:30 +0200 Subject: [PATCH 3/3] fix: restore featureStateToValue delegation from #8344 --- frontend/common/utils/utils.tsx | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index fa5861faffbf..0bdd7f58cb4f 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -4,7 +4,6 @@ import Project from 'common/project' import { ContentType, FeatureState, - FeatureStateValue, FlagsmithValue, MultivariateFeatureStateValue, MultivariateOption, @@ -22,6 +21,7 @@ import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import Constants from 'common/constants' import { getDefaultVariantKey } from './multivariate' +import { featureStateToValue } from './featureStateToValue' import { defaultFlags } from 'common/stores/default-flags' import Color from 'color' import { selectBuildVersion } from 'common/services/useBuildVersion' @@ -180,23 +180,7 @@ const Utils = Object.assign({}, BaseUtils, { } return null }, - featureStateToValue(featureState: FeatureStateValue) { - if (!featureState) { - return null - } - - //@ts-ignore value_type is the type key on core traits - switch (featureState.value_type || featureState.type) { - case 'bool': - return featureState.boolean_value - case 'float': - return featureState.float_value - case 'int': - return featureState.integer_value - default: - return featureState.string_value - } - }, + featureStateToValue, findOperator( operator: SegmentCondition['operator'], value: string,