Skip to content

Commit 1bf1705

Browse files
Merge pull request #373 from VivekTekwani021/feat/project-changelog
feat: Project Configuration Change Log
2 parents 94c9d71 + 16c6e28 commit 1bf1705

7 files changed

Lines changed: 612 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
'use strict';
2+
3+
/**
4+
* Tests for configLog.controller.js — getConfigLogs
5+
*
6+
* Coverage:
7+
* - 200 happy path (no filter)
8+
* - 200 happy path with valid category filter
9+
* - 400 on unknown category string
10+
* - 400 on non-string category (object injection attempt)
11+
* - Correct pagination maths (totalPages, skip, limit clamping)
12+
* - DB error forwarded via next()
13+
*/
14+
15+
// --- Mocks -------------------------------------------------------------------
16+
17+
const mockFind = jest.fn();
18+
const mockCountDocuments = jest.fn();
19+
20+
class MockAppError extends Error {
21+
constructor(statusCode, message) {
22+
super(message);
23+
this.statusCode = statusCode;
24+
}
25+
}
26+
27+
jest.mock('@urbackend/common', () => ({
28+
ProjectConfigLog: {
29+
find: mockFind,
30+
countDocuments: mockCountDocuments,
31+
},
32+
AppError: MockAppError,
33+
}));
34+
35+
// -----------------------------------------------------------------------------
36+
37+
const { getConfigLogs } = require('../controllers/configLog.controller');
38+
39+
// Helpers
40+
const makeReq = (params = {}, query = {}) => ({
41+
params: { projectId: 'proj_abc123', ...params },
42+
query,
43+
});
44+
45+
const makeRes = () => {
46+
const res = {
47+
status: jest.fn().mockReturnThis(),
48+
json: jest.fn().mockReturnThis(),
49+
};
50+
return res;
51+
};
52+
53+
/** Chainable Mongoose query stub: find().sort().skip().limit().select().lean() */
54+
const makeQueryChain = (resolvedValue) => ({
55+
sort: jest.fn().mockReturnThis(),
56+
skip: jest.fn().mockReturnThis(),
57+
limit: jest.fn().mockReturnThis(),
58+
select: jest.fn().mockReturnThis(),
59+
lean: jest.fn().mockResolvedValue(resolvedValue),
60+
});
61+
62+
// -----------------------------------------------------------------------------
63+
64+
describe('configLog.controller — getConfigLogs', () => {
65+
let next;
66+
67+
beforeEach(() => {
68+
jest.clearAllMocks();
69+
next = jest.fn();
70+
});
71+
72+
// ---------------------------------------------------------------------------
73+
// Happy paths
74+
// ---------------------------------------------------------------------------
75+
76+
it('returns 200 with logs and pagination when no category filter is given', async () => {
77+
const fakeLogs = [
78+
{ _id: 'log1', category: 'auth', label: 'Auth enabled', changedAt: new Date() },
79+
];
80+
mockFind.mockReturnValue(makeQueryChain(fakeLogs));
81+
mockCountDocuments.mockResolvedValue(1);
82+
83+
const req = makeReq({}, {});
84+
const res = makeRes();
85+
86+
await getConfigLogs(req, res, next);
87+
88+
expect(next).not.toHaveBeenCalled();
89+
expect(res.status).toHaveBeenCalledWith(200);
90+
91+
const body = res.json.mock.calls[0][0];
92+
expect(body.success).toBe(true);
93+
expect(body.message).toBe('Configuration change logs retrieved successfully.');
94+
expect(body.data.logs).toEqual(fakeLogs);
95+
expect(body.data.pagination).toEqual({
96+
page: 1,
97+
limit: 30,
98+
total: 1,
99+
totalPages: 1,
100+
});
101+
});
102+
103+
it('filters by a valid category and passes it to the DB query', async () => {
104+
mockFind.mockReturnValue(makeQueryChain([]));
105+
mockCountDocuments.mockResolvedValue(0);
106+
107+
const req = makeReq({}, { category: 'auth' });
108+
const res = makeRes();
109+
110+
await getConfigLogs(req, res, next);
111+
112+
expect(next).not.toHaveBeenCalled();
113+
// Both DB calls must receive the category in the filter
114+
expect(mockFind).toHaveBeenCalledWith(
115+
expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }),
116+
);
117+
expect(mockCountDocuments).toHaveBeenCalledWith(
118+
expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }),
119+
);
120+
expect(res.status).toHaveBeenCalledWith(200);
121+
});
122+
123+
it('accepts every value in ALLOWED_CATEGORIES without error', async () => {
124+
const ALLOWED = [
125+
'project_info', 'api_key', 'auth', 'public_signup', 'auth_providers',
126+
'allowed_domains', 'byod_db', 'byod_storage', 'collection_schema',
127+
'collection_rls', 'mail_template', 'resend', 'member',
128+
];
129+
130+
for (const cat of ALLOWED) {
131+
jest.clearAllMocks();
132+
mockFind.mockReturnValue(makeQueryChain([]));
133+
mockCountDocuments.mockResolvedValue(0);
134+
135+
const req = makeReq({}, { category: cat });
136+
const res = makeRes();
137+
138+
await getConfigLogs(req, res, next);
139+
140+
expect(next).not.toHaveBeenCalled();
141+
expect(res.status).toHaveBeenCalledWith(200);
142+
}
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// Pagination maths
147+
// ---------------------------------------------------------------------------
148+
149+
it('computes skip correctly for page > 1', async () => {
150+
mockFind.mockReturnValue(makeQueryChain([]));
151+
mockCountDocuments.mockResolvedValue(100);
152+
153+
const req = makeReq({}, { page: '3', limit: '10' });
154+
const res = makeRes();
155+
156+
await getConfigLogs(req, res, next);
157+
158+
const chain = mockFind.mock.results[0].value;
159+
expect(chain.skip).toHaveBeenCalledWith(20); // (3-1) * 10
160+
expect(chain.limit).toHaveBeenCalledWith(10);
161+
162+
const body = res.json.mock.calls[0][0];
163+
expect(body.data.pagination).toMatchObject({ page: 3, limit: 10, total: 100, totalPages: 10 });
164+
});
165+
166+
it('clamps limit to a maximum of 100', async () => {
167+
mockFind.mockReturnValue(makeQueryChain([]));
168+
mockCountDocuments.mockResolvedValue(0);
169+
170+
const req = makeReq({}, { limit: '999' });
171+
const res = makeRes();
172+
173+
await getConfigLogs(req, res, next);
174+
175+
const chain = mockFind.mock.results[0].value;
176+
expect(chain.limit).toHaveBeenCalledWith(100);
177+
const body = res.json.mock.calls[0][0];
178+
expect(body.data.pagination.limit).toBe(100);
179+
});
180+
181+
it('defaults page to 1 and limit to 30 when query params are absent', async () => {
182+
mockFind.mockReturnValue(makeQueryChain([]));
183+
mockCountDocuments.mockResolvedValue(0);
184+
185+
const req = makeReq({}, {});
186+
const res = makeRes();
187+
188+
await getConfigLogs(req, res, next);
189+
190+
const chain = mockFind.mock.results[0].value;
191+
expect(chain.skip).toHaveBeenCalledWith(0);
192+
expect(chain.limit).toHaveBeenCalledWith(30);
193+
});
194+
195+
it('floors page to 1 when page=0 or negative is supplied', async () => {
196+
mockFind.mockReturnValue(makeQueryChain([]));
197+
mockCountDocuments.mockResolvedValue(0);
198+
199+
const req = makeReq({}, { page: '-5' });
200+
const res = makeRes();
201+
202+
await getConfigLogs(req, res, next);
203+
204+
const chain = mockFind.mock.results[0].value;
205+
expect(chain.skip).toHaveBeenCalledWith(0);
206+
const body = res.json.mock.calls[0][0];
207+
expect(body.data.pagination.page).toBe(1);
208+
});
209+
210+
// ---------------------------------------------------------------------------
211+
// Security — NoSQL injection prevention
212+
// ---------------------------------------------------------------------------
213+
214+
it('rejects an unknown category string with 400', async () => {
215+
const req = makeReq({}, { category: 'unknown_bad_value' });
216+
const res = makeRes();
217+
218+
await getConfigLogs(req, res, next);
219+
220+
expect(mockFind).not.toHaveBeenCalled();
221+
expect(next).toHaveBeenCalledWith(expect.any(MockAppError));
222+
const err = next.mock.calls[0][0];
223+
expect(err.statusCode).toBe(400);
224+
expect(err.message).toMatch(/Invalid category/);
225+
});
226+
227+
it('rejects a MongoDB operator object injected via query string with 400', async () => {
228+
// Simulates ?category[$ne]=null parsed by Express as { category: { $ne: null } }
229+
const req = makeReq({}, { category: { $ne: null } });
230+
const res = makeRes();
231+
232+
await getConfigLogs(req, res, next);
233+
234+
expect(mockFind).not.toHaveBeenCalled();
235+
expect(next).toHaveBeenCalledWith(expect.any(MockAppError));
236+
const err = next.mock.calls[0][0];
237+
expect(err.statusCode).toBe(400);
238+
});
239+
240+
it('rejects an empty string category with 400', async () => {
241+
const req = makeReq({}, { category: '' });
242+
const res = makeRes();
243+
244+
await getConfigLogs(req, res, next);
245+
246+
// Empty string is not in ALLOWED_CATEGORIES
247+
expect(next).toHaveBeenCalledWith(expect.any(MockAppError));
248+
const err = next.mock.calls[0][0];
249+
expect(err.statusCode).toBe(400);
250+
});
251+
252+
// ---------------------------------------------------------------------------
253+
// Error handling
254+
// ---------------------------------------------------------------------------
255+
256+
it('forwards a DB error to next() without swallowing it', async () => {
257+
const dbError = new Error('MongoDB timeout');
258+
mockFind.mockReturnValue({
259+
sort: jest.fn().mockReturnThis(),
260+
skip: jest.fn().mockReturnThis(),
261+
limit: jest.fn().mockReturnThis(),
262+
select: jest.fn().mockReturnThis(),
263+
lean: jest.fn().mockRejectedValue(dbError),
264+
});
265+
mockCountDocuments.mockResolvedValue(0);
266+
267+
const req = makeReq({}, {});
268+
const res = makeRes();
269+
270+
await getConfigLogs(req, res, next);
271+
272+
expect(next).toHaveBeenCalledWith(dbError);
273+
expect(res.json).not.toHaveBeenCalled();
274+
});
275+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* configLog.controller.js
3+
*
4+
* Handles API requests for the Project Configuration Change Log feature.
5+
*/
6+
7+
const { ProjectConfigLog, AppError } = require('@urbackend/common');
8+
9+
/**
10+
* Exhaustive list of valid category values that can be stored by logConfigChange.
11+
* Used to whitelist the `category` query param and prevent NoSQL operator injection.
12+
*/
13+
const ALLOWED_CATEGORIES = new Set([
14+
'project_info',
15+
'api_key',
16+
'auth',
17+
'public_signup',
18+
'auth_providers',
19+
'allowed_domains',
20+
'byod_db',
21+
'byod_storage',
22+
'collection_schema',
23+
'collection_rls',
24+
'mail_template',
25+
'resend',
26+
'member',
27+
]);
28+
29+
/**
30+
* GET /api/projects/:projectId/config-logs
31+
*
32+
* Returns paginated configuration change logs for a project.
33+
* Accessible by any project member (admin or viewer).
34+
*
35+
* Query params:
36+
* page {number} 1-indexed page number (default: 1)
37+
* limit {number} items per page, max 100 (default: 30)
38+
* category {string} optional filter by category — must be a known category value
39+
*/
40+
module.exports.getConfigLogs = async (req, res, next) => {
41+
try {
42+
const { projectId } = req.params;
43+
44+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
45+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
46+
const skip = (page - 1) * limit;
47+
48+
// Build filter with a safe, scalar projectId (already validated by authorizeProject middleware).
49+
const filter = { projectId };
50+
51+
if (req.query.category !== undefined) {
52+
// Reject non-string values (e.g. objects from ?category[$ne]=null) and
53+
// unknown category names to prevent NoSQL operator injection.
54+
if (typeof req.query.category !== 'string' || !ALLOWED_CATEGORIES.has(req.query.category)) {
55+
return next(new AppError(400, `Invalid category. Allowed values: ${[...ALLOWED_CATEGORIES].join(', ')}`));
56+
}
57+
filter.category = req.query.category;
58+
}
59+
60+
const [logs, total] = await Promise.all([
61+
ProjectConfigLog.find(filter)
62+
.sort({ changedAt: -1 })
63+
.skip(skip)
64+
.limit(limit)
65+
.select('-__v')
66+
.lean(),
67+
ProjectConfigLog.countDocuments(filter),
68+
]);
69+
70+
return res.status(200).json({
71+
success: true,
72+
data: {
73+
logs,
74+
pagination: {
75+
page,
76+
limit,
77+
total,
78+
totalPages: Math.ceil(total / limit),
79+
},
80+
},
81+
message: 'Configuration change logs retrieved successfully.',
82+
});
83+
} catch (err) {
84+
next(err);
85+
}
86+
};

0 commit comments

Comments
 (0)