Files
jellyfin-ha-src/tests/Jellyfin.Server.Tests/QuickConnect/QuickConnectStatusCodeTests.cs
T
unkin-agent e1ca272d6c
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
fail quick connect closed on an unreachable valkey and restore the idempotent exchange
2026-09-26 14:28:45 +10:00

262 lines
10 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class QuickConnectStatusCodeTests : IAsyncLifetime
{
private readonly Mock<ISessionManager> _sessionManager = new();
private RedisTestServer _redis = null!;
private RedisFaultProxy _proxy = null!;
private IConnectionMultiplexer _connection = null!;
private QuickConnectManager _manager = null!;
private QuickConnectController _controller = null!;
/// <inheritdoc/>
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<IServerConfigurationManager>();
configManager.Setup(manager => manager.Configuration).Returns(new ServerConfiguration { QuickConnectAvailable = true });
_manager = new QuickConnectManager(
configManager.Object,
NullLogger<QuickConnectManager>.Instance,
_sessionManager.Object,
new RedisQuickConnectStore(_connection, NullLogger<RedisQuickConnectStore>.Instance));
_controller = new QuickConnectController(_manager, Mock.Of<IAuthorizationContext>());
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync().ConfigureAwait(false);
await _proxy.DisposeAsync().ConfigureAwait(false);
await _redis.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A secret valkey has never heard of is a 404, which is what ends a flow the user abandoned.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Poll_UnknownSecret_IsNotFound()
{
Assert.Equal(
StatusCodes.Status404NotFound,
await StatusCodeAsync(async () => StatusOf(await _controller.GetQuickConnectState(NewSecret()))));
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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))));
}
/// <summary>
/// The exchange leg tells the two apart the same way.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Exchange_UnknownSecret_IsNotFound()
{
Assert.Equal(
StatusCodes.Status404NotFound,
await StatusCodeAsync(async () =>
{
await _manager.GetAuthorizedRequest(NewSecret()).ConfigureAwait(false);
return StatusCodes.Status200OK;
}));
}
/// <summary>
/// The exchange leg while valkey is unreachable.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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;
}));
}
/// <summary>
/// The authorize leg while valkey is unreachable.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[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;
}));
}
/// <summary>
/// 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.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_AfterAMintThatFailed_IsConflictAndSaysToStartAgain()
{
var initiated = await _manager.TryConnect(AuthorizationInfo());
_sessionManager
.Setup(manager => manager.AuthenticateDirect(It.IsAny<AuthenticationRequest>()))
.ThrowsAsync(new InvalidOperationException("mint failed"));
await Assert.ThrowsAsync<InvalidOperationException>(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code));
var retry = await Record.ExceptionAsync(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code));
var conflict = Assert.IsType<ConflictException>(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;
}));
}
/// <summary>
/// A request that really was authorized still says so, so the accurate message above is not just a
/// blanket replacement for the old one.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_OfAnAuthorizedRequest_IsConflictAndSaysAlreadyAuthorized()
{
var initiated = await _manager.TryConnect(AuthorizationInfo());
var userId = Guid.NewGuid();
_sessionManager
.Setup(manager => manager.AuthenticateDirect(It.IsAny<AuthenticationRequest>()))
.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<ConflictException>(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<QuickConnectResult> result)
=> result.Result is IStatusCodeActionResult status
? status.StatusCode ?? StatusCodes.Status200OK
: StatusCodes.Status200OK;
private static async Task<int> StatusCodeAsync(Func<Task<int>> action)
{
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(paths => paths.ProgramSystemPath).Returns("/program");
appPaths.Setup(paths => paths.ProgramDataPath).Returns("/data");
var configManager = new Mock<IServerConfigurationManager>();
configManager.Setup(manager => manager.ApplicationPaths).Returns(appPaths.Object);
var hostEnvironment = new Mock<IWebHostEnvironment>();
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<ExceptionMiddleware>.Instance,
configManager.Object,
hostEnvironment.Object);
await middleware.Invoke(context).ConfigureAwait(false);
return context.Response.StatusCode;
}
private async Task<string> InitiateAsync()
=> (await _manager.TryConnect(AuthorizationInfo()).ConfigureAwait(false)).Secret;
}