From 7d84fa6a550cabc667cc3e56f46af080ebeac75b Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 28 Jun 2026 17:29:49 +0200 Subject: [PATCH] Add feature gate foundation --- .../users/feature-gate.service.spec.ts | 153 ++++++++++++++++++ .../src/modules/users/feature-gate.service.ts | 66 ++++++++ backend/src/modules/users/users.module.ts | 3 + 3 files changed, 222 insertions(+) create mode 100644 backend/src/modules/users/feature-gate.service.spec.ts create mode 100644 backend/src/modules/users/feature-gate.service.ts diff --git a/backend/src/modules/users/feature-gate.service.spec.ts b/backend/src/modules/users/feature-gate.service.spec.ts new file mode 100644 index 0000000..bd9ff3f --- /dev/null +++ b/backend/src/modules/users/feature-gate.service.spec.ts @@ -0,0 +1,153 @@ +import { ForbiddenException } from '@nestjs/common'; +import { SubscriptionPlan } from '@prisma/client'; +import { EntitlementService } from './entitlement.service'; +import { + FEATURE_KEYS, + FeatureGateService, + FeatureKey, +} from './feature-gate.service'; + +describe('FeatureGateService', () => { + const buildService = () => { + const entitlementService = { + hasEntitlement: jest.fn, [string, FeatureKey]>(), + getEffectivePlan: jest.fn, [string]>(), + }; + + return { + entitlementService, + service: new FeatureGateService( + entitlementService as unknown as EntitlementService, + ), + }; + }; + + it('returns true for a granted entitlement', async () => { + const { entitlementService, service } = buildService(); + const userId = 'user-with-access'; + entitlementService.hasEntitlement.mockResolvedValue(true); + + await expect( + service.canUseFeature(userId, FEATURE_KEYS.PRO_LIBRARY), + ).resolves.toBe(true); + expect(entitlementService.hasEntitlement).toHaveBeenCalledWith( + userId, + FEATURE_KEYS.PRO_LIBRARY, + ); + }); + + it('returns false for a missing entitlement', async () => { + const { entitlementService, service } = buildService(); + entitlementService.hasEntitlement.mockResolvedValue(false); + + await expect( + service.canUseFeature('free-user', FEATURE_KEYS.SMART_DOWNLOADS), + ).resolves.toBe(false); + }); + + it('passes when the required feature is granted', async () => { + const { entitlementService, service } = buildService(); + entitlementService.hasEntitlement.mockResolvedValue(true); + + await expect( + service.requireFeature('pro-user', FEATURE_KEYS.PRIORITY_SYNC), + ).resolves.toBeUndefined(); + }); + + it('throws a provider-neutral forbidden exception when access is denied', async () => { + const { entitlementService, service } = buildService(); + entitlementService.hasEntitlement.mockResolvedValue(false); + + let thrown: unknown; + try { + await service.requireFeature( + 'free-user', + FEATURE_KEYS.ADVANCED_AUDIO_ANALYSIS, + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(ForbiddenException); + expect((thrown as ForbiddenException).getResponse()).toEqual({ + statusCode: 403, + message: 'Access to this feature is not available for this account.', + error: 'Forbidden', + }); + expect(JSON.stringify((thrown as ForbiddenException).getResponse())).not.toMatch( + /provider|subscription|entitlement|paddle/i, + ); + }); + + it('returns the effective plan and all known feature keys', async () => { + const { entitlementService, service } = buildService(); + const userId = 'summary-user'; + entitlementService.getEffectivePlan.mockResolvedValue(SubscriptionPlan.PRO); + entitlementService.hasEntitlement.mockImplementation( + async (_requestedUserId, featureKey) => + featureKey !== FEATURE_KEYS.SMART_DOWNLOADS, + ); + + const summary = await service.getFeatureAccessSummary(userId); + + expect(summary.effectivePlan).toBe(SubscriptionPlan.PRO); + expect(summary.features).toEqual({ + PRO_LIBRARY: true, + UNLIMITED_DEVICES: true, + ADVANCED_AUDIO_ANALYSIS: true, + SMART_DOWNLOADS: false, + PRIORITY_SYNC: true, + }); + expect(Object.keys(summary.features)).toEqual(Object.values(FEATURE_KEYS)); + expect(entitlementService.getEffectivePlan).toHaveBeenCalledWith(userId); + expect(entitlementService.hasEntitlement).toHaveBeenCalledTimes( + Object.values(FEATURE_KEYS).length, + ); + }); + + it('exposes neither provider IDs nor entitlement source internals', async () => { + const { entitlementService, service } = buildService(); + entitlementService.getEffectivePlan.mockResolvedValue(SubscriptionPlan.FREE); + entitlementService.hasEntitlement.mockResolvedValue(false); + + const summary = await service.getFeatureAccessSummary('free-user'); + const serializedSummary = JSON.stringify(summary); + + expect(serializedSummary).not.toMatch( + /provider|customerId|subscriptionId|source|entitlement/i, + ); + }); + + it('keeps every delegated access check scoped to the requested user', async () => { + const { entitlementService, service } = buildService(); + const requestedUserId = 'requested-user'; + entitlementService.getEffectivePlan.mockResolvedValue(SubscriptionPlan.FREE); + entitlementService.hasEntitlement.mockResolvedValue(false); + + await service.getFeatureAccessSummary(requestedUserId); + + expect(entitlementService.getEffectivePlan).toHaveBeenCalledWith( + requestedUserId, + ); + for (const featureKey of Object.values(FEATURE_KEYS)) { + expect(entitlementService.hasEntitlement).toHaveBeenCalledWith( + requestedUserId, + featureKey, + ); + } + }); + + it('depends only on EntitlementService for plan and feature decisions', async () => { + const { entitlementService, service } = buildService(); + entitlementService.getEffectivePlan.mockResolvedValue(SubscriptionPlan.FREE); + entitlementService.hasEntitlement.mockResolvedValue(false); + + await service.getFeatureAccessSummary('delegated-user'); + + expect(Object.keys(service)).toEqual(['entitlementService']); + expect(entitlementService.getEffectivePlan).toHaveBeenCalledTimes(1); + expect(entitlementService.hasEntitlement).toHaveBeenCalledTimes( + Object.values(FEATURE_KEYS).length, + ); + }); +}); diff --git a/backend/src/modules/users/feature-gate.service.ts b/backend/src/modules/users/feature-gate.service.ts new file mode 100644 index 0000000..09b2904 --- /dev/null +++ b/backend/src/modules/users/feature-gate.service.ts @@ -0,0 +1,66 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { SubscriptionPlan } from '@prisma/client'; +import { + ENTITLEMENT_KEYS, + EntitlementKey, + EntitlementService, +} from './entitlement.service'; + +export const FEATURE_KEYS = ENTITLEMENT_KEYS; + +export type FeatureKey = EntitlementKey; + +export interface FeatureAccessSummary { + effectivePlan: SubscriptionPlan; + features: Record; +} + +const KNOWN_FEATURE_KEYS = Object.values(FEATURE_KEYS); + +@Injectable() +export class FeatureGateService { + constructor(private readonly entitlementService: EntitlementService) {} + + async canUseFeature( + userId: string, + featureKey: FeatureKey, + ): Promise { + return this.entitlementService.hasEntitlement(userId, featureKey); + } + + async requireFeature( + userId: string, + featureKey: FeatureKey, + ): Promise { + if (!(await this.canUseFeature(userId, featureKey))) { + throw new ForbiddenException( + 'Access to this feature is not available for this account.', + ); + } + } + + async getFeatureAccessSummary( + userId: string, + ): Promise { + const [effectivePlan, accessResults] = await Promise.all([ + this.entitlementService.getEffectivePlan(userId), + Promise.all( + KNOWN_FEATURE_KEYS.map((featureKey) => + this.canUseFeature(userId, featureKey), + ), + ), + ]); + + const features = Object.fromEntries( + KNOWN_FEATURE_KEYS.map((featureKey, index) => [ + featureKey, + accessResults[index], + ]), + ) as Record; + + return { + effectivePlan, + features, + }; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index c49420b..d415f33 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -8,6 +8,7 @@ import { DefaultUserService } from './default-user.service'; import { DeviceManagementService } from './device-management.service'; import { DeviceLinkingService } from './device-linking.service'; import { EntitlementService } from './entitlement.service'; +import { FeatureGateService } from './feature-gate.service'; import { OAuthIdentityService } from './oauth-identity.service'; import { OwnershipTransferService } from './ownership-transfer.service'; import { @@ -25,6 +26,7 @@ import { StorageModule } from '../storage/storage.module'; DeviceManagementService, DeviceLinkingService, EntitlementService, + FeatureGateService, OAuthIdentityService, OwnershipTransferService, BootstrapOwnerContextService, @@ -40,6 +42,7 @@ import { StorageModule } from '../storage/storage.module'; DeviceManagementService, DeviceLinkingService, EntitlementService, + FeatureGateService, OAuthIdentityService, OwnershipTransferService, ],