diff --git a/modules/analytics/middlewares/analytics.requireFeatureFlag.js b/modules/analytics/middlewares/analytics.requireFeatureFlag.js new file mode 100644 index 000000000..32e2f0807 --- /dev/null +++ b/modules/analytics/middlewares/analytics.requireFeatureFlag.js @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import FeatureFlagsService from '../services/analytics.featureFlags.service.js'; + +import responses from '../../../lib/helpers/responses.js'; + +/** + * Returns Express middleware that gates access based on a PostHog feature flag. + * + * The middleware evaluates the flag for the authenticated user, passing + * organisation context when available. Behaviour by scenario: + * + * - Flag enabled -> next() + * - Flag disabled -> 403 + * - Analytics not configured (no PostHog key) -> next() (fail-open so + * projects not using PostHog are never blocked) + * + * @param {string} flagName - PostHog feature flag key + * @returns {Function} Express middleware function + */ +function requireFeatureFlag(flagName) { + return async function requireFeatureFlagMiddleware(req, res, next) { + const distinctId = req.user?._id ? String(req.user._id) : undefined; + if (!distinctId) { + return responses.error(res, 401, 'Unauthorized', 'Authentication required to evaluate feature flag')(); + } + + try { + const options = {}; + if (req.organization?._id) { + options.groups = { company: String(req.organization._id) }; + } + + const enabled = await FeatureFlagsService.isEnabled(flagName, distinctId, options); + + // isEnabled returns false both when the flag is off AND when analytics + // is not configured. Distinguish by checking getVariant: undefined + // means not configured (fail-open), while false/string means configured. + if (!enabled) { + const variant = await FeatureFlagsService.getVariant(flagName, distinctId, options); + // undefined -> analytics not configured -> fail-open + if (variant === undefined) return next(); + + return responses.error(res, 403, 'Forbidden', 'Feature not available on your current plan')({ + type: 'FEATURE_FLAG_DISABLED', + flag: flagName, + }); + } + + return next(); + } catch (err) { + return next(err); + } + }; +} + +export default requireFeatureFlag; diff --git a/modules/analytics/services/analytics.featureFlags.service.js b/modules/analytics/services/analytics.featureFlags.service.js new file mode 100644 index 000000000..53718be4a --- /dev/null +++ b/modules/analytics/services/analytics.featureFlags.service.js @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import AnalyticsService from './analytics.service.js'; + +/** + * Check whether a feature flag is enabled for a given user. + * Builds the PostHog options object from the provided context so callers + * don't need to know the PostHog SDK shape. + * + * Returns `false` when analytics is not configured (safe default) so that + * downstream projects without PostHog are never blocked. + * + * @param {string} flag - Feature flag key + * @param {string} distinctId - User identifier + * @param {Object} [options] - Evaluation context + * @param {Object} [options.personProperties] - Properties for local evaluation + * @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId }) + * @param {Object} [options.groupProperties] - Properties per group type + * @returns {Promise} true when the flag is enabled, false otherwise + */ +const isEnabled = async (flag, distinctId, options = {}) => { + const { personProperties, groups, groupProperties } = options; + const phOptions = {}; + if (personProperties) phOptions.personProperties = personProperties; + if (groups) phOptions.groups = groups; + if (groupProperties) phOptions.groupProperties = groupProperties; + + const result = await AnalyticsService.isFeatureEnabled(flag, distinctId, phOptions); + // Normalise undefined (not configured) to false for a safe default + return result === true; +}; + +/** + * Get the variant value of a feature flag for a given user. + * Returns the variant key string, a boolean, or `undefined` when analytics + * is not configured. + * + * @param {string} flag - Feature flag key + * @param {string} distinctId - User identifier + * @param {Object} [options] - Evaluation context + * @param {Object} [options.personProperties] - Properties for local evaluation + * @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId }) + * @param {Object} [options.groupProperties] - Properties per group type + * @returns {Promise} Variant value + */ +const getVariant = async (flag, distinctId, options = {}) => { + const { personProperties, groups, groupProperties } = options; + const phOptions = {}; + if (personProperties) phOptions.personProperties = personProperties; + if (groups) phOptions.groups = groups; + if (groupProperties) phOptions.groupProperties = groupProperties; + + return AnalyticsService.getFeatureFlag(flag, distinctId, phOptions); +}; + +export default { + isEnabled, + getVariant, +}; diff --git a/modules/analytics/tests/analytics.featureFlags.service.unit.tests.js b/modules/analytics/tests/analytics.featureFlags.service.unit.tests.js new file mode 100644 index 000000000..66cc72a2a --- /dev/null +++ b/modules/analytics/tests/analytics.featureFlags.service.unit.tests.js @@ -0,0 +1,126 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests for analytics feature flags service + */ +describe('Analytics feature flags service unit tests:', () => { + let FeatureFlagsService; + let mockAnalyticsService; + + beforeEach(async () => { + jest.resetModules(); + + mockAnalyticsService = { + isFeatureEnabled: jest.fn(), + getFeatureFlag: jest.fn(), + }; + + jest.unstable_mockModule('../services/analytics.service.js', () => ({ + default: mockAnalyticsService, + })); + + const mod = await import('../services/analytics.featureFlags.service.js'); + FeatureFlagsService = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('isEnabled', () => { + test('should return true when flag is enabled', async () => { + mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true); + + const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); + + expect(result).toBe(true); + expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {}); + }); + + test('should return false when flag is disabled', async () => { + mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false); + + const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); + + expect(result).toBe(false); + }); + + test('should return false when analytics is not configured (undefined)', async () => { + mockAnalyticsService.isFeatureEnabled.mockResolvedValue(undefined); + + const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1'); + + expect(result).toBe(false); + }); + + test('should pass personProperties and groups to analytics service', async () => { + mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true); + + await FeatureFlagsService.isEnabled('my-flag', 'user-1', { + personProperties: { plan: 'pro' }, + groups: { company: 'org-1' }, + groupProperties: { company: { plan: 'enterprise' } }, + }); + + expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', { + personProperties: { plan: 'pro' }, + groups: { company: 'org-1' }, + groupProperties: { company: { plan: 'enterprise' } }, + }); + }); + + test('should omit undefined option keys from PostHog options', async () => { + mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false); + + await FeatureFlagsService.isEnabled('my-flag', 'user-1', {}); + + expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {}); + }); + }); + + describe('getVariant', () => { + test('should return variant string when flag has multivariate value', async () => { + mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-a'); + + const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); + + expect(result).toBe('variant-a'); + expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', {}); + }); + + test('should return boolean when flag is a simple toggle', async () => { + mockAnalyticsService.getFeatureFlag.mockResolvedValue(false); + + const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); + + expect(result).toBe(false); + }); + + test('should return undefined when analytics is not configured', async () => { + mockAnalyticsService.getFeatureFlag.mockResolvedValue(undefined); + + const result = await FeatureFlagsService.getVariant('my-flag', 'user-1'); + + expect(result).toBeUndefined(); + }); + + test('should pass personProperties, groups, and groupProperties', async () => { + mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-b'); + + await FeatureFlagsService.getVariant('my-flag', 'user-1', { + personProperties: { email: 'a@b.com' }, + groups: { company: 'org-1' }, + groupProperties: { company: { name: 'Acme' } }, + }); + + expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', { + personProperties: { email: 'a@b.com' }, + groups: { company: 'org-1' }, + groupProperties: { company: { name: 'Acme' } }, + }); + }); + }); +}); diff --git a/modules/analytics/tests/analytics.requireFeatureFlag.unit.tests.js b/modules/analytics/tests/analytics.requireFeatureFlag.unit.tests.js new file mode 100644 index 000000000..295b0d28c --- /dev/null +++ b/modules/analytics/tests/analytics.requireFeatureFlag.unit.tests.js @@ -0,0 +1,123 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests for requireFeatureFlag middleware + */ +describe('requireFeatureFlag middleware unit tests:', () => { + let requireFeatureFlag; + let mockFeatureFlagsService; + let req; + let res; + let next; + + beforeEach(async () => { + jest.resetModules(); + + mockFeatureFlagsService = { + isEnabled: jest.fn(), + getVariant: jest.fn(), + }; + + jest.unstable_mockModule('../services/analytics.featureFlags.service.js', () => ({ + default: mockFeatureFlagsService, + })); + + const mod = await import('../middlewares/analytics.requireFeatureFlag.js'); + requireFeatureFlag = mod.default; + + req = { + user: { _id: 'user-123' }, + organization: { _id: 'org-456' }, + }; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + next = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should call next when flag is enabled', async () => { + mockFeatureFlagsService.isEnabled.mockResolvedValue(true); + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith( + 'beta-feature', + 'user-123', + { groups: { company: 'org-456' } }, + ); + }); + + test('should return 403 when flag is disabled', async () => { + mockFeatureFlagsService.isEnabled.mockResolvedValue(false); + mockFeatureFlagsService.getVariant.mockResolvedValue(false); + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: 'Forbidden', + }), + ); + }); + + test('should call next when analytics is not configured (fail-open)', async () => { + mockFeatureFlagsService.isEnabled.mockResolvedValue(false); + mockFeatureFlagsService.getVariant.mockResolvedValue(undefined); + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); + + test('should return 401 when user is not authenticated', async () => { + req.user = undefined; + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + test('should not include groups when organization is absent', async () => { + req.organization = undefined; + mockFeatureFlagsService.isEnabled.mockResolvedValue(true); + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith( + 'beta-feature', + 'user-123', + {}, + ); + expect(next).toHaveBeenCalledWith(); + }); + + test('should call next(err) on unexpected errors', async () => { + const error = new Error('PostHog timeout'); + mockFeatureFlagsService.isEnabled.mockRejectedValue(error); + + const middleware = requireFeatureFlag('beta-feature'); + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(error); + }); +});