diff --git a/backend/src/core/middleware/require-tier.test.ts b/backend/src/core/middleware/require-tier.test.ts new file mode 100644 index 0000000..13fc80f --- /dev/null +++ b/backend/src/core/middleware/require-tier.test.ts @@ -0,0 +1,191 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { requireTier } from './require-tier'; + +// Mock logger to suppress output during tests +jest.mock('../logging/logger', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + }, +})); + +const createRequest = (subscriptionTier?: string): Partial => { + if (subscriptionTier === undefined) { + return { userContext: undefined }; + } + return { + userContext: { + userId: 'auth0|user123456789', + email: 'user@example.com', + emailVerified: true, + onboardingCompleted: true, + isAdmin: false, + subscriptionTier: subscriptionTier as any, + }, + }; +}; + +const createReply = (): Partial & { statusCode?: number; payload?: unknown } => { + const reply: any = { + 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; + }), + }; + return reply; +}; + +describe('requireTier middleware', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pro user passes fuelLog.receiptScan check', () => { + it('allows pro user through without sending a response', async () => { + const handler = requireTier('fuelLog.receiptScan'); + const request = createRequest('pro'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + expect(reply.send).not.toHaveBeenCalled(); + }); + }); + + describe('enterprise user passes all checks (tier inheritance)', () => { + it('allows enterprise user access to pro-gated features', async () => { + const handler = requireTier('fuelLog.receiptScan'); + const request = createRequest('enterprise'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + expect(reply.send).not.toHaveBeenCalled(); + }); + + it('allows enterprise user access to document.scanMaintenanceSchedule', async () => { + const handler = requireTier('document.scanMaintenanceSchedule'); + const request = createRequest('enterprise'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + expect(reply.send).not.toHaveBeenCalled(); + }); + + it('allows enterprise user access to vehicle.vinDecode', async () => { + const handler = requireTier('vehicle.vinDecode'); + const request = createRequest('enterprise'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).not.toHaveBeenCalled(); + expect(reply.send).not.toHaveBeenCalled(); + }); + }); + + describe('free user blocked with 403 and correct response body', () => { + it('blocks free user from fuelLog.receiptScan', async () => { + const handler = requireTier('fuelLog.receiptScan'); + const request = createRequest('free'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + featureName: 'Receipt Scan', + upgradePrompt: expect.any(String), + }), + ); + }); + + it('blocks free user from document.scanMaintenanceSchedule', async () => { + const handler = requireTier('document.scanMaintenanceSchedule'); + const request = createRequest('free'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + requiredTier: 'pro', + currentTier: 'free', + featureName: 'Scan for Maintenance Schedule', + upgradePrompt: expect.any(String), + }), + ); + }); + + it('response body includes all required fields', async () => { + const handler = requireTier('fuelLog.receiptScan'); + const request = createRequest('free'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + const body = (reply.send as jest.Mock).mock.calls[0][0]; + expect(body).toHaveProperty('requiredTier', 'pro'); + expect(body).toHaveProperty('currentTier', 'free'); + expect(body).toHaveProperty('featureName', 'Receipt Scan'); + expect(body).toHaveProperty('upgradePrompt'); + expect(typeof body.upgradePrompt).toBe('string'); + expect(body.upgradePrompt.length).toBeGreaterThan(0); + }); + }); + + describe('unknown feature key returns 500', () => { + it('returns 500 INTERNAL_ERROR for unregistered feature', async () => { + const handler = requireTier('unknown.nonexistent.feature'); + const request = createRequest('pro'); + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'INTERNAL_ERROR', + message: 'Unknown feature configuration', + }), + ); + }); + }); + + describe('missing user.tier on request returns 403', () => { + it('defaults to free tier when userContext is undefined', async () => { + const handler = requireTier('fuelLog.receiptScan'); + const request = createRequest(); // no tier = undefined userContext + const reply = createReply(); + + await handler(request as FastifyRequest, reply as FastifyReply); + + expect(reply.code).toHaveBeenCalledWith(403); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'TIER_REQUIRED', + currentTier: 'free', + requiredTier: 'pro', + }), + ); + }); + }); +}); diff --git a/backend/src/core/middleware/require-tier.ts b/backend/src/core/middleware/require-tier.ts new file mode 100644 index 0000000..9b21931 --- /dev/null +++ b/backend/src/core/middleware/require-tier.ts @@ -0,0 +1,64 @@ +/** + * @ai-summary Standalone tier guard middleware for route-level feature gating + * @ai-context Returns a Fastify preHandler that checks user subscription tier against feature requirements. + * Must be composed AFTER requireAuth in preHandler arrays. + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { canAccessFeature, getFeatureConfig } from '../config/feature-tiers'; +import { logger } from '../logging/logger'; + +/** + * Creates a preHandler middleware that enforces subscription tier requirements. + * + * Reads the user's tier from request.userContext.subscriptionTier (set by auth middleware). + * Must be placed AFTER requireAuth in the preHandler chain. + * + * Usage: + * fastify.post('/premium-route', { + * preHandler: [requireAuth, requireTier('fuelLog.receiptScan')], + * handler: controller.method + * }); + * + * @param featureKey - Key from FEATURE_TIERS registry (e.g. 'fuelLog.receiptScan') + * @returns Fastify preHandler function + */ +export function requireTier(featureKey: string) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + // Validate feature key exists in registry + const featureConfig = getFeatureConfig(featureKey); + if (!featureConfig) { + logger.error('requireTier: unknown feature key', { featureKey }); + return reply.code(500).send({ + error: 'INTERNAL_ERROR', + message: 'Unknown feature configuration', + }); + } + + // Get user tier from userContext (populated by auth middleware) + const currentTier = request.userContext?.subscriptionTier || 'free'; + + if (!canAccessFeature(currentTier, featureKey)) { + logger.warn('requireTier: access denied', { + userId: request.userContext?.userId?.substring(0, 8) + '...', + currentTier, + requiredTier: featureConfig.minTier, + featureKey, + }); + + return reply.code(403).send({ + error: 'TIER_REQUIRED', + requiredTier: featureConfig.minTier, + currentTier, + featureName: featureConfig.name, + upgradePrompt: featureConfig.upgradePrompt, + }); + } + + logger.debug('requireTier: access granted', { + userId: request.userContext?.userId?.substring(0, 8) + '...', + currentTier, + featureKey, + }); + }; +}