Jellyfin 12 moved to .NET 10 and changed the IUserManager surface the plugin relies on: Users/UsersIds became GetUsers()/GetUsersIds(), ChangePassword takes a user id, HasPassword left the provider contract, and the user cache is gone, so every lookup is a detached copy. The plugin now multi-targets net9.0 (against 10.11.5) and net10.0 (against 12.0.0). The differences sit behind a JELLYFIN_12 constant in Compat/UserManagerCompat.cs, whose ChangePasswordAsync also carries the stored hash back onto the caller's instance: on 12 the UpdateUserAsync that claims the account would otherwise write the stale null password back over the one provisioning just set. Each release ships one package per generation, with the fourth version segment naming the target (x.y.z.11 and x.y.z.12) so a 12 server picks the 12 package over the 10.11 one. scripts/package.sh wraps jprm for a single generation and the workflows call it twice. The builder image moves to the .NET 10 SDK, which builds both targets; the net9.0 test run rolls forward onto the .NET 10 runtime. CA1873 is a .NET 10 analyzer that flags the same log calls CA1848 does; it is set to Info, as in the upstream Jellyfin 12 tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
160 lines
6.4 KiB
C#
160 lines
6.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Database.Implementations.Entities;
|
|
using Jellyfin.Plugin.WatchedTogether.Auth;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Exercises the whole path a real login takes: a group created on demand by typing "alice+bob",
|
|
/// then logged into again afterwards by each member in turn.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The other suites mock <see cref="IProvisioningService"/>, which is why an ordering bug inside the
|
|
/// real provisioning code reached a release. These tests wire the real services together and only
|
|
/// stub the host's user manager and crypto.
|
|
/// </remarks>
|
|
[Collection(nameof(PluginTestContext))]
|
|
public class SharedAccountEndToEndTests
|
|
{
|
|
private const string AliceHash = "$PBKDF2-SHA512$iterations=210000$A1A1A1A1$AAAAAAAABBBBBBBB";
|
|
private const string BobHash = "$PBKDF2-SHA512$iterations=210000$B2B2B2B2$CCCCCCCCDDDDDDDD";
|
|
|
|
private static readonly string AuthProviderId =
|
|
typeof(SharedAccountAuthenticationProvider).FullName!;
|
|
|
|
private sealed record Harness(
|
|
SharedAccountAuthenticationProvider Provider,
|
|
List<User> Users);
|
|
|
|
private static User MakeUser(string name, string? password = null)
|
|
=> new(name, "Prov", "ResetProv") { Password = password! };
|
|
|
|
/// <summary>
|
|
/// Wires the real provisioning, group, dynamic-group and authentication services over a user
|
|
/// manager that behaves like Jellyfin's: password changes dispatch to the user's assigned
|
|
/// provider, and users resolve by both name and id.
|
|
/// </summary>
|
|
private static Harness MakeHarness(List<User> users, params (string Hash, string Password)[] validPairs)
|
|
{
|
|
var crypto = new StubCryptoProvider(validPairs);
|
|
var userManager = new Mock<IUserManager>();
|
|
|
|
userManager.Setup(m => m.GetUserById(It.IsAny<Guid>()))
|
|
.Returns((Guid id) => users.Find(u => u.Id == id)!);
|
|
|
|
userManager.Setup(m => m.GetUserByName(It.IsAny<string>()))
|
|
.Returns((string n) => users.Find(
|
|
u => string.Equals(u.Username, n, StringComparison.OrdinalIgnoreCase))!);
|
|
|
|
userManager.Setup(m => m.CreateUserAsync(It.IsAny<string>()))
|
|
.ReturnsAsync((string name) =>
|
|
{
|
|
var created = MakeUser(name);
|
|
users.Add(created);
|
|
return created;
|
|
});
|
|
|
|
userManager.Setup(m => m.UpdateUserAsync(It.IsAny<User>())).Returns(Task.CompletedTask);
|
|
|
|
userManager.SetupChangePassword(users, (user, password) =>
|
|
{
|
|
// Jellyfin routes this to the user's assigned provider; ours refuses by design.
|
|
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
|
|
{
|
|
throw new NotSupportedException(
|
|
"A Watched Together shared account has no password of its own.");
|
|
}
|
|
|
|
user.Password = password;
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
var groupService = new GroupService(
|
|
userManager.Object,
|
|
NullLogger<GroupService>.Instance);
|
|
|
|
var provisioning = new ProvisioningService(
|
|
userManager.Object,
|
|
Mock.Of<ILibraryAccessService>(),
|
|
NullLogger<ProvisioningService>.Instance);
|
|
|
|
var dynamicGroups = new DynamicGroupService(
|
|
userManager.Object,
|
|
provisioning,
|
|
crypto,
|
|
NullLogger<DynamicGroupService>.Instance);
|
|
|
|
var provider = new SharedAccountAuthenticationProvider(
|
|
crypto,
|
|
new Lazy<IGroupService>(() => groupService),
|
|
new Lazy<IDynamicGroupService>(() => dynamicGroups),
|
|
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
|
|
|
return new Harness(provider, users);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("alice-pw")]
|
|
[InlineData("bob-pw")]
|
|
public async Task GroupCreatedOnDemand_ThenUnlockedByEitherMemberPassword(string creatingPassword)
|
|
{
|
|
using var ctx = PluginTestContext.Create();
|
|
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var users = new List<User> { alice, bob };
|
|
|
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
|
|
|
// Creating the group by typing both names. Whichever member types their own password, the
|
|
// resulting account must behave identically.
|
|
var created = await h.Provider.Authenticate("alice+bob", creatingPassword, null);
|
|
Assert.Equal("alice+bob", created.Username);
|
|
|
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
|
Assert.NotNull(shared);
|
|
|
|
// The account exists now, so Jellyfin resolves it and hands it to us as resolvedUser. Both
|
|
// members must be able to unlock it, regardless of who created it.
|
|
var asAlice = await h.Provider.Authenticate("alice+bob", "alice-pw", shared);
|
|
Assert.Equal("alice+bob", asAlice.Username);
|
|
|
|
var asBob = await h.Provider.Authenticate("alice+bob", "bob-pw", shared);
|
|
Assert.Equal("alice+bob", asBob.Username);
|
|
|
|
// And an outsider's password still must not.
|
|
await Assert.ThrowsAsync<AuthenticationException>(
|
|
() => h.Provider.Authenticate("alice+bob", "not-a-member-pw", shared!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupCreatedOnDemand_ClaimsTheAccountAndKeepsAPassword()
|
|
{
|
|
using var ctx = PluginTestContext.Create();
|
|
|
|
var alice = MakeUser("alice", AliceHash);
|
|
var bob = MakeUser("bob", BobHash);
|
|
var users = new List<User> { alice, bob };
|
|
|
|
var h = MakeHarness(users, (AliceHash, "alice-pw"), (BobHash, "bob-pw"));
|
|
|
|
await h.Provider.Authenticate("alice+bob", "alice-pw", null);
|
|
|
|
var shared = h.Users.Find(u => u.Username == "alice+bob");
|
|
Assert.NotNull(shared);
|
|
|
|
// Claimed by us, so future logins route here, and holding a password of its own so it is
|
|
// not directly loginable if the provider is ever unassigned.
|
|
Assert.Equal(AuthProviderId, shared!.AuthenticationProviderId);
|
|
Assert.False(string.IsNullOrEmpty(shared.Password));
|
|
}
|
|
}
|