Provisioning assigned AuthenticationProviderId and only then called IUserManager.ChangePassword. Jellyfin dispatches that call to the provider the user is currently assigned to, so it reached this plugin's own ChangePassword, which refuses by design. Creating a group by typing "alice+bob" at the login screen therefore died with NotSupportedException. Set the placeholder password first, while the freshly created account is still on Jellyfin's default provider, then claim it. The new end-to-end tests wire the real provisioning, group and authentication services together rather than mocking IProvisioningService, and cover a group created on demand being unlocked afterwards by either member's password. With the old ordering restored, six of them fail with the original exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
6.4 KiB
C#
161 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.Setup(m => m.ChangePassword(It.IsAny<User>(), It.IsAny<string>()))
|
|
.Returns((User user, string 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));
|
|
}
|
|
}
|