Add feature gate foundation
This commit is contained in:
parent
aa7d85b2e0
commit
7d84fa6a55
153
backend/src/modules/users/feature-gate.service.spec.ts
Normal file
153
backend/src/modules/users/feature-gate.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
66
backend/src/modules/users/feature-gate.service.ts
Normal file
66
backend/src/modules/users/feature-gate.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user