diff options
Diffstat (limited to 'tests/Jellyfin.Server.Implementations.Tests/Users')
6 files changed, 546 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/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs index 4cea53bd3d..2bf1d1d05b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData(" thishasaspaceatthestart")] [InlineData(" thishasaspaceatbothends ")] [InlineData(" this has a space at both ends and inbetween ")] + [InlineData(".")] + [InlineData("..")] public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) { Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username)); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs new file mode 100644 index 0000000000..e91ebdf1b6 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +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; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +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; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public sealed class UserManagerUpdateUserTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly UserManager _userManager; + + public UserManagerUpdateUserTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock<ICryptoProvider>(); + var configManager = new Mock<IServerConfigurationManager>(); + var appPaths = new Mock<IServerApplicationPaths>(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock<IApplicationHost>(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger<DefaultAuthenticationProvider>.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock<INetworkManager>().Object, + appHost.Object, + new Mock<IImageProcessor>().Object, + NullLogger<UserManager>.Instance, + configManager.Object, + [defaultPasswordResetProvider], + [defaultAuthProvider, invalidAuthProvider]); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + [Fact] + public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences() + { + var user = await _userManager.CreateUserAsync("orphanuser"); + var permissionCount = user.Permissions.Count; + var preferenceCount = user.Preferences.Count; + + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + await _userManager.UpdateUserAsync(user); + + await using var context = CreateDbContext(); + Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken)); + Assert.All( + await context.Permissions.ToListAsync(TestContext.Current.CancellationToken), + permission => Assert.Equal(user.Id, permission.UserId)); + Assert.All( + await context.Preferences.ToListAsync(TestContext.Current.CancellationToken), + preference => Assert.Equal(user.Id, preference.UserId)); + } + + [Fact] + public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched() + { + var user = await _userManager.CreateUserAsync("churnuser"); + var before = await ReadChildRowsAsync(); + + // A session activity stamp goes through the same path. It must not rewrite all 37 child + // rows, which is what tearing the collections down and rebuilding them used to do. + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + + 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() + { + var user = await _userManager.CreateUserAsync("policyuser"); + Assert.False(user.HasPermission(PermissionKind.IsAdministrator)); + + user.SetPermission(PermissionKind.IsAdministrator, true); + user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]); + user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels)); + + await _userManager.UpdateUserAsync(user); + + var reloaded = _userManager.GetUserById(user.Id)!; + Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator)); + Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags)); + Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels); + + await using var context = CreateDbContext(); + Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + /// <summary> + /// Reads the identity and concurrency token of every permission and preference row. + /// </summary> + private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync() + { + await using var context = CreateDbContext(); + var permissions = await context.Permissions + .OrderBy(permission => permission.Id) + .Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + var preferences = await context.Preferences + .OrderBy(preference => preference.Id) + .Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + + return permissions.Concat(preferences).ToList(); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish<T>(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync<T>(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } +} |
