From 94e93bae636ba3a2193e6c6ebed3b6dd841e778d Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Sat, 21 Feb 2026 08:32:01 -0800 Subject: [PATCH 1/5] fix: apply showMemberMiddleware to flowsheet write routes The middleware was imported but never applied, allowing any authenticated DJ to modify flowsheet entries regardless of show membership. Co-authored-by: Cursor --- apps/backend/app.ts | 1 - apps/backend/middleware/checkShowMember.ts | 21 ++--- apps/backend/routes/flowsheet.route.ts | 4 + tests/unit/middleware/checkShowMember.test.ts | 84 +++++++++++++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 tests/unit/middleware/checkShowMember.test.ts diff --git a/apps/backend/app.ts b/apps/backend/app.ts index e6abbba2..c2b3ddc2 100644 --- a/apps/backend/app.ts +++ b/apps/backend/app.ts @@ -12,7 +12,6 @@ import { library_route } from './routes/library.route.js'; import { schedule_route } from './routes/schedule.route.js'; import { events_route } from './routes/events.route.js'; import { request_line_route } from './routes/requestLine.route.js'; -import { showMemberMiddleware } from './middleware/checkShowMember.js'; import { activeShow } from './middleware/checkActiveShow.js'; import errorHandler from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; diff --git a/apps/backend/middleware/checkShowMember.ts b/apps/backend/middleware/checkShowMember.ts index 8d07269f..85a8875a 100644 --- a/apps/backend/middleware/checkShowMember.ts +++ b/apps/backend/middleware/checkShowMember.ts @@ -2,16 +2,17 @@ import { RequestHandler } from 'express'; import { getDJsInCurrentShow } from '../services/flowsheet.service.js'; export const showMemberMiddleware: RequestHandler = async (req, res, next) => { - const show_djs = await getDJsInCurrentShow(); - // Get user ID from JWT - check both req.auth (from better-auth middleware) and res.locals (legacy) - const user_id = req.auth?.id || req.auth?.sub || res.locals.decodedJWT?.id || res.locals.decodedJWT?.userId; - const dj_in_show = show_djs.filter((dj) => { - return dj.id === user_id; - }).length; + try { + const show_djs = await getDJsInCurrentShow(); + const user_id = req.auth?.id || req.auth?.sub || res.locals.decodedJWT?.id || res.locals.decodedJWT?.userId; + const dj_in_show = show_djs.some((dj) => dj.id === user_id); - if (dj_in_show > 0) { - next(); - } else { - res.status(400).json({ message: 'Bad Request: DJ not a member of show' }); + if (dj_in_show) { + next(); + } else { + res.status(400).json({ message: 'Bad Request: DJ not a member of show' }); + } + } catch { + res.status(500).json({ message: 'Internal server error checking show membership' }); } }; diff --git a/apps/backend/routes/flowsheet.route.ts b/apps/backend/routes/flowsheet.route.ts index 38385dc5..07f968e1 100644 --- a/apps/backend/routes/flowsheet.route.ts +++ b/apps/backend/routes/flowsheet.route.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import * as flowsheetController from '../controllers/flowsheet.controller'; import { flowsheetMirror } from '../middleware/legacy/flowsheet.mirror'; import { conditionalGet } from '../middleware/conditionalGet'; +import { showMemberMiddleware } from '../middleware/checkShowMember'; export const flowsheet_route = Router(); @@ -11,6 +12,7 @@ flowsheet_route.get('/', conditionalGet, flowsheetMirror.getEntries, flowsheetCo flowsheet_route.post( '/', requirePermissions({ flowsheet: ['write'] }), + showMemberMiddleware, flowsheetMirror.addEntry, flowsheetController.addEntry ); @@ -18,6 +20,7 @@ flowsheet_route.post( flowsheet_route.patch( '/', requirePermissions({ flowsheet: ['write'] }), + showMemberMiddleware, flowsheetMirror.updateEntry, flowsheetController.updateEntry ); @@ -25,6 +28,7 @@ flowsheet_route.patch( flowsheet_route.delete( '/', requirePermissions({ flowsheet: ['write'] }), + showMemberMiddleware, flowsheetMirror.deleteEntry, flowsheetController.deleteEntry ); diff --git a/tests/unit/middleware/checkShowMember.test.ts b/tests/unit/middleware/checkShowMember.test.ts new file mode 100644 index 00000000..9c42e16e --- /dev/null +++ b/tests/unit/middleware/checkShowMember.test.ts @@ -0,0 +1,84 @@ +import { jest } from '@jest/globals'; +import type { Request, Response, NextFunction } from 'express'; + +const mockGetDJsInCurrentShow = jest.fn<() => Promise<{ id: string }[]>>(); + +jest.mock('../../../apps/backend/services/flowsheet.service', () => ({ + getDJsInCurrentShow: mockGetDJsInCurrentShow, +})); + +import { showMemberMiddleware } from '../../../apps/backend/middleware/checkShowMember'; + +function createMockReqResNext(userId: string) { + const req = { + auth: { id: userId }, + } as unknown as Request; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + locals: {}, + } as unknown as Response; + + const next = jest.fn() as unknown as NextFunction; + + return { req, res, next }; +} + +describe('showMemberMiddleware', () => { + it('rejects a DJ who is not in the current show', async () => { + mockGetDJsInCurrentShow.mockResolvedValue([ + { id: 'dj-alice' }, + { id: 'dj-bob' }, + ]); + + const { req, res, next } = createMockReqResNext('dj-charlie'); + + await showMemberMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Bad Request: DJ not a member of show', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows a DJ who is in the current show', async () => { + mockGetDJsInCurrentShow.mockResolvedValue([ + { id: 'dj-alice' }, + { id: 'dj-bob' }, + ]); + + const { req, res, next } = createMockReqResNext('dj-alice'); + + await showMemberMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('rejects when there are no DJs in the current show', async () => { + mockGetDJsInCurrentShow.mockResolvedValue([]); + + const { req, res, next } = createMockReqResNext('dj-alice'); + + await showMemberMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 500 when getDJsInCurrentShow throws', async () => { + mockGetDJsInCurrentShow.mockRejectedValue(new Error('DB connection lost')); + + const { req, res, next } = createMockReqResNext('dj-alice'); + + await showMemberMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: 'Internal server error checking show membership', + }); + expect(next).not.toHaveBeenCalled(); + }); +}); From 0ca0d7d29f13292db1965e4d4a6f7fae62936680 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 27 Feb 2026 14:03:00 -0800 Subject: [PATCH 2/5] fix: resolve lint errors --- tests/unit/middleware/checkShowMember.test.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/tests/unit/middleware/checkShowMember.test.ts b/tests/unit/middleware/checkShowMember.test.ts index 9c42e16e..206dc01b 100644 --- a/tests/unit/middleware/checkShowMember.test.ts +++ b/tests/unit/middleware/checkShowMember.test.ts @@ -14,69 +14,65 @@ function createMockReqResNext(userId: string) { auth: { id: userId }, } as unknown as Request; + const statusMock = jest.fn().mockReturnThis(); + const jsonMock = jest.fn().mockReturnThis(); const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), + status: statusMock, + json: jsonMock, locals: {}, } as unknown as Response; const next = jest.fn() as unknown as NextFunction; - return { req, res, next }; + return { req, res, next, statusMock, jsonMock }; } describe('showMemberMiddleware', () => { it('rejects a DJ who is not in the current show', async () => { - mockGetDJsInCurrentShow.mockResolvedValue([ - { id: 'dj-alice' }, - { id: 'dj-bob' }, - ]); + mockGetDJsInCurrentShow.mockResolvedValue([{ id: 'dj-alice' }, { id: 'dj-bob' }]); - const { req, res, next } = createMockReqResNext('dj-charlie'); + const { req, res, next, statusMock, jsonMock } = createMockReqResNext('dj-charlie'); await showMemberMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ + expect(statusMock).toHaveBeenCalledWith(400); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Bad Request: DJ not a member of show', }); expect(next).not.toHaveBeenCalled(); }); it('allows a DJ who is in the current show', async () => { - mockGetDJsInCurrentShow.mockResolvedValue([ - { id: 'dj-alice' }, - { id: 'dj-bob' }, - ]); + mockGetDJsInCurrentShow.mockResolvedValue([{ id: 'dj-alice' }, { id: 'dj-bob' }]); - const { req, res, next } = createMockReqResNext('dj-alice'); + const { req, res, next, statusMock } = createMockReqResNext('dj-alice'); await showMemberMiddleware(req, res, next); expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); + expect(statusMock).not.toHaveBeenCalled(); }); it('rejects when there are no DJs in the current show', async () => { mockGetDJsInCurrentShow.mockResolvedValue([]); - const { req, res, next } = createMockReqResNext('dj-alice'); + const { req, res, next, statusMock } = createMockReqResNext('dj-alice'); await showMemberMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(statusMock).toHaveBeenCalledWith(400); expect(next).not.toHaveBeenCalled(); }); it('returns 500 when getDJsInCurrentShow throws', async () => { mockGetDJsInCurrentShow.mockRejectedValue(new Error('DB connection lost')); - const { req, res, next } = createMockReqResNext('dj-alice'); + const { req, res, next, statusMock, jsonMock } = createMockReqResNext('dj-alice'); await showMemberMiddleware(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Internal server error checking show membership', }); expect(next).not.toHaveBeenCalled(); From 045d898b5c76b6b74af7b4dee309a2f8db66ce32 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Mon, 9 Mar 2026 23:27:00 -0700 Subject: [PATCH 3/5] refactor: apply showMemberMiddleware to /end and /play-order, remove inline check Adds showMemberMiddleware to the remaining flowsheet write routes and removes the redundant DJ-in-show check from leaveShow since the middleware now handles it. --- apps/backend/controllers/flowsheet.controller.ts | 7 ++----- apps/backend/routes/flowsheet.route.ts | 2 ++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/backend/controllers/flowsheet.controller.ts b/apps/backend/controllers/flowsheet.controller.ts index 9ac09f27..7b892fcd 100644 --- a/apps/backend/controllers/flowsheet.controller.ts +++ b/apps/backend/controllers/flowsheet.controller.ts @@ -354,11 +354,8 @@ export const leaveShow: RequestHandler = asy res.status(404).json({ message: 'Bad Request: No active show session found.' }); } else { try { - // Catch case where DJ is not in show, but attempting to hit this endpoint - const show_djs = await flowsheet_service.getDJsInCurrentShow(); - if (!show_djs.map((dj) => dj.id).includes(req.body.dj_id)) { - res.status(400).json({ message: 'Bad Request: DJ not in current show' }); - } else if (req.body.dj_id === currentShow.primary_dj_id) { + // Show membership is verified by showMemberMiddleware on the route + if (req.body.dj_id === currentShow.primary_dj_id) { const finalizedShow: Show = await flowsheet_service.endShow(currentShow); res.status(200).json(finalizedShow); } else { diff --git a/apps/backend/routes/flowsheet.route.ts b/apps/backend/routes/flowsheet.route.ts index 07f968e1..bcf8c7f3 100644 --- a/apps/backend/routes/flowsheet.route.ts +++ b/apps/backend/routes/flowsheet.route.ts @@ -36,6 +36,7 @@ flowsheet_route.delete( flowsheet_route.patch( '/play-order', requirePermissions({ flowsheet: ['write'] }), + showMemberMiddleware, /*flowsheetMirror.changeOrder,*/ flowsheetController.changeOrder ); @@ -52,6 +53,7 @@ flowsheet_route.post( flowsheet_route.post( '/end', requirePermissions({ flowsheet: ['write'] }), + showMemberMiddleware, flowsheetMirror.endShow, flowsheetController.leaveShow ); From 066e8dc8bc326b2c5b393ce3876afe156a234a74 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Mon, 9 Mar 2026 23:55:59 -0700 Subject: [PATCH 4/5] fix: add AUTH_BYPASS to showMemberMiddleware When AUTH_BYPASS=true, requirePermissions skips JWT verification and never populates req.auth. The showMemberMiddleware then fails to identify the user and returns 400 for every request. Adding an AUTH_BYPASS early return fixes integration tests. --- apps/backend/middleware/checkShowMember.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/middleware/checkShowMember.ts b/apps/backend/middleware/checkShowMember.ts index 85a8875a..703d885e 100644 --- a/apps/backend/middleware/checkShowMember.ts +++ b/apps/backend/middleware/checkShowMember.ts @@ -2,6 +2,10 @@ import { RequestHandler } from 'express'; import { getDJsInCurrentShow } from '../services/flowsheet.service.js'; export const showMemberMiddleware: RequestHandler = async (req, res, next) => { + if (process.env.AUTH_BYPASS === 'true') { + return next(); + } + try { const show_djs = await getDJsInCurrentShow(); const user_id = req.auth?.id || req.auth?.sub || res.locals.decodedJWT?.id || res.locals.decodedJWT?.userId; From 9c034bc476b23975a7f1e67263a4a6f274cd1b17 Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Tue, 10 Mar 2026 00:00:57 -0700 Subject: [PATCH 5/5] fix: use WxycError in leaveShow service for DJ-not-in-show case The service's guard clause threw a plain Error (500) when a DJ wasn't in the show. With AUTH_BYPASS=true the showMemberMiddleware is skipped, so this code path is reachable during integration tests. Using WxycError returns the proper 400 status. --- apps/backend/services/flowsheet.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/services/flowsheet.service.ts b/apps/backend/services/flowsheet.service.ts index 3dafb34d..e25293f9 100644 --- a/apps/backend/services/flowsheet.service.ts +++ b/apps/backend/services/flowsheet.service.ts @@ -1,4 +1,5 @@ import { sql, desc, eq, and, lte, gte, inArray } from 'drizzle-orm'; +import WxycError from '../utils/error.js'; import { db, FSEntry, @@ -419,7 +420,7 @@ export const leaveShow = async (dj_id: string, currentShow: Show): Promise