using System.Reflection; using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using MassTransit; using Tiku.Api.Security; using Tiku.Application.Security; using Tiku.Infrastructure.Messaging; namespace Tiku.IntegrationTests.Api; public sealed class AuthorizationManifestTests { private const int ExpectedActionCount = 381; private const string ExpectedSha256 = "0846541f337a32d7ff13f0e0a29eaa17c2e4b7f90c8d6813c977a1855776bab3"; [Fact] public void Controller_authorization_surface_matches_reviewed_manifest() { var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes() .Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type)) .SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) .Where(method => method.GetCustomAttributes().Any()) .Select(method => Describe(type, method))) .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', descriptors)))) .ToLowerInvariant(); Assert.True( descriptors.Length == ExpectedActionCount && hash == ExpectedSha256, $"Authorization manifest changed. count={descriptors.Length}, sha256={hash}"); } [Fact] public async Task Runtime_controller_endpoints_have_authorization_and_audit_metadata() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); _ = await client.GetAsync("/api/health"); var endpoints = factory.Services.GetRequiredService().Endpoints .Where(endpoint => endpoint.Metadata.GetMetadata() is not null) .ToArray(); Assert.NotEmpty(endpoints); foreach (var endpoint in endpoints) { var anonymous = endpoint.Metadata.GetMetadata() is not null; var metadata = endpoint.Metadata.GetMetadata(); if (anonymous) { Assert.Null(metadata); continue; } Assert.NotNull(metadata); Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction)); Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" }); if (metadata.Realm == "tenant" && metadata.Module is { } module && PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var requiredFeature) && requiredFeature is not null) { Assert.Contains(requiredFeature, metadata.RequiredFeatures); } } } [Fact] public void Message_consumers_have_reviewed_authorization_and_audit_metadata() { var consumers = typeof(MessagingOptions).Assembly.GetTypes() .Where(type => !type.IsAbstract && type.GetInterfaces().Any(candidate => candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IConsumer<>))) .ToArray(); Assert.Equal(2, consumers.Length); foreach (var consumer in consumers) { var metadata = consumer.GetCustomAttribute(); Assert.NotNull(metadata); Assert.Contains(metadata.Realm, new[] { "tenant", "platform", "system" }); Assert.False(string.IsNullOrWhiteSpace(metadata.Module)); Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction)); if (consumer.Name == "BackgroundJobRequestedConsumer") { Assert.Equal(CapabilityOperation.Write, metadata.Operation); Assert.True(metadata.RequiresSystemScope); } } } [Theory] [InlineData("questions", SaasFeatureCatalog.PrivateQuestionBank)] [InlineData("vocabulary", SaasFeatureCatalog.Vocabulary)] [InlineData("handbook", SaasFeatureCatalog.Handbook)] [InlineData("scoreline", SaasFeatureCatalog.Scoreline)] [InlineData("videos", SaasFeatureCatalog.Video)] public void Content_import_route_maps_to_an_explicit_feature(string importType, string expectedFeature) { Assert.Equal(expectedFeature, SaasFeatureCatalog.ResolveContentImportFeature(importType)); } [Fact] public void Tenant_content_permissions_are_split_by_purchasable_feature() { var expected = new Dictionary(StringComparer.Ordinal) { [BackendPermissions.TenantContentManage] = SaasFeatureCatalog.PrivateQuestionBank, [BackendPermissions.TenantVocabularyManage] = SaasFeatureCatalog.Vocabulary, [BackendPermissions.TenantHandbookManage] = SaasFeatureCatalog.Handbook, [BackendPermissions.TenantVideoManage] = SaasFeatureCatalog.Video, [BackendPermissions.TenantScorelineManage] = SaasFeatureCatalog.Scoreline, [BackendPermissions.TenantSiteContentManage] = SaasFeatureCatalog.SiteContent }; foreach (var pair in expected) { var module = PermissionModuleCatalog.ResolvePermissionModuleCode(pair.Key); Assert.Equal(pair.Value, PermissionModuleCatalog.RequiredFeatures[module]); } } private static string Describe(Type controller, MethodInfo action) { var controllerRoute = controller.GetCustomAttribute()?.Template ?? string.Empty; var http = action.GetCustomAttributes().ToArray(); var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal)); var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal)); var policies = controller.GetCustomAttributes() .Concat(action.GetCustomAttributes()) .Select(attribute => attribute.Policy ?? "authenticated") .Order(StringComparer.Ordinal); var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) || action.IsDefined(typeof(AllowAnonymousAttribute)); return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}"; } }