Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions modules/analytics/middlewares/analytics.requireFeatureFlag.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.

return next();
} catch (err) {
return next(err);
}
};
}

export default requireFeatureFlag;
60 changes: 60 additions & 0 deletions modules/analytics/services/analytics.featureFlags.service.js
Original file line number Diff line number Diff line change
@@ -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<boolean>} 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;
Comment thread
PierreBrisorgueil marked this conversation as resolved.
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

/**
* 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<string|boolean|undefined>} 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,
};
126 changes: 126 additions & 0 deletions modules/analytics/tests/analytics.featureFlags.service.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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);
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.

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' } },
});
});
});
});
123 changes: 123 additions & 0 deletions modules/analytics/tests/analytics.requireFeatureFlag.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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' },
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.

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);
});
});