import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import tierGuardPlugin from '../tier-guard.plugin'; const createReply = (): Partial & { 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', }) ); }); }); });