Add feature gate foundation

This commit is contained in:
diyaa 2026-06-28 17:29:49 +02:00
parent aa7d85b2e0
commit 7d84fa6a55
3 changed files with 222 additions and 0 deletions

View File

@ -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<Promise<boolean>, [string, FeatureKey]>(),
getEffectivePlan: jest.fn<Promise<SubscriptionPlan>, [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,
);
});
});

View File

@ -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<FeatureKey, boolean>;
}
const KNOWN_FEATURE_KEYS = Object.values(FEATURE_KEYS);
@Injectable()
export class FeatureGateService {
constructor(private readonly entitlementService: EntitlementService) {}
async canUseFeature(
userId: string,
featureKey: FeatureKey,
): Promise<boolean> {
return this.entitlementService.hasEntitlement(userId, featureKey);
}
async requireFeature(
userId: string,
featureKey: FeatureKey,
): Promise<void> {
if (!(await this.canUseFeature(userId, featureKey))) {
throw new ForbiddenException(
'Access to this feature is not available for this account.',
);
}
}
async getFeatureAccessSummary(
userId: string,
): Promise<FeatureAccessSummary> {
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<FeatureKey, boolean>;
return {
effectivePlan,
features,
};
}
}

View File

@ -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,
],