diff --git a/src/__tests__/SubscriptionService.test.ts b/src/__tests__/SubscriptionService.test.ts new file mode 100644 index 0000000..5bc00f1 --- /dev/null +++ b/src/__tests__/SubscriptionService.test.ts @@ -0,0 +1,271 @@ +/* eslint-disable max-lines-per-function */ +import { Repository } from 'typeorm'; +import { SubscriptionService } from '../services/SubscriptionService'; +import { Subscription, SubscriptionTier, SubscriptionStatus } from '../entities/Subscription'; +import AppDataSource from '../config/db'; +import { AppError } from '../errors/AppError'; + +jest.mock('../config/db', () => ({ + __esModule: true, + default: { + getRepository: jest.fn(), + }, +})); + +describe('SubscriptionService', () => { + let subscriptionService: SubscriptionService; + let mockSubscriptionRepo: jest.Mocked>; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSubscriptionRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked>; + + (AppDataSource.getRepository as jest.Mock).mockReturnValue(mockSubscriptionRepo); + + subscriptionService = new SubscriptionService(); + }); + + describe('getUserSubscription', () => { + const userId = 'user-123'; + + it('should return active subscription for user', async () => { + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.ARTIST_PRO, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + endDate: new Date('2027-01-01'), + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await subscriptionService.getUserSubscription(userId); + + expect(mockSubscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + }, + order: { + createdAt: 'DESC', + }, + }); + expect(result).toEqual(mockSubscription); + }); + + it('should return null if no active subscription found', async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + const result = await subscriptionService.getUserSubscription(userId); + + expect(result).toBeNull(); + }); + + it('should mark expired subscriptions and return null', async () => { + const expiredDate = new Date('2025-01-01'); + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.ARTIST_PRO, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2024-01-01'), + endDate: expiredDate, + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + mockSubscriptionRepo.save.mockResolvedValue(mockSubscription as Subscription); + + const result = await subscriptionService.getUserSubscription(userId); + + expect(mockSubscription.status).toBe(SubscriptionStatus.EXPIRED); + expect(mockSubscriptionRepo.save).toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should throw error for missing userId', async () => { + await expect(subscriptionService.getUserSubscription('')).rejects.toThrow(AppError); + }); + }); + + describe('createOrUpgradeSubscription', () => { + const userId = 'user-123'; + + it('should create new subscription for user without existing subscription', async () => { + const tier = SubscriptionTier.ARTIST_PRO; + const endDate = new Date('2027-01-01'); + + mockSubscriptionRepo.findOne.mockResolvedValue(null); + mockSubscriptionRepo.create.mockReturnValue({ + userId, + tier, + status: SubscriptionStatus.ACTIVE, + startDate: expect.any(Date), + endDate, + } as Subscription); + mockSubscriptionRepo.save.mockResolvedValue({ + id: 'sub-123', + userId, + tier, + status: SubscriptionStatus.ACTIVE, + startDate: new Date(), + endDate, + } as Subscription); + + const result = await subscriptionService.createOrUpgradeSubscription(userId, tier, endDate); + + expect(mockSubscriptionRepo.create).toHaveBeenCalled(); + expect(mockSubscriptionRepo.save).toHaveBeenCalled(); + expect(result.tier).toBe(tier); + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should upgrade existing subscription', async () => { + const existingSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.FREE, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + }; + + const newTier = SubscriptionTier.LABEL; + + mockSubscriptionRepo.findOne.mockResolvedValue(existingSubscription as Subscription); + mockSubscriptionRepo.save.mockResolvedValue({ + ...existingSubscription, + tier: newTier, + } as Subscription); + + const result = await subscriptionService.createOrUpgradeSubscription(userId, newTier); + + expect(result.tier).toBe(newTier); + expect(mockSubscriptionRepo.save).toHaveBeenCalled(); + }); + + it('should throw error for invalid tier', async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + await expect( + subscriptionService.createOrUpgradeSubscription(userId, 'invalid' as SubscriptionTier), + ).rejects.toThrow(AppError); + }); + + it('should throw error for missing userId', async () => { + await expect( + subscriptionService.createOrUpgradeSubscription('', SubscriptionTier.ARTIST_PRO), + ).rejects.toThrow(AppError); + }); + }); + + describe('cancelSubscription', () => { + const userId = 'user-123'; + + it('should cancel active subscription', async () => { + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.ARTIST_PRO, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + endDate: new Date('2027-01-01'), + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + mockSubscriptionRepo.save.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.CANCELLED, + } as Subscription); + + const result = await subscriptionService.cancelSubscription(userId); + + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + expect(mockSubscriptionRepo.save).toHaveBeenCalled(); + }); + + it('should throw error if no active subscription found', async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + await expect(subscriptionService.cancelSubscription(userId)).rejects.toThrow(AppError); + await expect(subscriptionService.cancelSubscription(userId)).rejects.toThrow( + /No active subscription/, + ); + }); + }); + + describe('hasTierAccess', () => { + const userId = 'user-123'; + + it('should return true for FREE tier when user has no subscription', async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.FREE); + + expect(result).toBe(true); + }); + + it('should return false for ARTIST_PRO when user has no subscription', async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO); + + expect(result).toBe(false); + }); + + it('should return true when user has exact required tier', async () => { + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.ARTIST_PRO, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + endDate: new Date('2027-01-01'), + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO); + + expect(result).toBe(true); + }); + + it('should return true when user has higher tier than required', async () => { + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.LABEL, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + endDate: new Date('2027-01-01'), + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.ARTIST_PRO); + + expect(result).toBe(true); + }); + + it('should return false when user has lower tier than required', async () => { + const mockSubscription: Partial = { + id: 'sub-123', + userId, + tier: SubscriptionTier.ARTIST_PRO, + status: SubscriptionStatus.ACTIVE, + startDate: new Date('2026-01-01'), + endDate: new Date('2027-01-01'), + }; + + mockSubscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await subscriptionService.hasTierAccess(userId, SubscriptionTier.LABEL); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/app.ts b/src/app.ts index 9e8d6ed..2e824fb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import royaltyTemplateRoutes from './routes/royaltyTemplateRoutes'; import chartRoutes from './routes/ChartRoutes'; import tagRoutes from './routes/TagRoutes'; import releaseRoutes from './routes/ReleaseRoutes'; +import subscriptionRoutes from './routes/subscriptionRoutes'; import { getPoolStats, checkDbHealth } from './services/DbPoolMonitor'; import { dbConnectionState } from './services/DatabaseConnectionManager'; import { JSON_BODY_LIMIT, URLENCODED_BODY_LIMIT } from './config/constants'; @@ -142,6 +143,10 @@ app.use('/api/admin', adminRoutes); // User profile routes app.use('/api/user', userRoutes); +// Subscription management routes (Issue #99) +app.use('/api/users', subscriptionRoutes); +app.use('/api/subscriptions', subscriptionRoutes); + //TWITTER CALLBACK ROUTE app.use('/api/auth/twitter', twitterRoutes); diff --git a/src/config/db.ts b/src/config/db.ts index 472540e..1585f76 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -14,6 +14,7 @@ import { Tag } from '../entities/Tag'; import { SongTag } from '../entities/SongTag'; import { Release } from '../entities/Release'; import { ReleaseTrack } from '../entities/ReleaseTrack'; +import { Subscription } from '../entities/Subscription'; dotenv.config(); @@ -82,6 +83,7 @@ const AppDataSource = new DataSource({ SongTag, Release, ReleaseTrack, + Subscription, ], migrations: ['src/migrations/*.ts', 'dist/migrations/*.js'], migrationsTableName: 'migrations', diff --git a/src/controllers/AdminController.ts b/src/controllers/AdminController.ts new file mode 100644 index 0000000..a2ada4d --- /dev/null +++ b/src/controllers/AdminController.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { UserService } from '../services/UserService'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; + +/** + * Admin-facing user & role management (Issue #100). + */ +export class AdminController { + private static userService = new UserService(); + + /** + * POST /api/admin/users/:id/role — assign a role to a user. + * + * Authorization is enforced by the route (admin-only). The request body is + * validated against {@link AssignRoleDTO} so `role` is always a valid + * {@link UserRole}. + */ + static assignRole = async (req: Request, res: Response) => { + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { role } = req.body; + + const user = await AdminController.userService.assignRole(id, role); + + return res.status(HTTP_STATUS.OK).json({ + success: true, + message: 'Role assigned successfully', + id: user.id, + role: user.role, + }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/controllers/SubscriptionController.ts b/src/controllers/SubscriptionController.ts new file mode 100644 index 0000000..e8da06e --- /dev/null +++ b/src/controllers/SubscriptionController.ts @@ -0,0 +1,127 @@ +import { Request, Response } from 'express'; +import { SubscriptionService } from '../services/SubscriptionService'; +import { SubscriptionTier } from '../entities/Subscription'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; + +/** + * Controller for subscription-related endpoints. + * Handles subscription management for authenticated users. + */ +export class SubscriptionController { + private subscriptionService: SubscriptionService; + + constructor() { + this.subscriptionService = new SubscriptionService(); + } + + /** + * Get the current user's subscription. + * GET /api/users/me/subscription + * + * @param req - Express request with authenticated user + * @param res - Express response + */ + getMySubscription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError(req, res, { message: 'User not authenticated', statusCode: 401 }); + } + + const subscription = await this.subscriptionService.getUserSubscription(userId); + + if (!subscription) { + res.status(HTTP_STATUS.OK).json({ + subscription: null, + tier: SubscriptionTier.FREE, + message: 'No active subscription - user is on FREE tier', + }); + return; + } + + res.status(HTTP_STATUS.OK).json({ + subscription: { + id: subscription.id, + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + }, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * Create or upgrade a subscription. + * POST /api/subscriptions + * + * @param req - Express request with tier and optional endDate in body + * @param res - Express response + */ + createOrUpgradeSubscription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError(req, res, { message: 'User not authenticated', statusCode: 401 }); + } + + const { tier, endDate } = req.body; + + const subscription = await this.subscriptionService.createOrUpgradeSubscription( + userId, + tier, + endDate ? new Date(endDate) : undefined, + ); + + res.status(HTTP_STATUS.CREATED).json({ + message: 'Subscription created/upgraded successfully', + subscription: { + id: subscription.id, + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + }, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * Cancel the current user's subscription. + * DELETE /api/subscriptions + * + * @param req - Express request with authenticated user + * @param res - Express response + */ + cancelSubscription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError(req, res, { message: 'User not authenticated', statusCode: 401 }); + } + + const subscription = await this.subscriptionService.cancelSubscription(userId); + + res.status(HTTP_STATUS.OK).json({ + message: 'Subscription cancelled successfully. Access will remain until end date.', + subscription: { + id: subscription.id, + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + }, + }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/controllers/__tests__/AdminController.test.ts b/src/controllers/__tests__/AdminController.test.ts new file mode 100644 index 0000000..eaac384 --- /dev/null +++ b/src/controllers/__tests__/AdminController.test.ts @@ -0,0 +1,54 @@ +import { AdminController } from '../AdminController'; +import { + createMockRequest, + createMockResponse, + assertSuccessResponse, + assertErrorResponse, +} from '../../../tests/helpers'; +import { UserRole } from '../../entities/User'; + +// handleError -> logRequestError imports the live Redis client, which opens a +// socket that keeps the process alive. Mock it so the suite exits cleanly. +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { incr: jest.fn(), expire: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn() }, +})); + +// Factory mock so the controller never loads the real UserService (which +// pulls in the DB data source at import time). AdminController instantiates +// UserService at module load, so the factory runs during import — reference +// mockAssignRole lazily through a wrapper to sidestep the const TDZ. +const mockAssignRole = jest.fn(); +jest.mock('../../services/UserService', () => ({ + UserService: jest.fn().mockImplementation(() => ({ + assignRole: (...args: unknown[]) => mockAssignRole(...args), + })), +})); + +describe('AdminController.assignRole (#100)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 200 with the updated id and role', async () => { + mockAssignRole.mockResolvedValue({ id: 'u1', role: UserRole.MODERATOR }); + const req = createMockRequest({ params: { id: 'u1' }, body: { role: UserRole.MODERATOR } }); + const res = createMockResponse(); + + await AdminController.assignRole(req, res as any); + + expect(mockAssignRole).toHaveBeenCalledWith('u1', UserRole.MODERATOR); + assertSuccessResponse(res, { status: 200, data: { id: 'u1', role: UserRole.MODERATOR } }); + }); + + it('surfaces a not-found service error as an error response', async () => { + const { AppError } = jest.requireActual('../../errors/AppError'); + mockAssignRole.mockRejectedValue(AppError.notFound('User not found')); + const req = createMockRequest({ params: { id: 'missing' }, body: { role: UserRole.ADMIN } }); + const res = createMockResponse(); + + await AdminController.assignRole(req, res as any); + + assertErrorResponse(res, { status: 404, message: 'User not found' }); + }); +}); diff --git a/src/dtos/AssignRoleDTO.ts b/src/dtos/AssignRoleDTO.ts new file mode 100644 index 0000000..7776695 --- /dev/null +++ b/src/dtos/AssignRoleDTO.ts @@ -0,0 +1,8 @@ +import { IsEnum } from 'class-validator'; +import { UserRole } from '../entities/User'; + +/** Body for `POST /api/admin/users/:id/role` — assigns a role to a user (#100). */ +export class AssignRoleDTO { + @IsEnum(UserRole) + role!: UserRole; +} diff --git a/src/dtos/CreateSubscriptionDTO.ts b/src/dtos/CreateSubscriptionDTO.ts new file mode 100644 index 0000000..bd130ac --- /dev/null +++ b/src/dtos/CreateSubscriptionDTO.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsOptional, IsDateString } from 'class-validator'; +import { SubscriptionTier } from '../entities/Subscription'; + +/** + * DTO for creating or upgrading a subscription. + */ +export class CreateSubscriptionDTO { + @IsEnum(SubscriptionTier, { + message: `Tier must be one of: ${Object.values(SubscriptionTier).join(', ')}`, + }) + tier!: SubscriptionTier; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/entities/Subscription.ts b/src/entities/Subscription.ts new file mode 100644 index 0000000..35215eb --- /dev/null +++ b/src/entities/Subscription.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from './User'; + +/** + * Subscription tier levels for the platform. + */ +export enum SubscriptionTier { + FREE = 'free', + ARTIST_PRO = 'artist_pro', + LABEL = 'label', +} + +/** + * Subscription status values. + */ +export enum SubscriptionStatus { + ACTIVE = 'active', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +/** + * Subscription entity representing a user's premium tier access. + * Tracks subscription tier, status, and validity period. + */ +@Entity('subscriptions') +@Index(['userId']) +@Index(['status']) +export class Subscription { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'uuid' }) + userId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @Column({ + type: 'enum', + enum: SubscriptionTier, + default: SubscriptionTier.FREE, + }) + tier!: SubscriptionTier; + + @Column({ + type: 'enum', + enum: SubscriptionStatus, + default: SubscriptionStatus.ACTIVE, + }) + status!: SubscriptionStatus; + + @Column({ type: 'timestamp' }) + startDate!: Date; + + @Column({ type: 'timestamp', nullable: true }) + endDate?: Date; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/User.ts b/src/entities/User.ts index 8af504a..1807775 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -6,19 +6,22 @@ import { CreateDateColumn, UpdateDateColumn, Unique, - OneToOne, OneToMany, } from 'typeorm'; import { Song } from './Song'; -import { Transaction } from 'ethers'; import { TransactionLog } from './TransactionLog'; import { Album } from './Album'; import { RoyaltyPayout } from './RoyaltyPayout'; export enum UserRole { + // `listener` is the default role for a regular platform user. LISTENER = 'listener', ARTIST = 'artist', + // `moderator` can act on content (flag/unflag) without full admin rights. + MODERATOR = 'moderator', ADMIN = 'admin', + // `super_admin` holds every permission, including managing other admins. + SUPER_ADMIN = 'super_admin', } @Entity('users') diff --git a/src/middlewares/__tests__/requirePermission.test.ts b/src/middlewares/__tests__/requirePermission.test.ts new file mode 100644 index 0000000..7aca08a --- /dev/null +++ b/src/middlewares/__tests__/requirePermission.test.ts @@ -0,0 +1,74 @@ +import { requirePermission } from '../authMiddleware'; +import { Permission } from '../../types/Permissions'; +import { UserRole } from '../../entities/User'; +import { + createMockRequest, + createAuthenticatedRequest, + createMockResponse, +} from '../../../tests/helpers'; + +// handleError -> logRequestError imports the live Redis client, which opens a +// socket that keeps the process alive. Mock it so the suite exits cleanly. +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { incr: jest.fn(), expire: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn() }, +})); + +const asRes = (res: ReturnType): any => res; + +describe('requirePermission (#100)', () => { + it('responds 401 when the request is unauthenticated', () => { + const req = createMockRequest(); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 403 (not 401) when an authenticated role lacks the permission', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.LISTENER } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('calls next() when the role holds the required permission', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.MODERATOR } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.CONTENT_MODERATE)(req, asRes(res), next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('allows super_admin through any permission gate', () => { + const req = createAuthenticatedRequest({ user: { role: UserRole.SUPER_ADMIN } }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.ROLE_ASSIGN)(req, asRes(res), next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('responds 403 when the token carries no role claim', () => { + const req = createAuthenticatedRequest({ user: {} }); + const res = createMockResponse(); + const next = jest.fn(); + + requirePermission(Permission.JOBS_VIEW)(req, asRes(res), next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/middlewares/__tests__/subscriptionMiddleware.test.ts b/src/middlewares/__tests__/subscriptionMiddleware.test.ts new file mode 100644 index 0000000..c7546eb --- /dev/null +++ b/src/middlewares/__tests__/subscriptionMiddleware.test.ts @@ -0,0 +1,127 @@ +/* eslint-disable max-lines-per-function */ +import { Request, Response, NextFunction } from 'express'; +import { requireTier, checkTierAccess } from '../subscriptionMiddleware'; +import { SubscriptionTier } from '../../entities/Subscription'; +import { SubscriptionService } from '../../services/SubscriptionService'; + +jest.mock('../../services/SubscriptionService'); + +describe('Subscription Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: NextFunction; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRequest = { + headers: {}, + }; + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + nextFunction = jest.fn(); + + mockSubscriptionService = { + hasTierAccess: jest.fn(), + } as any; + + (SubscriptionService as jest.Mock).mockImplementation(() => mockSubscriptionService); + }); + + describe('requireTier', () => { + it('should allow access when user has required tier', async () => { + (mockRequest as any).user = { + id: 'user-123', + role: 'artist', + }; + + mockSubscriptionService.hasTierAccess.mockResolvedValue(true); + + const middleware = requireTier(SubscriptionTier.ARTIST_PRO); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockSubscriptionService.hasTierAccess).toHaveBeenCalledWith( + 'user-123', + SubscriptionTier.ARTIST_PRO, + ); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should reject access when user lacks required tier (returns 403)', async () => { + (mockRequest as any).user = { + id: 'user-123', + role: 'artist', + }; + + mockSubscriptionService.hasTierAccess.mockResolvedValue(false); + + const middleware = requireTier(SubscriptionTier.LABEL); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockSubscriptionService.hasTierAccess).toHaveBeenCalledWith( + 'user-123', + SubscriptionTier.LABEL, + ); + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should reject access when user is not authenticated', async () => { + (mockRequest as any).user = undefined; + + const middleware = requireTier(SubscriptionTier.ARTIST_PRO); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockSubscriptionService.hasTierAccess).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + (mockRequest as any).user = { + id: 'user-123', + role: 'artist', + }; + + mockSubscriptionService.hasTierAccess.mockRejectedValue(new Error('Database error')); + + const middleware = requireTier(SubscriptionTier.ARTIST_PRO); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalled(); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); + + describe('checkTierAccess', () => { + it('should return true when user has tier access', async () => { + mockSubscriptionService.hasTierAccess.mockResolvedValue(true); + + const result = await checkTierAccess('user-123', SubscriptionTier.ARTIST_PRO); + + expect(result).toBe(true); + expect(mockSubscriptionService.hasTierAccess).toHaveBeenCalledWith( + 'user-123', + SubscriptionTier.ARTIST_PRO, + ); + }); + + it('should return false when user lacks tier access', async () => { + mockSubscriptionService.hasTierAccess.mockResolvedValue(false); + + const result = await checkTierAccess('user-123', SubscriptionTier.LABEL); + + expect(result).toBe(false); + expect(mockSubscriptionService.hasTierAccess).toHaveBeenCalledWith( + 'user-123', + SubscriptionTier.LABEL, + ); + }); + }); +}); diff --git a/src/middlewares/authMiddleware.ts b/src/middlewares/authMiddleware.ts index d69986b..96cde1d 100644 --- a/src/middlewares/authMiddleware.ts +++ b/src/middlewares/authMiddleware.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { UserRole } from '../entities/User'; +import { Permission, roleHasPermission } from '../types/Permissions'; import { AppError } from '../errors/AppError'; import { handleError } from '../utils/helpers'; @@ -30,7 +31,7 @@ export const requireAuth = (req: Request, res: Response, next: NextFunction) => const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { - return handleError(res, AppError.authentication('Unauthorized: No token provided')); + return handleError(req, res, AppError.authentication('Unauthorized: No token provided')); } const token = authHeader.split(' ')[1]; @@ -38,14 +39,14 @@ export const requireAuth = (req: Request, res: Response, next: NextFunction) => const decoded = jwt.verify(token, secret) as JwtPayload; if (!decoded) { - return handleError(res, AppError.authentication('Unauthorized: Invalid token')); + return handleError(req, res, AppError.authentication('Unauthorized: Invalid token')); } (req as any).user = decoded; next(); } catch (error) { console.error('JWT verification error:', error); - return handleError(res, AppError.authentication('Unauthorized: Invalid or expired token')); + return handleError(req, res, AppError.authentication('Unauthorized: Invalid or expired token')); } }; @@ -56,6 +57,7 @@ export const requireRoles = const role = (req as any).user?.role as UserRole | undefined; if (!role || !allowedRoles.includes(role)) { return handleError( + req, res, AppError.authorization( `Forbidden: one of these roles is required: ${allowedRoles.join(', ')}`, @@ -70,14 +72,42 @@ export const requireRoles = export const authArtistMiddleware = requireRoles(UserRole.ARTIST, UserRole.ADMIN); export const authListenerMiddleware = requireRoles(UserRole.LISTENER, UserRole.ADMIN); +/** + * RBAC middleware: authenticates the request, then authorizes it against a + * granular permission (Issue #100). Unlike {@link requireRoles}, callers name + * the capability they need rather than the roles that happen to have it. + * + * Returns 401 when the caller is unauthenticated (no/invalid token) and 403 + * when authenticated but the role lacks the required permission. + */ +export const requirePermission = + (permission: Permission) => (req: Request, res: Response, next: NextFunction) => { + return requireAuth(req, res, () => { + const role = (req as any).user?.role as UserRole | undefined; + if (!role || !roleHasPermission(role, permission)) { + return handleError( + req, + res, + AppError.authorization(`Forbidden: missing required permission: ${permission}`), + ); + } + + return next(); + }); + }; + export const requireEmailVerified = (req: Request, res: Response, next: NextFunction) => { const user = (req as any).user; if (!user) { - return handleError(res, AppError.authentication('Unauthorized: No user in session')); + return handleError(req, res, AppError.authentication('Unauthorized: No user in session')); } if (user.emailVerified === false) { - return handleError(res, AppError.authorization('Email verification required for this action')); + return handleError( + req, + res, + AppError.authorization('Email verification required for this action'), + ); } next(); diff --git a/src/middlewares/subscriptionMiddleware.ts b/src/middlewares/subscriptionMiddleware.ts new file mode 100644 index 0000000..54ffec9 --- /dev/null +++ b/src/middlewares/subscriptionMiddleware.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express'; +import { SubscriptionService } from '../services/SubscriptionService'; +import { SubscriptionTier } from '../entities/Subscription'; +import { handleError } from '../utils/helpers'; +import { AppError } from '../errors/AppError'; + +/** + * Middleware to enforce tier-based access control. + * Checks if the authenticated user has the required subscription tier. + * + * @param requiredTier - The minimum tier required to access the resource + * @returns Express middleware function + */ +export const requireTier = + (requiredTier: SubscriptionTier) => + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError( + req, + res, + AppError.authentication('Authentication required for this endpoint'), + ); + } + + const subscriptionService = new SubscriptionService(); + const hasAccess = await subscriptionService.hasTierAccess(userId, requiredTier); + + if (!hasAccess) { + return handleError( + req, + res, + AppError.authorization( + `This feature requires ${requiredTier} tier or higher. Please upgrade your subscription.`, + ), + ); + } + + next(); + } catch (error) { + handleError(req, res, error); + } + }; + +/** + * Utility function to check tier access in service/controller logic. + * Use this when you need to check tier access without blocking the request. + * + * @param userId - The user's UUID + * @param requiredTier - The minimum tier required + * @returns Promise - true if user has access + */ +export async function checkTierAccess( + userId: string, + requiredTier: SubscriptionTier, +): Promise { + const subscriptionService = new SubscriptionService(); + return await subscriptionService.hasTierAccess(userId, requiredTier); +} diff --git a/src/migrations/1751600000000-AddRbacRolesToUser.ts b/src/migrations/1751600000000-AddRbacRolesToUser.ts new file mode 100644 index 0000000..7d7e869 --- /dev/null +++ b/src/migrations/1751600000000-AddRbacRolesToUser.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Ensures the RBAC `role` column exists on the users table (Issue #100). + * + * The column was introduced in the initial schema as `varchar(50)` with a + * default of `'listener'`. Because roles are stored as strings, the new + * `moderator` and `super_admin` values require no structural change — this + * migration is an idempotent safety net that (re)creates the column for any + * database provisioned without it, keeping fresh and legacy environments in + * sync with the {@link UserRole} enum. + */ +export class AddRbacRolesToUser1751600000000 implements MigrationInterface { + private readonly table = 'user'; + private readonly column = 'role'; + + public async up(queryRunner: QueryRunner): Promise { + const hasColumn = await queryRunner.hasColumn(this.table, this.column); + if (!hasColumn) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: this.column, + type: 'varchar', + length: '50', + default: "'listener'", + }), + ); + } + } + + public async down(): Promise { + // Intentionally a no-op: `role` is a core authentication field and is owned + // by the initial schema migration. Dropping it here would break login for + // every user, so this migration does not remove it on rollback. + } +} diff --git a/src/migrations/1753100000000-AddSubscriptionEntity.ts b/src/migrations/1753100000000-AddSubscriptionEntity.ts new file mode 100644 index 0000000..643872b --- /dev/null +++ b/src/migrations/1753100000000-AddSubscriptionEntity.ts @@ -0,0 +1,129 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Migration to create the subscriptions table for managing user premium tiers. + * Supports free, artist_pro, and label subscription tiers. + */ +export class AddSubscriptionEntity1753100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'subscriptions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'userId', + type: 'uuid', + }, + { + name: 'tier', + type: 'varchar', + length: '50', + default: "'free'", + }, + { + name: 'status', + type: 'varchar', + length: '50', + default: "'active'", + }, + { + name: 'startDate', + type: 'timestamp', + }, + { + name: 'endDate', + type: 'timestamp', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + // Add foreign key constraint + await queryRunner.createForeignKey( + 'subscriptions', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'user', + onDelete: 'CASCADE', + }), + ); + + // Add indexes for performance + await queryRunner.createIndex( + 'subscriptions', + new TableIndex({ + name: 'IDX_subscriptions_userId', + columnNames: ['userId'], + }), + ); + + await queryRunner.createIndex( + 'subscriptions', + new TableIndex({ + name: 'IDX_subscriptions_status', + columnNames: ['status'], + }), + ); + + // Add check constraint for valid tier values + await queryRunner.query(` + ALTER TABLE "subscriptions" + ADD CONSTRAINT "CHK_subscriptions_tier_valid" + CHECK (tier IN ('free', 'artist_pro', 'label')); + `); + + // Add check constraint for valid status values + await queryRunner.query(` + ALTER TABLE "subscriptions" + ADD CONSTRAINT "CHK_subscriptions_status_valid" + CHECK (status IN ('active', 'cancelled', 'expired')); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop constraints + await queryRunner.query(` + ALTER TABLE "subscriptions" + DROP CONSTRAINT IF EXISTS "CHK_subscriptions_status_valid"; + `); + + await queryRunner.query(` + ALTER TABLE "subscriptions" + DROP CONSTRAINT IF EXISTS "CHK_subscriptions_tier_valid"; + `); + + // Drop indexes + await queryRunner.dropIndex('subscriptions', 'IDX_subscriptions_status'); + await queryRunner.dropIndex('subscriptions', 'IDX_subscriptions_userId'); + + // Drop foreign key (TypeORM will handle the name) + const table = await queryRunner.getTable('subscriptions'); + const foreignKey = table?.foreignKeys.find((fk) => fk.columnNames.indexOf('userId') !== -1); + if (foreignKey) { + await queryRunner.dropForeignKey('subscriptions', foreignKey); + } + + // Drop table + await queryRunner.dropTable('subscriptions'); + } +} diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 0f6f592..c65c92e 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -1,25 +1,59 @@ import { Router } from 'express'; -import { requireRoles } from '../middlewares/authMiddleware'; -import { UserRole } from '../entities/User'; +import { requirePermission } from '../middlewares/authMiddleware'; +import { Permission } from '../types/Permissions'; +import { validateDTO } from '../middlewares/validate'; +import { AssignRoleDTO } from '../dtos/AssignRoleDTO'; import { SongController } from '../controllers/SongController'; import { JobController } from '../controllers/JobController'; +import { AdminController } from '../controllers/AdminController'; const router = Router(); -router.use(requireRoles(UserRole.ADMIN)); +// RBAC (Issue #100): each route requires the specific permission it needs +// rather than a blanket admin check. requirePermission returns 401 when the +// caller is unauthenticated and 403 when the role lacks the permission. -router.patch('/song/:id/flag', SongController.flagSong); -router.patch('/song/:id/unflag', SongController.unflagSong); +// Content moderation — moderators and above. +router.patch( + '/song/:id/flag', + requirePermission(Permission.CONTENT_MODERATE), + SongController.flagSong, +); +router.patch( + '/song/:id/unflag', + requirePermission(Permission.CONTENT_MODERATE), + SongController.unflagSong, +); // Manual retry for failed song processing (Issues #123, #125) -router.post('/songs/:id/retry', SongController.retryFailedSong); -router.post('/song/:id/retry', SongController.retryFailedSong); +router.post( + '/songs/:id/retry', + requirePermission(Permission.CONTENT_MODERATE), + SongController.retryFailedSong, +); +router.post( + '/song/:id/retry', + requirePermission(Permission.CONTENT_MODERATE), + SongController.retryFailedSong, +); // Search index maintenance (Issue #135) -router.post('/search/rebuild', SongController.rebuildSearchIndex); +router.post( + '/search/rebuild', + requirePermission(Permission.SEARCH_MANAGE), + SongController.rebuildSearchIndex, +); // Background job queue visibility (Issue #132) -router.get('/jobs', JobController.getJobs); -router.get('/jobs/:id', JobController.getJob); +router.get('/jobs', requirePermission(Permission.JOBS_VIEW), JobController.getJobs); +router.get('/jobs/:id', requirePermission(Permission.JOBS_VIEW), JobController.getJob); + +// Role assignment (Issue #100) — admins and super_admins only. +router.post( + '/users/:id/role', + requirePermission(Permission.ROLE_ASSIGN), + validateDTO(AssignRoleDTO), + AdminController.assignRole, +); export default router; diff --git a/src/routes/subscriptionRoutes.ts b/src/routes/subscriptionRoutes.ts new file mode 100644 index 0000000..eba6d8e --- /dev/null +++ b/src/routes/subscriptionRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { SubscriptionController } from '../controllers/SubscriptionController'; +import { requireAuth } from '../middlewares/authMiddleware'; +import { validateDTO } from '../middlewares/validate'; +import { CreateSubscriptionDTO } from '../dtos/CreateSubscriptionDTO'; + +const router = Router(); +const subscriptionController = new SubscriptionController(); + +// All subscription routes require authentication +router.use(requireAuth); + +// Get current user's subscription +router.get('/me/subscription', subscriptionController.getMySubscription); + +// Create or upgrade subscription +router.post( + '/', + validateDTO(CreateSubscriptionDTO), + subscriptionController.createOrUpgradeSubscription, +); + +// Cancel subscription +router.delete('/', subscriptionController.cancelSubscription); + +export default router; diff --git a/src/services/SubscriptionService.ts b/src/services/SubscriptionService.ts new file mode 100644 index 0000000..c2916b4 --- /dev/null +++ b/src/services/SubscriptionService.ts @@ -0,0 +1,145 @@ +import { Repository } from 'typeorm'; +import { Subscription, SubscriptionTier, SubscriptionStatus } from '../entities/Subscription'; +import AppDataSource from '../config/db'; +import { AppError } from '../errors/AppError'; +import { ERROR_MESSAGES } from '../config/constants'; +import { validateRequired } from '../validators/ServiceValidator'; + +/** + * Service layer for subscription management. + * Handles subscription creation, updates, cancellation, and tier checks. + */ +export class SubscriptionService { + private subscriptionRepo: Repository; + + constructor() { + this.subscriptionRepo = AppDataSource.getRepository(Subscription); + } + + /** + * Get user's current active subscription. + * + * @param userId - The user's UUID + * @returns Active subscription or null if user has no active subscription + */ + async getUserSubscription(userId: string): Promise { + validateRequired(userId, 'userId'); + + const subscription = await this.subscriptionRepo.findOne({ + where: { + userId, + status: SubscriptionStatus.ACTIVE, + }, + order: { + createdAt: 'DESC', + }, + }); + + // Check if subscription has expired + if (subscription && subscription.endDate && new Date() > subscription.endDate) { + subscription.status = SubscriptionStatus.EXPIRED; + await this.subscriptionRepo.save(subscription); + return null; + } + + return subscription; + } + + /** + * Create or upgrade a user's subscription. + * + * @param userId - The user's UUID + * @param tier - The subscription tier to create/upgrade to + * @param endDate - Optional end date for the subscription + * @returns The created or updated subscription + */ + async createOrUpgradeSubscription( + userId: string, + tier: SubscriptionTier, + endDate?: Date, + ): Promise { + validateRequired(userId, 'userId'); + validateRequired(tier, 'tier'); + + // Validate tier + if (!Object.values(SubscriptionTier).includes(tier)) { + throw AppError.validation(`Invalid tier: ${tier}`, [ + { + field: 'tier', + message: `Tier must be one of: ${Object.values(SubscriptionTier).join(', ')}`, + }, + ]); + } + + // Check for existing active subscription + const existingSubscription = await this.getUserSubscription(userId); + + if (existingSubscription) { + // Upgrade/downgrade existing subscription + existingSubscription.tier = tier; + if (endDate) { + existingSubscription.endDate = endDate; + } + return await this.subscriptionRepo.save(existingSubscription); + } + + // Create new subscription + const subscription = this.subscriptionRepo.create({ + userId, + tier, + status: SubscriptionStatus.ACTIVE, + startDate: new Date(), + endDate, + }); + + return await this.subscriptionRepo.save(subscription); + } + + /** + * Cancel a user's subscription at the period end. + * The subscription remains active until endDate. + * + * @param userId - The user's UUID + * @returns The cancelled subscription + * @throws {AppError} If no active subscription found + */ + async cancelSubscription(userId: string): Promise { + validateRequired(userId, 'userId'); + + const subscription = await this.getUserSubscription(userId); + + if (!subscription) { + throw AppError.notFound('No active subscription found'); + } + + subscription.status = SubscriptionStatus.CANCELLED; + + return await this.subscriptionRepo.save(subscription); + } + + /** + * Check if a user has access to a specific tier level. + * Used for feature gating based on subscription tier. + * + * @param userId - The user's UUID + * @param requiredTier - The minimum tier required + * @returns true if user has the required tier or higher + */ + async hasTierAccess(userId: string, requiredTier: SubscriptionTier): Promise { + const subscription = await this.getUserSubscription(userId); + + if (!subscription) { + // No subscription means FREE tier + return requiredTier === SubscriptionTier.FREE; + } + + // Tier hierarchy: FREE < ARTIST_PRO < LABEL + const tierHierarchy = { + [SubscriptionTier.FREE]: 0, + [SubscriptionTier.ARTIST_PRO]: 1, + [SubscriptionTier.LABEL]: 2, + }; + + return tierHierarchy[subscription.tier] >= tierHierarchy[requiredTier]; + } +} diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 0c8608e..db3c714 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -1,8 +1,7 @@ import { Repository } from 'typeorm'; import AppDataSource from '../config/db'; -import { User } from '../entities/User'; +import { User, UserRole } from '../entities/User'; import { CreateUserDTO } from '../dtos/CreateUserDTO'; -import { validate } from 'class-validator'; import { verifyMessage } from 'ethers'; import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; @@ -17,7 +16,6 @@ import { } from '../validators/ServiceValidator'; import { ERROR_MESSAGES, - SUCCESS_MESSAGES, JWT_EXPIRATION, TRANSACTION_ACTIONS, REGEX_PATTERNS, @@ -141,6 +139,28 @@ export class UserService { return await this.userRepo.findOneBy({ id }); } + /** + * Assign a role to a user (Issue #100). Used by the admin role-management + * endpoint to promote/demote users across the RBAC role set. + * + * @param id - The target user's UUID. + * @param role - The role to assign. + * @returns The updated User entity. + * @throws {AppError} If the user does not exist. + */ + async assignRole(id: string, role: UserRole): Promise { + validateRequired(id, 'id'); + validateRequired(role, 'role'); + + const user = await this.userRepo.findOneBy({ id }); + if (!user) { + throw AppError.notFound(ERROR_MESSAGES.USER_NOT_FOUND); + } + + user.role = role; + return await this.userRepo.save(user); + } + /** * Update a user's profile fields. Validates uniqueness constraints for * walletAddress, email, and username before saving. diff --git a/src/services/__tests__/UserService.assignRole.test.ts b/src/services/__tests__/UserService.assignRole.test.ts new file mode 100644 index 0000000..44e7b94 --- /dev/null +++ b/src/services/__tests__/UserService.assignRole.test.ts @@ -0,0 +1,55 @@ +import 'reflect-metadata'; + +jest.mock('../../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); +jest.mock('../../config/redis', () => ({ + __esModule: true, + default: { set: jest.fn(), get: jest.fn(), del: jest.fn() }, +})); + +import AppDataSource from '../../config/db'; +import { UserService } from '../UserService'; +import { UserRole } from '../../entities/User'; + +const mockUserRepo = { + findOneBy: jest.fn(), + save: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + // UserService requests both the User and TransactionLog repositories. + (AppDataSource.getRepository as jest.Mock).mockReturnValue(mockUserRepo); +}); + +describe('UserService.assignRole (#100)', () => { + it('throws 404 when the user does not exist', async () => { + mockUserRepo.findOneBy.mockResolvedValue(null); + const svc = new UserService(); + + await expect(svc.assignRole('missing-id', UserRole.MODERATOR)).rejects.toThrow( + 'User not found', + ); + expect(mockUserRepo.save).not.toHaveBeenCalled(); + }); + + it('persists the new role and returns the updated user', async () => { + const user = { id: 'u1', role: UserRole.LISTENER }; + mockUserRepo.findOneBy.mockResolvedValue(user); + mockUserRepo.save.mockImplementation(async (u) => u); + const svc = new UserService(); + + const result = await svc.assignRole('u1', UserRole.MODERATOR); + + expect(user.role).toBe(UserRole.MODERATOR); + expect(mockUserRepo.save).toHaveBeenCalledWith(user); + expect(result.role).toBe(UserRole.MODERATOR); + }); + + it('throws when id is missing', async () => { + const svc = new UserService(); + await expect(svc.assignRole('', UserRole.ADMIN)).rejects.toThrow(); + }); +}); diff --git a/src/types/Permissions.ts b/src/types/Permissions.ts new file mode 100644 index 0000000..6a05365 --- /dev/null +++ b/src/types/Permissions.ts @@ -0,0 +1,49 @@ +import { UserRole } from '../entities/User'; + +/** + * Granular permissions checked by the RBAC middleware (Issue #100). + * + * A permission names a single capability. Roles are mapped to the set of + * permissions they hold in {@link ROLE_PERMISSIONS}, so route handlers can + * require a capability rather than hard-coding which roles happen to have it. + */ +export enum Permission { + // Content moderation + CONTENT_MODERATE = 'content:moderate', + + // Background jobs / operational visibility + JOBS_VIEW = 'jobs:view', + + // Search index maintenance + SEARCH_MANAGE = 'search:manage', + + // User & role administration + USER_MANAGE = 'user:manage', + ROLE_ASSIGN = 'role:assign', +} + +/** + * Maps each role to the permissions it is granted. + * + * Roles are additive but not hierarchical: a role holds exactly the + * permissions listed for it. `super_admin` is granted every permission so + * that adding a new capability does not silently exclude the top role. + */ +export const ROLE_PERMISSIONS: Record = { + [UserRole.LISTENER]: [], + [UserRole.ARTIST]: [], + [UserRole.MODERATOR]: [Permission.CONTENT_MODERATE, Permission.JOBS_VIEW], + [UserRole.ADMIN]: [ + Permission.CONTENT_MODERATE, + Permission.JOBS_VIEW, + Permission.SEARCH_MANAGE, + Permission.USER_MANAGE, + Permission.ROLE_ASSIGN, + ], + [UserRole.SUPER_ADMIN]: Object.values(Permission), +}; + +/** True when `role` is granted `permission`. */ +export function roleHasPermission(role: UserRole, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; +} diff --git a/src/types/__tests__/Permissions.test.ts b/src/types/__tests__/Permissions.test.ts new file mode 100644 index 0000000..e29b68b --- /dev/null +++ b/src/types/__tests__/Permissions.test.ts @@ -0,0 +1,36 @@ +import { UserRole } from '../../entities/User'; +import { Permission, ROLE_PERMISSIONS, roleHasPermission } from '../Permissions'; + +describe('RBAC permission map (#100)', () => { + it('defines a permission list for every role', () => { + for (const role of Object.values(UserRole)) { + expect(Array.isArray(ROLE_PERMISSIONS[role])).toBe(true); + } + }); + + it('grants no elevated permissions to listeners or artists', () => { + expect(ROLE_PERMISSIONS[UserRole.LISTENER]).toEqual([]); + expect(ROLE_PERMISSIONS[UserRole.ARTIST]).toEqual([]); + }); + + it('lets moderators moderate content but not manage users or roles', () => { + expect(roleHasPermission(UserRole.MODERATOR, Permission.CONTENT_MODERATE)).toBe(true); + expect(roleHasPermission(UserRole.MODERATOR, Permission.USER_MANAGE)).toBe(false); + expect(roleHasPermission(UserRole.MODERATOR, Permission.ROLE_ASSIGN)).toBe(false); + }); + + it('lets admins assign roles and manage users', () => { + expect(roleHasPermission(UserRole.ADMIN, Permission.ROLE_ASSIGN)).toBe(true); + expect(roleHasPermission(UserRole.ADMIN, Permission.USER_MANAGE)).toBe(true); + }); + + it('grants super_admin every defined permission', () => { + for (const permission of Object.values(Permission)) { + expect(roleHasPermission(UserRole.SUPER_ADMIN, permission)).toBe(true); + } + }); + + it('returns false for a role that is not in the map', () => { + expect(roleHasPermission('ghost' as UserRole, Permission.CONTENT_MODERATE)).toBe(false); + }); +}); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 8109e36..a2fa429 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,9 +1,17 @@ -import { Response } from 'express'; +import { Request, Response } from 'express'; +import { ValidationError } from 'class-validator'; import crypto from 'crypto'; import { mapToOnChainError } from '../types/OnChainErrorCodes'; import { AppError } from '../errors/AppError'; import { logRequestError } from './errorLogger'; +/** Shape returned by {@link formatValidationErrors}. */ +interface IValidationFormatResult { + success: false; + fields: Record; + message: string[]; +} + export function formatValidationErrors(errors: ValidationError[]): IValidationFormatResult { const fields: Record = {}; const message: string[] = [];