Files
allstarr/allstarr.Tests/Api/AdminAuthControllerTests.cs
T

473 lines
20 KiB
C#

using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Configuration;
using Moq;
using allstarr.Controllers;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Core.Identity;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public class AdminAuthControllerTests
{
[Fact]
public async Task Login_WithValidNonAdminJellyfinUser_CreatesSessionAndCookie()
{
HttpRequestMessage? capturedRequest = null;
string? capturedBody = null;
var handler = new DelegateHttpMessageHandler(async (request, _) =>
{
capturedRequest = request;
capturedBody = request.Content is null ? null : await request.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"AccessToken":"token-123",
"ServerId":"server-1",
"User":{
"Id":"user-1",
"Name":"josh",
"Policy":{"IsAdministrator":false}
}
}
""")
};
});
var sessionService = AdminAuthSessionTestSupport.Create();
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["X-Forwarded-Proto"] = "https";
var controller = CreateController(handler, sessionService, httpContext);
var result = await controller.Login(new AdminAuthController.LoginRequest
{
Username = " josh ",
Password = "secret-pass"
});
var ok = Assert.IsType<OkObjectResult>(result);
var payloadJson = JsonSerializer.Serialize(ok.Value);
using var payload = JsonDocument.Parse(payloadJson);
Assert.True(payload.RootElement.GetProperty("authenticated").GetBoolean());
Assert.Equal("user-1", payload.RootElement.GetProperty("user").GetProperty("id").GetString());
Assert.Equal("josh", payload.RootElement.GetProperty("user").GetProperty("name").GetString());
Assert.False(payload.RootElement.GetProperty("user").GetProperty("isAdministrator").GetBoolean());
Assert.Equal("Jellyfin", payload.RootElement.GetProperty("backend").GetString());
Assert.Equal(
"/api/admin/auth/me/avatar?user=user-1",
payload.RootElement.GetProperty("user").GetProperty("avatarUrl").GetString());
Assert.NotNull(capturedRequest);
Assert.Equal(HttpMethod.Post, capturedRequest!.Method);
Assert.Equal("http://jellyfin.local/Users/AuthenticateByName", capturedRequest.RequestUri?.ToString());
Assert.StartsWith("MediaBrowser Client=\"AllstarrAdmin\"", capturedRequest.Headers.GetValues("Authorization").Single());
Assert.False(capturedRequest.Headers.Contains("X-Emby-Authorization"));
Assert.NotNull(capturedBody);
Assert.Contains("\"Username\":\"josh\"", capturedBody!);
Assert.Contains("\"Pw\":\"secret-pass\"", capturedBody!);
var setCookies = httpContext.Response.Headers.SetCookie;
Assert.Single(setCookies);
var setCookieHeader = setCookies[0] ?? string.Empty;
Assert.Contains($"{AdminAuthSessionService.SessionCookieName}=", setCookieHeader);
Assert.Contains("httponly", setCookieHeader.ToLowerInvariant());
Assert.Contains("secure", setCookieHeader.ToLowerInvariant());
Assert.Contains("samesite=strict", setCookieHeader.ToLowerInvariant());
var sessionId = ExtractCookieValue(setCookieHeader);
var session = await sessionService.GetValidSessionAsync(sessionId);
Assert.NotNull(session);
Assert.Equal("user-1", session.UserId);
Assert.Equal("josh", session.UserName);
Assert.False(session.IsAdministrator);
}
[Fact]
public async Task Login_WithInvalidCredentials_ReturnsUnauthorized()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)));
var sessionService = AdminAuthSessionTestSupport.Create();
var httpContext = new DefaultHttpContext();
var controller = CreateController(handler, sessionService, httpContext);
var result = await controller.Login(new AdminAuthController.LoginRequest
{
Username = "josh",
Password = "wrong"
});
var unauthorized = Assert.IsType<UnauthorizedObjectResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, unauthorized.StatusCode);
Assert.False(httpContext.Response.Headers.ContainsKey("Set-Cookie"));
}
[Fact]
public async Task Login_WithValidSubsonicAdmin_UsesFormPostAndStoresNoPassword()
{
HttpRequestMessage? capturedRequest = null;
string? capturedBody = null;
var handler = new DelegateHttpMessageHandler(async (request, _) =>
{
capturedRequest = request;
capturedBody = await request.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"subsonic-response": {
"status": "ok",
"version": "1.16.1",
"user": { "username": "alice", "adminRole": true }
}
}
""")
};
});
var sessionService = AdminAuthSessionTestSupport.Create();
var httpContext = new DefaultHttpContext();
var controller = CreateController(
handler,
sessionService,
httpContext,
BackendType.Subsonic);
var result = await controller.Login(new AdminAuthController.LoginRequest
{
Username = "alice",
Password = "secret-pass"
});
var ok = Assert.IsType<OkObjectResult>(result);
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value));
Assert.Equal("Subsonic", payload.RootElement.GetProperty("backend").GetString());
Assert.True(payload.RootElement.GetProperty("user").GetProperty("isAdministrator").GetBoolean());
Assert.Equal(JsonValueKind.Null,
payload.RootElement.GetProperty("user").GetProperty("avatarUrl").ValueKind);
Assert.NotNull(capturedRequest);
Assert.Equal(HttpMethod.Post, capturedRequest!.Method);
Assert.Equal("http://subsonic.local/rest/getUser.view", capturedRequest.RequestUri?.ToString());
Assert.DoesNotContain("secret-pass", capturedRequest.RequestUri?.ToString());
Assert.Contains("u=alice", capturedBody);
Assert.Contains("p=secret-pass", capturedBody);
var sessionId = ExtractCookieValue(httpContext.Response.Headers.SetCookie[0]!);
var session = await sessionService.GetValidSessionAsync(sessionId);
Assert.NotNull(session);
Assert.Equal("Subsonic", session.BackendType);
Assert.Equal(string.Empty, session.JellyfinAccessToken);
}
[Fact]
public async Task Login_WithSubsonicProtocolAuthFailure_ReturnsUnauthorized()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{"subsonic-response":{"status":"failed","error":{"code":40,"message":"Wrong username or password"}}}
""")
}));
var controller = CreateController(
handler,
AdminAuthSessionTestSupport.Create(),
new DefaultHttpContext(),
BackendType.Subsonic);
var result = await controller.Login(new AdminAuthController.LoginRequest
{
Username = "alice",
Password = "wrong"
});
Assert.IsType<UnauthorizedObjectResult>(result);
}
[Theory]
[InlineData(BackendType.Jellyfin)]
[InlineData(BackendType.Subsonic)]
public async Task Login_WithMalformedBackendResponse_ReturnsBadGateway(BackendType backend)
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("not-json")
}));
var controller = CreateController(
handler,
AdminAuthSessionTestSupport.Create(),
new DefaultHttpContext(),
backend);
var result = Assert.IsType<ObjectResult>(await controller.Login(new AdminAuthController.LoginRequest
{
Username = "alice",
Password = "secret-pass"
}));
Assert.Equal(StatusCodes.Status502BadGateway, result.StatusCode);
Assert.Contains("invalid authentication response", JsonSerializer.Serialize(result.Value),
StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(BackendType.Jellyfin)]
[InlineData(BackendType.Subsonic)]
public async Task Login_WhenRequestIsCanceled_ReturnsClientClosedRequest(BackendType backend)
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var context = new DefaultHttpContext { RequestAborted = cancellation.Token };
var handler = new DelegateHttpMessageHandler((_, token) =>
Task.FromCanceled<HttpResponseMessage>(token));
var controller = CreateController(
handler,
AdminAuthSessionTestSupport.Create(),
context,
backend);
var result = Assert.IsType<StatusCodeResult>(await controller.Login(new AdminAuthController.LoginRequest
{
Username = "alice",
Password = "secret-pass"
}));
Assert.Equal(499, result.StatusCode);
}
[Fact]
public async Task GetCurrentSession_WithUnknownCookie_ReturnsUnauthenticated()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
var sessionService = AdminAuthSessionTestSupport.Create();
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}=missing-session";
var controller = CreateController(handler, sessionService, httpContext);
var result = await controller.GetCurrentSession();
var ok = Assert.IsType<OkObjectResult>(result);
var payloadJson = JsonSerializer.Serialize(ok.Value);
using var payload = JsonDocument.Parse(payloadJson);
Assert.False(payload.RootElement.GetProperty("authenticated").GetBoolean());
var setCookies = httpContext.Response.Headers.SetCookie;
Assert.Equal(3, setCookies.Count);
Assert.Contains(setCookies, value => value!.Contains($"{AdminAuthSessionService.SessionCookieName}=") &&
value.Contains("expires=", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData(ProviderAccountManagementMode.AdminManaged)]
[InlineData(ProviderAccountManagementMode.UserManaged)]
[InlineData(ProviderAccountManagementMode.Hybrid)]
public async Task GetCurrentSession_ExposesOnlyTheNonSecretAccountManagementMode(
ProviderAccountManagementMode managementMode)
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
var controller = CreateController(
handler,
AdminAuthSessionTestSupport.Create(),
new DefaultHttpContext(),
managementMode: managementMode);
var result = Assert.IsType<OkObjectResult>(await controller.GetCurrentSession());
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
Assert.False(payload.RootElement.GetProperty("authenticated").GetBoolean());
Assert.Equal(
managementMode.ToString(),
payload.RootElement.GetProperty("providerAccountManagementMode").GetString());
Assert.False(payload.RootElement.TryGetProperty("secret", out _));
Assert.False(payload.RootElement.TryGetProperty("accounts", out _));
}
[Fact]
public async Task GetCurrentSession_WithValidCookie_ReturnsSessionUser()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
var sessionService = AdminAuthSessionTestSupport.Create();
var session = await sessionService.CreateSessionAsync(
userId: "user-42",
userName: "alice",
isAdministrator: true,
jellyfinAccessToken: "token",
jellyfinServerId: "server");
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}={session.SessionId}";
var controller = CreateController(handler, sessionService, httpContext);
var result = await controller.GetCurrentSession();
var ok = Assert.IsType<OkObjectResult>(result);
var payloadJson = JsonSerializer.Serialize(ok.Value);
using var payload = JsonDocument.Parse(payloadJson);
Assert.True(payload.RootElement.GetProperty("authenticated").GetBoolean());
Assert.Equal("user-42", payload.RootElement.GetProperty("user").GetProperty("id").GetString());
Assert.Equal("alice", payload.RootElement.GetProperty("user").GetProperty("name").GetString());
Assert.True(payload.RootElement.GetProperty("user").GetProperty("isAdministrator").GetBoolean());
Assert.Equal(
"Hybrid",
payload.RootElement.GetProperty("providerAccountManagementMode").GetString());
var refreshedCookie = Assert.Single(httpContext.Response.Headers.SetCookie);
Assert.Contains($"{AdminAuthSessionService.SessionCookieName}={session.SessionId}", refreshedCookie);
Assert.Contains("path=/", refreshedCookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task GetCurrentUserAvatar_UsesTheScopedMediaAssetCache()
{
var requests = 0;
var handler = new DelegateHttpMessageHandler((_, _) =>
{
Interlocked.Increment(ref requests);
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent([1, 2, 3, 4])
};
response.Content.Headers.ContentType = new("image/png");
response.Headers.ETag = new("\"avatar-v1\"");
return Task.FromResult(response);
});
var sessionService = AdminAuthSessionTestSupport.Create();
var session = await sessionService.CreateSessionAsync(
userId: "user-42",
userName: "alice",
isAdministrator: true,
jellyfinAccessToken: "token",
jellyfinServerId: "server",
tenantId: Guid.CreateVersion7(),
allstarrUserId: Guid.CreateVersion7());
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie =
$"{AdminAuthSessionService.SessionCookieName}={session.SessionId}";
var controller = CreateController(handler, sessionService, httpContext);
var first = Assert.IsType<FileContentResult>(
await controller.GetCurrentUserAvatar(CancellationToken.None));
var second = Assert.IsType<FileContentResult>(
await controller.GetCurrentUserAvatar(CancellationToken.None));
Assert.Equal(1, requests);
Assert.Equal(first.FileContents, second.FileContents);
Assert.Equal("image/png", second.ContentType);
Assert.Equal("private, max-age=300", httpContext.Response.Headers.CacheControl);
Assert.Equal("Cookie", httpContext.Response.Headers.Vary);
}
[Fact]
public async Task GetCurrentSession_WithLegacyCookie_MigratesToV3Cookie()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
var sessionService = AdminAuthSessionTestSupport.Create();
var session = await sessionService.CreateSessionAsync(
userId: "legacy-user",
userName: "legacy",
isAdministrator: true,
jellyfinAccessToken: "token",
jellyfinServerId: "server");
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.LegacySessionCookieName}={session.SessionId}";
var result = Assert.IsType<OkObjectResult>(
await CreateController(handler, sessionService, httpContext).GetCurrentSession());
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(result.Value));
Assert.True(payload.RootElement.GetProperty("authenticated").GetBoolean());
var migratedCookie = Assert.Single(httpContext.Response.Headers.SetCookie);
Assert.Contains($"{AdminAuthSessionService.SessionCookieName}={session.SessionId}", migratedCookie);
Assert.Contains("path=/", migratedCookie, StringComparison.OrdinalIgnoreCase);
}
private static AdminAuthController CreateController(
HttpMessageHandler handler,
AdminAuthSessionService sessionService,
HttpContext httpContext,
BackendType backendType = BackendType.Jellyfin,
ProviderAccountManagementMode managementMode = ProviderAccountManagementMode.Hybrid)
{
var jellyfinOptions = Options.Create(new JellyfinSettings
{
Url = "http://jellyfin.local"
});
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handler));
var logger = new Mock<ILogger<AdminAuthController>>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Backend:Type"] = backendType.ToString()
})
.Build();
var controller = new AdminAuthController(
jellyfinOptions,
Options.Create(new SubsonicSettings { Url = "http://subsonic.local" }),
configuration,
httpClientFactory.Object,
sessionService,
logger.Object,
new MediaAssetResolver(
new TestMemoryApplicationCache(),
NullLogger<MediaAssetResolver>.Instance),
identityResolver: null,
providerAccountManagementOptions: new ProviderAccountManagementOptions
{
ManagementMode = managementMode.ToString()
})
{
ControllerContext = new ControllerContext
{
HttpContext = httpContext
}
};
return controller;
}
private static string ExtractCookieValue(string setCookieHeader)
{
var cookiePart = setCookieHeader.Split(';', 2)[0];
var parts = cookiePart.Split('=', 2);
return parts.Length == 2 ? parts[1] : string.Empty;
}
private sealed class DelegateHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public DelegateHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}