aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Implementations.Tests/Users
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Users')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs111
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs99
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs53
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs23
5 files changed, 363 insertions, 0 deletions
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
new file mode 100644
index 0000000000..5bb5081b60
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
@@ -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);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs
new file mode 100644
index 0000000000..72ca680cf2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerConcurrentCreateTests.cs
@@ -0,0 +1,77 @@
+using System;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Jellyfin.Server.Implementations.Users;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+/// <summary>
+/// Two first-ever requests for the same preferences can both find nothing and both insert; the unique index lets one
+/// through. The other has to return the stored preferences instead of failing.
+/// </summary>
+public sealed class DisplayPreferencesManagerConcurrentCreateTests : SqliteDbTestFixture
+{
+ private const string Client = "client";
+
+ private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ private readonly CompetingInsert _competingInsert;
+ private readonly DisplayPreferencesManager _manager;
+
+ public DisplayPreferencesManagerConcurrentCreateTests()
+ : this(new CompetingInsert())
+ {
+ }
+
+ private DisplayPreferencesManagerConcurrentCreateTests(CompetingInsert competingInsert)
+ : base(competingInsert)
+ {
+ _competingInsert = competingInsert;
+ _manager = new DisplayPreferencesManager(CreateDbContextFactory());
+
+ using var context = CreateDbContext();
+ context.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+ context.SaveChanges();
+ }
+
+ [Fact]
+ public void GetDisplayPreferences_AnotherRequestStoresThemFirst_ReturnsTheStoredPreferences()
+ {
+ var storedId = 0;
+ _competingInsert.Before = () =>
+ {
+ using var context = CreateDbContext();
+ var stored = new DisplayPreferences(_userId, _itemId, Client);
+ context.DisplayPreferences.Add(stored);
+ context.SaveChanges();
+ storedId = stored.Id;
+ };
+
+ var preferences = _manager.GetDisplayPreferences(_userId, _itemId, Client);
+
+ Assert.NotEqual(0, storedId);
+ Assert.Equal(storedId, preferences.Id);
+ using var check = CreateDbContext();
+ Assert.Single(check.DisplayPreferences);
+ }
+
+ /// <summary>
+ /// Stores the other request's row just before the next save, after the manager has looked for the preferences.
+ /// </summary>
+ private sealed class CompetingInsert : SaveChangesInterceptor
+ {
+ public Action? Before { get; set; }
+
+ public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
+ {
+ // Disarmed first, because the competing save goes through this interceptor as well.
+ var before = Before;
+ Before = null;
+ before?.Invoke();
+ return result;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs
new file mode 100644
index 0000000000..13688c7574
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerFailedWriteTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Jellyfin.Server.Implementations.Users;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public sealed class DisplayPreferencesManagerFailedWriteTests : SqliteDbTestFixture
+{
+ private const string Client = "client";
+
+ private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ private readonly StatementInterceptor _statements;
+ private readonly DisplayPreferencesManager _manager;
+
+ public DisplayPreferencesManagerFailedWriteTests()
+ : this(new StatementInterceptor())
+ {
+ }
+
+ private DisplayPreferencesManagerFailedWriteTests(StatementInterceptor statements)
+ : base(statements)
+ {
+ _statements = statements;
+ _manager = new DisplayPreferencesManager(CreateDbContextFactory());
+ }
+
+ [Fact]
+ public void SetCustomItemDisplayPreferences_InsertFails_KeepsThePreviousPreferences()
+ {
+ _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "1" });
+
+ _statements.FailInserts = true;
+ Assert.Throws<DbUpdateException>(() => _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["second"] = "2" }));
+
+ Assert.Equal(
+ new Dictionary<string, string?> { ["first"] = "1" },
+ _manager.ListCustomItemDisplayPreferences(_userId, _itemId, Client));
+ }
+
+ [Fact]
+ public void SetCustomItemDisplayPreferences_AnotherRequestStoredAKeyAfterTheDelete_StoresThePreferencesItWasGiven()
+ {
+ _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "1" });
+
+ // The delete misses the row, as it does when another request stores it after this one's delete, so the unique
+ // index rejects this request's insert.
+ _statements.SkipNextDelete = true;
+ _manager.SetCustomItemDisplayPreferences(_userId, _itemId, Client, new Dictionary<string, string?> { ["first"] = "2" });
+
+ Assert.Equal(
+ new Dictionary<string, string?> { ["first"] = "2" },
+ _manager.ListCustomItemDisplayPreferences(_userId, _itemId, Client));
+ }
+
+ /// <summary>
+ /// Fails inserts into CustomItemDisplayPreferences with the error SQLite reports when the unique index rejects a row, or
+ /// leaves out the next delete from it.
+ /// </summary>
+ private sealed class StatementInterceptor : DbCommandInterceptor
+ {
+ public bool FailInserts { get; set; }
+
+ public bool SkipNextDelete { get; set; }
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ FailInsert(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ if (SkipNextDelete && command.CommandText.StartsWith("DELETE FROM \"CustomItemDisplayPreferences\"", StringComparison.Ordinal))
+ {
+ SkipNextDelete = false;
+ return InterceptionResult<int>.SuppressWithResult(0);
+ }
+
+ FailInsert(command);
+ return result;
+ }
+
+ private void FailInsert(DbCommand command)
+ {
+ if (FailInserts && command.CommandText.StartsWith("INSERT INTO \"CustomItemDisplayPreferences\"", StringComparison.Ordinal))
+ {
+ throw new SqliteException("SQLite Error 19: 'UNIQUE constraint failed: CustomItemDisplayPreferences.UserId, CustomItemDisplayPreferences.ItemId, CustomItemDisplayPreferences.Client, CustomItemDisplayPreferences.Key'.", 19);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs
new file mode 100644
index 0000000000..4110eb513f
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DisplayPreferencesManagerTests.cs
@@ -0,0 +1,53 @@
+using System;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Tests.Item;
+using Jellyfin.Server.Implementations.Users;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+/// <summary>
+/// Item display preferences are looked up by the item they belong to, so they have to be stored under it as well:
+/// a row stored under any other item is never found again, and every later request stores another one.
+/// </summary>
+public sealed class DisplayPreferencesManagerTests : SqliteDbTestFixture
+{
+ private const string Client = "client";
+
+ private static readonly Guid _userId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _itemId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ private readonly DisplayPreferencesManager _manager;
+
+ public DisplayPreferencesManagerTests()
+ {
+ _manager = new DisplayPreferencesManager(CreateDbContextFactory());
+
+ using var context = CreateDbContext();
+ context.Users.Add(new User("user", "auth-provider", "reset-provider") { Id = _userId });
+ context.SaveChanges();
+ }
+
+ [Fact]
+ public void GetItemDisplayPreferences_NewItem_StoresThePreferencesUnderThatItem()
+ {
+ var preferences = _manager.GetItemDisplayPreferences(_userId, _itemId, Client);
+
+ Assert.Equal(_itemId, preferences.ItemId);
+
+ using var context = CreateDbContext();
+ Assert.Equal(_itemId, Assert.Single(context.ItemDisplayPreferences).ItemId);
+ }
+
+ [Fact]
+ public void GetItemDisplayPreferences_SameItemTwice_ReturnsTheStoredPreferences()
+ {
+ var first = _manager.GetItemDisplayPreferences(_userId, _itemId, Client);
+ var second = _manager.GetItemDisplayPreferences(_userId, _itemId, Client);
+
+ Assert.Equal(first.Id, second.Id);
+
+ using var context = CreateDbContext();
+ Assert.Single(context.ItemDisplayPreferences);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
index c940f92109..e91ebdf1b6 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -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;
@@ -120,6 +122,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable
}
[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()
{
var user = await _userManager.CreateUserAsync("policyuser");