diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue
index 5cec2255a..f0d43c14f 100644
--- a/client/components/modals/collections/AddCreateModal.vue
+++ b/client/components/modals/collections/AddCreateModal.vue
@@ -25,7 +25,7 @@
{{ $strings.MessageBookshelfNoCollectionsHelp }}
-
+
help_outline
diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue
index 6a8856a0e..b27925ce4 100644
--- a/client/components/modals/libraries/LibraryScannerSettings.vue
+++ b/client/components/modals/libraries/LibraryScannerSettings.vue
@@ -8,7 +8,7 @@
{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}
-
+
help_outline
diff --git a/client/components/modals/playlists/AddCreateModal.vue b/client/components/modals/playlists/AddCreateModal.vue
index 9aa68bd80..f8543f1de 100644
--- a/client/components/modals/playlists/AddCreateModal.vue
+++ b/client/components/modals/playlists/AddCreateModal.vue
@@ -25,7 +25,7 @@
{{ $strings.MessageNoUserPlaylistsHelp }}
-
+
help_outline
diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue
index 88208a9b1..2523feed8 100644
--- a/client/pages/config/api-keys/index.vue
+++ b/client/pages/config/api-keys/index.vue
@@ -7,7 +7,7 @@
-
+
help_outline
diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue
index 4f47ce3c0..f31f9ea22 100644
--- a/client/pages/config/authentication.vue
+++ b/client/pages/config/authentication.vue
@@ -24,7 +24,7 @@
{{ $strings.HeaderOpenIDConnectAuthentication }}
-
+
help_outline
diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue
index 075593ac5..33fbdd32f 100644
--- a/client/pages/config/email.vue
+++ b/client/pages/config/email.vue
@@ -3,7 +3,7 @@
-
+
help_outline
diff --git a/client/pages/config/item-metadata-utils/custom-metadata-providers.vue b/client/pages/config/item-metadata-utils/custom-metadata-providers.vue
index 4cd538f1b..82de604a1 100644
--- a/client/pages/config/item-metadata-utils/custom-metadata-providers.vue
+++ b/client/pages/config/item-metadata-utils/custom-metadata-providers.vue
@@ -8,7 +8,7 @@
-
+
help_outline
diff --git a/client/pages/config/libraries.vue b/client/pages/config/libraries.vue
index 28ca9a50d..efb9c49c5 100644
--- a/client/pages/config/libraries.vue
+++ b/client/pages/config/libraries.vue
@@ -3,7 +3,7 @@
-
+
help_outline
diff --git a/client/pages/config/log.vue b/client/pages/config/log.vue
index 5a9c03cea..8fef304ee 100644
--- a/client/pages/config/log.vue
+++ b/client/pages/config/log.vue
@@ -3,7 +3,7 @@
-
+
help_outline
diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue
index 6aae0dff2..b5ba90a38 100644
--- a/client/pages/config/rss-feeds.vue
+++ b/client/pages/config/rss-feeds.vue
@@ -3,7 +3,7 @@
-
+
help_outline
diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue
index ff9d049c0..5bd53e0e5 100644
--- a/client/pages/config/users/index.vue
+++ b/client/pages/config/users/index.vue
@@ -7,7 +7,7 @@
-
+
help_outline
diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js
index f91adc8b9..01cfc4ad7 100644
--- a/server/auth/TokenManager.js
+++ b/server/auth/TokenManager.js
@@ -17,8 +17,6 @@ class TokenManager {
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
/** @type {number} Access token expiry in seconds */
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
- /** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
- this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
@@ -26,9 +24,6 @@ class TokenManager {
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
}
- if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
- Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
- }
}
get TokenSecret() {
@@ -219,13 +214,9 @@ class TokenManager {
let lastRefreshTokenExpiresAt = null
if (gracePeriod) {
// Set grace period of old refresh token in case of race condition in token rotation.
- // During this window a retry with the old refresh token returns the already-rotated
- // current token instead of failing, so a client that never received the rotation
- // response (e.g. dropped/suspended mobile request) can still recover the session.
- // Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
- // user data takes longer due to large progress objects.
+ // This grace period may need to be longer if fetching the user data takes longer due to large progress objects
lastRefreshToken = previousRefreshToken
- lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
+ lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
}
// Only update if this session row still has the refresh token we read
diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js
index ec2198799..c5968f521 100644
--- a/server/controllers/MeController.js
+++ b/server/controllers/MeController.js
@@ -26,49 +26,6 @@ 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
- *
- * @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 1702c9b37..e89b364f3 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -171,9 +171,6 @@ 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
new file mode 100644
index 000000000..3cc5496d9
--- /dev/null
+++ b/test/server/controllers/MeController.test.js
@@ -0,0 +1,636 @@
+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('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()
+ })
+ })
+})