Skip to content

Commit 4a7b936

Browse files
authored
Merge pull request #66 from DIFoundation/feat/admin-role-change
feat: add admin role change endpoint with self-demotion protection
2 parents e200ccf + 3e460d3 commit 4a7b936

4 files changed

Lines changed: 156 additions & 2 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const User = require('../models/User.model');
2+
const { updateUserRole } = require('../controllers/admin.users.controller');
3+
4+
jest.mock('../models/User.model', () => ({
5+
findById: jest.fn(),
6+
}));
7+
8+
describe('Admin user role management', () => {
9+
let req;
10+
let res;
11+
let next;
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
16+
req = {
17+
params: { id: '507f1f77bcf86cd799439011' },
18+
body: { role: 'admin' },
19+
userId: '507f1f77bcf86cd799439012',
20+
};
21+
22+
res = {
23+
status: jest.fn().mockReturnThis(),
24+
json: jest.fn().mockReturnThis(),
25+
};
26+
27+
next = jest.fn();
28+
});
29+
30+
it('allows an admin to promote another user', async () => {
31+
const user = {
32+
id: '507f1f77bcf86cd799439011',
33+
email: 'alice@example.com',
34+
role: 'user',
35+
save: jest.fn().mockResolvedValue(undefined),
36+
};
37+
38+
User.findById.mockResolvedValue(user);
39+
40+
await updateUserRole(req, res, next);
41+
42+
expect(User.findById).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
43+
expect(user.role).toBe('admin');
44+
expect(user.save).toHaveBeenCalledTimes(1);
45+
expect(res.status).toHaveBeenCalledWith(200);
46+
expect(res.json).toHaveBeenCalledWith({
47+
success: true,
48+
statusCode: 200,
49+
message: 'User role updated successfully',
50+
data: {
51+
id: '507f1f77bcf86cd799439011',
52+
email: 'alice@example.com',
53+
role: 'admin',
54+
},
55+
});
56+
expect(next).not.toHaveBeenCalled();
57+
});
58+
59+
it('rejects invalid role values', async () => {
60+
req.body.role = 'superadmin';
61+
62+
await updateUserRole(req, res, next);
63+
64+
expect(User.findById).not.toHaveBeenCalled();
65+
expect(res.status).toHaveBeenCalledWith(400);
66+
expect(res.json).toHaveBeenCalledWith({
67+
success: false,
68+
statusCode: 400,
69+
message: 'Role must be either user or admin',
70+
data: {},
71+
});
72+
expect(next).not.toHaveBeenCalled();
73+
});
74+
75+
it('blocks admins from downgrading their own role', async () => {
76+
req.userId = '507f1f77bcf86cd799439011';
77+
req.body.role = 'user';
78+
79+
await updateUserRole(req, res, next);
80+
81+
expect(User.findById).not.toHaveBeenCalled();
82+
expect(res.status).toHaveBeenCalledWith(403);
83+
expect(res.json).toHaveBeenCalledWith({
84+
success: false,
85+
statusCode: 403,
86+
message: 'You cannot downgrade your own role',
87+
data: {},
88+
});
89+
expect(next).not.toHaveBeenCalled();
90+
});
91+
92+
it('returns 404 when the user does not exist', async () => {
93+
User.findById.mockResolvedValue(null);
94+
95+
await updateUserRole(req, res, next);
96+
97+
expect(User.findById).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
98+
expect(res.status).toHaveBeenCalledWith(404);
99+
expect(res.json).toHaveBeenCalledWith({
100+
success: false,
101+
statusCode: 404,
102+
message: 'User not found',
103+
data: {},
104+
});
105+
expect(next).not.toHaveBeenCalled();
106+
});
107+
});

src/controllers/admin.users.controller.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,51 @@ const listUsers = async (req, res, next) => {
8686
}
8787
};
8888

89+
/**
90+
* Update a user role (admin only)
91+
* @route PATCH /api/admin/users/:id/role
92+
* @access Admin only
93+
*/
94+
const updateUserRole = async (req, res, next) => {
95+
try {
96+
const { id } = req.params;
97+
const { role } = req.body;
98+
99+
const validRoles = ['user', 'admin'];
100+
if (!role || !validRoles.includes(role)) {
101+
return sendError(res, 'Role must be either user or admin', 400);
102+
}
103+
104+
if (req.userId === id && role === 'user') {
105+
return sendError(res, 'You cannot downgrade your own role', 403);
106+
}
107+
108+
const user = await User.findById(id);
109+
if (!user) {
110+
return sendError(res, 'User not found', 404);
111+
}
112+
113+
user.role = role;
114+
await user.save();
115+
116+
return sendSuccess(
117+
res,
118+
{
119+
id: user.id,
120+
email: user.email,
121+
role: user.role,
122+
},
123+
200,
124+
'User role updated successfully'
125+
);
126+
} catch (error) {
127+
next(error);
128+
}
129+
};
130+
89131
module.exports = {
90132
deleteUser,
91133
restoreUser,
92134
listUsers,
135+
updateUserRole,
93136
};

src/models/User.model.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ const userSchema = new mongoose.Schema(
8484
type: Date,
8585
default: null,
8686
},
87-
},
88-
{
8987
timestamps: true,
9088
}
89+
}
90+
9191
);
9292

9393
// Middleware to exclude soft-deleted users from all queries

src/routes/admin.routes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
deleteUser,
66
restoreUser,
77
listUsers,
8+
updateUserRole,
89
} = require('../controllers/admin.users.controller');
910

1011
const router = express.Router();
@@ -22,4 +23,7 @@ router.delete('/users/:id', deleteUser);
2223
// POST /api/admin/users/:id/restore - Restore a soft-deleted user
2324
router.post('/users/:id/restore', restoreUser);
2425

26+
// PATCH /api/admin/users/:id/role - Update a user role
27+
router.patch('/users/:id/role', updateUserRole);
28+
2529
module.exports = router;

0 commit comments

Comments
 (0)