feat: Expand OCR with fuel receipt scanning and maintenance extraction (#129) #147
191
backend/src/core/middleware/require-tier.test.ts
Normal file
191
backend/src/core/middleware/require-tier.test.ts
Normal file
@@ -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<FastifyRequest> => {
|
||||
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<FastifyReply> & { 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
64
backend/src/core/middleware/require-tier.ts
Normal file
64
backend/src/core/middleware/require-tier.ts
Normal file
@@ -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<void> => {
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user