Files
dtourolle b4134dd744 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.
2026-07-29 00:15:32 +02:00

232 lines
8.0 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.WatchedTogether.Tests;
/// <summary>
/// Covers creating a shared account on the fly from a name typed at the login screen.
/// </summary>
/// <remarks>
/// These tests drive <see cref="DynamicGroupService"/> through a stubbed user manager. They rely on
/// <see cref="Plugin.Instance"/> configuration, which is set up per test via
/// <see cref="PluginTestContext"/>.
/// </remarks>
[Collection(nameof(PluginTestContext))]
public class DynamicGroupTests
{
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 = null, bool disabled = false)
{
var user = new User(name, "Prov", "ResetProv");
if (password is not null)
{
user.Password = password;
}
if (disabled)
{
user.SetPermission(PermissionKind.IsDisabled, true);
}
return user;
}
private sealed record Harness(
DynamicGroupService Service,
Mock<IProvisioningService> Provisioning);
private static Harness MakeService(
IReadOnlyList<User> knownUsers,
params (string Hash, string Password)[] validPairs)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
.Returns((string n) =>
{
foreach (var u in knownUsers)
{
if (string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))
{
return u;
}
}
return null!;
});
var provisioning = new Mock<IProvisioningService>();
var createdShared = MakeUser("created-shared");
provisioning.Setup(p => p.CreateGroupAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<string?>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, string? name) =>
new SharedGroup { SharedUserId = createdShared.Id, MemberUserIds = [.. ids] });
userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared);
var service = new DynamicGroupService(
userManager.Object,
provisioning.Object,
new StubCryptoProvider(validPairs),
NullLogger<DynamicGroupService>.Instance);
return new Harness(service, provisioning);
}
[Fact]
public async Task TypingTwoMemberNames_WithAMemberPassword_CreatesTheAccount()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var h = MakeService([alice, bob], (AliceHash, "alice-pw"));
var result = await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw");
Assert.NotNull(result);
h.Provisioning.Verify(
p => p.CreateGroupAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
"alice+bob"),
Times.Once);
}
[Fact]
public async Task AnyNamedMembersPassword_Works()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
Assert.NotNull(await h.Service.TryCreateFromLoginAsync("alice+bob", "bob-pw"));
}
[Fact]
public async Task WithoutAMatchingPassword_NothingIsCreated()
{
// Otherwise anyone who knows two usernames could conjure a shared account into existence.
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var h = MakeService([alice, bob], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+bob", "guessing"));
h.Provisioning.VerifyNoOtherCalls();
}
[Fact]
public async Task AnUnknownNamePart_IsRejected()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var h = MakeService([alice], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+nobody", "alice-pw"));
h.Provisioning.VerifyNoOtherCalls();
}
[Fact]
public async Task ADisabledMember_IsRejected()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash, disabled: true);
var h = MakeService([alice, bob], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw"));
}
[Fact]
public async Task ASingleName_IsNotAGroup()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var h = MakeService([alice], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice", "alice-pw"));
}
[Fact]
public async Task TheSameMemberTwice_IsRejected()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var h = MakeService([alice], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+alice", "alice-pw"));
}
[Fact]
public async Task AnExistingSharedAccountNamedPart_IsRejected()
{
// Shared accounts must not nest inside other shared accounts.
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var existingShared = MakeUser("shared", BobHash);
ctx.Configuration.Groups.Add(new SharedGroup { SharedUserId = existingShared.Id });
var h = MakeService([alice, existingShared], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+shared", "alice-pw"));
}
[Fact]
public async Task WhenDisabledInConfiguration_NothingIsCreated()
{
using var ctx = PluginTestContext.Create();
ctx.Configuration.EnableDynamicGroups = false;
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var h = MakeService([alice, bob], (AliceHash, "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw"));
}
[Fact]
public async Task AConfiguredSeparator_IsHonoured()
{
using var ctx = PluginTestContext.Create();
ctx.Configuration.NameSeparator = "_";
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var h = MakeService([alice, bob], (AliceHash, "alice-pw"));
Assert.NotNull(await h.Service.TryCreateFromLoginAsync("alice_bob", "alice-pw"));
Assert.Null(await h.Service.TryCreateFromLoginAsync("alice+bob", "alice-pw"));
}
[Fact]
public async Task ThreeOrMoreMembers_AreSupported()
{
using var ctx = PluginTestContext.Create();
var alice = MakeUser("alice", AliceHash);
var bob = MakeUser("bob", BobHash);
var carol = MakeUser("carol", BobHash);
var h = MakeService([alice, bob, carol], (AliceHash, "alice-pw"));
Assert.NotNull(await h.Service.TryCreateFromLoginAsync("alice+bob+carol", "alice-pw"));
h.Provisioning.Verify(
p => p.CreateGroupAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3),
It.IsAny<string?>()),
Times.Once);
}
}