diff --git a/.env.example b/.env.example
index 30180b1..a7e7e75 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,29 @@
# Choose which media server backend to use: Subsonic or Jellyfin
BACKEND_TYPE=Jellyfin
+# ===== ADMIN NETWORK ACCESS (PORT 5275) =====
+# Keep false to bind admin UI to localhost only (recommended)
+# Set true only if you need LAN access from another device
+ADMIN_BIND_ANY_IP=false
+
+# Comma-separated trusted CIDR ranges allowed to access admin port when bind is enabled
+# Examples: 192.168.1.0/24,10.0.0.0/8
+ADMIN_TRUSTED_SUBNETS=
+
+# ===== CORS POLICY =====
+# Cross-origin requests are disabled by default.
+# Set explicit origins if you need browser access from another host.
+# Example: https://my-jellyfin.example.com,http://localhost:3000
+CORS_ALLOWED_ORIGINS=
+
+# Explicit allowed methods and headers for CORS preflight.
+# Keep these restrictive unless you have a concrete requirement.
+CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD
+CORS_ALLOWED_HEADERS=Accept,Authorization,Content-Type,Range,X-Requested-With,X-Emby-Authorization,X-MediaBrowser-Token
+
+# Set true only when your allowed origins require cookies/auth credentials.
+CORS_ALLOW_CREDENTIALS=false
+
# ===== REDIS CACHE (REQUIRED) =====
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
# File cache (/app/cache) acts as a persistence layer for cold starts
@@ -113,6 +136,14 @@ QOBUZ_USER_ID=
# If not specified, the highest available quality will be used
QOBUZ_QUALITY=
+# ===== MUSICBRAINZ CONFIGURATION =====
+# Enable MusicBrainz metadata lookups (optional, default: true)
+MUSICBRAINZ_ENABLED=true
+
+# Optional MusicBrainz account credentials for authenticated requests
+MUSICBRAINZ_USERNAME=
+MUSICBRAINZ_PASSWORD=
+
# ===== GENERAL SETTINGS =====
# External playlists support (optional, default: true)
# When enabled, allows searching and downloading playlists from Deezer/Qobuz
@@ -232,6 +263,11 @@ SCROBBLING_ENABLED=false
# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz)
SCROBBLING_LOCAL_TRACKS_ENABLED=false
+# Emit synthetic local "played" events from progress when local scrobbling is disabled (default: false)
+# Only enable this if you explicitly need UserPlayedItems-based plugin triggering.
+# Keep false to avoid duplicate local scrobbles with Jellyfin plugins.
+SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false
+
# ===== LAST.FM SCROBBLING =====
# Enable Last.fm scrobbling (default: false)
SCROBBLING_LASTFM_ENABLED=false
@@ -270,7 +306,11 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=
# Enable detailed request logging (default: false)
# When enabled, logs every incoming HTTP request with full details:
# - Method, path, query string
-# - Headers (auth tokens are masked)
+# - Headers
# - Response status and timing
# Useful for debugging client issues and seeing what API calls are being made
DEBUG_LOG_ALL_REQUESTS=false
+
+# Redact auth/query sensitive values in request logs (default: false).
+# Set true if you want DEBUG_LOG_ALL_REQUESTS while still masking tokens.
+DEBUG_REDACT_SENSITIVE_REQUEST_VALUES=false
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..aba20cd
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+ false
+
+
diff --git a/Dockerfile b/Dockerfile
index 0328a37..5afefe9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,7 @@ WORKDIR /src
COPY allstarr.sln .
COPY allstarr/allstarr.csproj allstarr/
+COPY allstarr/AppVersion.cs allstarr/
COPY allstarr.Tests/allstarr.Tests.csproj allstarr.Tests/
RUN dotnet restore
diff --git a/allstarr.Tests/AdminAuthControllerTests.cs b/allstarr.Tests/AdminAuthControllerTests.cs
new file mode 100644
index 0000000..233b339
--- /dev/null
+++ b/allstarr.Tests/AdminAuthControllerTests.cs
@@ -0,0 +1,213 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using allstarr.Controllers;
+using allstarr.Models.Settings;
+using allstarr.Services.Admin;
+
+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 = new AdminAuthSessionService();
+ 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(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.NotNull(capturedRequest);
+ Assert.Equal(HttpMethod.Post, capturedRequest!.Method);
+ Assert.Equal("http://jellyfin.local/Users/AuthenticateByName", capturedRequest.RequestUri?.ToString());
+ Assert.Contains("X-Emby-Authorization", capturedRequest.Headers.Select(h => h.Key));
+
+ 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);
+ Assert.True(sessionService.TryGetValidSession(sessionId, out var 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 = new AdminAuthSessionService();
+ 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(result);
+ Assert.Equal(StatusCodes.Status401Unauthorized, unauthorized.StatusCode);
+ Assert.False(httpContext.Response.Headers.ContainsKey("Set-Cookie"));
+ }
+
+ [Fact]
+ public void GetCurrentSession_WithUnknownCookie_ReturnsUnauthenticated()
+ {
+ var handler = new DelegateHttpMessageHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
+
+ var sessionService = new AdminAuthSessionService();
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}=missing-session";
+
+ var controller = CreateController(handler, sessionService, httpContext);
+ var result = controller.GetCurrentSession();
+
+ var ok = Assert.IsType(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.Single(setCookies);
+ var setCookieHeader = setCookies[0] ?? string.Empty;
+ Assert.Contains($"{AdminAuthSessionService.SessionCookieName}=", setCookieHeader);
+ Assert.Contains("expires=", setCookieHeader.ToLowerInvariant());
+ }
+
+ [Fact]
+ public void GetCurrentSession_WithValidCookie_ReturnsSessionUser()
+ {
+ var handler = new DelegateHttpMessageHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
+
+ var sessionService = new AdminAuthSessionService();
+ var session = sessionService.CreateSession(
+ 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 = controller.GetCurrentSession();
+
+ var ok = Assert.IsType(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());
+ }
+
+ private static AdminAuthController CreateController(
+ HttpMessageHandler handler,
+ AdminAuthSessionService sessionService,
+ HttpContext httpContext)
+ {
+ var jellyfinOptions = Options.Create(new JellyfinSettings
+ {
+ Url = "http://jellyfin.local"
+ });
+
+ var httpClientFactory = new Mock();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(new HttpClient(handler));
+
+ var logger = new Mock>();
+ var controller = new AdminAuthController(
+ jellyfinOptions,
+ httpClientFactory.Object,
+ sessionService,
+ logger.Object)
+ {
+ 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> _handler;
+
+ public DelegateHttpMessageHandler(Func> handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return _handler(request, cancellationToken);
+ }
+ }
+}
diff --git a/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs
new file mode 100644
index 0000000..17d23fc
--- /dev/null
+++ b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs
@@ -0,0 +1,198 @@
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+using allstarr.Middleware;
+using allstarr.Services.Admin;
+
+namespace allstarr.Tests;
+
+public class AdminAuthenticationMiddlewareTests
+{
+ [Fact]
+ public async Task InvokeAsync_UnauthenticatedAdminRequest_Returns401()
+ {
+ var sessionService = new AdminAuthSessionService();
+ var nextInvoked = false;
+
+ var middleware = new AdminAuthenticationMiddleware(
+ _ =>
+ {
+ nextInvoked = true;
+ return Task.CompletedTask;
+ },
+ sessionService,
+ NullLogger.Instance);
+
+ var context = CreateContext(
+ path: "/api/admin/config",
+ method: HttpMethods.Get,
+ localPort: 5275);
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked);
+ Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
+
+ var body = await ReadResponseBodyAsync(context);
+ Assert.Contains("Authentication required", body);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonAdminUser_AllowedRoute_PassesThrough()
+ {
+ var sessionService = new AdminAuthSessionService();
+ var session = sessionService.CreateSession(
+ userId: "user-1",
+ userName: "josh",
+ isAdministrator: false,
+ jellyfinAccessToken: "token",
+ jellyfinServerId: "server");
+
+ var nextInvoked = false;
+ var middleware = new AdminAuthenticationMiddleware(
+ context =>
+ {
+ nextInvoked = true;
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return Task.CompletedTask;
+ },
+ sessionService,
+ NullLogger.Instance);
+
+ var context = CreateContext(
+ path: "/api/admin/jellyfin/playlists",
+ method: HttpMethods.Get,
+ localPort: 5275,
+ sessionIdCookie: session.SessionId);
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked);
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ Assert.True(context.Items.ContainsKey(AdminAuthSessionService.HttpContextSessionItemKey));
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonAdminUser_DisallowedRoute_Returns403()
+ {
+ var sessionService = new AdminAuthSessionService();
+ var session = sessionService.CreateSession(
+ userId: "user-1",
+ userName: "josh",
+ isAdministrator: false,
+ jellyfinAccessToken: "token",
+ jellyfinServerId: "server");
+
+ var nextInvoked = false;
+ var middleware = new AdminAuthenticationMiddleware(
+ _ =>
+ {
+ nextInvoked = true;
+ return Task.CompletedTask;
+ },
+ sessionService,
+ NullLogger.Instance);
+
+ var context = CreateContext(
+ path: "/api/admin/config",
+ method: HttpMethods.Get,
+ localPort: 5275,
+ sessionIdCookie: session.SessionId);
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked);
+ Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
+
+ var body = await ReadResponseBodyAsync(context);
+ Assert.Contains("Administrator permissions required", body);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminUser_DisallowedForUserButAllowedForAdmin_PassesThrough()
+ {
+ var sessionService = new AdminAuthSessionService();
+ var session = sessionService.CreateSession(
+ userId: "admin-1",
+ userName: "admin",
+ isAdministrator: true,
+ jellyfinAccessToken: "token",
+ jellyfinServerId: "server");
+
+ var nextInvoked = false;
+ var middleware = new AdminAuthenticationMiddleware(
+ context =>
+ {
+ nextInvoked = true;
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return Task.CompletedTask;
+ },
+ sessionService,
+ NullLogger.Instance);
+
+ var context = CreateContext(
+ path: "/api/admin/config",
+ method: HttpMethods.Get,
+ localPort: 5275,
+ sessionIdCookie: session.SessionId);
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked);
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminApiOnMainPort_PassesThroughForDownstreamFilter()
+ {
+ var sessionService = new AdminAuthSessionService();
+ var nextInvoked = false;
+
+ var middleware = new AdminAuthenticationMiddleware(
+ context =>
+ {
+ nextInvoked = true;
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return Task.CompletedTask;
+ },
+ sessionService,
+ NullLogger.Instance);
+
+ var context = CreateContext(
+ path: "/api/admin/config",
+ method: HttpMethods.Get,
+ localPort: 5274);
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked);
+ Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
+ }
+
+ private static DefaultHttpContext CreateContext(
+ string path,
+ string method,
+ int localPort,
+ string? sessionIdCookie = null)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.Path = path;
+ context.Request.Method = method;
+ context.Connection.LocalPort = localPort;
+ context.Response.Body = new MemoryStream();
+
+ if (!string.IsNullOrWhiteSpace(sessionIdCookie))
+ {
+ context.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}={sessionIdCookie}";
+ }
+
+ return context;
+ }
+
+ private static async Task ReadResponseBodyAsync(HttpContext context)
+ {
+ context.Response.Body.Seek(0, SeekOrigin.Begin);
+ using var reader = new StreamReader(context.Response.Body, Encoding.UTF8, leaveOpen: true);
+ return await reader.ReadToEndAsync();
+ }
+}
diff --git a/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs b/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs
new file mode 100644
index 0000000..b0088cd
--- /dev/null
+++ b/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs
@@ -0,0 +1,93 @@
+using System.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using allstarr.Middleware;
+
+namespace allstarr.Tests;
+
+public class AdminNetworkAllowlistMiddlewareTests
+{
+ [Fact]
+ public async Task InvokeAsync_AdminPortLoopback_AllowsRequest()
+ {
+ var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked);
+ var context = CreateContext(5275, "127.0.0.1");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked());
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminPortUntrustedSubnet_BlocksRequest()
+ {
+ var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked);
+ var context = CreateContext(5275, "192.168.1.25");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked());
+ Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminPortTrustedSubnet_AllowsRequest()
+ {
+ var middleware = CreateMiddleware(new Dictionary
+ {
+ ["Admin:TrustedSubnets"] = "192.168.1.0/24"
+ }, out var nextInvoked);
+ var context = CreateContext(5275, "192.168.1.25");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked());
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonAdminPort_BypassesAllowlist()
+ {
+ var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked);
+ var context = CreateContext(8080, "8.8.8.8");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked());
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ }
+
+ private static AdminNetworkAllowlistMiddleware CreateMiddleware(
+ IDictionary configValues,
+ out Func nextInvoked)
+ {
+ var invoked = false;
+ nextInvoked = () => invoked;
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configValues)
+ .Build();
+
+ return new AdminNetworkAllowlistMiddleware(
+ context =>
+ {
+ invoked = true;
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return Task.CompletedTask;
+ },
+ configuration,
+ NullLogger.Instance);
+ }
+
+ private static DefaultHttpContext CreateContext(int localPort, string remoteIp)
+ {
+ var context = new DefaultHttpContext();
+ context.Connection.LocalPort = localPort;
+ context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp);
+ context.Request.Path = "/api/admin/status";
+ context.Response.Body = new MemoryStream();
+ return context;
+ }
+}
diff --git a/allstarr.Tests/AdminNetworkBindingPolicyTests.cs b/allstarr.Tests/AdminNetworkBindingPolicyTests.cs
new file mode 100644
index 0000000..d00db6e
--- /dev/null
+++ b/allstarr.Tests/AdminNetworkBindingPolicyTests.cs
@@ -0,0 +1,67 @@
+using allstarr.Services.Common;
+using Microsoft.Extensions.Configuration;
+using System.Net;
+
+namespace allstarr.Tests;
+
+public class AdminNetworkBindingPolicyTests
+{
+ [Fact]
+ public void ShouldBindAdminAnyIp_DefaultsToFalse_WhenUnset()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary())
+ .Build();
+
+ var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration);
+
+ Assert.False(bindAnyIp);
+ }
+
+ [Fact]
+ public void ShouldBindAdminAnyIp_ReturnsTrue_WhenConfigured()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Admin:BindAnyIp"] = "true"
+ })
+ .Build();
+
+ var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration);
+
+ Assert.True(bindAnyIp);
+ }
+
+ [Fact]
+ public void ParseTrustedSubnets_ReturnsOnlyValidNetworks()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Admin:TrustedSubnets"] = "192.168.1.0/24, invalid,10.0.0.0/8"
+ })
+ .Build();
+
+ var subnets = AdminNetworkBindingPolicy.ParseTrustedSubnets(configuration);
+
+ Assert.Equal(2, subnets.Count);
+ Assert.Contains(subnets, n => n.ToString() == "192.168.1.0/24");
+ Assert.Contains(subnets, n => n.ToString() == "10.0.0.0/8");
+ }
+
+ [Theory]
+ [InlineData("127.0.0.1", true)]
+ [InlineData("192.168.1.55", true)]
+ [InlineData("10.25.1.3", false)]
+ public void IsRemoteIpAllowed_HonorsLoopbackAndTrustedSubnets(string ip, bool expected)
+ {
+ var trusted = new List();
+ Assert.True(IPNetwork.TryParse("192.168.1.0/24", out var subnet));
+ trusted.Add(subnet);
+
+ var allowed = AdminNetworkBindingPolicy.IsRemoteIpAllowed(IPAddress.Parse(ip), trusted);
+
+ Assert.Equal(expected, allowed);
+ }
+}
diff --git a/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs b/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs
new file mode 100644
index 0000000..b63cc3b
--- /dev/null
+++ b/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs
@@ -0,0 +1,147 @@
+using allstarr.Middleware;
+using Microsoft.AspNetCore.Http;
+using Moq;
+
+namespace allstarr.Tests;
+
+public class AdminStaticFilesMiddlewareTests
+{
+ [Fact]
+ public async Task InvokeAsync_AdminRootPath_ServesIndexHtml()
+ {
+ var webRoot = CreateTempWebRoot();
+ await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "ok");
+
+ try
+ {
+ var middleware = CreateMiddleware(webRoot, out var nextInvoked);
+ var context = CreateContext(localPort: 5275, path: "/");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked());
+ Assert.Equal("text/html", context.Response.ContentType);
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ }
+ finally
+ {
+ DeleteTempWebRoot(webRoot);
+ }
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminPathTraversalAttempt_ReturnsNotFound()
+ {
+ var webRoot = CreateTempWebRoot();
+ var parent = Directory.GetParent(webRoot)!.FullName;
+ await File.WriteAllTextAsync(Path.Combine(parent, "secret.txt"), "secret");
+
+ try
+ {
+ var middleware = CreateMiddleware(webRoot, out var nextInvoked);
+ var context = CreateContext(localPort: 5275, path: "/../secret.txt");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked());
+ Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
+ }
+ finally
+ {
+ DeleteTempWebRoot(webRoot);
+ }
+ }
+
+ [Fact]
+ public async Task InvokeAsync_AdminValidStaticFile_ServesFile()
+ {
+ var webRoot = CreateTempWebRoot();
+ var jsDir = Path.Combine(webRoot, "js");
+ Directory.CreateDirectory(jsDir);
+ await File.WriteAllTextAsync(Path.Combine(jsDir, "app.js"), "console.log('ok');");
+
+ try
+ {
+ var middleware = CreateMiddleware(webRoot, out var nextInvoked);
+ var context = CreateContext(localPort: 5275, path: "/js/app.js");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.False(nextInvoked());
+ Assert.Equal("application/javascript", context.Response.ContentType);
+ Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
+ }
+ finally
+ {
+ DeleteTempWebRoot(webRoot);
+ }
+ }
+
+ [Fact]
+ public async Task InvokeAsync_NonAdminPort_BypassesStaticMiddleware()
+ {
+ var webRoot = CreateTempWebRoot();
+ await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "ok");
+
+ try
+ {
+ var middleware = CreateMiddleware(webRoot, out var nextInvoked);
+ var context = CreateContext(localPort: 8080, path: "/index.html");
+
+ await middleware.InvokeAsync(context);
+
+ Assert.True(nextInvoked());
+ Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
+ }
+ finally
+ {
+ DeleteTempWebRoot(webRoot);
+ }
+ }
+
+ private static AdminStaticFilesMiddleware CreateMiddleware(
+ string webRootPath,
+ out Func nextInvoked)
+ {
+ var invoked = false;
+ nextInvoked = () => invoked;
+
+ var environment = new Mock();
+ environment.SetupGet(x => x.WebRootPath).Returns(webRootPath);
+
+ return new AdminStaticFilesMiddleware(
+ context =>
+ {
+ invoked = true;
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return Task.CompletedTask;
+ },
+ environment.Object);
+ }
+
+ private static DefaultHttpContext CreateContext(int localPort, string path)
+ {
+ var context = new DefaultHttpContext();
+ context.Connection.LocalPort = localPort;
+ context.Request.Method = HttpMethods.Get;
+ context.Request.Path = path;
+ context.Response.Body = new MemoryStream();
+ return context;
+ }
+
+ private static string CreateTempWebRoot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"), "wwwroot");
+ Directory.CreateDirectory(root);
+ return root;
+ }
+
+ private static void DeleteTempWebRoot(string webRoot)
+ {
+ var testRoot = Directory.GetParent(webRoot)?.FullName;
+ if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot))
+ {
+ Directory.Delete(testRoot, recursive: true);
+ }
+ }
+}
diff --git a/allstarr.Tests/ApiKeyAuthFilterTests.cs b/allstarr.Tests/ApiKeyAuthFilterTests.cs
deleted file mode 100644
index 3015301..0000000
--- a/allstarr.Tests/ApiKeyAuthFilterTests.cs
+++ /dev/null
@@ -1,167 +0,0 @@
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Abstractions;
-using Microsoft.AspNetCore.Mvc.Filters;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using Moq;
-using System.Collections.Generic;
-using allstarr.Filters;
-using allstarr.Models.Settings;
-
-namespace allstarr.Tests;
-
-public class ApiKeyAuthFilterTests
-{
- private readonly Mock> _loggerMock;
- private readonly IOptions _options;
-
- public ApiKeyAuthFilterTests()
- {
- _loggerMock = new Mock>();
- _options = Options.Create(new JellyfinSettings { ApiKey = "secret-key" });
- }
-
- private static (ActionExecutingContext ExecContext, ActionContext ActionContext) CreateContext(HttpContext httpContext)
- {
- var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
- var execContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), controller: new object());
- return (execContext, actionContext);
- }
-
- private static ActionExecutionDelegate CreateNext(ActionContext actionContext, Action onInvoke)
- {
- return () =>
- {
- onInvoke();
- var executedContext = new ActionExecutedContext(actionContext, new List(), controller: new object());
- return Task.FromResult(executedContext);
- };
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_WithValidHeader_AllowsRequest()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- httpContext.Request.Headers["X-Api-Key"] = "secret-key";
-
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.True(invoked, "Next delegate should be invoked for valid API key header");
- Assert.Null(ctx.Result);
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_WithValidQuery_AllowsRequest()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- httpContext.Request.QueryString = new QueryString("?api_key=secret-key");
-
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.True(invoked, "Next delegate should be invoked for valid API key query");
- Assert.Null(ctx.Result);
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_WithXEmbyTokenHeader_AllowsRequest()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- httpContext.Request.Headers["X-Emby-Token"] = "secret-key";
-
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.True(invoked, "Next delegate should be invoked for valid X-Emby-Token header");
- Assert.Null(ctx.Result);
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_WithMissingKey_ReturnsUnauthorized()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.False(invoked, "Next delegate should not be invoked when API key is missing");
- Assert.IsType(ctx.Result);
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_WithWrongKey_ReturnsUnauthorized()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- httpContext.Request.Headers["X-Api-Key"] = "wrong-key";
-
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.False(invoked, "Next delegate should not be invoked for wrong API key");
- Assert.IsType(ctx.Result);
- }
-
- [Fact]
- public async Task OnActionExecutionAsync_ConstantTimeComparison_WorksForDifferentLengths()
- {
- // Arrange
- var httpContext = new DefaultHttpContext();
- httpContext.Request.Headers["X-Api-Key"] = "short";
-
- var (ctx, actionCtx) = CreateContext(httpContext);
- var filter = new ApiKeyAuthFilter(Options.Create(new JellyfinSettings { ApiKey = "much-longer-secret-key" }), _loggerMock.Object);
-
- var invoked = false;
- var next = CreateNext(actionCtx, () => invoked = true);
-
- // Act
- await filter.OnActionExecutionAsync(ctx, next);
-
- // Assert
- Assert.False(invoked, "Next should not be invoked for wrong key");
- Assert.IsType(ctx.Result);
- }
-}
diff --git a/allstarr.Tests/AuthHeaderHelperTests.cs b/allstarr.Tests/AuthHeaderHelperTests.cs
new file mode 100644
index 0000000..bf004e2
--- /dev/null
+++ b/allstarr.Tests/AuthHeaderHelperTests.cs
@@ -0,0 +1,87 @@
+using allstarr.Services.Common;
+using Microsoft.AspNetCore.Http;
+using Xunit;
+
+namespace allstarr.Tests;
+
+public class AuthHeaderHelperTests
+{
+ [Fact]
+ public void ForwardAuthHeaders_ShouldPreferXEmbyAuthorization()
+ {
+ var headers = new HeaderDictionary
+ {
+ ["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\"",
+ ["Authorization"] = "Bearer xyz"
+ };
+
+ using var request = new HttpRequestMessage();
+ var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
+
+ Assert.True(forwarded);
+ Assert.True(request.Headers.TryGetValues("X-Emby-Authorization", out var values));
+ Assert.Contains("MediaBrowser Token=\"abc\"", values);
+ Assert.False(request.Headers.Contains("Authorization"));
+ }
+
+ [Fact]
+ public void ForwardAuthHeaders_ShouldMapMediaBrowserAuthorizationToXEmby()
+ {
+ var headers = new HeaderDictionary
+ {
+ ["Authorization"] = "MediaBrowser Client=\"Feishin\", Token=\"abc\""
+ };
+
+ using var request = new HttpRequestMessage();
+ var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
+
+ Assert.True(forwarded);
+ Assert.True(request.Headers.Contains("X-Emby-Authorization"));
+ }
+
+ [Fact]
+ public void ForwardAuthHeaders_ShouldForwardStandardAuthorization()
+ {
+ var headers = new HeaderDictionary
+ {
+ ["Authorization"] = "Bearer xyz"
+ };
+
+ using var request = new HttpRequestMessage();
+ var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
+
+ Assert.True(forwarded);
+ Assert.True(request.Headers.Contains("Authorization"));
+ }
+
+ [Fact]
+ public void ExtractDeviceIdAndClientName_ShouldParseMediaBrowserHeader()
+ {
+ var headers = new HeaderDictionary
+ {
+ ["X-Emby-Authorization"] =
+ "MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
+ };
+
+ Assert.Equal("dev-123", AuthHeaderHelper.ExtractDeviceId(headers));
+ Assert.Equal("Feishin", AuthHeaderHelper.ExtractClientName(headers));
+ }
+
+ [Fact]
+ public void CreateAuthHeader_ShouldBuildMediaBrowserString()
+ {
+ var header = AuthHeaderHelper.CreateAuthHeader(
+ token: "abc",
+ client: "Feishin",
+ device: "Desktop",
+ deviceId: "dev-123",
+ version: "1.0");
+
+ Assert.Contains("MediaBrowser", header);
+ Assert.Contains("Client=\"Feishin\"", header);
+ Assert.Contains("Device=\"Desktop\"", header);
+ Assert.Contains("DeviceId=\"dev-123\"", header);
+ Assert.Contains("Version=\"1.0\"", header);
+ Assert.Contains("Token=\"abc\"", header);
+ }
+}
diff --git a/allstarr.Tests/CacheKeyBuilderTests.cs b/allstarr.Tests/CacheKeyBuilderTests.cs
new file mode 100644
index 0000000..a6293fb
--- /dev/null
+++ b/allstarr.Tests/CacheKeyBuilderTests.cs
@@ -0,0 +1,71 @@
+using allstarr.Services.Common;
+using Xunit;
+
+namespace allstarr.Tests;
+
+public class CacheKeyBuilderTests
+{
+ [Fact]
+ public void SearchKey_ShouldIncludeRouteContextDimensions()
+ {
+ var key = CacheKeyBuilder.BuildSearchKey(
+ " DATA ",
+ "MusicAlbum",
+ 500,
+ 0,
+ "efa26829c37196b030fa31d127e0715b",
+ "DateCreated,SortName",
+ "Descending",
+ true,
+ "1635cd7d23144ba08251ebe22a56119e");
+
+ Assert.Equal(
+ "search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e",
+ key);
+ }
+
+ [Fact]
+ public void SearchKey_OldOverload_ShouldRemainCompatible()
+ {
+ Assert.Equal("search:data:Audio:500:0", CacheKeyBuilder.BuildSearchKey("DATA", "Audio", 500, 0));
+ }
+
+ [Fact]
+ public void SpotifyKeys_ShouldMatchExpectedFormats()
+ {
+ Assert.Equal("spotify:playlist:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistKey("Road Trip"));
+ Assert.Equal("spotify:playlist:items:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistItemsKey("Road Trip"));
+ Assert.Equal("spotify:playlist:ordered:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey("Road Trip"));
+ Assert.Equal("spotify:matched:ordered:Road Trip", CacheKeyBuilder.BuildSpotifyMatchedTracksKey("Road Trip"));
+ Assert.Equal("spotify:matched:Road Trip", CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey("Road Trip"));
+ Assert.Equal("spotify:playlist:stats:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistStatsKey("Road Trip"));
+ Assert.Equal("spotify:manual-map:Road Trip:abc123", CacheKeyBuilder.BuildSpotifyManualMappingKey("Road Trip", "abc123"));
+ Assert.Equal("spotify:external-map:Road Trip:abc123", CacheKeyBuilder.BuildSpotifyExternalMappingKey("Road Trip", "abc123"));
+ Assert.Equal("spotify:global-map:abc123", CacheKeyBuilder.BuildSpotifyGlobalMappingKey("abc123"));
+ Assert.Equal("spotify:global-map:all-ids", CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey());
+ }
+
+ [Fact]
+ public void LyricsAndGenreKeys_ShouldMatchExpectedFormats()
+ {
+ Assert.Equal("lyrics:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240));
+ Assert.Equal("lyricsplus:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240));
+ Assert.Equal("lyrics:manual-map:Artist:Title", CacheKeyBuilder.BuildLyricsManualMappingKey("Artist", "Title"));
+ Assert.Equal("lyrics:id:42", CacheKeyBuilder.BuildLyricsByIdKey(42));
+
+ Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track", "Artist"));
+ Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist"));
+ Assert.Equal("genre:rock", CacheKeyBuilder.BuildGenreKey("Rock"));
+ }
+
+ [Fact]
+ public void MusicBrainzAndOdesliKeys_ShouldMatchExpectedFormats()
+ {
+ Assert.Equal("musicbrainz:isrc:USABC123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123"));
+ Assert.Equal("musicbrainz:search:title:artist:5", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5));
+ Assert.Equal("musicbrainz:mbid:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def"));
+
+ Assert.Equal("odesli:tidal-to-spotify:123", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123"));
+ Assert.Equal("odesli:url-to-spotify:https://example.com/track", CacheKeyBuilder.BuildOdesliUrlToSpotifyKey("https://example.com/track"));
+ }
+}
diff --git a/allstarr.Tests/ConfigControllerAuthorizationTests.cs b/allstarr.Tests/ConfigControllerAuthorizationTests.cs
new file mode 100644
index 0000000..c2cee63
--- /dev/null
+++ b/allstarr.Tests/ConfigControllerAuthorizationTests.cs
@@ -0,0 +1,166 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using allstarr.Controllers;
+using allstarr.Models.Admin;
+using allstarr.Models.Settings;
+using allstarr.Services.Admin;
+using allstarr.Services.Common;
+using allstarr.Services.Spotify;
+
+namespace allstarr.Tests;
+
+public class ConfigControllerAuthorizationTests
+{
+ [Fact]
+ public async Task UpdateConfig_WithoutAdminSession_ReturnsForbidden()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: false));
+ var result = await controller.UpdateConfig(new ConfigUpdateRequest
+ {
+ Updates = new Dictionary { ["TEST_KEY"] = "value" }
+ });
+
+ AssertForbidden(result);
+ }
+
+ [Fact]
+ public async Task RestartContainer_WithoutAdminSession_ReturnsForbidden()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: false));
+ var result = await controller.RestartContainer();
+
+ AssertForbidden(result);
+ }
+
+ [Fact]
+ public void ExportEnv_WithoutAdminSession_ReturnsForbidden()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: false));
+ var result = controller.ExportEnv();
+
+ AssertForbidden(result);
+ }
+
+ [Fact]
+ public async Task ImportEnv_WithoutAdminSession_ReturnsForbidden()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: false));
+ var file = new FormFile(Stream.Null, 0, 0, "file", "config.env");
+ var result = await controller.ImportEnv(file);
+
+ AssertForbidden(result);
+ }
+
+ [Fact]
+ public async Task UpdateConfig_WithAdminSession_ContinuesToValidation()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: true));
+ var result = await controller.UpdateConfig(new ConfigUpdateRequest());
+
+ var badRequest = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
+ }
+
+ [Fact]
+ public void ExportEnv_WithAdminSession_WhenFeatureDisabled_ReturnsNotFound()
+ {
+ var controller = CreateController(CreateHttpContextWithSession(isAdmin: true));
+ var result = controller.ExportEnv();
+
+ var notFound = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status404NotFound, notFound.StatusCode);
+ }
+
+ private static HttpContext CreateHttpContextWithSession(bool isAdmin)
+ {
+ var context = new DefaultHttpContext();
+ context.Connection.LocalPort = 5275;
+ context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
+ {
+ SessionId = "session-id",
+ UserId = "user-id",
+ UserName = "user",
+ IsAdministrator = isAdmin,
+ JellyfinAccessToken = "token",
+ JellyfinServerId = "server-id",
+ ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
+ LastSeenUtc = DateTime.UtcNow
+ };
+
+ return context;
+ }
+
+ private static ConfigController CreateController(
+ HttpContext httpContext,
+ Dictionary? configValues = null)
+ {
+ var logger = new Mock>();
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configValues ?? new Dictionary())
+ .Build();
+
+ var webHostEnvironment = new Mock();
+ webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
+ webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
+ var helperLogger = new Mock>();
+ var helperService = new AdminHelperService(
+ helperLogger.Object,
+ Options.Create(new JellyfinSettings()),
+ webHostEnvironment.Object);
+
+ var redisLogger = new Mock>();
+ var redisCache = new RedisCacheService(
+ Options.Create(new RedisSettings
+ {
+ Enabled = false,
+ ConnectionString = "localhost:6379"
+ }),
+ redisLogger.Object);
+ var spotifyCookieLogger = new Mock>();
+ var spotifySessionCookieService = new SpotifySessionCookieService(
+ Options.Create(new SpotifyApiSettings()),
+ helperService,
+ spotifyCookieLogger.Object);
+
+ var controller = new ConfigController(
+ logger.Object,
+ configuration,
+ Options.Create(new SpotifyApiSettings()),
+ Options.Create(new JellyfinSettings()),
+ Options.Create(new SubsonicSettings()),
+ Options.Create(new DeezerSettings()),
+ Options.Create(new QobuzSettings()),
+ Options.Create(new SquidWTFSettings()),
+ Options.Create(new MusicBrainzSettings()),
+ Options.Create(new SpotifyImportSettings()),
+ Options.Create(new ScrobblingSettings()),
+ helperService,
+ spotifySessionCookieService,
+ redisCache)
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = httpContext
+ }
+ };
+
+ return controller;
+ }
+
+ private static void AssertForbidden(IActionResult result)
+ {
+ var forbidden = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
+
+ var payload = JsonSerializer.Serialize(forbidden.Value);
+ using var document = JsonDocument.Parse(payload);
+ Assert.Equal("Administrator permissions required", document.RootElement.GetProperty("error").GetString());
+ }
+}
diff --git a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs
new file mode 100644
index 0000000..8712fe4
--- /dev/null
+++ b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs
@@ -0,0 +1,117 @@
+using allstarr.Controllers;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace allstarr.Tests;
+
+public class DownloadsControllerPathSecurityTests
+{
+ [Fact]
+ public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
+ {
+ var testRoot = CreateTestRoot();
+ var downloadsRoot = Path.Combine(testRoot, "downloads");
+ var keptRoot = Path.Combine(downloadsRoot, "kept");
+ var siblingRoot = Path.Combine(downloadsRoot, "kept-malicious");
+
+ Directory.CreateDirectory(keptRoot);
+ Directory.CreateDirectory(siblingRoot);
+ File.WriteAllText(Path.Combine(siblingRoot, "attack.mp3"), "not-allowed");
+
+ try
+ {
+ var controller = CreateController(downloadsRoot);
+ var result = controller.DownloadFile("../kept-malicious/attack.mp3");
+
+ var badRequest = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
+ }
+ finally
+ {
+ DeleteTestRoot(testRoot);
+ }
+ }
+
+ [Fact]
+ public void DeleteDownload_PathTraversalIntoPrefixedSibling_IsRejected()
+ {
+ var testRoot = CreateTestRoot();
+ var downloadsRoot = Path.Combine(testRoot, "downloads");
+ var keptRoot = Path.Combine(downloadsRoot, "kept");
+ var siblingRoot = Path.Combine(downloadsRoot, "kept-malicious");
+ var siblingFile = Path.Combine(siblingRoot, "attack.mp3");
+
+ Directory.CreateDirectory(keptRoot);
+ Directory.CreateDirectory(siblingRoot);
+ File.WriteAllText(siblingFile, "not-allowed");
+
+ try
+ {
+ var controller = CreateController(downloadsRoot);
+ var result = controller.DeleteDownload("../kept-malicious/attack.mp3");
+
+ var badRequest = Assert.IsType(result);
+ Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
+ Assert.True(File.Exists(siblingFile));
+ }
+ finally
+ {
+ DeleteTestRoot(testRoot);
+ }
+ }
+
+ [Fact]
+ public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
+ {
+ var testRoot = CreateTestRoot();
+ var downloadsRoot = Path.Combine(testRoot, "downloads");
+ var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
+ var validFile = Path.Combine(artistDir, "track.mp3");
+
+ Directory.CreateDirectory(artistDir);
+ File.WriteAllText(validFile, "ok");
+
+ try
+ {
+ var controller = CreateController(downloadsRoot);
+ var result = controller.DownloadFile("Artist/track.mp3");
+
+ Assert.IsType(result);
+ }
+ finally
+ {
+ DeleteTestRoot(testRoot);
+ }
+ }
+
+ private static DownloadsController CreateController(string downloadsRoot)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Library:DownloadPath"] = downloadsRoot
+ })
+ .Build();
+
+ return new DownloadsController(
+ NullLogger.Instance,
+ config);
+ }
+
+ private static string CreateTestRoot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ return root;
+ }
+
+ private static void DeleteTestRoot(string root)
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+}
diff --git a/allstarr.Tests/ExplicitContentFilterTests.cs b/allstarr.Tests/ExplicitContentFilterTests.cs
new file mode 100644
index 0000000..e6b5516
--- /dev/null
+++ b/allstarr.Tests/ExplicitContentFilterTests.cs
@@ -0,0 +1,43 @@
+using allstarr.Models.Domain;
+using allstarr.Models.Settings;
+using allstarr.Services.Common;
+using Xunit;
+
+namespace allstarr.Tests;
+
+public class ExplicitContentFilterTests
+{
+ [Fact]
+ public void ShouldIncludeSong_ShouldIncludeUnknownExplicitState()
+ {
+ var song = new Song { ExplicitContentLyrics = null };
+
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.All));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.ExplicitOnly));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.CleanOnly));
+ }
+
+ [Fact]
+ public void ShouldIncludeSong_ExplicitOnly_ShouldExcludeOnlyCleanEditedValue3()
+ {
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 0 }, ExplicitFilter.ExplicitOnly));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.ExplicitOnly));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 2 }, ExplicitFilter.ExplicitOnly));
+ Assert.False(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.ExplicitOnly));
+ }
+
+ [Fact]
+ public void ShouldIncludeSong_CleanOnly_ShouldExcludeExplicitValue1()
+ {
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 0 }, ExplicitFilter.CleanOnly));
+ Assert.False(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.CleanOnly));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.CleanOnly));
+ }
+
+ [Fact]
+ public void ShouldIncludeSong_All_ShouldAlwaysInclude()
+ {
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.All));
+ Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.All));
+ }
+}
diff --git a/allstarr.Tests/JavaScriptSyntaxTests.cs b/allstarr.Tests/JavaScriptSyntaxTests.cs
index e27916a..6f74e39 100644
--- a/allstarr.Tests/JavaScriptSyntaxTests.cs
+++ b/allstarr.Tests/JavaScriptSyntaxTests.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Text.RegularExpressions;
using Xunit;
namespace allstarr.Tests;
@@ -23,7 +24,7 @@ public class JavaScriptSyntaxTests
{
var filePath = Path.Combine(_wwwrootPath, "app.js");
Assert.True(File.Exists(filePath), $"app.js not found at {filePath}");
-
+
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
Assert.True(isValid, $"app.js has syntax errors:\n{error}");
}
@@ -33,7 +34,7 @@ public class JavaScriptSyntaxTests
{
var filePath = Path.Combine(_wwwrootPath, "spotify-mappings.js");
Assert.True(File.Exists(filePath), $"spotify-mappings.js not found at {filePath}");
-
+
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
Assert.True(isValid, $"spotify-mappings.js has syntax errors:\n{error}");
}
@@ -43,7 +44,7 @@ public class JavaScriptSyntaxTests
{
var filePath = Path.Combine(_wwwrootPath, "js", "utils.js");
Assert.True(File.Exists(filePath), $"js/utils.js not found at {filePath}");
-
+
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
Assert.True(isValid, $"js/utils.js has syntax errors:\n{error}");
}
@@ -53,7 +54,7 @@ public class JavaScriptSyntaxTests
{
var filePath = Path.Combine(_wwwrootPath, "js", "api.js");
Assert.True(File.Exists(filePath), $"js/api.js not found at {filePath}");
-
+
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
Assert.True(isValid, $"js/api.js has syntax errors:\n{error}");
}
@@ -63,17 +64,40 @@ public class JavaScriptSyntaxTests
{
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
Assert.True(File.Exists(filePath), $"js/main.js not found at {filePath}");
-
+
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
Assert.True(isValid, $"js/main.js has syntax errors:\n{error}");
}
+ [Fact]
+ public void ModularJs_ExtractedModulesShouldHaveValidSyntax()
+ {
+ var moduleFiles = new[]
+ {
+ "settings-editor.js",
+ "auth-session.js",
+ "dashboard-data.js",
+ "operations.js",
+ "playlist-admin.js",
+ "scrobbling-admin.js"
+ };
+
+ foreach (var moduleFile in moduleFiles)
+ {
+ var filePath = Path.Combine(_wwwrootPath, "js", moduleFile);
+ Assert.True(File.Exists(filePath), $"js/{moduleFile} not found at {filePath}");
+
+ var isValid = ValidateJavaScriptSyntax(filePath, out var error);
+ Assert.True(isValid, $"js/{moduleFile} has syntax errors:\n{error}");
+ }
+ }
+
[Fact]
public void AppJs_ShouldBeDeprecated()
{
var filePath = Path.Combine(_wwwrootPath, "app.js");
var content = File.ReadAllText(filePath);
-
+
// Check that the file is now just a deprecation notice
Assert.Contains("DEPRECATED", content);
Assert.Contains("main.js", content);
@@ -82,18 +106,45 @@ public class JavaScriptSyntaxTests
[Fact]
public void MainJs_ShouldBeComplete()
{
- var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
- var content = File.ReadAllText(filePath);
-
- // Check that critical window functions exist
- Assert.Contains("window.fetchStatus", content);
- Assert.Contains("window.fetchPlaylists", content);
- Assert.Contains("window.fetchConfig", content);
- Assert.Contains("window.fetchEndpointUsage", content);
-
- // Check that the file has proper initialization
- Assert.Contains("DOMContentLoaded", content);
- Assert.Contains("window.fetchStatus();", content);
+ var mainPath = Path.Combine(_wwwrootPath, "js", "main.js");
+ var dashboardPath = Path.Combine(_wwwrootPath, "js", "dashboard-data.js");
+ var settingsPath = Path.Combine(_wwwrootPath, "js", "settings-editor.js");
+ var authPath = Path.Combine(_wwwrootPath, "js", "auth-session.js");
+ var operationsPath = Path.Combine(_wwwrootPath, "js", "operations.js");
+ var playlistPath = Path.Combine(_wwwrootPath, "js", "playlist-admin.js");
+ var scrobblingPath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js");
+
+ Assert.True(File.Exists(mainPath), $"js/main.js not found at {mainPath}");
+ Assert.True(File.Exists(dashboardPath), $"js/dashboard-data.js not found at {dashboardPath}");
+ Assert.True(File.Exists(settingsPath), $"js/settings-editor.js not found at {settingsPath}");
+ Assert.True(File.Exists(authPath), $"js/auth-session.js not found at {authPath}");
+ Assert.True(File.Exists(operationsPath), $"js/operations.js not found at {operationsPath}");
+ Assert.True(File.Exists(playlistPath), $"js/playlist-admin.js not found at {playlistPath}");
+ Assert.True(File.Exists(scrobblingPath), $"js/scrobbling-admin.js not found at {scrobblingPath}");
+
+ var mainContent = File.ReadAllText(mainPath);
+ var dashboardContent = File.ReadAllText(dashboardPath);
+ var settingsContent = File.ReadAllText(settingsPath);
+ var authContent = File.ReadAllText(authPath);
+ var operationsContent = File.ReadAllText(operationsPath);
+ var playlistContent = File.ReadAllText(playlistPath);
+ var scrobblingContent = File.ReadAllText(scrobblingPath);
+
+ Assert.Contains("DOMContentLoaded", mainContent);
+ Assert.Contains("authSession.bootstrapAuth()", mainContent);
+ Assert.Contains("initDashboardData", mainContent);
+
+ Assert.Contains("window.fetchStatus", dashboardContent);
+ Assert.Contains("window.fetchPlaylists", dashboardContent);
+ Assert.Contains("window.fetchConfig", dashboardContent);
+ Assert.Contains("window.fetchEndpointUsage", dashboardContent);
+
+ Assert.Contains("window.openEditSetting", settingsContent);
+ Assert.Contains("window.saveEditSetting", settingsContent);
+ Assert.Contains("window.logoutAdminSession", authContent);
+ Assert.Contains("window.restartContainer", operationsContent);
+ Assert.Contains("window.linkPlaylist", playlistContent);
+ Assert.Contains("window.loadScrobblingConfig", scrobblingContent);
}
[Fact]
@@ -103,10 +154,10 @@ public class JavaScriptSyntaxTests
// Skip this test or check main.js instead
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
var content = File.ReadAllText(filePath);
-
+
var openBraces = content.Count(c => c == '{');
var closeBraces = content.Count(c => c == '}');
-
+
Assert.Equal(openBraces, closeBraces);
}
@@ -116,15 +167,42 @@ public class JavaScriptSyntaxTests
// app.js is now deprecated and just contains comments
// Skip this test or check main.js instead
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
-
+
// Use Node.js to validate syntax instead of counting parentheses
// This is more reliable than regex-based string/comment removal
string error;
var isValid = ValidateJavaScriptSyntax(filePath, out error);
-
+
Assert.True(isValid, $"JavaScript syntax validation failed: {error}");
}
+ [Fact]
+ public void ApiJs_ShouldCentralizeFetchHandling()
+ {
+ var filePath = Path.Combine(_wwwrootPath, "js", "api.js");
+ var content = File.ReadAllText(filePath);
+
+ Assert.Contains("async function requestJson", content);
+ Assert.Contains("async function requestBlob", content);
+ Assert.Contains("async function requestOptionalJson", content);
+
+ var fetchCallCount = Regex.Matches(content, @"\bfetch\(").Count;
+ Assert.Equal(3, fetchCallCount);
+ }
+
+ [Fact]
+ public void ScrobblingAdmin_ShouldUseApiWrappersInsteadOfDirectFetch()
+ {
+ var filePath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js");
+ var content = File.ReadAllText(filePath);
+
+ Assert.DoesNotContain("fetch(", content);
+ Assert.Contains("API.fetchScrobblingStatus()", content);
+ Assert.Contains("API.updateLocalTracksScrobbling", content);
+ Assert.Contains("API.authenticateLastFm()", content);
+ Assert.Contains("API.validateListenBrainzToken", content);
+ }
+
private bool ValidateJavaScriptSyntax(string filePath, out string error)
{
error = string.Empty;
@@ -165,23 +243,4 @@ public class JavaScriptSyntaxTests
}
}
- private string RemoveStringsAndComments(string content)
- {
- // Simple removal of strings and comments for brace counting
- // This is not perfect but good enough for basic validation
- var result = content;
-
- // Remove single-line comments
- result = System.Text.RegularExpressions.Regex.Replace(result, @"//.*$", "", System.Text.RegularExpressions.RegexOptions.Multiline);
-
- // Remove multi-line comments
- result = System.Text.RegularExpressions.Regex.Replace(result, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
-
- // Remove strings (simple approach)
- result = System.Text.RegularExpressions.Regex.Replace(result, @"""(?:[^""\\]|\\.)*""", "");
- result = System.Text.RegularExpressions.Regex.Replace(result, @"'(?:[^'\\]|\\.)*'", "");
- result = System.Text.RegularExpressions.Regex.Replace(result, @"`(?:[^`\\]|\\.)*`", "");
-
- return result;
- }
}
diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs
index 15b0620..a967cbc 100644
--- a/allstarr.Tests/JellyfinProxyServiceTests.cs
+++ b/allstarr.Tests/JellyfinProxyServiceTests.cs
@@ -182,7 +182,7 @@ public class JellyfinProxyServiceTests
// Assert
Assert.NotNull(captured);
var url = captured!.RequestUri!.ToString();
-
+
// Verify the query parameters are properly URL encoded
Assert.Contains("searchTerm=", url);
Assert.Contains("test", url);
@@ -192,7 +192,7 @@ public class JellyfinProxyServiceTests
Assert.Contains("MusicAlbum", url);
Assert.Contains("limit=25", url);
Assert.Contains("recursive=true", url);
-
+
// Verify spaces are encoded (either as %20 or +)
var uri = captured.RequestUri;
var searchTermValue = System.Web.HttpUtility.ParseQueryString(uri!.Query).Get("searchTerm");
@@ -225,6 +225,67 @@ public class JellyfinProxyServiceTests
Assert.Equal(200, statusCode);
}
+ [Fact]
+ public async Task GetJsonAsync_WithEndpointQuery_PreservesCallerParameters()
+ {
+ // Arrange
+ HttpRequestMessage? captured = null;
+ _mockHandler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback((req, ct) => captured = req)
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"Id\":\"abc-123\"}")
+ });
+
+ // Act
+ await _service.GetJsonAsync(
+ "Users/user-abc/Items/abc-123?api_key=query-token&Fields=DateCreated,PremiereDate,ProductionYear");
+
+ // Assert
+ Assert.NotNull(captured);
+ var requestUri = captured!.RequestUri!;
+ Assert.Contains("/Users/user-abc/Items/abc-123", requestUri.ToString());
+
+ var query = System.Web.HttpUtility.ParseQueryString(requestUri.Query);
+ Assert.Equal("query-token", query.Get("api_key"));
+ Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields"));
+ }
+
+ [Fact]
+ public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
+ {
+ // Arrange
+ HttpRequestMessage? captured = null;
+ _mockHandler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback((req, ct) => captured = req)
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"Items\":[]}")
+ });
+
+ // Act
+ await _service.GetJsonAsync(
+ "Items/abc-123?api_key=endpoint-token&Fields=DateCreated",
+ new Dictionary
+ {
+ ["api_key"] = "explicit-token",
+ ["UserId"] = "route-user"
+ });
+
+ // Assert
+ Assert.NotNull(captured);
+ var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
+ Assert.Equal("explicit-token", query.Get("api_key"));
+ Assert.Equal("DateCreated", query.Get("Fields"));
+ Assert.Equal("route-user", query.Get("UserId"));
+ }
+
[Fact]
public async Task GetArtistsAsync_WithSearchTerm_IncludesInQuery()
{
@@ -277,6 +338,30 @@ public class JellyfinProxyServiceTests
Assert.Contains("maxHeight=300", url);
}
+ [Fact]
+ public async Task GetImageAsync_WithTag_IncludesTagInQuery()
+ {
+ // Arrange
+ HttpRequestMessage? captured = null;
+ _mockHandler.Protected()
+ .Setup>("SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback((req, ct) => captured = req)
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
+ });
+
+ // Act
+ await _service.GetImageAsync("item-123", "Primary", imageTag: "playlist-art-v2");
+
+ // Assert
+ Assert.NotNull(captured);
+ var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
+ Assert.Equal("playlist-art-v2", query.Get("tag"));
+ }
+
[Fact]
diff --git a/allstarr.Tests/JellyfinQueryRedactionTests.cs b/allstarr.Tests/JellyfinQueryRedactionTests.cs
new file mode 100644
index 0000000..36d926e
--- /dev/null
+++ b/allstarr.Tests/JellyfinQueryRedactionTests.cs
@@ -0,0 +1,42 @@
+using System.Reflection;
+using allstarr.Controllers;
+
+namespace allstarr.Tests;
+
+public class JellyfinQueryRedactionTests
+{
+ [Fact]
+ public void MaskSensitiveQueryString_RedactsSensitiveValues()
+ {
+ var masked = InvokeMaskSensitiveQueryString(
+ "?api_key=secret1&query=hello&x-emby-token=secret2&AuthToken=secret3");
+
+ Assert.Contains("api_key=", masked);
+ Assert.Contains("query=hello", masked);
+ Assert.Contains("x-emby-token=", masked);
+ Assert.Contains("AuthToken=", masked);
+ Assert.DoesNotContain("secret1", masked);
+ Assert.DoesNotContain("secret2", masked);
+ Assert.DoesNotContain("secret3", masked);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void MaskSensitiveQueryString_EmptyOrNull_ReturnsEmpty(string? input)
+ {
+ var masked = InvokeMaskSensitiveQueryString(input);
+ Assert.Equal(string.Empty, masked);
+ }
+
+ private static string InvokeMaskSensitiveQueryString(string? queryString)
+ {
+ var method = typeof(JellyfinController).GetMethod(
+ "MaskSensitiveQueryString",
+ BindingFlags.Static | BindingFlags.NonPublic);
+
+ Assert.NotNull(method);
+ var result = method!.Invoke(null, new object?[] { queryString });
+ return Assert.IsType(result);
+ }
+}
diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs
index 39b1d8b..47e9246 100644
--- a/allstarr.Tests/JellyfinResponseBuilderTests.cs
+++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs
@@ -75,6 +75,58 @@ public class JellyfinResponseBuilderTests
Assert.Equal("USRC12345678", providerIds["ISRC"]);
}
+ [Theory]
+ [InlineData("deezer")]
+ [InlineData("qobuz")]
+ [InlineData("squidwtf")]
+ [InlineData("Deezer")]
+ [InlineData("Qobuz")]
+ [InlineData("SquidWTF")]
+ public void ConvertSongToJellyfinItem_ExternalStreamingProviders_DisableTranscoding(string provider)
+ {
+ // Arrange
+ var song = new Song
+ {
+ Id = $"ext-{provider}-song-123",
+ Title = "External Track",
+ Artist = "External Artist",
+ IsLocal = false,
+ ExternalProvider = provider,
+ ExternalId = "123"
+ };
+
+ // Act
+ var result = _builder.ConvertSongToJellyfinItem(song);
+
+ // Assert
+ var mediaSources = Assert.IsAssignableFrom