diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/SessionManager')
4 files changed, 402 insertions, 0 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs new file mode 100644 index 0000000000..7722707cbe --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/IdlePlaybackTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class IdlePlaybackTests +{ + [Theory] + [InlineData(null, null)] + [InlineData(123456789L, 123456789L)] + public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks) + { + var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + var eventManager = new Mock<IEventManager>(); + eventManager + .Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>())) + .Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks)) + .Returns(Task.CompletedTask); + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + eventManager.Object, + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + var session = await sessionManager.LogSessionActivity( + "Test Client", + "1.0.0", + "test-device", + "Test Device", + "127.0.0.1", + null); + session.NowPlayingItem = new BaseItemDto + { + Id = Guid.NewGuid(), + Name = "Test Item" + }; + session.PlayState.PositionTicks = 987654321; + + if (clientPositionTicks.HasValue) + { + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = clientPositionTicks + }); + session.StopAutomaticProgress(); + } + + var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager) + .GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!; + idlePlaybackCallback.Invoke(sessionManager, new object?[] { null }); + + var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(expectedPositionTicks, stoppedPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs new file mode 100644 index 0000000000..a07e79baa3 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class PlayCommandQueueTests : IDisposable +{ + private readonly ILibraryManager? _previousLibraryManager; + + public PlayCommandQueueTests() + { + _previousLibraryManager = BaseItem.LibraryManager; + } + + /// <summary> + /// A music genre tags its artists as well as their songs, and a by-name artist row is not a + /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a + /// client that reaches it in the queue gets an error instead of the next track. + /// </summary> + /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns> + [Fact] + public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems() + { + var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" }; + var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" }; + var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre); + libraryManager + .Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())) + .Returns(new List<BaseItem> { artist, song }); + BaseItem.LibraryManager = libraryManager.Object; + + await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<IUserManager>(), + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + + var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null); + + var command = new PlayRequest + { + ItemIds = new[] { genre.Id }, + PlayCommand = PlayCommand.PlayNow + }; + + await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None); + + Assert.Equal(new[] { song.Id }, command.ItemIds); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + BaseItem.LibraryManager = _previousLibraryManager!; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs new file mode 100644 index 0000000000..c5b8f661b5 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionInfoTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.SessionManager; + +public class SessionInfoTests +{ + [Fact] + public async Task StartAutomaticProgress_SnapshotsClientReportedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var progressInfo = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }; + + session.StartAutomaticProgress(progressInfo); + + Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot() + { + var sessionManager = new Mock<ISessionManager>(); + await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance); + var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously); + const long reportedPositionTicks = 123456789; + + sessionManager + .Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true)) + .Callback<PlaybackProgressInfo, bool>((info, _) => + { + session.PlayState.PositionTicks = info.PositionTicks; + automaticProgress.TrySetResult(info.PositionTicks); + }) + .Returns(Task.CompletedTask); + session.PlayState.PositionTicks = reportedPositionTicks; + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + PositionTicks = reportedPositionTicks + }); + + var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.StopAutomaticProgress(); + + Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks); + Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks); + Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 123456789 + }); + + session.StartAutomaticProgress(new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 987654321 + }); + + Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks); + } + + [Fact] + public async Task StartAutomaticProgress_PreservesExactPausedPosition() + { + await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance); + var pausedProgress = new PlaybackProgressInfo + { + IsPaused = true, + PositionTicks = 314159265 + }; + + session.StartAutomaticProgress(pausedProgress); + + Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs index a5a67046d1..f803c69af2 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs @@ -1,6 +1,9 @@ using System; +using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -108,4 +113,136 @@ public class SessionManagerTests return data; } + + [Fact] + public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand( + attackerSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "test exploit!" }, + CancellationToken.None)); + } + + [Fact] + public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("controller", "default", "default"); + attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var controllingSession = await LogSessionActivity(sessionManager, attacker); + + await sessionManager.SendMessageCommand( + controllingSession.Id, + victimSession.Id, + new MessageCommand { Header = "Custom Message", Text = "hello" }, + CancellationToken.None); + } + + [Fact] + public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + // Client name and device id are attacker controlled, so they must not identify a session on their own. + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.NotEqual(victimSession.Id, attackerSession.Id); + Assert.Equal(victim.Id, victimSession.UserId); + } + + [Fact] + public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser() + { + var attacker = new User("attacker", "default", "default"); + var victim = new User("victim", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id)); + } + + [Fact] + public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator() + { + var admin = new User("admin", "default", "default"); + admin.SetPermission(PermissionKind.IsAdministrator, true); + var guest = new User("guest", "default", "default"); + await using var sessionManager = CreateSessionManager(admin, guest); + + var adminSession = await LogSessionActivity(sessionManager, admin); + + sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id); + + Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id)); + } + + [Fact] + public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id)); + } + + [Fact] + public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession() + { + var victim = new User("victim", "default", "default"); + var attacker = new User("attacker", "default", "default"); + await using var sessionManager = CreateSessionManager(victim, attacker); + + var victimSession = await LogSessionActivity(sessionManager, victim); + var attackerSession = await LogSessionActivity(sessionManager, attacker); + + Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities())); + } + + private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users) + { + var userManager = new Mock<IUserManager>(); + foreach (var user in users) + { + userManager.Setup(i => i.GetUserById(user.Id)).Returns(user); + } + + return new Emby.Server.Implementations.Session.SessionManager( + NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance, + Mock.Of<IEventManager>(), + Mock.Of<IUserDataManager>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILibraryManager>(), + userManager.Object, + Mock.Of<IMusicManager>(), + Mock.Of<IDtoService>(), + Mock.Of<IImageProcessor>(), + Mock.Of<IServerApplicationHost>(), + Mock.Of<IDeviceManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IHostApplicationLifetime>()); + } + + // All sessions are logged with the same client and device id on purpose, those values are taken + // from the request headers and are not bound to the access token of the calling user. + private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user) + => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user); } |
