using System; using System.Collections.Generic; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.WatchedTogether.Auth; using Jellyfin.Plugin.WatchedTogether.Services; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Plugin.WatchedTogether.Tests; /// /// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob", /// then logged into again afterwards by each member in turn. /// /// /// The other suites mock , which is why an ordering bug inside the /// real provisioning code reached a release. These tests wire the real services together and only /// stub the host's user manager and crypto. /// [Collection(nameof(PluginTestContext))] public class SharedAccountEndToEndTests { private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB"; private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD"; private static readonly string AuthProviderId = typeof(SharedAccountAuthenticationProvider).FullName!; private sealed record Harness( SharedAccountAuthenticationProvider Provider, List Users); private static User MakeUser(string name, string? password = null) => new(name, "Prov", "ResetProv") { Password = password! }; /// /// Wires the real provisioning, group, dynamic-group and authentication services over a user /// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned /// provider, and users resolve by both name and id. /// private static Harness MakeHarness(List users, params (string Hash, string Password)[] validPairs) { var crypto = new StubCryptoProvider(validPairs); var userManager = new Mock(); userManager.Setup(m => m.GetUserById(It.IsAny())) .Returns((Guid id) => users.Find(u => u.Id == id)!); userManager.Setup(m => m.GetUserByName(It.IsAny())) .Returns((string n) => users.Find( u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!); userManager.Setup(m => m.CreateUserAsync(It.IsAny())) .ReturnsAsync((string name) => { var created = MakeUser(name); users.Add(created); return created; }); userManager.Setup(m => m.UpdateUserAsync(It.IsAny())).Returns(Task.CompletedTask); userManager.Setup(m => m.ChangePassword(It.IsAny(), It.IsAny())) .Returns((User user, string password) => { // Jellyfin routes this to the user's assigned provider; ours refuses by design. if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal)) { throw new NotSupportedException( "A Watched Together shared account has no password of its own."); } user.Password = password; return Task.CompletedTask; }); var groupService = new GroupService( userManager.Object, NullLogger.Instance); var provisioning = new ProvisioningService( userManager.Object, Mock.Of(), NullLogger.Instance); var dynamicGroups = new DynamicGroupService( userManager.Object, provisioning, crypto, NullLogger.Instance); var provider = new SharedAccountAuthenticationProvider( crypto, new Lazy(() => groupService), new Lazy(() => dynamicGroups), NullLogger.Instance); return new Harness(provider, users); } [Theory] [InlineData("alice-pw")] [InlineData("bob-pw")] public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword) { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", BobHash); var users = new List { alice, bob }; var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw")); // Creating the group by typing both names. Whichever member types their own password, the // resulting account must behave identically. var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null); Assert.Equal("alice+bob", created.Username); var shared = h.Users.Find(u => u.Username == "alice+bob"); Assert.NotNull(shared); // The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both // members must be able to unlock it, regardless of who created it. var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared); Assert.Equal("alice+bob", asAlice.Username); var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared); Assert.Equal("alice+bob", asBob.Username); // And an outsider's password still must not. await Assert.ThrowsAsync( () => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!)); } [Fact] public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", BobHash); var users = new List { alice, bob }; var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw")); await h.Provider.Authenticate("alice+bob", "alice-pw", null); var shared = h.Users.Find(u => u.Username == "alice+bob"); Assert.NotNull(shared); // Claimed by us, so future logins route here, and holding a password of its own so it is // not directly loginable if the provider is ever unassigned. Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId); Assert.False(string.IsNullOrEmpty(shared.Password)); } }