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
145 changes: 145 additions & 0 deletions src/__tests__/admin.user.status.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions src/controllers/admin.users.controller.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -132,5 +166,6 @@ module.exports = {
deleteUser,
restoreUser,
listUsers,
updateUserStatus,
updateUserRole,
};
8 changes: 8 additions & 0 deletions src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions src/models/User.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
3 changes: 3 additions & 0 deletions src/routes/admin.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
deleteUser,
restoreUser,
listUsers,
updateUserStatus,
updateUserRole,
} = require('../controllers/admin.users.controller');

Expand All @@ -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);

Expand Down
Loading