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));
}
}