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 { DeviceManagementService } from './device-management.service';
|
||||||
import { DeviceLinkingService } from './device-linking.service';
|
import { DeviceLinkingService } from './device-linking.service';
|
||||||
import { EntitlementService } from './entitlement.service';
|
import { EntitlementService } from './entitlement.service';
|
||||||
|
import { FeatureGateService } from './feature-gate.service';
|
||||||
import { OAuthIdentityService } from './oauth-identity.service';
|
import { OAuthIdentityService } from './oauth-identity.service';
|
||||||
import { OwnershipTransferService } from './ownership-transfer.service';
|
import { OwnershipTransferService } from './ownership-transfer.service';
|
||||||
import {
|
import {
|
||||||
@ -25,6 +26,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
DeviceManagementService,
|
DeviceManagementService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
EntitlementService,
|
EntitlementService,
|
||||||
|
FeatureGateService,
|
||||||
OAuthIdentityService,
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
BootstrapOwnerContextService,
|
BootstrapOwnerContextService,
|
||||||
@ -40,6 +42,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
DeviceManagementService,
|
DeviceManagementService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
EntitlementService,
|
EntitlementService,
|
||||||
|
FeatureGateService,
|
||||||
OAuthIdentityService,
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user