Add REST endpoints backing the SPA media detail pages and image browser:
- GET /api/movies/{id}, /api/shows/{id}, /api/seasons/{id}, /api/artists/{id}
wrapping the existing detail queries; 404 on None.
- GET /api/media-items/{id}/info wrapping GetMediaItemInfo; UnableToLocateMediaItem
-> 404, other errors -> 422.
- GET /api/images/folders?parentId= and PUT /api/images/folders/{id}/duration
(validates null-or-positive -> 400; existence guard via new ImageFolderExists
query -> 404).
- Extend GetLibraryBrowseItems parentId drill-in to Episode (episodes of a season,
episode-number order) and MusicVideo (an artist's music videos, album/track order),
alongside the existing TelevisionSeason branch.
Response DTOs live in ErsatzTV.Core/Api/* and never expose Application VMs. Artwork
values are rooted for the SPA via a shared ErsatzTV.Core/Api/ApiArtwork helper
(mirrors the #180/#181 browse-handler logic; handles jellyfin/emby proxy prefixes,
http passthrough, empty). Regenerated OpenAPI v1.json + web v1.d.ts. New controllers
registered in ApiControllerSecurityTests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
5.2 KiB
C#
140 lines
5.2 KiB
C#
using System.Reflection;
|
|
using ErsatzTV;
|
|
using ErsatzTV.Controllers.Api;
|
|
using ErsatzTV.Filters;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Routing;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.FileProviders;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Controllers;
|
|
|
|
[TestFixture]
|
|
public class ApiControllerSecurityTests
|
|
{
|
|
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
|
|
|
|
[Test]
|
|
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
|
{
|
|
Type[] apiControllers =
|
|
[
|
|
typeof(ArtistsController),
|
|
typeof(BlockController),
|
|
typeof(ChannelController),
|
|
typeof(CollectionController),
|
|
typeof(DecoController),
|
|
typeof(DecoTemplateController),
|
|
typeof(FFmpegProfileController),
|
|
typeof(FillerPresetController),
|
|
typeof(ImagesController),
|
|
typeof(LibrariesController),
|
|
typeof(LogsController),
|
|
typeof(MaintenanceController),
|
|
typeof(MediaItemsController),
|
|
typeof(MoviesController),
|
|
typeof(PlayoutController),
|
|
typeof(ResolutionController),
|
|
typeof(ScannerController),
|
|
typeof(ScheduleController),
|
|
typeof(ScriptedScheduleController),
|
|
typeof(SeasonsController),
|
|
typeof(SessionController),
|
|
typeof(ShowsController),
|
|
typeof(SettingsController),
|
|
typeof(SmartCollectionController),
|
|
typeof(TemplateController),
|
|
typeof(TraktController),
|
|
typeof(TroubleshootController),
|
|
typeof(WatermarkController)
|
|
];
|
|
|
|
foreach (Type controllerType in apiControllers)
|
|
{
|
|
bool controllerSkipsApiKey = controllerType
|
|
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
|
.Any();
|
|
|
|
foreach (MethodInfo action in controllerType
|
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
|
{
|
|
bool isMutating = action
|
|
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
|
|
.SelectMany(a => a.HttpMethods)
|
|
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
|
|
|
|
if (!isMutating)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bool actionSkipsApiKey = action
|
|
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
|
.Any();
|
|
|
|
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
|
|
.ShouldBeTrue($"{controllerType.Name}.{action.Name} must be covered by global API write auth or explicitly exempt");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
|
|
{
|
|
Type[] exemptControllers = typeof(ScannerController)
|
|
.Assembly
|
|
.GetTypes()
|
|
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
|
|
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
|
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
|
|
.ToArray();
|
|
|
|
exemptControllers.ShouldBe([typeof(ScannerController)]);
|
|
}
|
|
|
|
[Test]
|
|
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
|
|
{
|
|
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
|
|
}
|
|
|
|
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
|
|
|
|
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["provider"] = "sqlite",
|
|
["ConnectionStrings:Data"] = "Data Source=:memory:"
|
|
};
|
|
|
|
IConfiguration configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(settings)
|
|
.Build();
|
|
|
|
var environment = Substitute.For<IWebHostEnvironment>();
|
|
environment.ApplicationName.Returns("ErsatzTV");
|
|
environment.EnvironmentName.Returns("Development");
|
|
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
|
|
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
|
|
environment.ContentRootFileProvider.Returns(new NullFileProvider());
|
|
environment.WebRootFileProvider.Returns(new NullFileProvider());
|
|
|
|
var services = new ServiceCollection();
|
|
new Startup(configuration, environment).ConfigureServices(services);
|
|
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
MvcOptions options = provider.GetRequiredService<IOptions<MvcOptions>>().Value;
|
|
|
|
return options.Filters
|
|
.OfType<ServiceFilterAttribute>()
|
|
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
|
}
|
|
}
|