diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 5ae5ef59d5..399306d6c4 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -147,6 +147,15 @@ describe('POST /auth/jellyfin/quickconnect/initiate', () => { assert.strictEqual(initiateQCMock.mock.callCount(), 1); }); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await request(app).post('/auth/jellyfin/quickconnect/initiate'); + + assert.strictEqual(res.status, 403); + assert.strictEqual(initiateQCMock.mock.callCount(), 0); + }); + it('returns 500 when Jellyfin API fails', async () => { initiateQCMock.mock.mockImplementation(async () => { throw new Error('Connection refused'); @@ -239,6 +248,17 @@ describe('GET /auth/jellyfin/quickconnect/check', () => { assert.strictEqual(checkQCMock.mock.callCount(), 0); }); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + it('returns error when Jellyfin API fails', async () => { checkQCMock.mock.mockImplementation(async () => { throw new ApiError(500, ApiErrorCode.Unknown); @@ -396,38 +416,15 @@ describe('POST /auth/jellyfin/quickconnect/authenticate', () => { assert.strictEqual(meRes.status, 200); }); - it('sets userType to EMBY when media server is Emby', async () => { - const settings = getSettings(); - settings.main.mediaServerType = MediaServerType.EMBY; - settings.main.newPlexLogin = true; - - authenticateQCMock.mock.mockImplementation(async () => ({ - User: { - Id: 'emby-new-user', - Name: 'embyuser', - ServerId: 'server-1', - Policy: { IsAdministrator: false }, - }, - AccessToken: 'emby-token', - })); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; - const agent = request.agent(app); - const res = await agent + const res = await request(app) .post('/auth/jellyfin/quickconnect/authenticate') .send({ secret: 'abc123def456abc123def456' }); - assert.strictEqual(res.status, 200); - - const meRes = await agent.get('/auth/me'); - assert.strictEqual(meRes.status, 200); - assert.strictEqual(meRes.body.jellyfinUsername, 'embyuser'); - - const userRepo = getRepository(User); - const user = await userRepo.findOne({ - where: { jellyfinUserId: 'emby-new-user' }, - }); - assert.ok(user); - assert.strictEqual(user.userType, UserType.EMBY); + assert.strictEqual(res.status, 403); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); }); it('applies default permissions to newly created users', async () => { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ab22ceba73..24cbb395fd 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -603,6 +603,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }); authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => { + const settings = getSettings(); + + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + try { const hostname = getHostname(); const jellyfinServer = new JellyfinAPI( @@ -630,6 +639,15 @@ authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => { }); authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => { + const settings = getSettings(); + + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + const result = quickConnectSecret.safeParse(req.query); if (!result.success) { return next({ @@ -684,6 +702,13 @@ authRoutes.post( }); } + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + try { const hostname = getHostname(); const jellyfinServer = new JellyfinAPI( @@ -744,10 +769,7 @@ authRoutes.post( jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - userType: - settings.main.mediaServerType === MediaServerType.JELLYFIN - ? UserType.JELLYFIN - : UserType.EMBY, + userType: UserType.JELLYFIN, }); user.avatar = getUserAvatarUrl(user); await userRepository.save(user); diff --git a/server/routes/user/usersettings.test.ts b/server/routes/user/usersettings.test.ts new file mode 100644 index 0000000000..ba83358756 --- /dev/null +++ b/server/routes/user/usersettings.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; + +import JellyfinAPI from '@server/api/jellyfin'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import authRoutes from '@server/routes/auth'; +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 userRoutes from '.'; + +const defaultAuthenticateResponse = { + User: { + Id: 'jf-link-user-001', + Name: 'linkeduser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'fake-qc-access-token', +}; + +const authenticateQCMock = mock.method( + JellyfinAPI.prototype, + 'authenticateQuickConnect', + async () => ({ ...defaultAuthenticateResponse }) +); + +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('/user', isAuthenticated(), userRoutes); + 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(); +}); + +setupTestDb(); + +function configureJellyfin() { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.JELLYFIN; + settings.jellyfin.ip = 'localhost'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; +} + +async function loginAs(email: string, password: string) { + const settings = getSettings(); + settings.main.localLogin = true; + + const agent = request.agent(app); + const res = await agent.post('/auth/local').send({ email, password }); + + assert.strictEqual(res.status, 200); + return { agent, userId: res.body.id as number }; +} + +describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => { + beforeEach(() => { + authenticateQCMock.mock.resetCalls(); + authenticateQCMock.mock.mockImplementation(async () => ({ + ...defaultAuthenticateResponse, + })); + configureJellyfin(); + }); + + it('links the account when the media server is Jellyfin', async () => { + const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + + const res = await agent + .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`) + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 204); + assert.strictEqual(authenticateQCMock.mock.callCount(), 1); + + const user = await getRepository(User).findOneOrFail({ + where: { id: userId }, + }); + assert.strictEqual(user.jellyfinUserId, 'jf-link-user-001'); + assert.strictEqual(user.userType, UserType.JELLYFIN); + }); + + it('returns 403 when the media server is Emby', async () => { + const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await agent + .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`) + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + + const user = await getRepository(User).findOneOrFail({ + where: { id: userId }, + }); + assert.strictEqual(user.jellyfinUserId, null); + }); +}); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index bff500d58a..c724109634 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -542,6 +542,12 @@ userSettingsRoutes.post<{ secret: string }>( .json({ message: 'Jellyfin/Emby login is disabled' }); } + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return res + .status(403) + .json({ message: 'Quick Connect is only supported by Jellyfin.' }); + } + const hostname = getHostname(); const jellyfinServer = new JellyfinAPI(hostname); @@ -563,10 +569,7 @@ userSettingsRoutes.post<{ secret: string }>( user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}` ).toString('base64'); - user.userType = - settings.main.mediaServerType === MediaServerType.EMBY - ? UserType.EMBY - : UserType.JELLYFIN; + user.userType = UserType.JELLYFIN; user.jellyfinUserId = account.User.Id; user.jellyfinUsername = account.User.Name; user.jellyfinAuthToken = account.AccessToken; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 01278e4a91..e73d35cbaa 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -217,17 +217,19 @@ const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { }} -