151 lines
6.3 KiB
C#
151 lines
6.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Drives the whole configuration path a deployment uses: a bare
|
|
/// <c>Jellyfin__TranscodeStore__RedisConnectionString</c> environment variable, the server's own
|
|
/// configuration builder, the store registration, and a quick connect flow against a real valkey.
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <inheritdoc/>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
|
|
|
|
if (_configDirectory.Length > 0)
|
|
{
|
|
Directory.Delete(_configDirectory, true);
|
|
}
|
|
|
|
await _redis.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The variable form deployments set selects the shared store, and that store really talks to valkey.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ManifestStyleEnvironmentVariable_SelectsTheSharedStore()
|
|
{
|
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, _redis.ConnectionString);
|
|
|
|
await using var provider = BuildProvider();
|
|
|
|
var store = provider.GetRequiredService<IQuickConnectStore>();
|
|
Assert.IsType<RedisQuickConnectStore>(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<IConnectionMultiplexer>();
|
|
Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Without the variable the deployment is single-instance and gets the process-local store, which
|
|
/// runs a whole flow on its own.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task NoEnvironmentVariable_SelectsTheProcessLocalStore()
|
|
{
|
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
|
|
|
|
await using var provider = BuildProvider();
|
|
|
|
var store = provider.GetRequiredService<IQuickConnectStore>();
|
|
Assert.IsType<InMemoryQuickConnectStore>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="QuickConnectStartupTests"/>
|
|
/// retry the build and report an eager store's outage as the same operator-facing failure it reports
|
|
/// for the lazily connecting form.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UnreachableRedisAtStartup_FailsClosed()
|
|
{
|
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, "127.0.0.1:1,connectTimeout=250,connectRetry=0");
|
|
|
|
using var provider = BuildProvider();
|
|
|
|
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
|
|
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
|
|
}
|
|
|
|
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<IApplicationPaths>();
|
|
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();
|
|
}
|
|
}
|