Skip to content

Commit 0f168a1

Browse files
feat(billing): add requirePlan middleware to gate routes by subscription plan
Introduces requirePlan(...plans) middleware that checks the organization's subscription plan before allowing access. Orthogonal to CASL role-based authorization — requirePlan gates by plan, CASL gates by role. Closes #3246
1 parent d5a66e2 commit 0f168a1

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

modules/billing/middlewares/.gitkeep

Whitespace-only changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
5+
6+
import responses from '../../../lib/helpers/responses.js';
7+
8+
/**
9+
* Returns Express middleware that gates access based on subscription plan.
10+
* This middleware is orthogonal to CASL — CASL gates by role, requirePlan
11+
* gates by subscription plan.
12+
*
13+
* Expects `req.organization` to be set by resolveOrganization upstream.
14+
*
15+
* @param {...string} plans - One or more allowed plan identifiers.
16+
* @returns {Function} Express middleware function.
17+
*/
18+
function requirePlan(...plans) {
19+
return async function requirePlanMiddleware(req, res, next) {
20+
if (!req.organization) {
21+
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check subscription plan')();
22+
}
23+
24+
try {
25+
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
26+
const currentPlan = subscription?.plan || 'free';
27+
28+
if (plans.includes(currentPlan)) return next();
29+
30+
return responses.error(res, 403, 'Forbidden', 'Your current plan does not allow access to this resource')({
31+
type: 'PLAN_REQUIRED',
32+
requiredPlans: plans,
33+
currentPlan,
34+
});
35+
} catch (err) {
36+
return next(err);
37+
}
38+
};
39+
}
40+
41+
export default requirePlan;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
7+
8+
const mockFindByOrganization = jest.fn();
9+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
10+
default: { findByOrganization: mockFindByOrganization },
11+
}));
12+
13+
const { default: requirePlan } = await import('../middlewares/billing.requirePlan.js');
14+
15+
/**
16+
* Unit tests for the requirePlan middleware.
17+
*/
18+
describe('requirePlan middleware unit tests:', () => {
19+
const fakeOrgId = new mongoose.Types.ObjectId();
20+
21+
/**
22+
* @desc Build a minimal Express-like req object
23+
* @param {Object} overrides - Properties to merge onto the request
24+
* @returns {Object} mock request
25+
*/
26+
function mockReq(overrides = {}) {
27+
return {
28+
organization: { _id: fakeOrgId, name: 'Test Org' },
29+
...overrides,
30+
};
31+
}
32+
33+
/**
34+
* @desc Build a minimal Express-like res object with spies
35+
* @returns {Object} mock response
36+
*/
37+
function mockRes() {
38+
const res = {};
39+
res.status = jest.fn().mockReturnValue(res);
40+
res.json = jest.fn().mockReturnValue(res);
41+
return res;
42+
}
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
});
47+
48+
test('should call next when subscription plan matches the required plan', async () => {
49+
mockFindByOrganization.mockResolvedValue({ plan: 'pro' });
50+
51+
const middleware = requirePlan('pro');
52+
const req = mockReq();
53+
const res = mockRes();
54+
const next = jest.fn();
55+
56+
await middleware(req, res, next);
57+
58+
expect(next).toHaveBeenCalledTimes(1);
59+
expect(res.status).not.toHaveBeenCalled();
60+
});
61+
62+
test('should call next when subscription plan is in multiple allowed plans', async () => {
63+
mockFindByOrganization.mockResolvedValue({ plan: 'starter' });
64+
65+
const middleware = requirePlan('starter', 'pro', 'enterprise');
66+
const req = mockReq();
67+
const res = mockRes();
68+
const next = jest.fn();
69+
70+
await middleware(req, res, next);
71+
72+
expect(next).toHaveBeenCalledTimes(1);
73+
expect(res.status).not.toHaveBeenCalled();
74+
});
75+
76+
test('should return 403 when subscription plan does not match', async () => {
77+
mockFindByOrganization.mockResolvedValue({ plan: 'free' });
78+
79+
const middleware = requirePlan('pro', 'enterprise');
80+
const req = mockReq();
81+
const res = mockRes();
82+
const next = jest.fn();
83+
84+
await middleware(req, res, next);
85+
86+
expect(next).not.toHaveBeenCalled();
87+
expect(res.status).toHaveBeenCalledWith(403);
88+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'Forbidden' }));
89+
});
90+
91+
test('should default to free plan when no subscription exists', async () => {
92+
mockFindByOrganization.mockResolvedValue(null);
93+
94+
const middleware = requirePlan('free');
95+
const req = mockReq();
96+
const res = mockRes();
97+
const next = jest.fn();
98+
99+
await middleware(req, res, next);
100+
101+
expect(next).toHaveBeenCalledTimes(1);
102+
expect(res.status).not.toHaveBeenCalled();
103+
});
104+
105+
test('should return 403 when no subscription exists and free is not allowed', async () => {
106+
mockFindByOrganization.mockResolvedValue(null);
107+
108+
const middleware = requirePlan('pro');
109+
const req = mockReq();
110+
const res = mockRes();
111+
const next = jest.fn();
112+
113+
await middleware(req, res, next);
114+
115+
expect(next).not.toHaveBeenCalled();
116+
expect(res.status).toHaveBeenCalledWith(403);
117+
});
118+
119+
test('should return 403 when organization is missing from request', async () => {
120+
const middleware = requirePlan('pro');
121+
const req = mockReq({ organization: undefined });
122+
const res = mockRes();
123+
const next = jest.fn();
124+
125+
await middleware(req, res, next);
126+
127+
expect(next).not.toHaveBeenCalled();
128+
expect(res.status).toHaveBeenCalledWith(403);
129+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'Forbidden' }));
130+
expect(mockFindByOrganization).not.toHaveBeenCalled();
131+
});
132+
});

0 commit comments

Comments
 (0)