diff --git a/seerr-api.yml b/seerr-api.yml index c5044f0ad..18f3361d6 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -7592,6 +7592,9 @@ paths: type: string mediaId: type: number + userId: + type: number + nullable: true responses: '201': description: Succesfully created the issue diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts index e5b3643cd..136807d56 100644 --- a/server/interfaces/api/issueInterfaces.ts +++ b/server/interfaces/api/issueInterfaces.ts @@ -1,6 +1,16 @@ +import type { IssueType } from '@server/constants/issue'; import type Issue from '@server/entity/Issue'; import type { PaginatedResponse } from './common'; export interface IssueResultsResponse extends PaginatedResponse { results: Issue[]; } + +export type IssueRequestBody = { + message: string; + mediaId: number; + issueType: IssueType; + problemSeason?: number; + problemEpisode?: number; + userId?: number; +}; diff --git a/server/routes/issue.test.ts b/server/routes/issue.test.ts new file mode 100644 index 000000000..9c76f5ecb --- /dev/null +++ b/server/routes/issue.test.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; + +import { IssueType } from '@server/constants/issue'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Issue from '@server/entity/Issue'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import { checkUser } from '@server/middleware/auth'; +import { IssueSubscriber } from '@server/subscriber/IssueSubscriber'; +import { setupTestDb } from '@server/test/db'; +import type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import authRoutes from './auth'; +import issueRoutes from './issue'; + +const sendIssueNotificationMock = mock.method( + IssueSubscriber.prototype as unknown as { + sendIssueNotification: (...args: unknown[]) => Promise; + }, + 'sendIssueNotification', + async () => undefined +).mock; + +let app: Express; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }) + ); + app.use(checkUser); + app.use('/auth', authRoutes); + app.use('/issue', issueRoutes); + app.use( + ( + err: { status?: number; message?: string }, + _req: express.Request, + res: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction + ) => { + res + .status(err.status ?? 500) + .json({ status: err.status ?? 500, message: err.message }); + } + ); + return app; +} + +before(async () => { + app = createApp(); +}); + +beforeEach(() => { + sendIssueNotificationMock.resetCalls(); +}); + +setupTestDb(); + +async function loginAs(email: string, password: string) { + const settings = getSettings(); + const priorLocalLogin = settings.main.localLogin; + settings.main.localLogin = true; + + try { + const agent = request.agent(app); + const res = await agent.post('/auth/local').send({ email, password }); + assert.strictEqual(res.status, 200); + return agent; + } finally { + settings.main.localLogin = priorLocalLogin; + } +} + +async function seedMedia() { + return getRepository(Media).save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 12345, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }) + ); +} + +describe('POST /issue', () => { + it('creates an issue on behalf of the supplied userId', async () => { + const issueRepo = getRepository(Issue); + const userRepo = getRepository(User); + const media = await seedMedia(); + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.post('/issue').send({ + issueType: IssueType.VIDEO, + message: 'Playback stutters near the end.', + mediaId: media.id, + problemSeason: 0, + problemEpisode: 0, + userId: friend.id, + }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev'); + assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev'); + + const persisted = await issueRepo.findOneOrFail({ + where: { id: res.body.id }, + }); + + assert.strictEqual(persisted.createdBy.id, friend.id); + assert.strictEqual(persisted.comments[0].user.id, friend.id); + }); + + it('defaults to the authenticated user when userId is omitted', async () => { + const media = await seedMedia(); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.post('/issue').send({ + issueType: IssueType.AUDIO, + message: 'Audio is out of sync.', + mediaId: media.id, + }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdBy.email, 'admin@seerr.dev'); + assert.strictEqual(res.body.comments[0].user.email, 'admin@seerr.dev'); + }); + + it('allows creators to supply their own userId', async () => { + const userRepo = getRepository(User); + const media = await seedMedia(); + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + friend.permissions = Permission.CREATE_ISSUES; + await userRepo.save(friend); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.post('/issue').send({ + issueType: IssueType.SUBTITLES, + message: 'Subtitles are missing.', + mediaId: media.id, + userId: friend.id, + }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(res.body.createdBy.email, 'friend@seerr.dev'); + assert.strictEqual(res.body.comments[0].user.email, 'friend@seerr.dev'); + }); + + it('prevents non-managers from supplying another userId', async () => { + const userRepo = getRepository(User); + const media = await seedMedia(); + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + const admin = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + + friend.permissions = Permission.CREATE_ISSUES; + await userRepo.save(friend); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.post('/issue').send({ + issueType: IssueType.OTHER, + message: 'Something else is wrong.', + mediaId: media.id, + userId: admin.id, + }); + + assert.strictEqual(res.status, 403); + assert.strictEqual( + res.body.message, + 'You do not have permission to create an issue on behalf of another user.' + ); + }); + + it('returns 404 when the supplied userId does not exist', async () => { + const media = await seedMedia(); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.post('/issue').send({ + issueType: IssueType.OTHER, + message: 'Something else is wrong.', + mediaId: media.id, + userId: 999999, + }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(res.body.message, 'Issue user not found'); + }); +}); diff --git a/server/routes/issue.ts b/server/routes/issue.ts index 1e8769f8e..8719c0071 100644 --- a/server/routes/issue.ts +++ b/server/routes/issue.ts @@ -3,7 +3,11 @@ import { getRepository } from '@server/datasource'; import Issue from '@server/entity/Issue'; import IssueComment from '@server/entity/IssueComment'; import Media from '@server/entity/Media'; -import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; +import { User } from '@server/entity/User'; +import type { + IssueRequestBody, + IssueResultsResponse, +} from '@server/interfaces/api/issueInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -95,17 +99,7 @@ issueRoutes.get, IssueResultsResponse>( } ); -issueRoutes.post< - Record, - Issue, - { - message: string; - mediaId: number; - issueType: number; - problemSeason: number; - problemEpisode: number; - } ->( +issueRoutes.post, Issue, IssueRequestBody>( '/', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', @@ -118,6 +112,7 @@ issueRoutes.post< const issueRepository = getRepository(Issue); const mediaRepository = getRepository(Media); + const userRepository = getRepository(User); const media = await mediaRepository.findOne({ where: { id: req.body.mediaId }, @@ -127,15 +122,37 @@ issueRoutes.post< return next({ status: 404, message: 'Media does not exist.' }); } + let createdBy = req.user; + + if (req.body.userId != null && req.body.userId !== req.user.id) { + if (!req.user.hasPermission(Permission.MANAGE_ISSUES)) { + return next({ + status: 403, + message: + 'You do not have permission to create an issue on behalf of another user.', + }); + } + + const user = await userRepository.findOne({ + where: { id: req.body.userId }, + }); + + if (!user) { + return next({ status: 404, message: 'Issue user not found' }); + } + + createdBy = user; + } + const issue = new Issue({ - createdBy: req.user, + createdBy, issueType: req.body.issueType, problemSeason: req.body.problemSeason, problemEpisode: req.body.problemEpisode, media, comments: [ new IssueComment({ - user: req.user, + user: createdBy, message: req.body.message, }), ], @@ -143,7 +160,7 @@ issueRoutes.post< const newIssue = await issueRepository.save(issue); - return res.status(200).json(newIssue); + return res.status(201).json(newIssue); } );