-
Notifications
You must be signed in to change notification settings - Fork 68
feat: Project Configuration Change Log #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
yash-pouranik
merged 5 commits into
geturbackend:main
from
VivekTekwani021:feat/project-changelog
Jul 31, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4df5df3
feat project changelog
VivekTekwani021 37bf8cd
fix: audit log review fixes - split byod logs, fix oauth diff, add re…
VivekTekwani021 90cf4d2
fix(security): prevent NoSQL injection in config-logs category filter…
VivekTekwani021 68051cb
test: add unit tests for configLog.controller (11 cases)
VivekTekwani021 16c6e28
feat project changelog
VivekTekwani021 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
275 changes: 275 additions & 0 deletions
275
apps/dashboard-api/src/__tests__/configLog.controller.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| 'use strict'; | ||
|
|
||
| /** | ||
| * Tests for configLog.controller.js — getConfigLogs | ||
| * | ||
| * Coverage: | ||
| * - 200 happy path (no filter) | ||
| * - 200 happy path with valid category filter | ||
| * - 400 on unknown category string | ||
| * - 400 on non-string category (object injection attempt) | ||
| * - Correct pagination maths (totalPages, skip, limit clamping) | ||
| * - DB error forwarded via next() | ||
| */ | ||
|
|
||
| // --- Mocks ------------------------------------------------------------------- | ||
|
|
||
| const mockFind = jest.fn(); | ||
| const mockCountDocuments = jest.fn(); | ||
|
|
||
| class MockAppError extends Error { | ||
| constructor(statusCode, message) { | ||
| super(message); | ||
| this.statusCode = statusCode; | ||
| } | ||
| } | ||
|
|
||
| jest.mock('@urbackend/common', () => ({ | ||
| ProjectConfigLog: { | ||
| find: mockFind, | ||
| countDocuments: mockCountDocuments, | ||
| }, | ||
| AppError: MockAppError, | ||
| })); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| const { getConfigLogs } = require('../controllers/configLog.controller'); | ||
|
|
||
| // Helpers | ||
| const makeReq = (params = {}, query = {}) => ({ | ||
| params: { projectId: 'proj_abc123', ...params }, | ||
| query, | ||
| }); | ||
|
|
||
| const makeRes = () => { | ||
| const res = { | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn().mockReturnThis(), | ||
| }; | ||
| return res; | ||
| }; | ||
|
|
||
| /** Chainable Mongoose query stub: find().sort().skip().limit().select().lean() */ | ||
| const makeQueryChain = (resolvedValue) => ({ | ||
| sort: jest.fn().mockReturnThis(), | ||
| skip: jest.fn().mockReturnThis(), | ||
| limit: jest.fn().mockReturnThis(), | ||
| select: jest.fn().mockReturnThis(), | ||
| lean: jest.fn().mockResolvedValue(resolvedValue), | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe('configLog.controller — getConfigLogs', () => { | ||
| let next; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| next = jest.fn(); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Happy paths | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('returns 200 with logs and pagination when no category filter is given', async () => { | ||
| const fakeLogs = [ | ||
| { _id: 'log1', category: 'auth', label: 'Auth enabled', changedAt: new Date() }, | ||
| ]; | ||
| mockFind.mockReturnValue(makeQueryChain(fakeLogs)); | ||
| mockCountDocuments.mockResolvedValue(1); | ||
|
|
||
| const req = makeReq({}, {}); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(next).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(200); | ||
|
|
||
| const body = res.json.mock.calls[0][0]; | ||
| expect(body.success).toBe(true); | ||
| expect(body.message).toBe('Configuration change logs retrieved successfully.'); | ||
| expect(body.data.logs).toEqual(fakeLogs); | ||
| expect(body.data.pagination).toEqual({ | ||
| page: 1, | ||
| limit: 30, | ||
| total: 1, | ||
| totalPages: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('filters by a valid category and passes it to the DB query', async () => { | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, { category: 'auth' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(next).not.toHaveBeenCalled(); | ||
| // Both DB calls must receive the category in the filter | ||
| expect(mockFind).toHaveBeenCalledWith( | ||
| expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }), | ||
| ); | ||
| expect(mockCountDocuments).toHaveBeenCalledWith( | ||
| expect.objectContaining({ projectId: 'proj_abc123', category: 'auth' }), | ||
| ); | ||
| expect(res.status).toHaveBeenCalledWith(200); | ||
| }); | ||
|
|
||
| it('accepts every value in ALLOWED_CATEGORIES without error', async () => { | ||
| const ALLOWED = [ | ||
| 'project_info', 'api_key', 'auth', 'public_signup', 'auth_providers', | ||
| 'allowed_domains', 'byod_db', 'byod_storage', 'collection_schema', | ||
| 'collection_rls', 'mail_template', 'resend', 'member', | ||
| ]; | ||
|
|
||
| for (const cat of ALLOWED) { | ||
| jest.clearAllMocks(); | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, { category: cat }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(next).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(200); | ||
| } | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Pagination maths | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('computes skip correctly for page > 1', async () => { | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(100); | ||
|
|
||
| const req = makeReq({}, { page: '3', limit: '10' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| const chain = mockFind.mock.results[0].value; | ||
| expect(chain.skip).toHaveBeenCalledWith(20); // (3-1) * 10 | ||
| expect(chain.limit).toHaveBeenCalledWith(10); | ||
|
|
||
| const body = res.json.mock.calls[0][0]; | ||
| expect(body.data.pagination).toMatchObject({ page: 3, limit: 10, total: 100, totalPages: 10 }); | ||
| }); | ||
|
|
||
| it('clamps limit to a maximum of 100', async () => { | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, { limit: '999' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| const chain = mockFind.mock.results[0].value; | ||
| expect(chain.limit).toHaveBeenCalledWith(100); | ||
| const body = res.json.mock.calls[0][0]; | ||
| expect(body.data.pagination.limit).toBe(100); | ||
| }); | ||
|
|
||
| it('defaults page to 1 and limit to 30 when query params are absent', async () => { | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, {}); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| const chain = mockFind.mock.results[0].value; | ||
| expect(chain.skip).toHaveBeenCalledWith(0); | ||
| expect(chain.limit).toHaveBeenCalledWith(30); | ||
| }); | ||
|
|
||
| it('floors page to 1 when page=0 or negative is supplied', async () => { | ||
| mockFind.mockReturnValue(makeQueryChain([])); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, { page: '-5' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| const chain = mockFind.mock.results[0].value; | ||
| expect(chain.skip).toHaveBeenCalledWith(0); | ||
| const body = res.json.mock.calls[0][0]; | ||
| expect(body.data.pagination.page).toBe(1); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Security — NoSQL injection prevention | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('rejects an unknown category string with 400', async () => { | ||
| const req = makeReq({}, { category: 'unknown_bad_value' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(mockFind).not.toHaveBeenCalled(); | ||
| expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); | ||
| const err = next.mock.calls[0][0]; | ||
| expect(err.statusCode).toBe(400); | ||
| expect(err.message).toMatch(/Invalid category/); | ||
| }); | ||
|
|
||
| it('rejects a MongoDB operator object injected via query string with 400', async () => { | ||
| // Simulates ?category[$ne]=null parsed by Express as { category: { $ne: null } } | ||
| const req = makeReq({}, { category: { $ne: null } }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(mockFind).not.toHaveBeenCalled(); | ||
| expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); | ||
| const err = next.mock.calls[0][0]; | ||
| expect(err.statusCode).toBe(400); | ||
| }); | ||
|
|
||
| it('rejects an empty string category with 400', async () => { | ||
| const req = makeReq({}, { category: '' }); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| // Empty string is not in ALLOWED_CATEGORIES | ||
| expect(next).toHaveBeenCalledWith(expect.any(MockAppError)); | ||
| const err = next.mock.calls[0][0]; | ||
| expect(err.statusCode).toBe(400); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Error handling | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| it('forwards a DB error to next() without swallowing it', async () => { | ||
| const dbError = new Error('MongoDB timeout'); | ||
| mockFind.mockReturnValue({ | ||
| sort: jest.fn().mockReturnThis(), | ||
| skip: jest.fn().mockReturnThis(), | ||
| limit: jest.fn().mockReturnThis(), | ||
| select: jest.fn().mockReturnThis(), | ||
| lean: jest.fn().mockRejectedValue(dbError), | ||
| }); | ||
| mockCountDocuments.mockResolvedValue(0); | ||
|
|
||
| const req = makeReq({}, {}); | ||
| const res = makeRes(); | ||
|
|
||
| await getConfigLogs(req, res, next); | ||
|
|
||
| expect(next).toHaveBeenCalledWith(dbError); | ||
| expect(res.json).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
86 changes: 86 additions & 0 deletions
86
apps/dashboard-api/src/controllers/configLog.controller.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /** | ||
| * configLog.controller.js | ||
| * | ||
| * Handles API requests for the Project Configuration Change Log feature. | ||
| */ | ||
|
|
||
| const { ProjectConfigLog, AppError } = require('@urbackend/common'); | ||
|
|
||
| /** | ||
| * Exhaustive list of valid category values that can be stored by logConfigChange. | ||
| * Used to whitelist the `category` query param and prevent NoSQL operator injection. | ||
| */ | ||
| const ALLOWED_CATEGORIES = new Set([ | ||
| 'project_info', | ||
| 'api_key', | ||
| 'auth', | ||
| 'public_signup', | ||
| 'auth_providers', | ||
| 'allowed_domains', | ||
| 'byod_db', | ||
| 'byod_storage', | ||
| 'collection_schema', | ||
| 'collection_rls', | ||
| 'mail_template', | ||
| 'resend', | ||
| 'member', | ||
| ]); | ||
|
|
||
| /** | ||
| * GET /api/projects/:projectId/config-logs | ||
| * | ||
| * Returns paginated configuration change logs for a project. | ||
| * Accessible by any project member (admin or viewer). | ||
| * | ||
| * Query params: | ||
| * page {number} 1-indexed page number (default: 1) | ||
| * limit {number} items per page, max 100 (default: 30) | ||
| * category {string} optional filter by category — must be a known category value | ||
| */ | ||
| module.exports.getConfigLogs = async (req, res, next) => { | ||
| try { | ||
| const { projectId } = req.params; | ||
|
|
||
| const page = Math.max(1, parseInt(req.query.page, 10) || 1); | ||
| const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30)); | ||
| const skip = (page - 1) * limit; | ||
|
|
||
| // Build filter with a safe, scalar projectId (already validated by authorizeProject middleware). | ||
| const filter = { projectId }; | ||
|
|
||
| if (req.query.category !== undefined) { | ||
| // Reject non-string values (e.g. objects from ?category[$ne]=null) and | ||
| // unknown category names to prevent NoSQL operator injection. | ||
| if (typeof req.query.category !== 'string' || !ALLOWED_CATEGORIES.has(req.query.category)) { | ||
| return next(new AppError(400, `Invalid category. Allowed values: ${[...ALLOWED_CATEGORIES].join(', ')}`)); | ||
| } | ||
| filter.category = req.query.category; | ||
| } | ||
|
|
||
| const [logs, total] = await Promise.all([ | ||
| ProjectConfigLog.find(filter) | ||
| .sort({ changedAt: -1 }) | ||
| .skip(skip) | ||
| .limit(limit) | ||
| .select('-__v') | ||
| .lean(), | ||
| ProjectConfigLog.countDocuments(filter), | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| ]); | ||
|
|
||
| return res.status(200).json({ | ||
| success: true, | ||
| data: { | ||
| logs, | ||
| pagination: { | ||
| page, | ||
| limit, | ||
| total, | ||
| totalPages: Math.ceil(total / limit), | ||
| }, | ||
| }, | ||
| message: 'Configuration change logs retrieved successfully.', | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.