feat: Implement user tier-based feature gating system (refs #8)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add subscription tier system to gate features behind Free/Pro/Enterprise tiers. Backend: - Create feature-tiers.ts with FEATURE_TIERS config and utilities - Add /api/config/feature-tiers endpoint for frontend config fetch - Create requireTier middleware for route-level tier enforcement - Add subscriptionTier to request.userContext in auth plugin - Gate scanForMaintenance in documents controller (Pro+ required) - Add migration to reset scanForMaintenance for free users Frontend: - Create useTierAccess hook for tier checking - Create UpgradeRequiredDialog component (responsive) - Gate DocumentForm checkbox with lock icon for free users - Add SubscriptionTier type to profile.types.ts Documentation: - Add TIER-GATING.md with usage guide Tests: 30 passing (feature-tiers, tier-guard, controller) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import tierGuardPlugin from '../tier-guard.plugin';
|
||||
|
||||
const createReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => {
|
||||
return {
|
||||
sent: false,
|
||||
code: jest.fn(function(this: any, status: number) {
|
||||
this.statusCode = status;
|
||||
return this;
|
||||
}),
|
||||
send: jest.fn(function(this: any, payload: unknown) {
|
||||
this.payload = payload;
|
||||
this.sent = true;
|
||||
return this;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('tier guard plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
let authenticateMock: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
// Mock authenticate to set userContext
|
||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'free',
|
||||
};
|
||||
});
|
||||
fastify.decorate('authenticate', authenticateMock);
|
||||
|
||||
await fastify.register(tierGuardPlugin);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('requireTier with minTier', () => {
|
||||
it('allows access when user tier meets minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access when user tier exceeds minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'enterprise',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies access when user tier is below minimum', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireTier with featureKey', () => {
|
||||
it('denies free tier access to pro feature', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
featureName: 'Scan for Maintenance Schedule',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('allows pro tier access to pro feature', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access for unknown feature (fail open)', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'unknown.feature' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns 500 when authenticate handler is not a function', async () => {
|
||||
const brokenFastify = Fastify();
|
||||
// Decorate with a non-function value to simulate missing handler
|
||||
brokenFastify.decorate('authenticate', 'not-a-function' as any);
|
||||
await brokenFastify.register(tierGuardPlugin);
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = brokenFastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(500);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Internal server error',
|
||||
message: 'Authentication handler missing',
|
||||
})
|
||||
);
|
||||
|
||||
await brokenFastify.close();
|
||||
});
|
||||
|
||||
it('defaults to free tier when userContext is missing', async () => {
|
||||
authenticateMock.mockImplementation(async () => {
|
||||
// Don't set userContext
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user