b662ffa48f
Add a Redis pub/sub invalidation bus, publish from the configuration and library-option write paths, and drop the matching local cache entry on the instances that did not write. No-op without a Redis connection string, and fails open when it is unreachable.
162 lines
6.3 KiB
C#
162 lines
6.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public sealed class LibraryVisibilityPropagationTests : IDisposable
|
|
{
|
|
private readonly string _libraryPath;
|
|
private readonly MyXmlSerializer _serializer = new MyXmlSerializer();
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LibraryVisibilityPropagationTests"/> class.
|
|
/// </summary>
|
|
public LibraryVisibilityPropagationTests()
|
|
{
|
|
_libraryPath = Path.Combine(Path.GetTempPath(), "jf-library-prop-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_libraryPath);
|
|
|
|
var applicationHost = new Mock<IServerApplicationHost>();
|
|
applicationHost.Setup(host => host.ExpandVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
|
|
applicationHost.Setup(host => host.ReverseVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
|
|
|
|
CollectionFolder.XmlSerializer = _serializer;
|
|
CollectionFolder.ApplicationHost = applicationHost.Object;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
CollectionFolder.InvalidationBus = NullConfigurationInvalidationBus.Instance;
|
|
CollectionFolder.InvalidateAllLibraryOptions();
|
|
|
|
try
|
|
{
|
|
Directory.Delete(_libraryPath, true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saving library options here has to tell the other replicas, which is the half of the exchange the
|
|
/// tests above take as given.
|
|
/// </summary>
|
|
[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<IConfigurationManager>(),
|
|
NullLogger<ConfigurationInvalidationSubscriber>.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;
|
|
}
|
|
}
|