Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions apps/dashboard-api/src/__tests__/configLog.controller.test.js
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 apps/dashboard-api/src/controllers/configLog.controller.js
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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.sort({ changedAt: -1 })
.skip(skip)
.limit(limit)
.select('-__v')
.lean(),
ProjectConfigLog.countDocuments(filter),
Comment thread
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.',
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
next(err);
}
};
Loading
Loading