From db72570bfbc8971b97ec1257ae4b0a6913e3c703 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 16 Apr 2026 20:04:06 -0300 Subject: [PATCH 1/2] fix(security): require guildModuleAccess on roles and moderation routes --- packages/backend/src/routes/moderation.ts | 9 +++++++++ packages/backend/src/routes/roles.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/packages/backend/src/routes/moderation.ts b/packages/backend/src/routes/moderation.ts index 41340cb88..17c150505 100644 --- a/packages/backend/src/routes/moderation.ts +++ b/packages/backend/src/routes/moderation.ts @@ -1,5 +1,6 @@ import type { Express, Response } from 'express' import { requireAuth, type AuthenticatedRequest } from '../middleware/auth' +import { requireGuildModuleAccess } from '../middleware/guildAccess' import { validateBody, validateParams, @@ -27,6 +28,7 @@ export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/cases', requireAuth, + requireGuildModuleAccess('moderation', 'view'), validateParams(s.guildIdParam), validateQuery(s.casesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { @@ -43,6 +45,7 @@ export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/cases/:caseNumber', requireAuth, + requireGuildModuleAccess('moderation', 'view'), validateParams(s.caseNumberParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const modCase = await moderationService.getCase( @@ -59,6 +62,7 @@ export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/users/:userId/cases', requireAuth, + requireGuildModuleAccess('moderation', 'view'), validateParams(s.userCasesParam), validateQuery(s.userCasesQuery), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { @@ -76,6 +80,7 @@ export function setupModerationRoutes(app: Express): void { app.patch( '/api/guilds/:guildId/moderation/cases/:caseNumber/reason', requireAuth, + requireGuildModuleAccess('moderation', 'manage'), writeLimiter, validateParams(s.caseNumberParam), validateBody(s.updateReasonBody), @@ -107,6 +112,7 @@ export function setupModerationRoutes(app: Express): void { app.post( '/api/guilds/:guildId/moderation/cases/:caseId/deactivate', requireAuth, + requireGuildModuleAccess('moderation', 'manage'), writeLimiter, validateParams(s.caseIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { @@ -129,6 +135,7 @@ export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/settings', requireAuth, + requireGuildModuleAccess('moderation', 'view'), validateParams(s.guildIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const settings = await moderationService.getSettings( @@ -141,6 +148,7 @@ export function setupModerationRoutes(app: Express): void { app.patch( '/api/guilds/:guildId/moderation/settings', requireAuth, + requireGuildModuleAccess('moderation', 'manage'), writeLimiter, validateParams(s.guildIdParam), validateBody(s.updateSettingsBody), @@ -164,6 +172,7 @@ export function setupModerationRoutes(app: Express): void { app.get( '/api/guilds/:guildId/moderation/stats', requireAuth, + requireGuildModuleAccess('moderation', 'view'), validateParams(s.guildIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const stats = await moderationService.getStats( diff --git a/packages/backend/src/routes/roles.ts b/packages/backend/src/routes/roles.ts index 39b18c703..b27ef05f2 100644 --- a/packages/backend/src/routes/roles.ts +++ b/packages/backend/src/routes/roles.ts @@ -1,5 +1,6 @@ import type { Express, Response } from 'express' import { requireAuth, type AuthenticatedRequest } from '../middleware/auth' +import { requireGuildModuleAccess } from '../middleware/guildAccess' import { validateParams } from '../middleware/validate' import { asyncHandler } from '../middleware/asyncHandler' import { managementSchemas as s } from '../schemas/management' @@ -16,6 +17,7 @@ export function setupRolesRoutes(app: Express): void { app.get( '/api/guilds/:guildId/reaction-roles', requireAuth, + requireGuildModuleAccess('overview', 'view'), validateParams(s.guildIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) @@ -28,6 +30,7 @@ export function setupRolesRoutes(app: Express): void { app.get( '/api/guilds/:guildId/roles/exclusive', requireAuth, + requireGuildModuleAccess('overview', 'view'), validateParams(s.guildIdParam), asyncHandler(async (req: AuthenticatedRequest, res: Response) => { const guildId = p(req.params.guildId) From 4d188e658bfc8e2c4f780b0be8b3c4e4e912a644 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 16 Apr 2026 20:09:39 -0300 Subject: [PATCH 2/2] test(security): update role and moderation tests to mock guildAccessService --- .../integration/routes/moderation.test.ts | 129 ++++++------------ .../tests/integration/routes/roles.test.ts | 21 ++- 2 files changed, 64 insertions(+), 86 deletions(-) diff --git a/packages/backend/tests/integration/routes/moderation.test.ts b/packages/backend/tests/integration/routes/moderation.test.ts index 80c771f7c..28158d0a5 100644 --- a/packages/backend/tests/integration/routes/moderation.test.ts +++ b/packages/backend/tests/integration/routes/moderation.test.ts @@ -5,6 +5,7 @@ import express from 'express' import { setupModerationRoutes } from '../../../src/routes/moderation' import { setupSessionMiddleware } from '../../../src/middleware/session' import { sessionService } from '../../../src/services/SessionService' +import { guildAccessService } from '../../../src/services/GuildAccessService' import { MOCK_SESSION_DATA } from '../../fixtures/mock-data' jest.mock('../../../src/services/SessionService', () => ({ @@ -13,6 +14,13 @@ jest.mock('../../../src/services/SessionService', () => ({ }, })) +jest.mock('../../../src/services/GuildAccessService', () => ({ + guildAccessService: { + resolveGuildContext: jest.fn(), + hasAccess: jest.fn(), + }, +})) + jest.mock('@lucky/shared/services', () => ({ redisClient: { isHealthy: jest.fn(() => true), @@ -51,6 +59,22 @@ jest.mock('@lucky/shared/services', () => ({ import { moderationService, serverLogService } from '@lucky/shared/services' describe('Moderation Routes Integration', () => { + const GUILD_ID = '111111111111111111' + + function setupAuth() { + const sessionMock = sessionService as jest.Mocked + sessionMock.getSession.mockResolvedValue(MOCK_SESSION_DATA) + + const accessMock = guildAccessService as jest.Mocked + accessMock.resolveGuildContext.mockResolvedValue({ + guildId: GUILD_ID, + userId: MOCK_SESSION_DATA.userId, + roles: [], + permissions: new Set(), + } as any) + accessMock.hasAccess.mockReturnValue(true) + } + let app: express.Express beforeEach(() => { @@ -64,10 +88,7 @@ describe('Moderation Routes Integration', () => { describe('GET /api/guilds/:guildId/moderation/cases', () => { test('should return recent cases with default limit', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockCases = [ { @@ -99,10 +120,7 @@ describe('Moderation Routes Integration', () => { }) test('should return recent cases with custom limit', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -134,10 +152,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -159,10 +174,7 @@ describe('Moderation Routes Integration', () => { describe('GET /api/guilds/:guildId/moderation/cases/:caseNumber', () => { test('should return specific case', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockCase = { id: 'case1', @@ -193,10 +205,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 404 when case not found', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -225,10 +234,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -248,10 +254,7 @@ describe('Moderation Routes Integration', () => { describe('GET /api/guilds/:guildId/moderation/users/:userId/cases', () => { test('should return user cases with activeOnly=false', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockCases = [ { @@ -287,10 +290,7 @@ describe('Moderation Routes Integration', () => { }) test('should return user cases with activeOnly=true', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -327,10 +327,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -354,10 +351,7 @@ describe('Moderation Routes Integration', () => { describe('PATCH /api/guilds/:guildId/moderation/cases/:caseNumber/reason', () => { test('should update case reason', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockCase = { id: 'case1', @@ -406,10 +400,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 400 when reason is missing', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const response = await request(app) .patch( @@ -427,10 +418,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 404 when case not found', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -465,10 +453,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -493,10 +478,7 @@ describe('Moderation Routes Integration', () => { describe('POST /api/guilds/:guildId/moderation/cases/:caseId/deactivate', () => { test('should deactivate case', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockUpdatedCase = { id: 'case1', @@ -558,10 +540,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -585,10 +564,7 @@ describe('Moderation Routes Integration', () => { describe('GET /api/guilds/:guildId/moderation/settings', () => { test('should return moderation settings', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockSettings = { guildId: '111111111111111111', @@ -627,10 +603,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -652,10 +625,7 @@ describe('Moderation Routes Integration', () => { describe('PATCH /api/guilds/:guildId/moderation/settings', () => { test('should update moderation settings', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockSettings = { guildId: '111111111111111111', @@ -719,10 +689,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService @@ -745,10 +712,7 @@ describe('Moderation Routes Integration', () => { describe('GET /api/guilds/:guildId/moderation/stats', () => { test('should return moderation stats', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockStats = { totalCases: 42, @@ -790,10 +754,7 @@ describe('Moderation Routes Integration', () => { }) test('should return 500 on service error', async () => { - const mockSessionService = sessionService as jest.Mocked< - typeof sessionService - > - mockSessionService.getSession.mockResolvedValue(MOCK_SESSION_DATA) + setupAuth() const mockModerationService = moderationService as jest.Mocked< typeof moderationService diff --git a/packages/backend/tests/integration/routes/roles.test.ts b/packages/backend/tests/integration/routes/roles.test.ts index 981b20032..7b15261d0 100644 --- a/packages/backend/tests/integration/routes/roles.test.ts +++ b/packages/backend/tests/integration/routes/roles.test.ts @@ -5,6 +5,7 @@ import express from 'express' import { setupRolesRoutes } from '../../../src/routes/roles' import { setupSessionMiddleware } from '../../../src/middleware/session' import { sessionService } from '../../../src/services/SessionService' +import { guildAccessService } from '../../../src/services/GuildAccessService' import { MOCK_SESSION_DATA } from '../../fixtures/mock-data' jest.mock('../../../src/services/SessionService', () => ({ @@ -13,6 +14,13 @@ jest.mock('../../../src/services/SessionService', () => ({ }, })) +jest.mock('../../../src/services/GuildAccessService', () => ({ + guildAccessService: { + resolveGuildContext: jest.fn(), + hasAccess: jest.fn(), + }, +})) + const mockListReactionRoles = jest.fn() const mockListExclusiveRoles = jest.fn() @@ -41,8 +49,17 @@ describe('Roles Routes', () => { const GUILD_ID = '111111111111111111' function authed() { - const mock = sessionService as jest.Mocked - mock.getSession.mockResolvedValue(MOCK_SESSION_DATA) + const sessionMock = sessionService as jest.Mocked + sessionMock.getSession.mockResolvedValue(MOCK_SESSION_DATA) + + const accessMock = guildAccessService as jest.Mocked + accessMock.resolveGuildContext.mockResolvedValue({ + guildId: GUILD_ID, + userId: MOCK_SESSION_DATA.userId, + roles: [], + permissions: new Set(), + } as any) + accessMock.hasAccess.mockReturnValue(true) } describe('GET /api/guilds/:guildId/reaction-roles', () => {