262 lines
10 KiB
C#
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;
|
|
}
|