mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-31 21:51:41 +00:00
Compare commits
11 commits
d063d851e1
...
a6cde926ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6cde926ee | ||
|
|
e8db14fe33 | ||
|
|
e9b533637f | ||
|
|
8b97da500d | ||
|
|
37b42601ef | ||
|
|
5da4ec51ce | ||
|
|
4c65a87d5c | ||
|
|
0e5618dcd8 | ||
|
|
95af4812cd | ||
|
|
4883d0f60c | ||
|
|
0784373b61 |
17 changed files with 70 additions and 651 deletions
|
|
@ -22,7 +22,7 @@
|
|||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
||||
{{ emptyMessageHelp }}
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
</template>
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<app-settings-content :header-text="$strings.HeaderLibraries">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ 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`)
|
||||
|
|
@ -24,6 +26,9 @@ 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() {
|
||||
|
|
@ -214,9 +219,13 @@ class TokenManager {
|
|||
let lastRefreshTokenExpiresAt = null
|
||||
if (gracePeriod) {
|
||||
// Set grace period of old refresh token in case of race condition in token rotation.
|
||||
// This grace period may need to be longer if fetching the user data takes longer due to large progress objects
|
||||
// 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.
|
||||
lastRefreshToken = previousRefreshToken
|
||||
lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
|
||||
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
|
||||
}
|
||||
|
||||
// Only update if this session row still has the refresh token we read
|
||||
|
|
|
|||
|
|
@ -26,6 +26,49 @@ 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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,636 +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('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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue