using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Plugin.WatchedTogether.Tests;
///
/// Covers shared-account provisioning against a user manager that dispatches password changes the
/// way Jellyfin's real one does.
///
[Collection(nameof(PluginTestContext))]
public class ProvisioningTests
{
private static readonly string AuthProviderId =
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
///
/// Builds a user manager that mimics the one behaviour that matters here: ChangePassword is
/// routed to the provider named by the user's AuthenticationProviderId, so an account already
/// claimed by us lands in our provider and is refused.
///
private static Mock MakeUserManager(List users, List callLog)
{
var userManager = new Mock();
userManager.Setup(m => m.GetUserById(It.IsAny()))
.Returns((Guid id) => users.Find(u => u.Id == id));
userManager.Setup(m => m.CreateUserAsync(It.IsAny()))
.ReturnsAsync((string name) =>
{
var created = MakeUser(name);
users.Add(created);
callLog.Add("CreateUser");
return created;
});
userManager.SetupChangePassword(users, (user, password) =>
{
callLog.Add($"ChangePassword(provider={user.AuthenticationProviderId})");
// This is the dispatch that made provisioning fail in 0.0.3: once the account is
// claimed, the call reaches our provider, which refuses it by design.
if (string.Equals(user.AuthenticationProviderId, AuthProviderId, StringComparison.Ordinal))
{
return new Auth.SharedAccountAuthenticationProvider(
Mock.Of(),
new Lazy(() => Mock.Of()),
new Lazy(() => Mock.Of()),
NullLogger.Instance)
.ChangePassword(user, password);
}
user.Password = password;
return Task.CompletedTask;
});
userManager.Setup(m => m.UpdateUserAsync(It.IsAny()))
.Returns((User user) =>
{
callLog.Add($"UpdateUser(provider={user.AuthenticationProviderId})");
return Task.CompletedTask;
});
return userManager;
}
private static ProvisioningService MakeService(Mock userManager)
=> new(
userManager.Object,
Mock.Of(),
NullLogger.Instance);
[Fact]
public async Task CreateGroupAsync_SetsPasswordBeforeClaimingTheAccount()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List { alice, bob };
var callLog = new List();
var service = MakeService(MakeUserManager(users, callLog));
// Before the fix this threw NotSupportedException from our own ChangePassword.
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
Assert.NotEqual(Guid.Empty, group.SharedUserId);
// The password must be set while the account is still on Jellyfin's default provider.
var changeIndex = callLog.FindIndex(c => c.StartsWith("ChangePassword", StringComparison.Ordinal));
var claimIndex = callLog.FindIndex(c => c.Contains(AuthProviderId, StringComparison.Ordinal));
Assert.True(changeIndex >= 0, "provisioning should set a password on the shared account");
Assert.True(claimIndex >= 0, "provisioning should claim the account for our provider");
Assert.True(
changeIndex < claimIndex,
$"password must be set before the account is claimed, but call order was: {string.Join(" -> ", callLog)}");
}
[Fact]
public async Task CreateGroupAsync_LeavesTheAccountClaimedByOurProvider()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List { alice, bob };
var callLog = new List();
var service = MakeService(MakeUserManager(users, callLog));
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
// Claiming the account is what routes its logins to us; provisioning is useless without it.
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(sharedUser);
Assert.Equal(AuthProviderId, sharedUser!.AuthenticationProviderId);
}
[Fact]
public async Task CreateGroupAsync_GivesTheSharedAccountANonEmptyPassword()
{
using var context = PluginTestContext.Create();
var alice = MakeUser("alice");
var bob = MakeUser("bob");
var users = new List { alice, bob };
var callLog = new List();
var service = MakeService(MakeUserManager(users, callLog));
var group = await service.CreateGroupAsync([alice.Id, bob.Id], null);
// A passwordless shared account would be directly loginable if the provider were ever
// unassigned, which is the reason provisioning sets one at all.
var sharedUser = users.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(sharedUser);
Assert.False(string.IsNullOrEmpty(sharedUser!.Password));
}
[Fact]
public async Task CreateGroupAsync_StoredPasswordSurvivesClaimingTheAccount()
{
using var context = PluginTestContext.Create();
// Models Jellyfin 12, where the user manager keeps no cache: every lookup returns a
// detached copy of the stored row, and UpdateUserAsync writes back every column of the
// instance it is handed. The random password provisioning sets must survive the provider
// assignment that is saved afterwards through a different instance.
var stored = new List { MakeUser("alice"), MakeUser("bob") };
var userManager = new Mock();
userManager.Setup(m => m.GetUserById(It.IsAny()))
.Returns((Guid id) => Copy(stored.Find(u => u.Id == id)));
userManager.Setup(m => m.CreateUserAsync(It.IsAny()))
.ReturnsAsync((string name) =>
{
var row = MakeUser(name);
stored.Add(row);
return Copy(row)!;
});
// On 12 the helper resolves the stored row by id, so this writes the hash there and only
// there; on 10.11 it writes to the caller's instance, as the real server does.
userManager.SetupChangePassword(stored, (user, password) =>
{
user.Password = password;
return Task.CompletedTask;
});
userManager.Setup(m => m.UpdateUserAsync(It.IsAny()))
.Returns((User user) =>
{
var row = stored.Find(u => u.Id == user.Id)!;
row.Password = user.Password;
row.AuthenticationProviderId = user.AuthenticationProviderId;
return Task.CompletedTask;
});
var service = MakeService(userManager);
var group = await service.CreateGroupAsync([stored[0].Id, stored[1].Id], null);
var row = stored.Find(u => u.Id == group.SharedUserId);
Assert.NotNull(row);
Assert.Equal(AuthProviderId, row!.AuthenticationProviderId);
Assert.False(
string.IsNullOrEmpty(row.Password),
"claiming the account must not overwrite the password provisioning stored");
}
///
/// Copies the columns provisioning touches into a fresh instance with the same id, the way a
/// cache-less user manager hands out rows.
///
private static User? Copy(User? row)
=> row is null
? null
: new User(row.Username, row.AuthenticationProviderId, row.PasswordResetProviderId)
{
Id = row.Id,
Password = row.Password,
};
}