Grant shared accounts the intersection of member library access
Previously the shared account's libraries were chosen independently of its members, so a group could see a library that one of its members was blocked from - joining a group became a way to gain access. That was especially sharp with auto-created groups, where no admin is in the loop. A shared account is now granted exactly the libraries every member can already reach. If one member is blocked from a library, no group containing them can see it. The account is therefore always a subset of what each member could reach alone, which is what makes creating groups at the login screen safe to leave on by default. Details: - "Enable all folders" is expanded to concrete library ids before intersecting, since it cannot otherwise be compared with an explicit list. Shared accounts are always given an explicit list, never the all-folders permission, so newly added libraries do not silently widen an existing group. - Explicitly blocked folders are subtracted even for members who otherwise have access to everything. - Fails closed: an unresolvable member contributes no access rather than being treated as unrestricted. - Recomputed when membership changes, and re-applied to every group at startup so narrowing a member's own access narrows their groups. Drops the now-meaningless EnableAllFolders/EnabledFolders provisioning inputs and the DynamicGroupsEnableAllFolders setting. Adds 8 tests covering the intersection rules.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Plugin.WatchedTogether.Services;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the rule that a shared account sees only what every member can already see.
|
||||
/// </summary>
|
||||
public class LibraryAccessTests
|
||||
{
|
||||
private static readonly Guid Movies = Guid.NewGuid();
|
||||
private static readonly Guid Shows = Guid.NewGuid();
|
||||
private static readonly Guid Kids = Guid.NewGuid();
|
||||
private static readonly Guid Adult = Guid.NewGuid();
|
||||
|
||||
private static readonly Guid[] AllLibraries = [Movies, Shows, Kids, Adult];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user with either full access or an explicit library list.
|
||||
/// </summary>
|
||||
private static User MakeUser(
|
||||
string name,
|
||||
bool allFolders = false,
|
||||
IEnumerable<Guid>? enabled = null,
|
||||
IEnumerable<Guid>? blocked = null)
|
||||
{
|
||||
var user = new User(name, "Prov", "ResetProv");
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, allFolders);
|
||||
user.SetPreference(PreferenceKind.EnabledFolders, (enabled ?? []).ToArray());
|
||||
user.SetPreference(PreferenceKind.BlockedMediaFolders, (blocked ?? []).ToArray());
|
||||
return user;
|
||||
}
|
||||
|
||||
private static LibraryAccessService MakeService(params User[] users)
|
||||
{
|
||||
var userManager = new Mock<IUserManager>();
|
||||
foreach (var u in users)
|
||||
{
|
||||
userManager.Setup(m => m.GetUserById(u.Id)).Returns(u);
|
||||
}
|
||||
|
||||
// The root folder's children are the server's top-level libraries.
|
||||
var children = AllLibraries.Select(id =>
|
||||
{
|
||||
var folder = new Mock<BaseItem>();
|
||||
folder.Object.Id = id;
|
||||
return folder.Object;
|
||||
}).ToList();
|
||||
|
||||
var root = new Mock<Folder>();
|
||||
root.Setup(r => r.Children).Returns(children);
|
||||
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
libraryManager.Setup(l => l.GetUserRootFolder()).Returns(root.Object);
|
||||
|
||||
return new LibraryAccessService(
|
||||
userManager.Object,
|
||||
libraryManager.Object,
|
||||
NullLogger<LibraryAccessService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoExplicitLists_IntersectToTheCommonLibraries()
|
||||
{
|
||||
var alice = MakeUser("alice", enabled: [Movies, Shows, Kids]);
|
||||
var bob = MakeUser("bob", enabled: [Shows, Kids, Adult]);
|
||||
var service = MakeService(alice, bob);
|
||||
|
||||
var result = service.ComputeIntersection([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal([Shows, Kids], result.OrderBy(g => g).ToList().OrderBy(g => g).ToList());
|
||||
Assert.DoesNotContain(Movies, result);
|
||||
Assert.DoesNotContain(Adult, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMemberWithAllFolders_DoesNotWidenTheGroup()
|
||||
{
|
||||
// The restricted member is what bounds the group, not the permissive one.
|
||||
var alice = MakeUser("alice", allFolders: true);
|
||||
var bob = MakeUser("bob", enabled: [Kids]);
|
||||
var service = MakeService(alice, bob);
|
||||
|
||||
var result = service.ComputeIntersection([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal([Kids], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMembersWithAllFolders_GetEveryLibrary()
|
||||
{
|
||||
var alice = MakeUser("alice", allFolders: true);
|
||||
var bob = MakeUser("bob", allFolders: true);
|
||||
var service = MakeService(alice, bob);
|
||||
|
||||
var result = service.ComputeIntersection([alice.Id, bob.Id]);
|
||||
|
||||
Assert.Equal(AllLibraries.OrderBy(g => g), result.OrderBy(g => g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABlockedFolder_IsRemovedEvenWithAllFolders()
|
||||
{
|
||||
// Blocking is an explicit denial and must survive the "sees everything" permission.
|
||||
var alice = MakeUser("alice", allFolders: true, blocked: [Adult]);
|
||||
var bob = MakeUser("bob", allFolders: true);
|
||||
var service = MakeService(alice, bob);
|
||||
|
||||
var result = service.ComputeIntersection([alice.Id, bob.Id]);
|
||||
|
||||
Assert.DoesNotContain(Adult, result);
|
||||
Assert.Contains(Movies, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MembersWithNothingInCommon_GetNoLibraries()
|
||||
{
|
||||
var alice = MakeUser("alice", enabled: [Movies]);
|
||||
var bob = MakeUser("bob", enabled: [Adult]);
|
||||
var service = MakeService(alice, bob);
|
||||
|
||||
Assert.Empty(service.ComputeIntersection([alice.Id, bob.Id]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddingAThirdMember_CanOnlyNarrowAccess()
|
||||
{
|
||||
var alice = MakeUser("alice", enabled: [Movies, Shows, Kids]);
|
||||
var bob = MakeUser("bob", enabled: [Shows, Kids]);
|
||||
var carol = MakeUser("carol", enabled: [Kids]);
|
||||
var service = MakeService(alice, bob, carol);
|
||||
|
||||
var pair = service.ComputeIntersection([alice.Id, bob.Id]);
|
||||
var trio = service.ComputeIntersection([alice.Id, bob.Id, carol.Id]);
|
||||
|
||||
Assert.Equal(2, pair.Count);
|
||||
Assert.Equal([Kids], trio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnresolvableMember_YieldsNoAccess()
|
||||
{
|
||||
// Failing closed: a member we cannot read must not be treated as unrestricted.
|
||||
var alice = MakeUser("alice", allFolders: true);
|
||||
var service = MakeService(alice);
|
||||
|
||||
Assert.Empty(service.ComputeIntersection([alice.Id, Guid.NewGuid()]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMembers_YieldsNoAccess()
|
||||
{
|
||||
var service = MakeService();
|
||||
|
||||
Assert.Empty(service.ComputeIntersection([]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user