using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.WatchedTogether.Auth; using Jellyfin.Plugin.WatchedTogether.Configuration; 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; /// /// Covers the rule that a shared account is at least as restricted as every member who can unlock /// it: how members' restrictions combine, and who an account with a chosen rating cap lets in. /// [Collection(nameof(PluginTestContext))] public class RestrictionTests { private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB"; private const string KidHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD"; private const string TeenHash = "$PBKDF2-SHA512$iterations=210000$C3C3C3C3$EEEEEEEEFFFFFFFF"; private static User MakeUser(string name, string? password = null) => new(name, "Prov", "ResetProv") { Password = password! }; /// /// A user manager that resolves the given users by id and name and round-trips policies. /// private static Mock MakeUserManager(List users) { 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.SetupChangePassword(users, (user, password) => { user.Password = password; return Task.CompletedTask; }); userManager.SetupPolicyRoundTrip(users); return userManager; } private static RestrictionService MakeService(List users) => new(MakeUserManager(users).Object, NullLogger.Instance); private sealed record Harness( SharedAccountAuthenticationProvider Provider, ProvisioningService Provisioning, List Users); /// /// Wires the real provisioning, group, dynamic-group, restriction and authentication services /// over a stub user manager, the same way does. /// private static Harness MakeHarness(List users, params (string Hash, string Password)[] validPairs) { var crypto = new StubCryptoProvider(validPairs); var userManager = MakeUserManager(users); var groupService = new GroupService(userManager.Object, NullLogger.Instance); var restrictions = new RestrictionService(userManager.Object, NullLogger.Instance); var provisioning = new ProvisioningService( userManager.Object, Mock.Of(), restrictions, NullLogger.Instance); var dynamicGroups = new DynamicGroupService( userManager.Object, provisioning, restrictions, crypto, NullLogger.Instance); var provider = new SharedAccountAuthenticationProvider( crypto, new Lazy(() => groupService), new Lazy(() => dynamicGroups), new Lazy(() => restrictions), NullLogger.Instance); return new Harness(provider, provisioning, users); } // ---- combining members ---------------------------------------------------------------- [Theory] [InlineData(null, 13, 13)] [InlineData(13, null, 13)] [InlineData(7, 17, 7)] [InlineData(null, null, null)] public void Rating_StrictestScoreWins(int? a, int? b, int? expected) { var alice = MakeUser("alice").Restrict(maxRating: a); var bob = MakeUser("bob").Restrict(maxRating: b); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.Equal(expected, result.MaxParentalRatingScore); } [Theory] [InlineData(null, 2, 2)] [InlineData(3, 2, 2)] [InlineData(2, 3, 2)] [InlineData(null, null, null)] public void Rating_AtEqualScore_StrictestSubScoreWins(int? a, int? b, int? expected) { // At the same score a null sub-cap allows every sub-score, so any value beats it. var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: a); var bob = MakeUser("bob").Restrict(maxRating: 13, maxSubRating: b); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.Equal(13, result.MaxParentalRatingScore); Assert.Equal(expected, result.MaxParentalRatingSubScore); } [Fact] public void Rating_LowerScore_WinsRegardlessOfSubScore() { var alice = MakeUser("alice").Restrict(maxRating: 13, maxSubRating: 0); var bob = MakeUser("bob").Restrict(maxRating: 7, maxSubRating: null); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.Equal(7, result.MaxParentalRatingScore); Assert.Null(result.MaxParentalRatingSubScore); } [Fact] public void UnratedAndBlockedTags_AreUnioned() { var alice = MakeUser("alice").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]); var bob = MakeUser("bob").Restrict(blockUnrated: [UnratedItem.Series], blockedTags: ["Gore", "horror"]); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.Equal(new HashSet { UnratedItem.Movie, UnratedItem.Series }, result.BlockUnratedItems); Assert.Equal(2, result.BlockedTags.Count); Assert.Contains("horror", result.BlockedTags); Assert.Contains("gore", result.BlockedTags); } [Fact] public void AllowedTags_NoWhitelists_StaysNoWhitelist() { var alice = MakeUser("alice"); var bob = MakeUser("bob"); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.False(result.HasAllowedTags); Assert.Empty(result.AllowedTagsForPolicy()); } [Fact] public void AllowedTags_MemberWithoutWhitelist_AcceptsTheOthers() { // An empty allowed-tag list in Jellyfin is "no whitelist", not "allows nothing": it must // not wipe out the other member's whitelist. var alice = MakeUser("alice"); var kid = MakeUser("kid").Restrict(allowedTags: ["kids", "family"]); var result = MakeService([alice, kid]).ComputeStrictest([alice.Id, kid.Id]); Assert.True(result.HasAllowedTags); Assert.Equal(new[] { "family", "kids" }, result.AllowedTagsForPolicy().OrderBy(t => t, StringComparer.Ordinal)); } [Fact] public void AllowedTags_TwoWhitelists_Intersect() { var alice = MakeUser("alice").Restrict(allowedTags: ["kids", "family"]); var bob = MakeUser("bob").Restrict(allowedTags: ["Family", "documentary"]); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); Assert.Equal(new[] { "family" }, result.AllowedTagsForPolicy()); } [Fact] public void AllowedTags_DisjointWhitelists_AllowNothing_AndSurviveARoundTrip() { var alice = MakeUser("alice").Restrict(allowedTags: ["kids"]); var bob = MakeUser("bob").Restrict(allowedTags: ["documentary"]); var result = MakeService([alice, bob]).ComputeStrictest([alice.Id, bob.Id]); // In force, but empty. Jellyfin would read an empty list as unrestricted, so what gets // written is a tag no item carries... Assert.True(result.HasAllowedTags); Assert.Empty(result.AllowedTags!); Assert.Equal(new[] { ContentRestrictions.NothingAllowedTag }, result.AllowedTagsForPolicy()); // ...and reading a user carrying only that tag comes back as "allows nothing", not as a // one-tag whitelist, so the unlock rule treats it as stricter than anything. var shared = MakeUser("shared").Restrict(allowedTags: result.AllowedTagsForPolicy()); var read = ContentRestrictions.FromUser(shared); Assert.True(read.HasAllowedTags); Assert.Empty(read.AllowedTags!); Assert.True(read.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice))); } [Fact] public void UnresolvableMember_YieldsFullyRestricted() { var alice = MakeUser("alice"); var result = MakeService([alice]).ComputeStrictest([alice.Id, Guid.NewGuid()]); Assert.Equal(ContentRestrictions.FullyRestricted, result); Assert.True(result.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(alice))); } [Fact] public async Task ApplyAsync_WritesTheStrictestPolicy_AndNeverAdministrator() { var alice = MakeUser("alice").Restrict(blockedTags: ["horror"]); var kid = MakeUser("kid").Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]); var shared = MakeUser("shared"); shared.SetPermission(PermissionKind.IsAdministrator, true); await MakeService([alice, kid, shared]).ApplyAsync(new SharedGroup { SharedUserId = shared.Id, MemberUserIds = [alice.Id, kid.Id] }); Assert.Equal(7, shared.MaxParentalRatingScore); Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues(PreferenceKind.BlockUnratedItems)); Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags)); Assert.False(shared.HasPermission(PermissionKind.IsAdministrator)); } // ---- the unlock rule ------------------------------------------------------------------ [Theory] [InlineData(null, null, true)] [InlineData(7, null, true)] [InlineData(7, 7, true)] [InlineData(7, 13, true)] [InlineData(13, 7, false)] [InlineData(null, 13, false)] public void IsAtLeastAsStrict_ComparesRatingCaps(int? shared, int? member, bool expected) { var sharedUser = MakeUser("shared").Restrict(maxRating: shared); var memberUser = MakeUser("member").Restrict(maxRating: member); Assert.Equal(expected, MakeService([]).IsAtLeastAsStrict(sharedUser, memberUser)); } [Fact] public void IsAtLeastAsStrict_RequiresEverySetToBeCovered() { var service = MakeService([]); var member = MakeUser("member").Restrict( blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"], allowedTags: ["kids", "family"]); Assert.True(service.IsAtLeastAsStrict( MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie, UnratedItem.Series], blockedTags: ["horror", "gore"], allowedTags: ["kids"]), member)); Assert.False(service.IsAtLeastAsStrict( MakeUser("s").Restrict(blockedTags: ["horror"], allowedTags: ["kids"]), member)); Assert.False(service.IsAtLeastAsStrict( MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], allowedTags: ["kids"]), member)); Assert.False(service.IsAtLeastAsStrict( MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"], allowedTags: ["kids", "family", "sport"]), member)); // No whitelist on the shared side is looser than any whitelist on the member's. Assert.False(service.IsAtLeastAsStrict( MakeUser("s").Restrict(blockUnrated: [UnratedItem.Movie], blockedTags: ["horror"]), member)); } // ---- the rating range --------------------------------------------------------------- [Fact] public void RatingRange_SpansStrictestToLoosest() { var alice = MakeUser("alice"); var teen = MakeUser("teen").Restrict(maxRating: 13); var kid = MakeUser("kid").Restrict(maxRating: 7); var service = MakeService([alice, teen, kid]); Assert.Equal(new RatingRange(7, 13), service.GetRatingRange([teen.Id, kid.Id])); Assert.Equal(new RatingRange(7, null), service.GetRatingRange([alice.Id, teen.Id, kid.Id])); Assert.Equal(new RatingRange(null, null), service.GetRatingRange([alice.Id])); Assert.False(service.GetRatingRange([alice.Id]).HasChoice); Assert.Equal(new RatingRange(0, 13), service.GetRatingRange([teen.Id, Guid.NewGuid()])); } // ---- choosing a cap ----------------------------------------------------------------- [Fact] public async Task ChosenCap_ShutsOutStricterMembers_AndLetsTheRestIn() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7); var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; // Inherited: the account carries the child's cap and everyone gets in. Assert.Equal(7, shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "kid-pw", shared); // Raised to the teen's level: the parent and the teen still unlock it, the child does not. await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, 13); Assert.Equal(13, shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "alice-pw", shared); await h.Provider.Authenticate(shared.Username, "teen-pw", shared); await Assert.ThrowsAsync( () => h.Provider.Authenticate(shared.Username, "kid-pw", shared)); // No cap at all: only the parent. await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null); Assert.Null(shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "alice-pw", shared); await Assert.ThrowsAsync( () => h.Provider.Authenticate(shared.Username, "teen-pw", shared)); } [Fact] public async Task ChosenCap_LeavesEverythingElseStrictest() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash).Restrict(blockedTags: ["horror"]); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 1, blockUnrated: [UnratedItem.Movie]); var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null); Assert.Null(shared.MaxParentalRatingScore); Assert.Null(shared.MaxParentalRatingSubScore); Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues(PreferenceKind.BlockUnratedItems)); Assert.Equal(["horror"], shared.GetPreference(PreferenceKind.BlockedTags)); } [Fact] public async Task ChosenCap_AboveTheLoosestMember_IsPulledBack() { using var ctx = PluginTestContext.Create(); var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7); var h = MakeHarness([teen, kid], (TeenHash, "teen-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([teen.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; // "No cap" past a group where everyone is capped would leave nobody able to unlock it. var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null); Assert.False(updated.InheritParentalRating); Assert.Equal(13, updated.ParentalRatingCap); Assert.Equal(13, Assert.Single(ctx.Configuration.Groups).ParentalRatingCap); Assert.Equal(13, shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "teen-pw", shared); } [Theory] [InlineData(7)] [InlineData(3)] public async Task ChosenCap_AtOrBelowTheStrictestMember_IsJustInheriting(int cap) { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, maxSubRating: 2); var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, cap); Assert.True(updated.InheritParentalRating); Assert.Null(updated.ParentalRatingCap); // Inheriting keeps the strictest member's sub-score too. Assert.Equal(7, shared.MaxParentalRatingScore); Assert.Equal(2, shared.MaxParentalRatingSubScore); } [Fact] public async Task ChosenCap_WhenNoMemberIsCapped_IsJustInheriting() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", KidHash); var h = MakeHarness([alice, bob]); var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null); var updated = await h.Provisioning.UpdateGroupAsync(group.SharedUserId, [alice.Id, bob.Id], true, false, false, false, null); Assert.True(updated.InheritParentalRating); } [Fact] public async Task ChosenCap_IsReclampedWhenMembershipChanges() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var teen = MakeUser("teen", TeenHash).Restrict(maxRating: 13); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7); var h = MakeHarness([alice, teen, kid], (AliceHash, "alice-pw"), (TeenHash, "teen-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, teen.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; // "No cap" is valid while the uncapped parent is a member... await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, teen.Id, kid.Id], true, false, false, false, null); Assert.Null(shared.MaxParentalRatingScore); // ...and is pulled back to the teen's level once the parent leaves, so the teen can still // unlock the account. var updated = await h.Provisioning.UpdateGroupAsync(shared.Id, [teen.Id, kid.Id], true, false, false, false, null); Assert.Equal(13, updated.ParentalRatingCap); Assert.Equal(13, shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "teen-pw", shared); } [Fact] public async Task InheritedGroup_MemberCapLoweredAfterLastReapply_IsRefusedUntilRecomputed() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 13); var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; Assert.Equal(13, shared.MaxParentalRatingScore); // The drift case: the child's cap is lowered in the user editor, nothing recomputes the // shared account, and the unlock rule still holds because it reads both users live. kid.Restrict(maxRating: 7); await Assert.ThrowsAsync( () => h.Provider.Authenticate(shared.Username, "kid-pw", shared)); await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, true, null); Assert.Equal(7, shared.MaxParentalRatingScore); await h.Provider.Authenticate(shared.Username, "kid-pw", shared); } [Fact] public async Task DynamicCreation_IsInherited_AndRestrictedBeforeTheFirstLoginCompletes() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7, blockUnrated: [UnratedItem.Movie]); var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw")); // The child types both names with their own password on a fresh server. The session this // creates must already be capped. var created = await h.Provider.Authenticate("alice+kid", "kid-pw", null); var shared = h.Users.Find(u => u.Username == created.Username)!; Assert.Equal(7, shared.MaxParentalRatingScore); Assert.Equal([UnratedItem.Movie], shared.GetPreferenceValues(PreferenceKind.BlockUnratedItems)); Assert.True(Assert.Single(ctx.Configuration.Groups).InheritParentalRating); } [Fact] public async Task DynamicLogin_ToAnExistingGroupWithAChosenCap_AppliesTheUnlockRule() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var kid = MakeUser("kid", KidHash).Restrict(maxRating: 7); var h = MakeHarness([alice, kid], (AliceHash, "alice-pw"), (KidHash, "kid-pw")); var group = await h.Provisioning.CreateGroupAsync([alice.Id, kid.Id], "family"); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, kid.Id], true, false, false, false, null); // "kid+alice" matches no account by name, so it reaches the dynamic path and resolves to // the existing group; the same rule must apply there. var asAlice = await h.Provider.Authenticate("kid+alice", "alice-pw", null); Assert.Equal("family", asAlice.Username); await Assert.ThrowsAsync( () => h.Provider.Authenticate("kid+alice", "kid-pw", null)); } [Fact] public async Task Provisioning_SharedAccountIsNeverAnAdministrator() { using var ctx = PluginTestContext.Create(); var alice = MakeUser("alice", AliceHash); var bob = MakeUser("bob", KidHash); alice.SetPermission(PermissionKind.IsAdministrator, true); bob.SetPermission(PermissionKind.IsAdministrator, true); var h = MakeHarness([alice, bob]); var group = await h.Provisioning.CreateGroupAsync([alice.Id, bob.Id], null); var shared = h.Users.Find(u => u.Id == group.SharedUserId)!; Assert.False(shared.HasPermission(PermissionKind.IsAdministrator)); // Not even if granted afterwards. shared.SetPermission(PermissionKind.IsAdministrator, true); await h.Provisioning.UpdateGroupAsync(shared.Id, [alice.Id, bob.Id], true, false, false, true, null); Assert.False(shared.HasPermission(PermissionKind.IsAdministrator)); } }