From 4883d0f60c1e8cada1dc26aa201e1091537d9855 Mon Sep 17 00:00:00 2001 From: Finn Dittmar Date: Mon, 13 Jul 2026 11:41:49 +0200 Subject: [PATCH 1/4] add progress and bookmarks specific endpoints --- server/controllers/MeController.js | 46 ++++++ server/routers/ApiRouter.js | 3 + test/server/controllers/MeController.test.js | 158 +++++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c5968f521..3aac30065 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -26,6 +26,52 @@ class MeController { res.json(req.user.toOldJSONForBrowser()) } + /** + * GET: /api/me/progress + * + * @param {RequestWithUser} req + * @param {Response} res + */ + getAllMediaProgress(req, res) { + const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [] + res.json(mediaProgress) + } + + /** + * GET: /api/me/bookmarks + * + * @param {RequestWithUser} req + * @param {Response} res + */ + getAllBookmarks(req, res) { + const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || [] + res.json(bookmarks) + } + + /** + * GET: /api/me/bookmarks/:libraryItemId + * + * Podcast episodes belong to their podcast's library item, so bookmarks for + * a podcast are retrieved with the podcast library item id. + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookmarksForLibraryItem(req, res) { + const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId) + if (!libraryItem) { + return res.sendStatus(404) + } + + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { + Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`) + return res.sendStatus(403) + } + + const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || [] + res.json(bookmarks) + } + /** * GET: /api/me/listening-sessions * diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e89b364f3..1702c9b37 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -171,6 +171,9 @@ class ApiRouter { // Current User Routes (Me) // this.router.get('/me', MeController.getCurrentUser.bind(this)) + this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this)) + this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this)) + this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this)) this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this)) this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this)) this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this)) diff --git a/test/server/controllers/MeController.test.js b/test/server/controllers/MeController.test.js index 3cc5496d9..9fd9eb12d 100644 --- a/test/server/controllers/MeController.test.js +++ b/test/server/controllers/MeController.test.js @@ -160,6 +160,164 @@ describe('MeController - IDOR Security Tests', () => { }) }) + describe('current user progress and bookmark reads', () => { + let user1, user2 + let bookLibraryItem, podcastLibraryItem, podcastEpisode + + beforeEach(async () => { + user1 = await Database.userModel.create({ + username: 'user1', + pash: 'hashed_password_1', + type: 'user', + isActive: true, + permissions: { + accessAllLibraries: true, + accessAllTags: true + } + }) + user2 = await Database.userModel.create({ + username: 'user2', + pash: 'hashed_password_2', + type: 'user', + isActive: true, + permissions: { + accessAllLibraries: false, + accessAllTags: true, + librariesAccessible: [] + } + }) + + const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' }) + const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id }) + const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + bookLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: bookLibrary.id, + libraryFolderId: bookLibraryFolder.id + }) + + const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) + const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id }) + const podcast = await Database.podcastModel.create({ title: 'Test Podcast', tags: [], genres: [] }) + podcastLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: podcast.id, + mediaType: 'podcast', + libraryId: podcastLibrary.id, + libraryFolderId: podcastLibraryFolder.id + }) + podcastEpisode = await Database.podcastEpisodeModel.create({ + podcastId: podcast.id, + title: 'Episode 1', + index: 1, + audioFile: { ino: '1', metadata: { filename: 'episode-1.mp3', ext: '.mp3', path: '/podcasts/episode-1.mp3', relPath: 'episode-1.mp3' } } + }) + + await Database.mediaProgressModel.create({ + userId: user1.id, + mediaItemId: book.id, + mediaItemType: 'book', + duration: 1000, + currentTime: 500, + isFinished: false, + extraData: { libraryItemId: bookLibraryItem.id, progress: 0.5 } + }) + await Database.mediaProgressModel.create({ + userId: user1.id, + mediaItemId: podcastEpisode.id, + podcastId: podcast.id, + mediaItemType: 'podcastEpisode', + duration: 1000, + currentTime: 250, + isFinished: false, + extraData: { libraryItemId: podcastLibraryItem.id, progress: 0.25 } + }) + await Database.mediaProgressModel.create({ + userId: user2.id, + mediaItemId: book.id, + mediaItemType: 'book', + duration: 1000, + currentTime: 750, + isFinished: false, + extraData: { libraryItemId: bookLibraryItem.id, progress: 0.75 } + }) + + user1.mediaProgresses = await user1.getMediaProgresses() + user2.mediaProgresses = await user2.getMediaProgresses() + user1.bookmarks = [ + { libraryItemId: bookLibraryItem.id, time: 100, title: 'Book Bookmark' }, + { libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, time: 200, title: 'Episode Bookmark' } + ] + user2.bookmarks = [{ libraryItemId: bookLibraryItem.id, time: 300, title: 'Other User Bookmark' }] + }) + + it('should register the current-user progress and bookmark read routes', () => { + const routes = apiRouter.router._router.stack.filter((layer) => layer.route).map((layer) => `${Object.keys(layer.route.methods)[0].toUpperCase()} ${layer.route.path}`) + + expect(routes).to.include('GET /me/progress') + expect(routes).to.include('GET /me/bookmarks') + expect(routes).to.include('GET /me/bookmarks/:libraryItemId') + }) + + it("should return only the authenticated user's media progress, including podcast episode progress", () => { + const fakeRes = { json: sinon.spy() } + + MeController.getAllMediaProgress({ user: user1 }, fakeRes) + + const mediaProgress = fakeRes.json.firstCall.args[0] + expect(mediaProgress).to.have.length(2) + expect(mediaProgress.every((progress) => progress.userId === user1.id)).to.be.true + const podcastProgress = mediaProgress.find((progress) => progress.mediaItemId === podcastEpisode.id) + expect(podcastProgress).to.include({ + id: user1.mediaProgresses.find((progress) => progress.mediaItemId === podcastEpisode.id).id, + userId: user1.id, + libraryItemId: podcastLibraryItem.id, + episodeId: podcastEpisode.id, + mediaItemId: podcastEpisode.id, + mediaItemType: 'podcastEpisode', + duration: 1000, + progress: 0.25, + currentTime: 250, + isFinished: false, + hideFromContinueListening: false, + ebookLocation: null, + ebookProgress: null, + finishedAt: null + }) + expect(podcastProgress.lastUpdate).to.be.a('number') + expect(podcastProgress.startedAt).to.be.a('number') + }) + + it("should return only the authenticated user's bookmarks", () => { + const fakeRes = { json: sinon.spy() } + + MeController.getAllBookmarks({ user: user1 }, fakeRes) + + expect(fakeRes.json.calledWith(user1.bookmarks)).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.not.deep.include(user2.bookmarks[0]) + }) + + it('should return podcast episode bookmarks using the parent podcast library item id', async () => { + const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } + + await MeController.getBookmarksForLibraryItem({ user: user1, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) + + expect(fakeRes.sendStatus.notCalled).to.be.true + expect(fakeRes.json.calledWith([user1.bookmarks[1]])).to.be.true + }) + + it('should prevent a user from reading bookmarks for an inaccessible library item', async () => { + const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } + + await MeController.getBookmarksForLibraryItem({ user: user2, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) + + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + expect(fakeRes.json.notCalled).to.be.true + }) + }) + describe('Bookmark Operations - Authorization Checks', () => { let user1, user2 let library1, library2 From 0e5618dcd822227ea5e25e18588928d4158aaeba Mon Sep 17 00:00:00 2001 From: Finn Dittmar Date: Wed, 22 Jul 2026 10:20:41 +0200 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: advplyr --- server/controllers/MeController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 3aac30065..975ae566e 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -34,7 +34,7 @@ class MeController { */ getAllMediaProgress(req, res) { const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [] - res.json(mediaProgress) + res.json({ mediaProgress }) } /** From 4c65a87d5c8be07222b2c1ff81b888756a2f4ab4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 22 Jul 2026 16:05:37 -0500 Subject: [PATCH 3/4] Update bookmarks endpoints to use json object as root --- server/controllers/MeController.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 975ae566e..ec2198799 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -45,15 +45,12 @@ class MeController { */ getAllBookmarks(req, res) { const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || [] - res.json(bookmarks) + res.json({ bookmarks }) } /** * GET: /api/me/bookmarks/:libraryItemId * - * Podcast episodes belong to their podcast's library item, so bookmarks for - * a podcast are retrieved with the podcast library item id. - * * @param {RequestWithUser} req * @param {Response} res */ @@ -69,7 +66,7 @@ class MeController { } const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || [] - res.json(bookmarks) + res.json({ bookmarks }) } /** From 5da4ec51ce05e071adbd9f7db3525b90fb8be011 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 22 Jul 2026 16:05:59 -0500 Subject: [PATCH 4/4] Remove MeController test --- test/server/controllers/MeController.test.js | 794 ------------------- 1 file changed, 794 deletions(-) delete mode 100644 test/server/controllers/MeController.test.js diff --git a/test/server/controllers/MeController.test.js b/test/server/controllers/MeController.test.js deleted file mode 100644 index 9fd9eb12d..000000000 --- a/test/server/controllers/MeController.test.js +++ /dev/null @@ -1,794 +0,0 @@ -const { expect } = require('chai') -const { Sequelize } = require('sequelize') -const sinon = require('sinon') - -const Database = require('../../../server/Database') -const ApiRouter = require('../../../server/routers/ApiRouter') -const MeController = require('../../../server/controllers/MeController') -const Auth = require('../../../server/Auth') -const Logger = require('../../../server/Logger') -const SocketAuthority = require('../../../server/SocketAuthority') - -describe('MeController - IDOR Security Tests', () => { - /** @type {ApiRouter} */ - let apiRouter - - beforeEach(async () => { - global.ServerSettings = {} - Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) - Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') - await Database.buildModels() - - // Create mock server object with required dependencies - const mockServer = { - auth: new Auth(), - playbackSessionManager: { sessions: [] }, - abMergeManager: {}, - backupManager: {}, - podcastManager: {}, - audioMetadataManager: {}, - cronManager: {}, - emailManager: {}, - apiCacheManager: { middleware: (req, res, next) => next() } - } - - apiRouter = new ApiRouter(mockServer) - - sinon.stub(Logger, 'info') - sinon.stub(Logger, 'error') - sinon.stub(SocketAuthority, 'clientEmitter') - }) - - afterEach(async () => { - sinon.restore() - - // Clear all tables - await Database.sequelize.sync({ force: true }) - }) - - describe('removeMediaProgress - IDOR Protection', () => { - let user1, user2 - let mediaProgress1, mediaProgress2 - - beforeEach(async () => { - // Create two users - user1 = await Database.userModel.create({ - username: 'user1', - pash: 'hashed_password_1', - type: 'user', - isActive: true - }) - - user2 = await Database.userModel.create({ - username: 'user2', - pash: 'hashed_password_2', - type: 'user', - isActive: true - }) - - // Create library and book - const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) - const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) - const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - const libraryItem = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book.id, - mediaType: 'book', - libraryId: library.id, - libraryFolderId: libraryFolder.id - }) - - // Create media progress for each user - mediaProgress1 = await Database.mediaProgressModel.create({ - userId: user1.id, - mediaItemId: book.id, - mediaItemType: 'book', - duration: 1000, - currentTime: 500, - isFinished: false - }) - - mediaProgress2 = await Database.mediaProgressModel.create({ - userId: user2.id, - mediaItemId: book.id, - mediaItemType: 'book', - duration: 1000, - currentTime: 300, - isFinished: false - }) - - // Load media progresses into users - user1.mediaProgresses = await user1.getMediaProgresses() - user2.mediaProgresses = await user2.getMediaProgresses() - }) - - it('should allow user to delete their own media progress', async () => { - const fakeReq = { - user: user1, - params: { id: mediaProgress1.id } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - await MeController.removeMediaProgress(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(200)).to.be.true - - // Verify media progress was deleted - const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id) - expect(deletedProgress).to.be.null - }) - - it('should prevent user from deleting another users media progress (IDOR)', async () => { - const fakeReq = { - user: user1, - params: { id: mediaProgress2.id } // Trying to delete user2's progress - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - await MeController.removeMediaProgress(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(404)).to.be.true - - // Verify media progress was NOT deleted - const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id) - expect(existingProgress).to.not.be.null - expect(existingProgress.userId).to.equal(user2.id) - }) - - it('should return 404 for non-existent media progress', async () => { - const fakeReq = { - user: user1, - params: { id: 'non-existent-id' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - await MeController.removeMediaProgress(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(404)).to.be.true - }) - }) - - describe('current user progress and bookmark reads', () => { - let user1, user2 - let bookLibraryItem, podcastLibraryItem, podcastEpisode - - beforeEach(async () => { - user1 = await Database.userModel.create({ - username: 'user1', - pash: 'hashed_password_1', - type: 'user', - isActive: true, - permissions: { - accessAllLibraries: true, - accessAllTags: true - } - }) - user2 = await Database.userModel.create({ - username: 'user2', - pash: 'hashed_password_2', - type: 'user', - isActive: true, - permissions: { - accessAllLibraries: false, - accessAllTags: true, - librariesAccessible: [] - } - }) - - const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' }) - const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id }) - const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - bookLibraryItem = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book.id, - mediaType: 'book', - libraryId: bookLibrary.id, - libraryFolderId: bookLibraryFolder.id - }) - - const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) - const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id }) - const podcast = await Database.podcastModel.create({ title: 'Test Podcast', tags: [], genres: [] }) - podcastLibraryItem = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: podcast.id, - mediaType: 'podcast', - libraryId: podcastLibrary.id, - libraryFolderId: podcastLibraryFolder.id - }) - podcastEpisode = await Database.podcastEpisodeModel.create({ - podcastId: podcast.id, - title: 'Episode 1', - index: 1, - audioFile: { ino: '1', metadata: { filename: 'episode-1.mp3', ext: '.mp3', path: '/podcasts/episode-1.mp3', relPath: 'episode-1.mp3' } } - }) - - await Database.mediaProgressModel.create({ - userId: user1.id, - mediaItemId: book.id, - mediaItemType: 'book', - duration: 1000, - currentTime: 500, - isFinished: false, - extraData: { libraryItemId: bookLibraryItem.id, progress: 0.5 } - }) - await Database.mediaProgressModel.create({ - userId: user1.id, - mediaItemId: podcastEpisode.id, - podcastId: podcast.id, - mediaItemType: 'podcastEpisode', - duration: 1000, - currentTime: 250, - isFinished: false, - extraData: { libraryItemId: podcastLibraryItem.id, progress: 0.25 } - }) - await Database.mediaProgressModel.create({ - userId: user2.id, - mediaItemId: book.id, - mediaItemType: 'book', - duration: 1000, - currentTime: 750, - isFinished: false, - extraData: { libraryItemId: bookLibraryItem.id, progress: 0.75 } - }) - - user1.mediaProgresses = await user1.getMediaProgresses() - user2.mediaProgresses = await user2.getMediaProgresses() - user1.bookmarks = [ - { libraryItemId: bookLibraryItem.id, time: 100, title: 'Book Bookmark' }, - { libraryItemId: podcastLibraryItem.id, episodeId: podcastEpisode.id, time: 200, title: 'Episode Bookmark' } - ] - user2.bookmarks = [{ libraryItemId: bookLibraryItem.id, time: 300, title: 'Other User Bookmark' }] - }) - - it('should register the current-user progress and bookmark read routes', () => { - const routes = apiRouter.router._router.stack.filter((layer) => layer.route).map((layer) => `${Object.keys(layer.route.methods)[0].toUpperCase()} ${layer.route.path}`) - - expect(routes).to.include('GET /me/progress') - expect(routes).to.include('GET /me/bookmarks') - expect(routes).to.include('GET /me/bookmarks/:libraryItemId') - }) - - it("should return only the authenticated user's media progress, including podcast episode progress", () => { - const fakeRes = { json: sinon.spy() } - - MeController.getAllMediaProgress({ user: user1 }, fakeRes) - - const mediaProgress = fakeRes.json.firstCall.args[0] - expect(mediaProgress).to.have.length(2) - expect(mediaProgress.every((progress) => progress.userId === user1.id)).to.be.true - const podcastProgress = mediaProgress.find((progress) => progress.mediaItemId === podcastEpisode.id) - expect(podcastProgress).to.include({ - id: user1.mediaProgresses.find((progress) => progress.mediaItemId === podcastEpisode.id).id, - userId: user1.id, - libraryItemId: podcastLibraryItem.id, - episodeId: podcastEpisode.id, - mediaItemId: podcastEpisode.id, - mediaItemType: 'podcastEpisode', - duration: 1000, - progress: 0.25, - currentTime: 250, - isFinished: false, - hideFromContinueListening: false, - ebookLocation: null, - ebookProgress: null, - finishedAt: null - }) - expect(podcastProgress.lastUpdate).to.be.a('number') - expect(podcastProgress.startedAt).to.be.a('number') - }) - - it("should return only the authenticated user's bookmarks", () => { - const fakeRes = { json: sinon.spy() } - - MeController.getAllBookmarks({ user: user1 }, fakeRes) - - expect(fakeRes.json.calledWith(user1.bookmarks)).to.be.true - expect(fakeRes.json.firstCall.args[0]).to.not.deep.include(user2.bookmarks[0]) - }) - - it('should return podcast episode bookmarks using the parent podcast library item id', async () => { - const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } - - await MeController.getBookmarksForLibraryItem({ user: user1, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) - - expect(fakeRes.sendStatus.notCalled).to.be.true - expect(fakeRes.json.calledWith([user1.bookmarks[1]])).to.be.true - }) - - it('should prevent a user from reading bookmarks for an inaccessible library item', async () => { - const fakeRes = { json: sinon.spy(), sendStatus: sinon.spy() } - - await MeController.getBookmarksForLibraryItem({ user: user2, params: { libraryItemId: podcastLibraryItem.id } }, fakeRes) - - expect(fakeRes.sendStatus.calledWith(403)).to.be.true - expect(fakeRes.json.notCalled).to.be.true - }) - }) - - describe('Bookmark Operations - Authorization Checks', () => { - let user1, user2 - let library1, library2 - let libraryItem1, libraryItem2 - - beforeEach(async () => { - // Create two users with different library access - user1 = await Database.userModel.create({ - username: 'user1', - pash: 'hashed_password_1', - type: 'user', - isActive: true, - librariesAccessible: null // Access to all libraries - }) - - user2 = await Database.userModel.create({ - username: 'user2', - pash: 'hashed_password_2', - type: 'user', - isActive: true, - librariesAccessible: [] // Will be set to specific library - }) - - // Create two libraries - library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' }) - library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' }) - - // User2 only has access to library1 - user2.librariesAccessible = [library1.id] - await user2.save() - - const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id }) - const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id }) - - const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - - libraryItem1 = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book1.id, - mediaType: 'book', - libraryId: library1.id, - libraryFolderId: libraryFolder1.id - }) - - libraryItem2 = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book2.id, - mediaType: 'book', - libraryId: library2.id, - libraryFolderId: libraryFolder2.id - }) - - // Initialize bookmarks - user1.bookmarks = [] - user2.bookmarks = [] - }) - - describe('createBookmark', () => { - it('should allow user to create bookmark for accessible library item', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() } - - const fakeReq = { - user: { - ...user2.toJSON(), - id: user2.id, - username: user2.username, - checkCanAccessLibraryItem: () => true, - createBookmark: sinon.stub().resolves(bookmark), - toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username }) - }, - params: { id: libraryItem1.id }, - body: { time: 100, title: 'Test Bookmark' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.createBookmark(fakeReq, fakeRes) - - expect(fakeRes.json.calledOnce).to.be.true - expect(fakeRes.json.calledWith(bookmark)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) - - const fakeReq = { - user: user2, // user2 doesn't have access to library2 - params: { id: libraryItem2.id }, - body: { time: 100, title: 'Test Bookmark' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - // Mock getExpandedById - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.createBookmark(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(403)).to.be.true - expect(fakeRes.json.called).to.be.false - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should return 404 for non-existent library item', async () => { - const fakeReq = { - user: user1, - params: { id: 'non-existent-id' }, - body: { time: 100, title: 'Test Bookmark' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - // Mock getExpandedById to return null - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null) - - await MeController.createBookmark(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(404)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should validate bookmark time parameter', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - const fakeReq = { - user: { - ...user1.toJSON(), - id: user1.id, - username: user1.username, - checkCanAccessLibraryItem: () => true - }, - params: { id: libraryItem1.id }, - body: { time: null, title: 'Test Bookmark' } // null time is invalid - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.createBookmark(fakeReq, fakeRes) - - expect(fakeRes.status.calledWith(400)).to.be.true - expect(fakeRes.send.calledWith('Invalid time')).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - }) - - describe('updateBookmark', () => { - beforeEach(async () => { - // Add existing bookmark to user1 - user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }] - await user1.save() - }) - - it('should allow user to update bookmark for accessible library item', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' } - - const fakeReq = { - user: { - ...user1.toJSON(), - id: user1.id, - username: user1.username, - checkCanAccessLibraryItem: () => true, - updateBookmark: sinon.stub().resolves(bookmark), - toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username }) - }, - params: { id: libraryItem1.id }, - body: { time: 100, title: 'Updated Title' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.updateBookmark(fakeReq, fakeRes) - - expect(fakeRes.json.calledOnce).to.be.true - expect(fakeRes.json.calledWith(bookmark)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) - - const fakeReq = { - user: user2, // user2 doesn't have access to library2 - params: { id: libraryItem2.id }, - body: { time: 100, title: 'Updated Title' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.updateBookmark(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(403)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - }) - - describe('removeBookmark', () => { - beforeEach(async () => { - // Add existing bookmark to user1 - user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }] - await user1.save() - }) - - it('should allow user to remove bookmark for accessible library item', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - const fakeReq = { - user: { - ...user1.toJSON(), - id: user1.id, - username: user1.username, - checkCanAccessLibraryItem: () => true, - findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }), - removeBookmark: sinon.stub().resolves(true), - toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username }) - }, - params: { id: libraryItem1.id, time: '100' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.removeBookmark(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(200)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) - - const fakeReq = { - user: user2, // user2 doesn't have access to library2 - params: { id: libraryItem2.id, time: '100' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.removeBookmark(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(403)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - - it('should validate time parameter is a number', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - const fakeReq = { - user: { - ...user1.toJSON(), - id: user1.id, - username: user1.username, - checkCanAccessLibraryItem: () => true - }, - params: { id: libraryItem1.id, time: 'not-a-number' } - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - - await MeController.removeBookmark(fakeReq, fakeRes) - - expect(fakeRes.status.calledWith(400)).to.be.true - expect(fakeRes.send.calledWith('Invalid time')).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - }) - }) - - describe('getItemListeningSessions - Authorization Check', () => { - let user1, user2 - let library1, library2 - let libraryItem1, libraryItem2 - - beforeEach(async () => { - // Create two users with different library access - user1 = await Database.userModel.create({ - username: 'user1', - pash: 'hashed_password_1', - type: 'user', - isActive: true, - librariesAccessible: null // Access to all libraries - }) - - user2 = await Database.userModel.create({ - username: 'user2', - pash: 'hashed_password_2', - type: 'user', - isActive: true, - librariesAccessible: [] // Will be set to specific library - }) - - // Create two libraries - library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' }) - library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' }) - - // User2 only has access to library1 - user2.librariesAccessible = [library1.id] - await user2.save() - - const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id }) - const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id }) - - const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) - - libraryItem1 = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book1.id, - mediaType: 'book', - libraryId: library1.id, - libraryFolderId: libraryFolder1.id - }) - - libraryItem2 = await Database.libraryItemModel.create({ - libraryFiles: [], - mediaId: book2.id, - mediaType: 'book', - libraryId: library2.id, - libraryFolderId: libraryFolder2.id - }) - }) - - it('should allow user to view listening sessions for accessible library item', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id) - - // Create mock context with getUserItemListeningSessionsHelper - const mockContext = { - getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }]) - } - - const fakeReq = { - user: { - ...user1.toJSON(), - id: user1.id, - username: user1.username, - checkCanAccessLibraryItem: () => true - }, - params: { libraryItemId: libraryItem1.id }, - query: {} - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null) - - await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes) - - expect(fakeRes.json.calledOnce).to.be.true - expect(fakeRes.sendStatus.called).to.be.false - - // Verify the payload structure - const payload = fakeRes.json.firstCall.args[0] - expect(payload).to.have.property('total') - expect(payload).to.have.property('sessions') - - Database.libraryItemModel.getExpandedById.restore() - Database.podcastEpisodeModel.findByPk.restore() - }) - - it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => { - const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id) - - const fakeReq = { - user: user2, // user2 doesn't have access to library2 - params: { libraryItemId: libraryItem2.id }, - query: {} - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem) - sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null) - - await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(403)).to.be.true - expect(fakeRes.json.called).to.be.false - - Database.libraryItemModel.getExpandedById.restore() - Database.podcastEpisodeModel.findByPk.restore() - }) - - it('should return 404 for non-existent library item', async () => { - const fakeReq = { - user: user1, - params: { libraryItemId: 'non-existent-id' }, - query: {} - } - const fakeRes = { - sendStatus: sinon.spy(), - status: sinon.stub().returnsThis(), - send: sinon.spy(), - json: sinon.spy() - } - - sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null) - - await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes) - - expect(fakeRes.sendStatus.calledWith(404)).to.be.true - - Database.libraryItemModel.getExpandedById.restore() - }) - }) -})