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
50 changes: 30 additions & 20 deletions apps/dashboard-api/src/__tests__/authMiddleware.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
'use strict';

class AppError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}

jest.mock('@urbackend/common', () => ({
AppError,
}));

jest.mock('jsonwebtoken');

const jwt = require('jsonwebtoken');
Expand Down Expand Up @@ -29,7 +41,7 @@ describe('authMiddleware', () => {
expect(jwt.verify).toHaveBeenCalledWith('cookietoken', 'test-secret');
expect(req.user).toEqual({ _id: 'user1' });
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith();
});
});

Expand Down Expand Up @@ -93,38 +105,36 @@ describe('authMiddleware', () => {

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Access Denied: No Token Provided',
});
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
expect(next.mock.calls[0][0].message).toBe('Access Denied: No Token Provided');
});

test('rejects header with wrong scheme (Token)', () => {
req.header.mockReturnValue('Token sometoken');

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
});

test('rejects header with no scheme (bare token only)', () => {
req.header.mockReturnValue('justtoken');

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
});

test('rejects empty Authorization header', () => {
req.header.mockReturnValue('');

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
});

test('rejects header with extra whitespace but no token value', () => {
Expand All @@ -134,8 +144,8 @@ describe('authMiddleware', () => {

// After trimming and splitting on whitespace, only the scheme 'Bearer'
// remains and there is no token part, so it's treated as missing token.
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
});
});

Expand All @@ -150,9 +160,9 @@ describe('authMiddleware', () => {

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Token' });
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
expect(next.mock.calls[0][0].message).toBe('Invalid Token');
});

test('rejects a malformed / invalid JWT', () => {
Expand All @@ -165,9 +175,9 @@ describe('authMiddleware', () => {

authMiddleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Token' });
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(AppError));
expect(next.mock.calls[0][0].statusCode).toBe(401);
expect(next.mock.calls[0][0].message).toBe('Invalid Token');
});

test('attaches decoded payload to req.user on success', () => {
Expand Down
50 changes: 38 additions & 12 deletions apps/dashboard-api/src/__tests__/auth_limiter.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
'use strict';

class AppError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}

jest.mock('@urbackend/common', () => ({
AppError,
}));

const express = require('express');
const request = require('supertest');
const { authLimiter } = require('../middlewares/auth_limiter');

// Helper: creates a test app with the limiter and a global error handler
// that mirrors the real dashboard-api error handler behavior.
const createTestApp = () => {
const app = express();
app.use(authLimiter);
app.get('/test', (_req, res) => res.json({ ok: true }));

// Global error handler (mirrors dashboard-api app.js)
app.use((err, _req, res, _next) => {
if (err.isOperational && err.statusCode) {
return res.status(err.statusCode).json({
success: false,
error: 'Error',
message: err.message,
});
}
res.status(500).json({ error: 'Internal Server Error' });
});

return app;
};

describe('authLimiter', () => {
test('exports authLimiter as a middleware function', () => {
expect(typeof authLimiter).toBe('function');
Expand All @@ -14,9 +48,7 @@ describe('authLimiter', () => {
try {
process.env.NODE_ENV = 'development';

const app = express();
app.use(authLimiter);
app.get('/test', (_req, res) => res.json({ ok: true }));
const app = createTestApp();

// Exceed the configured max (10) β€” all should still succeed because
// the limiter is skipped in development.
Expand All @@ -34,9 +66,7 @@ describe('authLimiter', () => {
try {
process.env.NODE_ENV = 'production';

const app = express();
app.use(authLimiter);
app.get('/test', (_req, res) => res.json({ ok: true }));
const app = createTestApp();

// Exhaust the 10-request window.
for (let i = 0; i < 10; i++) {
Expand All @@ -47,9 +77,7 @@ describe('authLimiter', () => {
// The 11th request should be rate-limited.
const blocked = await request(app).get('/test');
expect(blocked.status).toBe(429);
expect(blocked.body).toEqual({
error: 'Too many attempts. Please try again in 15 minutes.',
});
expect(blocked.body.message).toBe('Too many attempts. Please try again in 15 minutes.');
} finally {
process.env.NODE_ENV = originalEnv;
}
Expand All @@ -60,9 +88,7 @@ describe('authLimiter', () => {
try {
process.env.NODE_ENV = 'production';

const app = express();
app.use(authLimiter);
app.get('/test', (_req, res) => res.json({ ok: true }));
const app = createTestApp();

const res = await request(app).get('/test');
// standardHeaders:true β†’ RateLimit-* headers should be present.
Expand Down
71 changes: 71 additions & 0 deletions apps/dashboard-api/src/__tests__/loadProjectForAdmin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';

class AppError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}

jest.mock('@urbackend/common', () => ({
AppError,
Project: {
findOne: jest.fn()
}
}));

const { Project } = require('@urbackend/common');
const loadProjectForAdmin = require('../middlewares/loadProjectForAdmin');


describe('loadProjectForAdmin Middleware', () => {
let req, res, next;

beforeEach(() => {
req = {
params: {},
user: { _id: 'user123' }
};
res = {};
next = jest.fn();
jest.clearAllMocks();
});

it('should call next with AppError(400) if projectId is missing', async () => {
await loadProjectForAdmin(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(expect.any(AppError));
const error = next.mock.calls[0][0];
expect(error.statusCode).toBe(400);
expect(error.message).toBe("Project ID is required");
expect(Project.findOne).not.toHaveBeenCalled();
});

it('should call next with AppError(404) if project is not found', async () => {
req.params.projectId = 'proj123';
Project.findOne.mockResolvedValueOnce(null);

await loadProjectForAdmin(req, res, next);

expect(Project.findOne).toHaveBeenCalledWith({ _id: 'proj123', owner: 'user123' });
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith(expect.any(AppError));
const error = next.mock.calls[0][0];
expect(error.statusCode).toBe(404);
expect(error.message).toBe("Project not found or access denied");
});

it('should set req.project and call next without error if project is found', async () => {
req.params.projectId = 'proj123';
const mockProject = { _id: 'proj123', name: 'Test Project' };
Project.findOne.mockResolvedValueOnce(mockProject);

await loadProjectForAdmin(req, res, next);

expect(Project.findOne).toHaveBeenCalledWith({ _id: 'proj123', owner: 'user123' });
expect(req.project).toEqual(mockProject);
expect(next).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith();
});
});
5 changes: 3 additions & 2 deletions apps/dashboard-api/src/middlewares/authMiddleware.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const jwt = require('jsonwebtoken');
const { AppError } = require('@urbackend/common');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

module.exports = function (req, res, next) {
// Check for token in cookies (Primary for Web)
Expand All @@ -17,7 +18,7 @@ module.exports = function (req, res, next) {

// Check if any token was provided
if (!token) {
return res.status(401).json({ error: 'Access Denied: No Token Provided' });
return next(new AppError(401, 'Access Denied: No Token Provided'));
}

try {
Expand All @@ -35,6 +36,6 @@ module.exports = function (req, res, next) {
console.error(err);
}

res.status(400).json({ error: 'Invalid Token' });
return next(new AppError(401, 'Invalid Token'));
}
};
5 changes: 3 additions & 2 deletions apps/dashboard-api/src/middlewares/auth_limiter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const rateLimit = require('express-rate-limit');
const { AppError } = require('@urbackend/common');

// limiter for sensitive auth endpoints (login, register)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: { error: "Too many attempts. Please try again in 15 minutes." },
handler: (req, res, next) => next(new AppError(429, "Too many attempts. Please try again in 15 minutes.")),
skip: (req) => process.env.NODE_ENV === 'development',
standardHeaders: true,
legacyHeaders: false,
Expand All @@ -13,7 +14,7 @@ const authLimiter = rateLimit({
const publicLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 500,
message: { error: "Too many requests. Please try again later." },
handler: (req, res, next) => next(new AppError(429, "Too many requests. Please try again later.")),
skip: (req) => process.env.NODE_ENV === 'development',
standardHeaders: true,
legacyHeaders: false,
Expand Down
9 changes: 5 additions & 4 deletions apps/dashboard-api/src/middlewares/loadProjectForAdmin.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
// FUNCTION - LOAD PROJECT FOR ADMIN (MIDDLEWARE)
const Project = require('../models/Project');

const { Project, AppError } = require('@urbackend/common');

module.exports = async (req, res, next) => {
try {
const { projectId } = req.params;
if (!projectId) return res.status(400).json({ error: "Project ID is required" });
if (!projectId) return next(new AppError(400, "Project ID is required"));

const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) {
return res.status(404).json({ error: "Project not found or access denied" });
return next(new AppError(404, "Project not found or access denied"));
}

req.project = project;
next();
} catch (err) {
res.status(500).json({ error: err.message });
next(err);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading