using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
///
/// Library options are cached in a process-wide dictionary, so the replica that did not serve the admin's
/// request is the one under test here: the other replica's write reaches the shared library directory, and
/// this one has to stop answering out of its own stale copy.
///
///
/// Disabling a library is an access revocation that overrides every per-user check, so a stale replica
/// keeps serving content that is supposed to be hidden from everyone.
///
public sealed class LibraryVisibilityPropagationTests : IDisposable
{
private readonly string _libraryPath;
private readonly MyXmlSerializer _serializer = new MyXmlSerializer();
///
/// Initializes a new instance of the class.
///
public LibraryVisibilityPropagationTests()
{
_libraryPath = Path.Combine(Path.GetTempPath(), "jf-library-prop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_libraryPath);
var applicationHost = new Mock();
applicationHost.Setup(host => host.ExpandVirtualPath(It.IsAny())).Returns(path => path);
applicationHost.Setup(host => host.ReverseVirtualPath(It.IsAny())).Returns(path => path);
CollectionFolder.XmlSerializer = _serializer;
CollectionFolder.ApplicationHost = applicationHost.Object;
}
///
public void Dispose()
{
CollectionFolder.InvalidationBus = NullConfigurationInvalidationBus.Instance;
CollectionFolder.InvalidateAllLibraryOptions();
try
{
Directory.Delete(_libraryPath, true);
}
catch (IOException)
{
}
}
///
/// Disabling a library on one replica has to hide it on every replica. Until it does, the ones that did
/// not serve the request keep the library visible to every user.
///
/// A representing the asynchronous operation.
[Fact]
public async Task LibraryDisabledOnAnotherInstance_IsNotVisibleHere()
{
var fabric = new FakeInvalidationBusFabric();
var otherInstance = fabric.Connect("pod-a");
await SubscribeThisInstanceAsync(fabric);
WriteSharedOptions(enabled: true);
var user = CreateUser();
var library = new CollectionFolder { Path = _libraryPath, Name = "Movies" };
// This replica answers out of its cache from here on.
Assert.True(library.IsVisible(user));
// The admin disables the library on the other replica: it writes the shared directory and says so.
WriteSharedOptions(enabled: false);
otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath);
Assert.False(library.IsVisible(user));
Assert.False(CollectionFolder.GetLibraryOptions(_libraryPath).Enabled);
}
///
/// A path remap made on another replica has to reach this one, or it keeps resolving media against a
/// path that is no longer the library's.
///
/// A representing the asynchronous operation.
[Fact]
public async Task LibraryPathRemappedOnAnotherInstance_IsSeenHere()
{
var fabric = new FakeInvalidationBusFabric();
var otherInstance = fabric.Connect("pod-a");
await SubscribeThisInstanceAsync(fabric);
WriteSharedOptions(enabled: true, mediaPath: "/media/old");
Assert.Equal("/media/old", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path);
WriteSharedOptions(enabled: true, mediaPath: "/media/new");
otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath);
Assert.Equal("/media/new", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path);
}
///
/// Saving library options here has to tell the other replicas, which is the half of the exchange the
/// tests above take as given.
///
[Fact]
public void SaveLibraryOptions_AnnouncesTheLibraryToTheOtherInstances()
{
var fabric = new FakeInvalidationBusFabric();
ConfigurationInvalidation? received = null;
var otherInstance = fabric.Connect("pod-b");
otherInstance.Subscribe(invalidation => received = invalidation);
CollectionFolder.InvalidationBus = fabric.Connect("pod-a");
CollectionFolder.SaveLibraryOptions(_libraryPath, new LibraryOptions { Enabled = false });
Assert.NotNull(received);
Assert.Equal(ConfigurationInvalidationScope.LibraryOptions, received.Scope);
Assert.Equal(_libraryPath, received.Target);
}
private async Task SubscribeThisInstanceAsync(FakeInvalidationBusFabric fabric)
{
var bus = fabric.Connect("pod-b");
CollectionFolder.InvalidationBus = bus;
var subscriber = new ConfigurationInvalidationSubscriber(
bus,
Mock.Of(),
NullLogger.Instance);
await subscriber.StartAsync(CancellationToken.None);
}
private void WriteSharedOptions(bool enabled, string mediaPath = "/media")
{
// Written the way the other replica writes it, straight onto the shared directory.
var options = new LibraryOptions { Enabled = enabled, PathInfos = [new MediaPathInfo(mediaPath)] };
_serializer.SerializeToFile(options, Path.Combine(_libraryPath, "options.xml"));
}
private static User CreateUser()
{
var user = new User("propagation", "auth", "reset");
user.SetPermission(PermissionKind.EnableAllFolders, true);
return user;
}
}