using System; using System.IO; using System.Threading.Tasks; using Emby.Server.Implementations.QuickConnect; using Jellyfin.Api.Controllers; using Jellyfin.Api.Middleware; using Jellyfin.Server.Tests.HighAvailability; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.QuickConnect; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Moq; using StackExchange.Redis; using Xunit; namespace Jellyfin.Server.Tests.QuickConnect; /// /// The status a client actually sees. A missing key and an unreachable valkey are different answers and /// must not collapse into one: telling a polling client its secret is unknown ends its flow, while 503 /// tells it to keep trying. The exception the store really throws is run through the real exception /// middleware, so the mapping is exercised rather than assumed. /// [Trait("Category", "RequiresDocker")] public sealed class QuickConnectStatusCodeTests : IAsyncLifetime { private readonly Mock _sessionManager = new(); private RedisTestServer _redis = null!; private RedisFaultProxy _proxy = null!; private IConnectionMultiplexer _connection = null!; private QuickConnectManager _manager = null!; private QuickConnectController _controller = null!; /// public async ValueTask InitializeAsync() { _redis = await RedisTestServer.StartAsync().ConfigureAwait(false); _proxy = RedisFaultProxy.Start(_redis.ConnectionString); _connection = await ConnectionMultiplexer.ConnectAsync(_proxy.ConnectionString).ConfigureAwait(false); var configManager = new Mock(); configManager.Setup(manager => manager.Configuration).Returns(new ServerConfiguration { QuickConnectAvailable = true }); _manager = new QuickConnectManager( configManager.Object, NullLogger.Instance, _sessionManager.Object, new RedisQuickConnectStore(_connection, NullLogger.Instance)); _controller = new QuickConnectController(_manager, Mock.Of()); } /// public async ValueTask DisposeAsync() { await _connection.DisposeAsync().ConfigureAwait(false); await _proxy.DisposeAsync().ConfigureAwait(false); await _redis.DisposeAsync().ConfigureAwait(false); } /// /// A secret valkey has never heard of is a 404, which is what ends a flow the user abandoned. /// /// A representing the asynchronous operation. [Fact] public async Task Poll_UnknownSecret_IsNotFound() { Assert.Equal( StatusCodes.Status404NotFound, await StatusCodeAsync(async () => StatusOf(await _controller.GetQuickConnectState(NewSecret())))); } /// /// The same poll while valkey is unreachable is a 503. This is the bug the shared store is here to /// avoid: a blip must not tell every polling client that its secret is invalid. /// /// A representing the asynchronous operation. [Fact] public async Task Poll_WhileRedisIsUnreachable_IsServiceUnavailable() { var secret = await InitiateAsync(); _proxy.Cut(); Assert.Equal( StatusCodes.Status503ServiceUnavailable, await StatusCodeAsync(async () => StatusOf(await _controller.GetQuickConnectState(secret)))); } /// /// The exchange leg tells the two apart the same way. /// /// A representing the asynchronous operation. [Fact] public async Task Exchange_UnknownSecret_IsNotFound() { Assert.Equal( StatusCodes.Status404NotFound, await StatusCodeAsync(async () => { await _manager.GetAuthorizedRequest(NewSecret()).ConfigureAwait(false); return StatusCodes.Status200OK; })); } /// /// The exchange leg while valkey is unreachable. /// /// A representing the asynchronous operation. [Fact] public async Task Exchange_WhileRedisIsUnreachable_IsServiceUnavailable() { var secret = await InitiateAsync(); _proxy.Cut(); Assert.Equal( StatusCodes.Status503ServiceUnavailable, await StatusCodeAsync(async () => { await _manager.GetAuthorizedRequest(secret).ConfigureAwait(false); return StatusCodes.Status200OK; })); } /// /// The authorize leg while valkey is unreachable. /// /// A representing the asynchronous operation. [Fact] public async Task Authorize_WhileRedisIsUnreachable_IsServiceUnavailable() { var initiated = await _manager.TryConnect(AuthorizationInfo()); _proxy.Cut(); Assert.Equal( StatusCodes.Status503ServiceUnavailable, await StatusCodeAsync(async () => { await _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code).ConfigureAwait(false); return StatusCodes.Status200OK; })); } /// /// A mint that threw leaves its claim taken on purpose, because the write it failed on may have /// landed. Retrying then has to say so and be a 409 the client can act on, not a 500 and not the /// untrue claim that the request is already authorized. /// /// A representing the asynchronous operation. [Fact] public async Task Authorize_AfterAMintThatFailed_IsConflictAndSaysToStartAgain() { var initiated = await _manager.TryConnect(AuthorizationInfo()); _sessionManager .Setup(manager => manager.AuthenticateDirect(It.IsAny())) .ThrowsAsync(new InvalidOperationException("mint failed")); await Assert.ThrowsAsync(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code)); var retry = await Record.ExceptionAsync(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code)); var conflict = Assert.IsType(retry); Assert.DoesNotContain("already authorized", conflict.Message, StringComparison.Ordinal); Assert.Contains("Start quick connect again", conflict.Message, StringComparison.Ordinal); Assert.Equal( StatusCodes.Status409Conflict, await StatusCodeAsync(async () => { await _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code).ConfigureAwait(false); return StatusCodes.Status200OK; })); } /// /// A request that really was authorized still says so, so the accurate message above is not just a /// blanket replacement for the old one. /// /// A representing the asynchronous operation. [Fact] public async Task Authorize_OfAnAuthorizedRequest_IsConflictAndSaysAlreadyAuthorized() { var initiated = await _manager.TryConnect(AuthorizationInfo()); var userId = Guid.NewGuid(); _sessionManager .Setup(manager => manager.AuthenticateDirect(It.IsAny())) .ReturnsAsync(new AuthenticationResult { AccessToken = "token-1", ServerId = "server-1", User = new UserDto { Id = userId, Name = "user", ServerId = "server-1" } }); Assert.True(await _manager.AuthorizeRequest(userId, initiated.Code)); var retry = await Record.ExceptionAsync(() => _manager.AuthorizeRequest(userId, initiated.Code)); Assert.Equal("Request is already authorized", Assert.IsType(retry).Message); } private static AuthorizationInfo AuthorizationInfo() => new AuthorizationInfo { Device = "Living Room TV", DeviceId = Guid.NewGuid().ToString("N"), Client = "Jellyfin Web", Version = "1.0.0" }; private static string NewSecret() => Guid.NewGuid().ToString("N"); private static int StatusOf(ActionResult result) => result.Result is IStatusCodeActionResult status ? status.StatusCode ?? StatusCodes.Status200OK : StatusCodes.Status200OK; private static async Task StatusCodeAsync(Func> action) { var appPaths = new Mock(); appPaths.Setup(paths => paths.ProgramSystemPath).Returns("/program"); appPaths.Setup(paths => paths.ProgramDataPath).Returns("/data"); var configManager = new Mock(); configManager.Setup(manager => manager.ApplicationPaths).Returns(appPaths.Object); var hostEnvironment = new Mock(); hostEnvironment.SetupGet(environment => environment.EnvironmentName).Returns(Environments.Production); var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); var middleware = new ExceptionMiddleware( async _ => { context.Response.StatusCode = await action().ConfigureAwait(false); }, NullLogger.Instance, configManager.Object, hostEnvironment.Object); await middleware.Invoke(context).ConfigureAwait(false); return context.Response.StatusCode; } private async Task InitiateAsync() => (await _manager.TryConnect(AuthorizationInfo()).ConfigureAwait(false)).Secret; }