using System; using System.IO; using System.Threading.Tasks; using Emby.Server.Implementations.QuickConnect; using Jellyfin.Server.Extensions; using Jellyfin.Server.Tests.HighAvailability; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Model.QuickConnect; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Moq; using StackExchange.Redis; using Xunit; namespace Jellyfin.Server.Tests.QuickConnect; /// /// Drives the whole configuration path a deployment uses: a bare /// Jellyfin__TranscodeStore__RedisConnectionString environment variable, the server's own /// configuration builder, the store registration, and a quick connect flow against a real valkey. /// [Trait("Category", "RequiresDocker")] [Collection("JellyfinSectionConfiguration")] public sealed class QuickConnectStoreWiringTests : IAsyncLifetime { private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString"; private RedisTestServer _redis = null!; private string _configDirectory = string.Empty; /// public async ValueTask InitializeAsync() { _redis = await RedisTestServer.StartAsync().ConfigureAwait(false); _configDirectory = Directory.CreateTempSubdirectory("jellyfin-quickconnect-wiring").FullName; await File.WriteAllTextAsync(Path.Combine(_configDirectory, "logging.default.json"), "{}").ConfigureAwait(false); } /// public async ValueTask DisposeAsync() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null); if (_configDirectory.Length > 0) { Directory.Delete(_configDirectory, true); } await _redis.DisposeAsync().ConfigureAwait(false); } /// /// The variable form deployments set selects the shared store, and that store really talks to valkey. /// /// A representing the asynchronous operation. [Fact] public async Task ManifestStyleEnvironmentVariable_SelectsTheSharedStore() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, _redis.ConnectionString); await using var provider = BuildProvider(); var store = provider.GetRequiredService(); Assert.IsType(store); var request = NewRequest(); await store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), TestContext.Current.CancellationToken); Assert.True(await store.TryClaimAuthorizationAsync(request.Secret, DateTime.UtcNow.AddMinutes(10), TestContext.Current.CancellationToken)); var redis = provider.GetRequiredService(); Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret)); } /// /// Without the variable the deployment is single-instance and gets the process-local store, which /// runs a whole flow on its own. /// /// A representing the asynchronous operation. [Fact] public async Task NoEnvironmentVariable_SelectsTheProcessLocalStore() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null); await using var provider = BuildProvider(); var store = provider.GetRequiredService(); Assert.IsType(store); var cancellationToken = TestContext.Current.CancellationToken; var request = NewRequest(); await store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), cancellationToken); Assert.Equal(request.Secret, (await store.GetRequestByCodeAsync(request.Code, cancellationToken))?.Secret); Assert.True(await store.TryClaimAuthorizationAsync(request.Secret, DateTime.UtcNow.AddMinutes(10), cancellationToken)); await store.SetAuthorizationAsync( request.Secret, new AuthenticationResult { AccessToken = "token-1" }, DateTime.UtcNow.AddMinutes(10), cancellationToken); Assert.Equal("token-1", (await store.GetAuthorizationAsync(request.Secret, cancellationToken))?.AccessToken); Assert.Equal("token-1", (await store.GetAuthorizationAsync(request.Secret, cancellationToken))?.AccessToken); } /// /// An unreachable connection string that connects eagerly, the default, throws while the store is /// being built rather than on a read. A failed singleton factory is not cached, so every resolve /// throws afresh, which is what lets the startup probe in /// retry the build and report an eager store's outage as the same operator-facing failure it reports /// for the lazily connecting form. /// [Fact] public void UnreachableRedisAtStartup_FailsClosed() { Environment.SetEnvironmentVariable(RedisConnectionStringVariable, "127.0.0.1:1,connectTimeout=250,connectRetry=0"); using var provider = BuildProvider(); Assert.ThrowsAny(() => provider.GetRequiredService()); Assert.ThrowsAny(() => provider.GetRequiredService()); } private static QuickConnectResult NewRequest() => new QuickConnectResult( Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N").Substring(0, 6), DateTime.UtcNow, "device-1", "Living Room TV", "Jellyfin Web", "1.0.0"); private ServiceProvider BuildProvider() { var appPaths = new Mock(); appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory); IConfiguration configuration = Jellyfin.Server.Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object); var services = new ServiceCollection(); services.AddLogging(); services.AddTranscodeSessionStore(configuration, NullLogger.Instance); services.AddQuickConnectStore(configuration, NullLogger.Instance); return services.BuildServiceProvider(); } }