diff --git a/src/__tests__/admin.user.status.test.js b/src/__tests__/admin.user.status.test.js new file mode 100644 index 0000000..b0e418d --- /dev/null +++ b/src/__tests__/admin.user.status.test.js @@ -0,0 +1,145 @@ +const request = require('supertest'); + +jest.mock('../models/User.model', () => ({ + findOne: jest.fn(), + findById: jest.fn(), + find: jest.fn(), + findByIdAndUpdate: jest.fn(), +})); + +jest.mock('jsonwebtoken', () => ({ + verify: jest.fn(), + sign: jest.fn(), + decode: jest.fn(), +})); + +const User = require('../models/User.model'); +const jwt = require('jsonwebtoken'); +const app = require('../app'); + +const adminToken = 'Bearer admin-token'; +const adminPayload = { sub: 'admin-id', email: 'admin@example.com', role: 'admin', type: 'access' }; +const adminUser = { _id: 'admin-id', id: 'admin-id', email: 'admin@example.com', role: 'admin', deletedAt: null }; + +beforeAll(() => { + process.env.JWT_SECRET = 'test-secret'; +}); + +beforeEach(() => { + jest.clearAllMocks(); + jwt.verify.mockReturnValue(adminPayload); + User.findById.mockResolvedValue(adminUser); +}); + +describe('PATCH /api/admin/users/:id/status', () => { + const targetUser = { + id: 'user-id-123', + email: 'user@example.com', + fullName: 'Test User', + status: 'suspended', + }; + + it('suspends an active user', async () => { + User.findByIdAndUpdate.mockReturnValue({ + select: jest.fn().mockResolvedValue({ ...targetUser, status: 'suspended' }), + }); + + const res = await request(app) + .patch('/api/admin/users/user-id-123/status') + .set('Authorization', adminToken) + .send({ status: 'suspended' }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.status).toBe('suspended'); + }); + + it('reactivates a suspended user', async () => { + User.findByIdAndUpdate.mockReturnValue({ + select: jest.fn().mockResolvedValue({ ...targetUser, status: 'active' }), + }); + + const res = await request(app) + .patch('/api/admin/users/user-id-123/status') + .set('Authorization', adminToken) + .send({ status: 'active' }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.status).toBe('active'); + }); + + it('returns 400 for an invalid status value', async () => { + const res = await request(app) + .patch('/api/admin/users/user-id-123/status') + .set('Authorization', adminToken) + .send({ status: 'banned' }) + .expect(400); + + expect(res.body.success).toBe(false); + }); + + it('returns 404 when user does not exist', async () => { + User.findByIdAndUpdate.mockReturnValue({ + select: jest.fn().mockResolvedValue(null), + }); + + const res = await request(app) + .patch('/api/admin/users/nonexistent-id/status') + .set('Authorization', adminToken) + .send({ status: 'suspended' }) + .expect(404); + + expect(res.body.success).toBe(false); + }); + + it('returns 403 when admin tries to change their own status', async () => { + const res = await request(app) + .patch('/api/admin/users/admin-id/status') + .set('Authorization', adminToken) + .send({ status: 'suspended' }) + .expect(403); + + expect(res.body.success).toBe(false); + }); + + it('returns 401 without auth token', async () => { + const res = await request(app) + .patch('/api/admin/users/user-id-123/status') + .send({ status: 'suspended' }) + .expect(401); + + expect(res.body.success).toBe(false); + }); +}); + +describe('POST /api/auth/login - suspended user', () => { + it('returns 403 when a suspended user tries to log in', async () => { + jest.mock('bcryptjs', () => ({ compare: jest.fn().mockResolvedValue(true) })); + + const suspendedUser = { + _id: 'user-id-123', + email: 'suspended@example.com', + role: 'user', + isVerified: true, + status: 'suspended', + password: 'hashed-password', + save: jest.fn(), + }; + + User.findOne.mockReturnValue({ + select: jest.fn().mockResolvedValue(suspendedUser), + }); + + const bcrypt = require('bcryptjs'); + bcrypt.compare = jest.fn().mockResolvedValue(true); + + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'suspended@example.com', password: 'password123' }) + .expect(403); + + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/suspended/i); + }); +}); diff --git a/src/controllers/admin.users.controller.js b/src/controllers/admin.users.controller.js index 06ccee4..1acc80e 100644 --- a/src/controllers/admin.users.controller.js +++ b/src/controllers/admin.users.controller.js @@ -1,6 +1,40 @@ const User = require('../models/User.model'); const { sendSuccess, sendError } = require('../utils/response'); +/** + * Suspend or activate a user account (admin only) + * @route PATCH /api/admin/users/:id/status + * @access Admin only + */ +const updateUserStatus = async (req, res, next) => { + try { + const { id } = req.params; + const { status } = req.body; + + if (!['active', 'suspended'].includes(status)) { + return sendError(res, 'Status must be "active" or "suspended"', 400); + } + + if (id === req.userId) { + return sendError(res, 'You cannot change your own account status', 403); + } + + const user = await User.findByIdAndUpdate( + id, + { status }, + { new: true, runValidators: true } + ).select('id email fullName status'); + + if (!user) { + return sendError(res, 'User not found', 404); + } + + return sendSuccess(res, { id: user.id, email: user.email, fullName: user.fullName, status: user.status }, 200, `User account ${status} successfully`); + } catch (error) { + next(error); + } +}; + /** * Soft delete a user by ID (admin only) * @route DELETE /api/admin/users/:id @@ -132,5 +166,6 @@ module.exports = { deleteUser, restoreUser, listUsers, + updateUserStatus, updateUserRole, }; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 55d3d3d..3e89ed4 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -161,6 +161,14 @@ const login = async (req, res, next) => { return next(error); } + // Reject if user account is suspended + if (user.status === 'suspended') { + const error = new Error('Your account has been suspended. Please contact support.'); + error.statusCode = 403; + error.isOperational = true; + return next(error); + } + const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { const error = new Error('Invalid credentials'); diff --git a/src/models/User.model.js b/src/models/User.model.js index 1ef0aaf..7cc90fe 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -56,6 +56,11 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, + status: { + type: String, + enum: ['active', 'suspended'], + default: 'active', + }, kycStatus: { type: String, enum: ['pending', 'approved', 'rejected'], diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js index b7e15f7..1bc0564 100644 --- a/src/routes/admin.routes.js +++ b/src/routes/admin.routes.js @@ -5,6 +5,7 @@ const { deleteUser, restoreUser, listUsers, + updateUserStatus, updateUserRole, } = require('../controllers/admin.users.controller'); @@ -23,6 +24,8 @@ router.delete('/users/:id', deleteUser); // POST /api/admin/users/:id/restore - Restore a soft-deleted user router.post('/users/:id/restore', restoreUser); +// PATCH /api/admin/users/:id/status - Suspend or activate a user +router.patch('/users/:id/status', updateUserStatus); // PATCH /api/admin/users/:id/role - Update a user role router.patch('/users/:id/role', updateUserRole);