fail quick connect closed on an unreachable valkey and restore the idempotent exchange
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user