using System; using System.Collections.Generic; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.WatchedTogether.Services; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Plugin.WatchedTogether.Tests; /// /// Covers shared-account provisioning against a user manager that dispatches password changes the /// way Jellyfin's real one does. /// [Collection(nameof(PluginTestContext))] public class ProvisioningTests { private static readonly string AuthProviderId = typeof(Auth.SharedAccountAuthenticationProvider).FullName!; private static User MakeUser(string name) => new(name, "Prov", "ResetProv"); /// /// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is /// routed to the provider named by the user's AuthenticationProviderId, so an account already /// claimed by us lands in our provider and is refused. /// private static Mock MakeUserManager(List users, List callLog) { var userManager = new Mock(); userManager.Setup(m => m.GetUserById(It.IsAny())) .Returns((Guid id) => users.Find(u => u.Id == id)); userManager.Setup(m => m.CreateUserAsync(It.IsAny())) .ReturnsAsync((string name) => { var created = MakeUser(name); users.Add(created); callLog.Add("CreateUser"); return created; }); userManager.Setup(m => m.ChangePassword(It.IsAny(), It.IsAny())) .Returns((User user, string password) => { callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})"); // This is the dispatch that made provisioning fail in 0.0.3: once the account is // claimed, the call reaches our provider, which refuses it by design. if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal)) { return new Auth.SharedAccountAuthenticationProvider( Mock.Of(), new Lazy(() => Mock.Of()), new Lazy(() => Mock.Of()), NullLogger.Instance) .ChangePassword(user, password); } user.Password = password; return Task.CompletedTask; }); userManager.Setup(m => m.UpdateUserAsync(It.IsAny())) .Returns((User user) => { callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})"); return Task.CompletedTask; }); return userManager; } private static ProvisioningService MakeService(Mock userManager) => new( userManager.Object, Mock.Of(), NullLogger.Instance); [Fact] public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount() { using var context = PluginTestContext.Create(); var alice = MakeUser("alice"); var bob = MakeUser("bob"); var users = new List { alice, bob }; var callLog = new List(); var service = MakeService(MakeUserManager(users, callLog)); // Before the fix this threw NotSupportedException from our own ChangePassword. var group = await service.CreateGroupAsync([alice.Id, bob.Id], null); Assert.NotEqual(Guid.Empty, group.SharedUserId); // The password must be set while the account is still on Jellyfin's default provider. var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal)); var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal)); Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account"); Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider"); Assert.True( changeIndex < claimIndex, $"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}"); } [Fact] public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider() { using var context = PluginTestContext.Create(); var alice = MakeUser("alice"); var bob = MakeUser("bob"); var users = new List { alice, bob }; var callLog = new List(); var service = MakeService(MakeUserManager(users, callLog)); var group = await service.CreateGroupAsync([alice.Id, bob.Id], null); // Claiming the account is what routes its logins to us; provisioning is useless without it. var sharedUser = users.Find(u => u.Id == group.SharedUserId); Assert.NotNull(sharedUser); Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId); } [Fact] public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword() { using var context = PluginTestContext.Create(); var alice = MakeUser("alice"); var bob = MakeUser("bob"); var users = new List { alice, bob }; var callLog = new List(); var service = MakeService(MakeUserManager(users, callLog)); var group = await service.CreateGroupAsync([alice.Id, bob.Id], null); // A passwordless shared account would be directly loginable if the provider were ever // unassigned, which is the reason provisioning sets one at all. var sharedUser = users.Find(u => u.Id == group.SharedUserId); Assert.NotNull(sharedUser); Assert.False(string.IsNullOrEmpty(sharedUser!.Password)); } }