using System; using System.Collections.Generic; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.WatchedTogether.Auth; using Jellyfin.Plugin.WatchedTogether.Configuration; using Jellyfin.Plugin.WatchedTogether.Services; using MediaBrowser.Controller.Authentication; using MediaBrowser.Model.Cryptography; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Plugin.WatchedTogether.Tests; /// /// Covers the rule that makes this plugin work: any member's password unlocks the shared account, /// and nothing else does. /// public class AuthenticationTests { // PasswordHash.Parse requires hex-encoded salt and hash segments, so these fixtures use real // hex rather than readable placeholders. private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB"; private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD"; private static User MakeUser(string name, string? password) { var user = new User(name, "Prov", "ResetProv") { Password = password! }; return user; } /// /// Builds a provider whose crypto accepts exactly the (hash, password) pairs given. /// private static SharedAccountAuthenticationProvider MakeProvider( SharedGroup? group, IReadOnlyList members, params (string Hash, string Password)[] validPairs) => MakeProvider(group, members, null, validPairs); /// /// Builds a provider, optionally with a dynamic-group service that returns /// for an unresolved username. /// private static SharedAccountAuthenticationProvider MakeProvider( SharedGroup? group, IReadOnlyList members, DynamicGroupResult? dynamicResult, params (string Hash, string Password)[] validPairs) { // ICryptoProvider.Verify takes a ReadOnlySpan, which Moq cannot express as a generic // argument, so the crypto provider is stubbed by hand. var crypto = new StubCryptoProvider(validPairs); var groups = new Mock(); groups.Setup(g => g.GetGroupForSharedUser(It.IsAny())).Returns(group); groups.Setup(g => g.GetEligibleMembers(It.IsAny())).Returns(members); var dynamic = new Mock(); dynamic.Setup(d => d.TryCreateFromLoginAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(dynamicResult); return new SharedAccountAuthenticationProvider( crypto, new Lazy(() => groups.Object), new Lazy(() => dynamic.Object), NullLogger.Instance); } [Fact] public async Task Authenticate_WithFirstMemberPassword_Succeeds() { var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", BobHash); var shared = MakeUser("alice+bob", null); var group = new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [alice.Id, bob.Id] }; var provider = MakeProvider(group, [alice, bob], (AliceHash, "alice-pw")); var result = await provider.Authenticate("alice+bob", "alice-pw", shared); Assert.Equal("alice+bob", result.Username); } [Fact] public async Task Authenticate_WithLaterMemberPassword_Succeeds() { // The second member's password must work even though the first member's check failed // first - that loop is the whole point of the plugin. var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", BobHash); var shared = MakeUser("alice+bob", null); var group = new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [alice.Id, bob.Id] }; var provider = MakeProvider(group, [alice, bob], (BobHash, "bob-pw")); var result = await provider.Authenticate("alice+bob", "bob-pw", shared); Assert.Equal("alice+bob", result.Username); } [Fact] public async Task Authenticate_WithWrongPassword_Throws() { var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", BobHash); var shared = MakeUser("alice+bob", null); var group = new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [alice.Id, bob.Id] }; var provider = MakeProvider(group, [alice, bob], (AliceHash, "alice-pw")); await Assert.ThrowsAsync( () => provider.Authenticate("alice+bob", "not-the-password", shared)); } [Fact] public async Task Authenticate_WhenGroupDisabledOrUnknown_Throws() { // GetGroupForSharedUser returns null both for accounts we do not manage and for groups an // admin has suspended. Neither may be unlocked. var shared = MakeUser("alice+bob", null); var provider = MakeProvider(null, [], (AliceHash, "alice-pw")); await Assert.ThrowsAsync( () => provider.Authenticate("alice+bob", "alice-pw", shared)); } [Fact] public async Task Authenticate_WhenNoEligibleMembers_Throws() { // Every member disabled or deleted: a correct password for a now-disabled member must not // still open the account. var shared = MakeUser("alice+bob", null); var group = new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [Guid.NewGuid()] }; var provider = MakeProvider(group, [], (AliceHash, "alice-pw")); await Assert.ThrowsAsync( () => provider.Authenticate("alice+bob", "alice-pw", shared)); } [Fact] public async Task Authenticate_MemberWithNoPassword_IsSkipped() { // A passwordless member contributes no credential; an empty submitted password must not // match them and unlock the account. var ghost = MakeUser("ghost", null); var bob = MakeUser("bob", BobHash); var shared = MakeUser("ghost+bob", null); var group = new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [ghost.Id, bob.Id] }; var provider = MakeProvider(group, [ghost, bob], (BobHash, "bob-pw")); await Assert.ThrowsAsync( () => provider.Authenticate("ghost+bob", string.Empty, shared)); } [Fact] public async Task Authenticate_WithNullResolvedUser_AndNoDynamicMatch_Throws() { // Nothing resolved and the name is not a valid member combination. var provider = MakeProvider(null, [], dynamicResult: null, (AliceHash, "alice-pw")); await Assert.ThrowsAsync( () => provider.Authenticate("whoever", "alice-pw", null)); } [Fact] public async Task Authenticate_WithNullResolvedUser_CreatesGroupOnDemand() { // Typing "alice+bob" with a member's password provisions the shared account and logs in. var group = new SharedGroup { SharedUserId = Guid.NewGuid() }; var provider = MakeProvider( null, [], new DynamicGroupResult(group, "alice+bob"), (AliceHash, "alice-pw")); var result = await provider.Authenticate("alice+bob", "alice-pw", null); Assert.Equal("alice+bob", result.Username); } [Fact] public async Task Authenticate_TwoArgOverload_AlsoCreatesGroupOnDemand() { // The two-argument overload is equivalent to passing a null resolved user. var group = new SharedGroup { SharedUserId = Guid.NewGuid() }; var provider = MakeProvider( null, [], new DynamicGroupResult(group, "alice+bob"), (AliceHash, "alice-pw")); var result = await provider.Authenticate("alice+bob", "alice-pw"); Assert.Equal("alice+bob", result.Username); } [Fact] public void HasPassword_IsAlwaysTrue() { // Returning false would let a client offer a passwordless login for the shared account. var provider = MakeProvider(null, []); Assert.True(provider.HasPassword(MakeUser("alice+bob", null))); } [Fact] public async Task ChangePassword_IsNotSupported() { var provider = MakeProvider(null, []); await Assert.ThrowsAsync( () => provider.ChangePassword(MakeUser("alice+bob", null), "new-pw")); } }