A shared account previously inherited its members' library access but none of their content restrictions, so a child could log into "alice+kid" with their own password and get around their own rating cap. The shared account now gets the strictest member's parental rating, unrated-item block, blocked tags and allowed tags, recomputed at creation, on membership change and at startup. An admin can raise the rating cap on a slider between the strictest and the loosest member; unrated and tag rules stay strictest-wins. What makes raising the cap safe is the unlock rule: after a member's password matches, both users' live policies are compared and the login is refused if the account is looser than the member on any field. So raising the cap above the child's rating means the child's password no longer opens the account, while the parent's still does. The same rule bounds the slider - past the loosest member nobody could unlock the account - so a chosen cap is clamped back into range whenever applied. Allowed tags need care: Jellyfin reads an empty list as "no whitelist", so an empty intersection of members' whitelists is written as a sentinel tag no item carries. Access schedules and channels are not inherited yet. The shared account is never an administrator. Groups created at the login screen always inherit and are restricted before the first session exists. The dashboard shows each member's cap, who a chosen cap shuts out, and the restrictions in effect, and gains a per-group edit form for the sync options. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
342 lines
12 KiB
C#
342 lines
12 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,
|
|
StubCryptoProvider Crypto);
|
|
|
|
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!;
|
|
});
|
|
|
|
// Any known user must also resolve by id, so an existing group can be followed back to its
|
|
// shared account.
|
|
foreach (var u in knownUsers)
|
|
{
|
|
userManager.Setup(m => m.GetUserById(u.Id)).Returns(u);
|
|
}
|
|
|
|
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 crypto = new StubCryptoProvider(validPairs);
|
|
|
|
// Restrictions are covered by RestrictionTests; here every member is allowed through.
|
|
var restrictions = new Mock<IRestrictionService>();
|
|
restrictions.Setup(r => r.IsAtLeastAsStrict(It.IsAny<User>(), It.IsAny<User>())).Returns(true);
|
|
|
|
var service = new DynamicGroupService(
|
|
userManager.Object,
|
|
provisioning.Object,
|
|
restrictions.Object,
|
|
crypto,
|
|
NullLogger<DynamicGroupService>.Instance);
|
|
|
|
return new Harness(service, provisioning, crypto);
|
|
}
|
|
|
|
[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);
|
|
// No name is passed: provisioning generates the canonical alphabetical one.
|
|
h.Provisioning.Verify(
|
|
p => p.CreateGroupAsync(
|
|
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
|
|
null),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReversedNameOrder_ReusesTheExistingGroup()
|
|
{
|
|
// "john+jane" and "jane+john" are the same group. Jellyfin only calls this code when no
|
|
// account matches the typed name, so without an order-independent lookup the reversed
|
|
// spelling would quietly create a second account for the same two people.
|
|
using var ctx = PluginTestContext.Create();
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var shared = MakeUser("alice+bob");
|
|
|
|
ctx.Configuration.Groups.Add(new SharedGroup
|
|
{
|
|
SharedUserId = shared.Id,
|
|
MemberUserIds = [alice.Id, bob.Id]
|
|
});
|
|
|
|
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
|
|
|
var result = await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("alice+bob", result!.SharedUsername);
|
|
h.Provisioning.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANewGroup_IsNamedCanonically()
|
|
{
|
|
// Provisioning is asked for no particular name so it generates the canonical sorted one,
|
|
// rather than preserving whatever order happened to be typed.
|
|
using var ctx = PluginTestContext.Create();
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
|
|
|
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
|
|
|
h.Provisioning.Verify(
|
|
p => p.CreateGroupAsync(It.IsAny<IReadOnlyList<Guid>>(), null),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReversedNameOrder_WhenTheGroupIsDisabled_IsRejected()
|
|
{
|
|
using var ctx = PluginTestContext.Create();
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var shared = MakeUser("alice+bob");
|
|
|
|
ctx.Configuration.Groups.Add(new SharedGroup
|
|
{
|
|
SharedUserId = shared.Id,
|
|
MemberUserIds = [alice.Id, bob.Id],
|
|
IsDisabled = true
|
|
});
|
|
|
|
var h = MakeService([alice, bob, shared], (BobHash, "bob-pw"));
|
|
|
|
Assert.Null(await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw"));
|
|
h.Provisioning.VerifyNoOtherCalls();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheFirstTypedMember_HasTheirPasswordCheckedFirst()
|
|
{
|
|
// Password verification is a deliberately slow hash comparison, so whoever puts their own
|
|
// name first should be checked first.
|
|
using var ctx = PluginTestContext.Create();
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var h = MakeService([alice, bob], (BobHash, "bob-pw"));
|
|
|
|
await h.Service.TryCreateFromLoginAsync("bob+alice", "bob-pw");
|
|
|
|
// Bob was typed first and his password matched, so alice's hash is never touched.
|
|
Assert.Equal(["B2B2B2B2"], h.Crypto.VerifiedSalts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALaterMembersPassword_StillWorks()
|
|
{
|
|
// The first-typed member is only a preference: the second member's password must still
|
|
// unlock the group once the first fails to match.
|
|
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"));
|
|
Assert.Equal(["A1A1A1A1", "B2B2B2B2"], h.Crypto.VerifiedSalts);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|