using System.Reflection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Xunit; namespace JobTrackerApi.Tests; // Authorization posture, asserted by reflection over every controller in the assembly. // // The point is that a NEW controller cannot ship unprotected by accident. Authentication used to rely // on the Auth:Require fallback policy, which defaults to false — a deployment that lost that flag // would have served tenant data anonymously. Every controller must now declare its intent. // docs/production-readiness-review.md. public sealed class ApiAuthorizationPostureTests { // Endpoints that are anonymous ON PURPOSE. Adding to this list is a deliberate security decision. private static readonly HashSet IntentionallyAnonymous = new(StringComparer.Ordinal) { // Serves /cv/{slug} for variants the user explicitly published. Noindex by default. "PublicCvController", // Accepts browser error reports. Must work on pages reached before sign-in. "ClientErrorsController", }; // Property accessors and object overrides are not endpoints. private static bool IsEndpoint(MethodInfo m) => !( m.IsSpecialName || m.DeclaringType == typeof(object)); private static IEnumerable Controllers() => typeof(JobTrackerApi.Controllers.ApplicationWorkspaceController).Assembly .GetTypes() .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract); // A controller is covered if the CLASS requires authorization, or if every action declares its own // intent. AuthController and TwoFactorController are necessarily mixed — login and register must be // anonymous while the rest are not — so they mark each method individually. private static bool DeclaresIntent(Type controller) { if (controller.GetCustomAttribute(inherit: true) is not null) return true; var actions = controller .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(IsEndpoint) .ToList(); return actions.Count > 0 && actions.All(m => m.GetCustomAttribute(inherit: true) is not null || m.GetCustomAttribute(inherit: true) is not null); } [Fact] public void Every_controller_either_requires_authorization_or_is_listed_as_public() { var unprotected = Controllers() .Where(t => !IntentionallyAnonymous.Contains(t.Name)) .Where(t => !DeclaresIntent(t)) .Select(t => t.Name) .OrderBy(name => name, StringComparer.Ordinal) .ToList(); Assert.True( unprotected.Count == 0, "These controllers declare no authorization intent — neither on the class nor on every " + "action — so they would be served anonymously if Auth:Require were ever false: " + string.Join(", ", unprotected)); } [Fact] public void Every_action_on_the_mixed_auth_controllers_declares_its_own_intent() { // Sign-in surfaces are the easiest place to add an endpoint and forget to mark it. foreach (var name in new[] { "AuthController", "TwoFactorController" }) { var type = Controllers().Single(t => t.Name == name); var undeclared = type .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(IsEndpoint) .Where(m => m.GetCustomAttribute(inherit: true) is null && m.GetCustomAttribute(inherit: true) is null) .Select(m => m.Name) .ToList(); Assert.True(undeclared.Count == 0, $"{name} has actions with neither [Authorize] nor [AllowAnonymous]: {string.Join(", ", undeclared)}"); } } [Fact] public void The_user_owned_controllers_that_used_to_rely_on_the_fallback_policy_are_now_explicit() { // These five were the actual gap found in the Phase 5.6 audit. foreach (var name in new[] { "JobApplicationsController", "CompaniesController", "CorrespondenceController", "RulesController", "JobImportController", }) { var type = Controllers().Single(t => t.Name == name); var attribute = type.GetCustomAttribute(inherit: true); Assert.True(attribute is not null, $"{name} must declare [Authorize]."); Assert.Equal("local", attribute!.AuthenticationSchemes); } } [Fact] public void Admin_controllers_require_the_admin_role_not_merely_a_signed_in_user() { foreach (var name in new[] { "AdminAuditController", "AdminSystemController", "UsersController" }) { var type = Controllers().Single(t => t.Name == name); var attribute = type.GetCustomAttribute(inherit: true); Assert.True(attribute is not null, $"{name} must declare [Authorize]."); Assert.Equal("Admin", attribute!.Roles); } } [Fact] public void The_public_cv_controller_stays_anonymous() { // Regression guard in the other direction: locking this down would break every shared CV link. var type = Controllers().Single(t => t.Name == "PublicCvController"); Assert.Null(type.GetCustomAttribute(inherit: true)); } }