Skip to content
Closed
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
136 changes: 136 additions & 0 deletions src/__tests__/AdminService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Repository } from 'typeorm';
import { AdminService } from '../services/AdminService';
import { User, UserRole } from '../entities/User';
import AppDataSource from '../config/db';
import { AppError } from '../errors/AppError';

jest.mock('../config/db', () => ({
__esModule: true,
default: {
getRepository: jest.fn(),
},
}));

describe('AdminService', () => {
let adminService: AdminService;
let mockUserRepo: jest.Mocked<Repository<User>>;

beforeEach(() => {
jest.clearAllMocks();

mockUserRepo = {
findOneBy: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<Repository<User>>;

(AppDataSource.getRepository as jest.Mock).mockReturnValue(mockUserRepo);

adminService = new AdminService();
});

describe('assignRole', () => {
const userId = 'user-123';

it('should successfully assign a role to a user', async () => {
const mockUser: Partial<User> = {
id: userId,
email: 'test@example.com',
username: 'testuser',
role: UserRole.LISTENER,
};

const updatedUser: Partial<User> = {
...mockUser,
role: UserRole.ARTIST,
};

mockUserRepo.findOneBy.mockResolvedValue(mockUser as User);
mockUserRepo.save.mockResolvedValue(updatedUser as User);

const result = await adminService.assignRole(userId, UserRole.ARTIST);

expect(mockUserRepo.findOneBy).toHaveBeenCalledWith({ id: userId });
expect(mockUserRepo.save).toHaveBeenCalled();
expect(result.role).toBe(UserRole.ARTIST);
});

it('should assign MODERATOR role', async () => {
const mockUser: Partial<User> = {
id: userId,
email: 'test@example.com',
role: UserRole.LISTENER,
};

mockUserRepo.findOneBy.mockResolvedValue(mockUser as User);
mockUserRepo.save.mockImplementation(async (user) => user as User);

const result = await adminService.assignRole(userId, UserRole.MODERATOR);

expect(result.role).toBe(UserRole.MODERATOR);
});

it('should assign SUPER_ADMIN role', async () => {
const mockUser: Partial<User> = {
id: userId,
email: 'test@example.com',
role: UserRole.ADMIN,
};

mockUserRepo.findOneBy.mockResolvedValue(mockUser as User);
mockUserRepo.save.mockImplementation(async (user) => user as User);

const result = await adminService.assignRole(userId, UserRole.SUPER_ADMIN);

expect(result.role).toBe(UserRole.SUPER_ADMIN);
});

it('should throw AppError when user not found', async () => {
mockUserRepo.findOneBy.mockResolvedValue(null);

await expect(adminService.assignRole(userId, UserRole.ARTIST)).rejects.toThrow(AppError);

await expect(adminService.assignRole(userId, UserRole.ARTIST)).rejects.toThrow(/not found/i);
});

it('should throw AppError when userId is missing', async () => {
await expect(adminService.assignRole('', UserRole.ARTIST)).rejects.toThrow(AppError);
});

it('should throw AppError when role is missing', async () => {
await expect(adminService.assignRole(userId, '' as UserRole)).rejects.toThrow(AppError);
});

it('should throw AppError for invalid role', async () => {
const mockUser: Partial<User> = {
id: userId,
email: 'test@example.com',
role: UserRole.LISTENER,
};

mockUserRepo.findOneBy.mockResolvedValue(mockUser as User);

await expect(adminService.assignRole(userId, 'invalid_role' as UserRole)).rejects.toThrow(
AppError,
);

await expect(adminService.assignRole(userId, 'invalid_role' as UserRole)).rejects.toThrow(
/Invalid role/i,
);
});

it('should allow reassigning the same role', async () => {
const mockUser: Partial<User> = {
id: userId,
email: 'test@example.com',
role: UserRole.ARTIST,
};

mockUserRepo.findOneBy.mockResolvedValue(mockUser as User);
mockUserRepo.save.mockImplementation(async (user) => user as User);

const result = await adminService.assignRole(userId, UserRole.ARTIST);

expect(result.role).toBe(UserRole.ARTIST);
});
});
});
142 changes: 142 additions & 0 deletions src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
Permission,
ROLE_PERMISSIONS,
hasPermission,
hasAllPermissions,
hasAnyPermission,
} from '../types/permissions';
import { UserRole } from '../entities/User';

describe('Permission System', () => {
describe('ROLE_PERMISSIONS', () => {
it('should have permissions defined for all roles', () => {
expect(ROLE_PERMISSIONS[UserRole.LISTENER]).toBeDefined();
expect(ROLE_PERMISSIONS[UserRole.ARTIST]).toBeDefined();
expect(ROLE_PERMISSIONS[UserRole.MODERATOR]).toBeDefined();
expect(ROLE_PERMISSIONS[UserRole.ADMIN]).toBeDefined();
expect(ROLE_PERMISSIONS[UserRole.SUPER_ADMIN]).toBeDefined();
});

it('LISTENER should have minimal permissions', () => {
const permissions = ROLE_PERMISSIONS[UserRole.LISTENER];
expect(permissions).toContain(Permission.UPDATE_OWN_PROFILE);
expect(permissions).not.toContain(Permission.UPLOAD_SONG);
expect(permissions).not.toContain(Permission.DELETE_USER);
});

it('ARTIST should have song upload permissions', () => {
const permissions = ROLE_PERMISSIONS[UserRole.ARTIST];
expect(permissions).toContain(Permission.UPLOAD_SONG);
expect(permissions).toContain(Permission.DELETE_OWN_SONG);
expect(permissions).not.toContain(Permission.DELETE_ANY_SONG);
expect(permissions).not.toContain(Permission.ASSIGN_ROLE);
});

it('MODERATOR should have moderation permissions', () => {
const permissions = ROLE_PERMISSIONS[UserRole.MODERATOR];
expect(permissions).toContain(Permission.FLAG_SONG);
expect(permissions).toContain(Permission.MODERATE_SONG);
expect(permissions).toContain(Permission.MODERATE_COMMENTS);
expect(permissions).not.toContain(Permission.DELETE_USER);
expect(permissions).not.toContain(Permission.ASSIGN_ROLE);
});

it('ADMIN should have most permissions except role assignment', () => {
const permissions = ROLE_PERMISSIONS[UserRole.ADMIN];
expect(permissions).toContain(Permission.DELETE_USER);
expect(permissions).toContain(Permission.DELETE_ANY_SONG);
expect(permissions).toContain(Permission.MANAGE_JOBS);
expect(permissions).not.toContain(Permission.ASSIGN_ROLE);
});

it('SUPER_ADMIN should have all permissions including role assignment', () => {
const permissions = ROLE_PERMISSIONS[UserRole.SUPER_ADMIN];
expect(permissions).toContain(Permission.ASSIGN_ROLE);
expect(permissions).toContain(Permission.DELETE_USER);
expect(permissions).toContain(Permission.MANAGE_JOBS);
expect(permissions).toContain(Permission.UPLOAD_SONG);
});
});

describe('hasPermission', () => {
it('should return true when role has the permission', () => {
expect(hasPermission(UserRole.ARTIST, Permission.UPLOAD_SONG)).toBe(true);
expect(hasPermission(UserRole.MODERATOR, Permission.FLAG_SONG)).toBe(true);
expect(hasPermission(UserRole.SUPER_ADMIN, Permission.ASSIGN_ROLE)).toBe(true);
});

it('should return false when role does not have the permission', () => {
expect(hasPermission(UserRole.LISTENER, Permission.UPLOAD_SONG)).toBe(false);
expect(hasPermission(UserRole.ARTIST, Permission.DELETE_USER)).toBe(false);
expect(hasPermission(UserRole.ADMIN, Permission.ASSIGN_ROLE)).toBe(false);
});
});

describe('hasAllPermissions', () => {
it('should return true when role has all specified permissions', () => {
expect(
hasAllPermissions(UserRole.ARTIST, [Permission.UPLOAD_SONG, Permission.DELETE_OWN_SONG]),
).toBe(true);

expect(
hasAllPermissions(UserRole.MODERATOR, [Permission.FLAG_SONG, Permission.MODERATE_SONG]),
).toBe(true);
});

it('should return false when role is missing any permission', () => {
expect(
hasAllPermissions(UserRole.ARTIST, [Permission.UPLOAD_SONG, Permission.DELETE_USER]),
).toBe(false);

expect(
hasAllPermissions(UserRole.MODERATOR, [Permission.FLAG_SONG, Permission.ASSIGN_ROLE]),
).toBe(false);
});

it('should return true for empty permission array', () => {
expect(hasAllPermissions(UserRole.LISTENER, [])).toBe(true);
});
});

describe('hasAnyPermission', () => {
it('should return true when role has at least one permission', () => {
expect(
hasAnyPermission(UserRole.ARTIST, [Permission.UPLOAD_SONG, Permission.DELETE_USER]),
).toBe(true);

expect(
hasAnyPermission(UserRole.MODERATOR, [Permission.FLAG_SONG, Permission.ASSIGN_ROLE]),
).toBe(true);
});

it('should return false when role has none of the permissions', () => {
expect(
hasAnyPermission(UserRole.LISTENER, [Permission.DELETE_USER, Permission.ASSIGN_ROLE]),
).toBe(false);

expect(
hasAnyPermission(UserRole.ARTIST, [Permission.DELETE_USER, Permission.ASSIGN_ROLE]),
).toBe(false);
});

it('should return false for empty permission array', () => {
expect(hasAnyPermission(UserRole.ADMIN, [])).toBe(false);
});
});

describe('Permission boundaries', () => {
it('should ensure LISTENER cannot perform admin actions', () => {
expect(hasPermission(UserRole.LISTENER, Permission.DELETE_USER)).toBe(false);
expect(hasPermission(UserRole.LISTENER, Permission.MANAGE_JOBS)).toBe(false);
expect(hasPermission(UserRole.LISTENER, Permission.ASSIGN_ROLE)).toBe(false);
});

it('should ensure only SUPER_ADMIN can assign roles', () => {
expect(hasPermission(UserRole.LISTENER, Permission.ASSIGN_ROLE)).toBe(false);
expect(hasPermission(UserRole.ARTIST, Permission.ASSIGN_ROLE)).toBe(false);
expect(hasPermission(UserRole.MODERATOR, Permission.ASSIGN_ROLE)).toBe(false);
expect(hasPermission(UserRole.ADMIN, Permission.ASSIGN_ROLE)).toBe(false);
expect(hasPermission(UserRole.SUPER_ADMIN, Permission.ASSIGN_ROLE)).toBe(true);
});
});
});
44 changes: 44 additions & 0 deletions src/controllers/AdminController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Request, Response } from 'express';
import { AdminService } from '../services/AdminService';
import { handleError } from '../utils/helpers';
import { HTTP_STATUS } from '../config/constants';

/**
* Controller for administrative actions.
* All endpoints require appropriate admin-level permissions.
*/
export class AdminController {
private adminService: AdminService;

constructor() {
this.adminService = new AdminService();
}

/**
* Assign a role to a user.
* POST /api/admin/users/:id/role
*
* @param req - Express request with user ID in params and role in body
* @param res - Express response
*/
assignRole = async (req: Request, res: Response): Promise<void> => {
try {
const userId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const { role } = req.body;

const updatedUser = await this.adminService.assignRole(userId, role);

res.status(HTTP_STATUS.OK).json({
message: 'Role assigned successfully',
user: {
id: updatedUser.id,
email: updatedUser.email,
username: updatedUser.username,
role: updatedUser.role,
},
});
} catch (error) {
handleError(req, res, error);
}
};
}
14 changes: 14 additions & 0 deletions src/dtos/AssignRoleDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsEnum, IsNotEmpty } from 'class-validator';
import { UserRole } from '../entities/User';

/**
* DTO for assigning a role to a user.
* Used by the admin role assignment endpoint.
*/
export class AssignRoleDTO {
@IsEnum(UserRole, {
message: `Role must be one of: ${Object.values(UserRole).join(', ')}`,
})
@IsNotEmpty({ message: 'Role is required' })
role!: UserRole;
}
2 changes: 2 additions & 0 deletions src/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { RoyaltyPayout } from './RoyaltyPayout';
export enum UserRole {
LISTENER = 'listener',
ARTIST = 'artist',
MODERATOR = 'moderator',
ADMIN = 'admin',
SUPER_ADMIN = 'super_admin',
}

@Entity('users')
Expand Down
9 changes: 9 additions & 0 deletions src/errors/AppError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ export class AppError extends Error {
return new AppError(message, ErrorType.FORBIDDEN, 403, true, details, code);
}

/** Alias for authorization - semantically clearer for 403 errors */
static forbidden(
message: string,
details?: ErrorDetails | ErrorDetails[],
code?: string,
): AppError {
return AppError.authorization(message, details, code);
}

static notFound(
message: string,
details?: ErrorDetails | ErrorDetails[],
Expand Down
Loading
Loading