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 ApiAuthorizationFilterIsGlobal = IsApiAuthorizationFilterRegisteredGlobally(); [Test] public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt() { // Scan the assembly instead of hand-maintaining a list of controllers here: a // hardcoded array silently drifts as new controllers are added (9 were missing at // one point, including two with real mutating actions - ArtworkUploadController POST // and ChannelTemplateController POST/PUT/DELETE - that were never actually checked). // Note: not every controller in this namespace derives from ControllerBase (e.g. // ChannelController, ScannerController, SessionController, MaintenanceController, // TroubleshootController) - filter on [ApiController] + concrete class only, matching // the pattern already used by ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller // below, so those controllers stay covered rather than silently dropping out of the scan. Type[] apiControllers = typeof(CollectionController) .Assembly .GetTypes() .Where(t => t.Namespace == typeof(CollectionController).Namespace) .Where(t => t is { IsClass: true, IsAbstract: false }) .Where(t => t.GetCustomAttributes(inherit: true).Any()) .ToArray(); // Guard against the scan silently matching nothing (e.g. a namespace rename) and // giving this test a false pass. apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20); foreach (Type controllerType in apiControllers) { bool controllerSkipsApiKey = controllerType .GetCustomAttributes(inherit: true) .Any(); foreach (MethodInfo action in controllerType .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { bool isMutating = action .GetCustomAttributes(inherit: true) .SelectMany(a => a.HttpMethods) .Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE"); if (!isMutating) { continue; } bool actionSkipsApiKey = action .GetCustomAttributes(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 Only_Scanner_And_Auth_Controllers_Should_Be_Auth_Exempt() { // ScannerController: internal loopback callback, gated by [LocalhostOnly] instead of a credential. // AuthController: the /api/v1/auth/* surface itself must be reachable before a caller is authenticated // (config/session/login/setup) — its sensitive action (password change) self-checks the principal. // Any OTHER [SkipApiAuthorization] controller is a fail-open hole and must be caught here. Type[] exemptControllers = typeof(ScannerController) .Assembly .GetTypes() .Where(t => t.Namespace == typeof(ScannerController).Namespace) .Where(t => t.GetCustomAttributes(inherit: true).Any()) .Where(t => t.GetCustomAttributes(inherit: true).Any()) .ToArray(); exemptControllers.ShouldBe([typeof(ScannerController), typeof(AuthController)], ignoreOrder: true); } [Test] public void Startup_Should_Register_ApiAuthorizationFilter_Globally() { ApiAuthorizationFilterIsGlobal.ShouldBeTrue(); } [Test] public void Sensitive_Read_Controllers_Should_Require_Api_Key() { // These GET surfaces disclose secrets/paths or trigger work; they must stay gated even if an // operator disables Api:RequireKeyForReads (issue #282). Asserted reflectively so the tier // can't silently drop the attribute. Type[] sensitiveControllers = [ typeof(TroubleshootController), typeof(LogsController), typeof(SettingsController), typeof(MaintenanceController) ]; foreach (Type controllerType in sensitiveControllers) { controllerType.GetCustomAttributes(inherit: true).Any() .ShouldBeTrue($"{controllerType.Name} must carry [RequiresAuthentication]"); } } [Test] public void Local_Library_Detail_Should_Require_Authentication_While_Catalog_List_Remains_Opt_Out() { MethodInfo detailAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetById)) ?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetById)}"); MethodInfo listAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetAll)) ?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetAll)}"); EffectiveRequiresAuthentication(typeof(LocalLibrariesController), detailAction) .ShouldBeTrue("local-library detail exposes server filesystem paths and must stay authenticated"); EffectiveRequiresAuthentication(typeof(LocalLibrariesController), listAction) .ShouldBeFalse("the ordinary local-library catalog should retain the read-auth opt-out"); } [Test] public void ScannerController_Should_Be_Localhost_Only() { // The scanner callback is exempt from the API key (guessable scan-id GUID); a loopback check // replaces the GUID as the real gate (issue #285). typeof(ScannerController) .GetCustomAttributes(inherit: true) .Any() .ShouldBeTrue(); } private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal; private static bool EffectiveRequiresAuthentication(Type controllerType, MethodInfo action) => controllerType.GetCustomAttributes(inherit: true).Any() || action.GetCustomAttributes(inherit: true).Any(); private static bool IsApiAuthorizationFilterRegisteredGlobally() { var settings = new Dictionary { ["provider"] = "sqlite", ["ConnectionStrings:Data"] = "Data Source=:memory:" }; IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(settings) .Build(); var environment = Substitute.For(); 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>().Value; return options.Filters .OfType() .Any(a => a.ServiceType == typeof(ApiAuthorizationFilter)); } }