Backport pull request #18026 from jellyfin/release-12.z

Fix device access revocation not logging out existing sessions

Original-merge: 950c45cf20

Merged-by: crobibero <cody@robibe.ro>

Backported-by: Cody Robibero <cody@robibe.ro>
This commit is contained in:
fmarcac authored and Cody Robibero committed 2026-09-15 11:17:05 -04:00
1 parent 57d19b185c
commit c74ddebd17
6 files changed
+191 -5

No files matched your search

@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users;
public class DeviceAccessHostTests
{
[Fact]
public async Task OnUserUpdated_LogoutThrows_DoesNotEscapeToThreadPool()
{
var user = new User("test", "default", "default");
var device = new Device(user.Id, "app", "1.0", "device", "device-id");
var deviceManager = new Mock<IDeviceManager>();
deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
.Returns(new QueryResult<Device>(new[] { device }));
deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
var sessionManager = new Mock<ISessionManager>();
sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
.ThrowsAsync(new ObjectDisposedException(nameof(ISessionManager)));
var userManager = new Mock<IUserManager>();
var host = new DeviceAccessHost(
userManager.Object,
deviceManager.Object,
sessionManager.Object,
NullLogger<DeviceAccessHost>.Instance);
await host.StartAsync(TestContext.Current.CancellationToken);
var context = new CapturingSynchronizationContext();
var previous = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(context);
try
{
userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
}
finally
{
SynchronizationContext.SetSynchronizationContext(previous);
}
Assert.Empty(context.Exceptions);
}
[Fact]
public async Task OnUserUpdated_DeviceNoLongerAllowed_LogsOutDevice()
{
var user = new User("test", "default", "default");
var device = new Device(user.Id, "app", "1.0", "device", "device-id");
var deviceManager = new Mock<IDeviceManager>();
deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
.Returns(new QueryResult<Device>(new[] { device }));
deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
var loggedOut = new TaskCompletionSource();
var sessionManager = new Mock<ISessionManager>();
sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
.Callback(() => loggedOut.TrySetResult())
.Returns(Task.CompletedTask);
var userManager = new Mock<IUserManager>();
var host = new DeviceAccessHost(
userManager.Object,
deviceManager.Object,
sessionManager.Object,
NullLogger<DeviceAccessHost>.Instance);
await host.StartAsync(TestContext.Current.CancellationToken);
userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
await loggedOut.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sessionManager.Verify(s => s.Logout(device), Times.Once);
}
private sealed class CapturingSynchronizationContext : SynchronizationContext
{
public List<Exception> Exceptions { get; } = new List<Exception>();
public override void Post(SendOrPostCallback d, object? state) => Run(d, state);
public override void Send(SendOrPostCallback d, object? state) => Run(d, state);
private void Run(SendOrPostCallback d, object? state)
{
try
{
d(state);
}
catch (Exception ex)
{
Exceptions.Add(ex);
}
}
}
}
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Model.Cryptography;
using MediaBrowser.Model.Users;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
@@ -119,6 +121,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable
Assert.Equal(before, await ReadChildRowsAsync());
}
[Fact]
public async Task UpdatePolicyAsync_RaisesOnUserUpdated()
{
var user = await _userManager.CreateUserAsync("policyeventuser");
User? updated = null;
_userManager.OnUserUpdated += (_, e) => updated = e.Argument;
await _userManager.UpdatePolicyAsync(
user.Id,
new UserPolicy
{
EnableAllDevices = false,
AuthenticationProviderId = user.AuthenticationProviderId,
PasswordResetProviderId = user.PasswordResetProviderId
});
Assert.NotNull(updated);
Assert.Equal(user.Id, updated.Id);
}
[Fact]
public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
{
@@ -0,0 +1,26 @@
using Jellyfin.Server.Implementations.Users;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Jellyfin.Server.Integration.Tests;
public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory>
{
private readonly JellyfinApplicationFactory _factory;
public HostedServiceRegistrationTests(JellyfinApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public void DeviceAccessHost_IsRegisteredAsHostedService()
{
_ = _factory.CreateClient();
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, service => service is DeviceAccessHost);
}
}