"jane+john" and "john+jane" name the same group, but they did not behave that way. Jellyfin only routes a login here when no account matches the typed name, so logging in with the reversed spelling of an existing group found nothing and quietly created a second shared account for the same two people - each with its own watched state. Group identity is now order-independent: - Member names are sorted alphabetically when building an account name, so a given set of members always produces the same name. - Before creating anything, the login path looks for an existing group whose members are exactly the named set, compared as a set rather than a sequence, and logs into that account if it finds one. - Stored member lists are kept in the same canonical order on create and update, so a group's stored order does not depend on the order an admin happened to select members in. Passing no name through to provisioning lets it generate the canonical name, rather than preserving whatever order was typed. Members are also now checked in the order they were typed, stopping at the first match, so whoever puts their own name first is verified first. Verification is a deliberately slow hash comparison, so the ordering is worth having; it is only a preference, and any member's password still unlocks the group.
337 lines
12 KiB
C#
337 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);
|
|
|
|
var service = new DynamicGroupService(
|
|
userManager.Object,
|
|
provisioning.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);
|
|
}
|
|
}
|