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; } }