mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-22 03:11:31 +00:00
Add canStream user permission to control streaming access
Adds a per-user "Can Stream" permission mirroring the existing "Can Download" pattern. Server admins can now disable streaming for specific users, encouraging local downloads instead. Addresses #2572. Changes: - User model: stream permission in mapping, defaults, and getter - ApiKey model: stream permission in defaults - Controller: 403 enforcement on playback session creation endpoints - Frontend: permission toggle in admin UI, play button gated by canStream, download button shown when streaming disabled, message when neither allowed - Tests: 11 Mocha tests (model + controller), 1 Cypress test (card UI) - Localization: English strings for toggle label and fallback message The getter uses !== false (rather than !!) so existing users without the stream key in their permissions JSON default to allowed on upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
64cbf59609
commit
464b720d9e
11 changed files with 236 additions and 1 deletions
|
|
@ -436,7 +436,7 @@ export default {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
return this.userCanStream && !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.ebookFormat
|
return !this.isSelectionMode && this.ebookFormat
|
||||||
|
|
@ -476,6 +476,9 @@ export default {
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.store.getters['user/getUserCanDownload']
|
return this.store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
userCanStream() {
|
||||||
|
return this.store.getters['user/getUserCanStream']
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.store.getters['user/getIsAdminOrUp']
|
return this.store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p id="stream-permissions-toggle">{{ $strings.LabelPermissionsStream }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch labeledBy="stream-permissions-toggle" v-model="newUser.permissions.stream" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||||
|
|
@ -354,6 +363,7 @@ export default {
|
||||||
userTypeUpdated(type) {
|
userTypeUpdated(type) {
|
||||||
this.newUser.permissions = {
|
this.newUser.permissions = {
|
||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
|
stream: true,
|
||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin',
|
upload: type === 'admin',
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ function createMountOptions() {
|
||||||
'user/getUserCanUpdate': true,
|
'user/getUserCanUpdate': true,
|
||||||
'user/getUserCanDelete': true,
|
'user/getUserCanDelete': true,
|
||||||
'user/getUserCanDownload': true,
|
'user/getUserCanDownload': true,
|
||||||
|
'user/getUserCanStream': true,
|
||||||
'user/getIsAdminOrUp': true,
|
'user/getIsAdminOrUp': true,
|
||||||
'user/getUserMediaProgress': (id) => null,
|
'user/getUserMediaProgress': (id) => null,
|
||||||
'user/getUserSetting': (settingName) => false,
|
'user/getUserSetting': (settingName) => false,
|
||||||
|
|
@ -163,6 +164,15 @@ describe('LazyBookCard', () => {
|
||||||
cy.get('&ebookFormat').should('not.exist')
|
cy.get('&ebookFormat').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides play button on mouseover when user cannot stream', () => {
|
||||||
|
mountOptions.mocks.$store.getters['user/getUserCanStream'] = false
|
||||||
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
cy.get('#book-card-0').trigger('mouseover')
|
||||||
|
|
||||||
|
cy.get('&overlay').should('be.visible')
|
||||||
|
cy.get('&playButton').should('be.hidden')
|
||||||
|
})
|
||||||
|
|
||||||
it('routes to item page when clicked', () => {
|
it('routes to item page when clicked', () => {
|
||||||
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
||||||
cy.mount(LazyBookCard, mountOptions)
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,15 @@
|
||||||
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
|
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
|
<ui-btn v-else-if="!userCanStream && userCanDownload && tracks.length" color="bg" :padding-x="4" small class="flex items-center h-9 mr-2" @click="downloadLibraryItem">
|
||||||
|
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">download</span>
|
||||||
|
{{ $strings.LabelDownload }}
|
||||||
|
</ui-btn>
|
||||||
|
|
||||||
|
<p v-else-if="!userCanStream && !userCanDownload && tracks.length" class="text-sm text-gray-400 mr-2">
|
||||||
|
{{ $strings.MessageNoStreamOrDownloadAccess }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<ui-btn v-else-if="isMissing || isInvalid" color="bg-error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
<ui-btn v-else-if="isMissing || isInvalid" color="bg-error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||||
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||||
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
||||||
|
|
@ -229,6 +238,7 @@ export default {
|
||||||
return !!this.mediaMetadata.abridged
|
return !!this.mediaMetadata.abridged
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
|
if (!this.userCanStream) return false
|
||||||
if (this.isMissing || this.isInvalid) return false
|
if (this.isMissing || this.isInvalid) return false
|
||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
|
|
@ -352,6 +362,9 @@ export default {
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
userCanStream() {
|
||||||
|
return this.$store.getters['user/getUserCanStream']
|
||||||
|
},
|
||||||
showRssFeedBtn() {
|
showRssFeedBtn() {
|
||||||
if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export const getters = {
|
||||||
getUserCanDownload: (state) => {
|
getUserCanDownload: (state) => {
|
||||||
return !!state.user?.permissions?.download
|
return !!state.user?.permissions?.download
|
||||||
},
|
},
|
||||||
|
getUserCanStream: (state) => {
|
||||||
|
return state.user?.permissions?.stream !== false
|
||||||
|
},
|
||||||
getUserCanUpload: (state) => {
|
getUserCanUpload: (state) => {
|
||||||
return !!state.user?.permissions?.upload
|
return !!state.user?.permissions?.upload
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -512,6 +512,7 @@
|
||||||
"LabelPermissionsCreateEreader": "Can Create Ereader",
|
"LabelPermissionsCreateEreader": "Can Create Ereader",
|
||||||
"LabelPermissionsDelete": "Can Delete",
|
"LabelPermissionsDelete": "Can Delete",
|
||||||
"LabelPermissionsDownload": "Can Download",
|
"LabelPermissionsDownload": "Can Download",
|
||||||
|
"LabelPermissionsStream": "Can Stream",
|
||||||
"LabelPermissionsUpdate": "Can Update",
|
"LabelPermissionsUpdate": "Can Update",
|
||||||
"LabelPermissionsUpload": "Can Upload",
|
"LabelPermissionsUpload": "Can Upload",
|
||||||
"LabelPersonalYearReview": "Your Year in Review ({0})",
|
"LabelPersonalYearReview": "Your Year in Review ({0})",
|
||||||
|
|
@ -851,6 +852,7 @@
|
||||||
"MessageNoFoldersAvailable": "No Folders Available",
|
"MessageNoFoldersAvailable": "No Folders Available",
|
||||||
"MessageNoGenres": "No Genres",
|
"MessageNoGenres": "No Genres",
|
||||||
"MessageNoIssues": "No Issues",
|
"MessageNoIssues": "No Issues",
|
||||||
|
"MessageNoStreamOrDownloadAccess": "Contact your server admin for access",
|
||||||
"MessageNoItems": "No Items",
|
"MessageNoItems": "No Items",
|
||||||
"MessageNoItemsFound": "No items found",
|
"MessageNoItemsFound": "No items found",
|
||||||
"MessageNoListeningSessions": "No Listening Sessions",
|
"MessageNoListeningSessions": "No Listening Sessions",
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,10 @@ class LibraryItemController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
startPlaybackSession(req, res) {
|
startPlaybackSession(req, res) {
|
||||||
|
if (!req.user.canStream) {
|
||||||
|
Logger.warn(`User "${req.user.username}" attempted to stream without permission`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
if (!req.libraryItem.hasAudioTracks) {
|
if (!req.libraryItem.hasAudioTracks) {
|
||||||
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
|
|
@ -430,6 +434,10 @@ class LibraryItemController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
startEpisodePlaybackSession(req, res) {
|
startEpisodePlaybackSession(req, res) {
|
||||||
|
if (!req.user.canStream) {
|
||||||
|
Logger.warn(`User "${req.user.username}" attempted to stream without permission`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
if (!req.libraryItem.isPodcast) {
|
if (!req.libraryItem.isPodcast) {
|
||||||
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
|
Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const Logger = require('../Logger')
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ApiKeyPermissions
|
* @typedef {Object} ApiKeyPermissions
|
||||||
* @property {boolean} download
|
* @property {boolean} download
|
||||||
|
* @property {boolean} stream
|
||||||
* @property {boolean} update
|
* @property {boolean} update
|
||||||
* @property {boolean} delete
|
* @property {boolean} delete
|
||||||
* @property {boolean} upload
|
* @property {boolean} upload
|
||||||
|
|
@ -84,6 +85,7 @@ class ApiKey extends Model {
|
||||||
static getDefaultPermissions() {
|
static getDefaultPermissions() {
|
||||||
return {
|
return {
|
||||||
download: true,
|
download: true,
|
||||||
|
stream: true,
|
||||||
update: true,
|
update: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
upload: true,
|
upload: true,
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ class User extends Model {
|
||||||
*/
|
*/
|
||||||
static permissionMapping = {
|
static permissionMapping = {
|
||||||
canDownload: 'download',
|
canDownload: 'download',
|
||||||
|
canStream: 'stream',
|
||||||
canUpload: 'upload',
|
canUpload: 'upload',
|
||||||
canDelete: 'delete',
|
canDelete: 'delete',
|
||||||
canUpdate: 'update',
|
canUpdate: 'update',
|
||||||
|
|
@ -169,6 +170,7 @@ class User extends Model {
|
||||||
static getDefaultPermissionsForUserType(type) {
|
static getDefaultPermissionsForUserType(type) {
|
||||||
return {
|
return {
|
||||||
download: true,
|
download: true,
|
||||||
|
stream: true,
|
||||||
update: type === 'root' || type === 'admin',
|
update: type === 'root' || type === 'admin',
|
||||||
delete: type === 'root',
|
delete: type === 'root',
|
||||||
upload: type === 'root' || type === 'admin',
|
upload: type === 'root' || type === 'admin',
|
||||||
|
|
@ -567,6 +569,9 @@ class User extends Model {
|
||||||
get canDownload() {
|
get canDownload() {
|
||||||
return !!this.permissions?.download && this.isActive
|
return !!this.permissions?.download && this.isActive
|
||||||
}
|
}
|
||||||
|
get canStream() {
|
||||||
|
return (this.permissions?.stream !== false) && this.isActive
|
||||||
|
}
|
||||||
get canUpload() {
|
get canUpload() {
|
||||||
return !!this.permissions?.upload && this.isActive
|
return !!this.permissions?.upload && this.isActive
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
test/server/controllers/LibraryItemController.canStream.test.js
Normal file
101
test/server/controllers/LibraryItemController.canStream.test.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('LibraryItemController - canStream enforcement', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sinon.stub(Logger, 'warn')
|
||||||
|
sinon.stub(Logger, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('startPlaybackSession', () => {
|
||||||
|
it('should return 403 when user cannot stream', () => {
|
||||||
|
const req = {
|
||||||
|
user: { canStream: false, username: 'testuser' },
|
||||||
|
libraryItem: { hasAudioTracks: true, id: 'li_test' }
|
||||||
|
}
|
||||||
|
const res = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
LibraryItemController.startPlaybackSession.call({}, req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(Logger.warn.calledOnce).to.be.true
|
||||||
|
expect(Logger.warn.firstCall.args[0]).to.include('testuser')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not block when user can stream', () => {
|
||||||
|
const startSessionRequest = sinon.spy()
|
||||||
|
const req = {
|
||||||
|
user: { canStream: true },
|
||||||
|
libraryItem: { hasAudioTracks: true, id: 'li_test' }
|
||||||
|
}
|
||||||
|
const res = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
LibraryItemController.startPlaybackSession.call(
|
||||||
|
{ playbackSessionManager: { startSessionRequest } },
|
||||||
|
req,
|
||||||
|
res
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.sendStatus.called).to.be.false
|
||||||
|
expect(startSessionRequest.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 404 when item has no audio tracks', () => {
|
||||||
|
const req = {
|
||||||
|
user: { canStream: true },
|
||||||
|
libraryItem: { hasAudioTracks: false, id: 'li_test' }
|
||||||
|
}
|
||||||
|
const res = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
LibraryItemController.startPlaybackSession.call({}, req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(404)).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('startEpisodePlaybackSession', () => {
|
||||||
|
it('should return 403 when user cannot stream', () => {
|
||||||
|
const req = {
|
||||||
|
user: { canStream: false, username: 'testuser' },
|
||||||
|
libraryItem: { isPodcast: true, id: 'li_test' },
|
||||||
|
params: { episodeId: 'ep_1' }
|
||||||
|
}
|
||||||
|
const res = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
LibraryItemController.startEpisodePlaybackSession.call({}, req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(Logger.warn.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not block when user can stream', () => {
|
||||||
|
const startSessionRequest = sinon.spy()
|
||||||
|
const req = {
|
||||||
|
user: { canStream: true },
|
||||||
|
libraryItem: {
|
||||||
|
isPodcast: true,
|
||||||
|
id: 'li_test',
|
||||||
|
media: { podcastEpisodes: [{ id: 'ep_1' }] }
|
||||||
|
},
|
||||||
|
params: { episodeId: 'ep_1' }
|
||||||
|
}
|
||||||
|
const res = { sendStatus: sinon.spy() }
|
||||||
|
|
||||||
|
LibraryItemController.startEpisodePlaybackSession.call(
|
||||||
|
{ playbackSessionManager: { startSessionRequest } },
|
||||||
|
req,
|
||||||
|
res
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res.sendStatus.called).to.be.false
|
||||||
|
expect(startSessionRequest.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
78
test/server/models/User.canStream.test.js
Normal file
78
test/server/models/User.canStream.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
|
||||||
|
describe('User - canStream permission', () => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDefaultPermissionsForUserType', () => {
|
||||||
|
it('should default stream to true for all user types', () => {
|
||||||
|
for (const type of ['root', 'admin', 'user', 'guest']) {
|
||||||
|
const permissions = Database.userModel.getDefaultPermissionsForUserType(type)
|
||||||
|
expect(permissions.stream).to.equal(true, `stream should default to true for type "${type}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canStream getter', () => {
|
||||||
|
it('should return true when stream permission is true and user is active', async () => {
|
||||||
|
const user = await Database.userModel.create({
|
||||||
|
username: 'testuser',
|
||||||
|
pash: 'hashed',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: { stream: true, download: true }
|
||||||
|
})
|
||||||
|
expect(user.canStream).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when stream permission is explicitly false', async () => {
|
||||||
|
const user = await Database.userModel.create({
|
||||||
|
username: 'testuser',
|
||||||
|
pash: 'hashed',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: { stream: false, download: true }
|
||||||
|
})
|
||||||
|
expect(user.canStream).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true when stream permission is missing (migration safety)', async () => {
|
||||||
|
const user = await Database.userModel.create({
|
||||||
|
username: 'testuser',
|
||||||
|
pash: 'hashed',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: { download: true }
|
||||||
|
})
|
||||||
|
expect(user.canStream).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when user is inactive even with stream permission', async () => {
|
||||||
|
const user = await Database.userModel.create({
|
||||||
|
username: 'testuser',
|
||||||
|
pash: 'hashed',
|
||||||
|
type: 'user',
|
||||||
|
isActive: false,
|
||||||
|
permissions: { stream: true }
|
||||||
|
})
|
||||||
|
expect(user.canStream).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('permissionMapping', () => {
|
||||||
|
it('should map canStream to stream', () => {
|
||||||
|
expect(Database.userModel.permissionMapping.canStream).to.equal('stream')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue