Resolve the group services lazily in the authentication provider
Jellyfin's UserManager constructor-injects every IAuthenticationProvider, so building IUserManager forced SharedAccountAuthenticationProvider to be built first. That provider eagerly required IGroupService and IDynamicGroupService, both of which need IUserManager, and the container refused to start the server with "a circular dependency was detected". Take the two group services as Lazy<T> and dereference them at authentication time instead. Nobody can log in before the host is up, so the deferred lookup is always safe. Microsoft's container has no built-in Lazy<T> support, hence the explicit factory registrations. The accompanying test builds the service graph through a stand-in that mimics UserManager's constructor shape and validates it on build, so a reintroduced cycle fails in CI rather than at server startup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,8 +63,8 @@ public class AuthenticationTests
|
||||
|
||||
return new SharedAccountAuthenticationProvider(
|
||||
crypto,
|
||||
groups.Object,
|
||||
dynamic.Object,
|
||||
new Lazy<IGroupService>(() => groups.Object),
|
||||
new Lazy<IDynamicGroupService>(() => dynamic.Object),
|
||||
NullLogger<SharedAccountAuthenticationProvider>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.WatchedTogether;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the plugin's service graph against container-level cycles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin's real <c>UserManager</c> constructor-injects <c>IEnumerable<IAuthenticationProvider></c>.
|
||||
/// That means any plugin service reachable eagerly from our authentication provider must not itself
|
||||
/// require <see cref="IUserManager"/>, or the host dies at startup with "a circular dependency was
|
||||
/// detected". A cycle like that is invisible to unit tests that construct services by hand, so these
|
||||
/// tests build the graph the way the host does.
|
||||
/// </remarks>
|
||||
public class ServiceRegistrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Stands in for Jellyfin's UserManager, whose constructor takes every registered authentication
|
||||
/// provider. Only the constructor shape matters here - it is what closes the cycle.
|
||||
/// </summary>
|
||||
private sealed class UserManagerWithAuthProviders
|
||||
{
|
||||
public UserManagerWithAuthProviders(IEnumerable<IAuthenticationProvider> authenticationProviders)
|
||||
{
|
||||
AuthenticationProviders = authenticationProviders;
|
||||
}
|
||||
|
||||
public IEnumerable<IAuthenticationProvider> AuthenticationProviders { get; }
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildHostLikeProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
|
||||
// Host services the plugin consumes, other than IUserManager.
|
||||
services.AddSingleton(Mock.Of<ILibraryManager>());
|
||||
services.AddSingleton(Mock.Of<IUserDataManager>());
|
||||
services.AddSingleton(Mock.Of<ICryptoProvider>());
|
||||
|
||||
// IUserManager resolves through the fake UserManager so that building it forces every
|
||||
// IAuthenticationProvider to be built first, exactly as the real host does.
|
||||
services.AddSingleton<UserManagerWithAuthProviders>();
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
provider.GetRequiredService<UserManagerWithAuthProviders>();
|
||||
return Mock.Of<IUserManager>();
|
||||
});
|
||||
|
||||
new ServiceRegistrator().RegisterServices(services, Mock.Of<MediaBrowser.Controller.IServerApplicationHost>());
|
||||
|
||||
return services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateOnBuild = true,
|
||||
ValidateScopes = true
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginServices_ResolveWithoutCircularDependency()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
// Resolving IUserManager is what the host does during startup, and is the exact path that
|
||||
// previously threw InvalidOperationException for a circular dependency.
|
||||
var userManager = provider.GetRequiredService<IUserManager>();
|
||||
|
||||
Assert.NotNull(userManager);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthenticationProvider_IsConstructedWithoutResolvingUserManager()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
var authProviders = provider.GetRequiredService<IEnumerable<IAuthenticationProvider>>();
|
||||
|
||||
Assert.Contains(authProviders, p => p is Auth.SharedAccountAuthenticationProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupServices_AreStillResolvableOnceTheHostIsUp()
|
||||
{
|
||||
using var provider = BuildHostLikeProvider();
|
||||
|
||||
// The Lazy<T> indirection must not change what the services resolve to at authentication
|
||||
// time, and must hand back the same singletons the rest of the plugin uses.
|
||||
var lazyGroupService = provider.GetRequiredService<Lazy<Services.IGroupService>>();
|
||||
var lazyDynamicGroupService = provider.GetRequiredService<Lazy<Services.IDynamicGroupService>>();
|
||||
|
||||
Assert.Same(provider.GetRequiredService<Services.IGroupService>(), lazyGroupService.Value);
|
||||
Assert.Same(provider.GetRequiredService<Services.IDynamicGroupService>(), lazyDynamicGroupService.Value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user