using System.Reflection; using Grpc.AspNetCore.Server; using Grpc.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.Secrets.Replicator.Grpc; using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// Every actually mapped generated gRPC method must have an authentication gate. public class MappedGrpcAuthenticationCoverageTests { [Fact] public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings() { var builder = WebApplication.CreateBuilder(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["Secrets:Replication:Enabled"] = "true", ["Secrets:Replication:Mode"] = "Grpc", ["Secrets:GrpcHub:BearerToken"] = "coverage-test-token", }); builder.Services.AddGrpc(); builder.Services.AddZbSecretsGrpcHub(builder.Configuration, "Secrets:GrpcHub"); await using var app = builder.Build(); // Discover the production map calls, not a hand-maintained three-service type list. var root = FindRepositoryRoot(); var hostSources = Directory.EnumerateFiles(Path.Combine(root, "src", "ZB.MOM.WW.ScadaBridge.Host"), "*.cs", SearchOption.AllDirectories) .Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") && !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) .Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(p)).GetRoot()).ToList(); var typeNames = hostSources.SelectMany(s => s.DescendantNodes().OfType()) .Where(n => n.Identifier.ValueText == "MapGrpcService") .Select(n => n.TypeArgumentList.Arguments.Single().ToString()).Distinct().ToList(); Assert.NotEmpty(typeNames); var assemblies = typeof(SecretsRegistration).Assembly.GetReferencedAssemblies().Select(Assembly.Load) .Append(typeof(SecretsRegistration).Assembly).ToList(); var map = typeof(GrpcEndpointRouteBuilderExtensions).GetMethods() .Single(m => m.Name == "MapGrpcService" && m.IsGenericMethodDefinition); foreach (var name in typeNames) { var type = assemblies.Select(a => a.GetType(name)).Single(t => t is not null)!; map.MakeGenericMethod(type).Invoke(null, [app]); } // Exercise the production extension mappings too; these packages hide their MapGrpcService calls. Assert.Contains(hostSources.SelectMany(s => s.DescendantNodes().OfType()), n => n.Expression.ToString().EndsWith(".MapZbLocalDbSync", StringComparison.Ordinal)); app.MapZbLocalDbSync(); app.MapScadaBridgeSecretsHub(builder.Configuration); var metadata = ((IEndpointRouteBuilder)app).DataSources.SelectMany(s => s.Endpoints) .Select(e => e.Metadata.GetMetadata()).Where(m => m is not null).ToList(); Assert.NotEmpty(metadata); var serviceTypes = metadata.Select(m => m!.ServiceType).Distinct().ToList(); Assert.True(serviceTypes.Count >= typeNames.Count + 2, "External LocalDb and Secrets mappings must both be exercised."); var prefixes = ControlPlaneAuthInterceptor.DefaultGatedPrefixes .Concat(CentralControlAuthInterceptor.DefaultGatedPrefixes) .Append((string)typeof(LocalDbSyncAuthInterceptor).GetField("ServicePrefix", BindingFlags.Static | BindingFlags.NonPublic)!.GetRawConstantValue()!) .ToArray(); foreach (var type in serviceTypes) { var binder = new MethodCollector(); var attribute = type.GetCustomAttribute(inherit: true); Assert.NotNull(attribute); var bind = attribute.BindType.GetMethods(BindingFlags.Public | BindingFlags.Static) .Single(m => m.Name == attribute.BindMethodName && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(ServiceBinderBase)); bind.Invoke(null, [binder, null]); Assert.NotEmpty(binder.Methods); Assert.Equal(metadata.Where(m => m!.ServiceType == type).Select(m => m!.Method.FullName).Order(), binder.Methods.Order()); // Secrets installs an all-method interceptor on its own service options. Inspect actual DI options. var optionsType = typeof(IOptions<>).MakeGenericType(typeof(GrpcServiceOptions<>).MakeGenericType(type)); var options = app.Services.GetRequiredService(optionsType); var serviceOptions = (GrpcServiceOptions)optionsType.GetProperty("Value")!.GetValue(options)!; var hasSecretsGate = serviceOptions.Interceptors.Any(i => i.Type == typeof(SecretsHubAuthInterceptor)); Assert.All(binder.Methods, method => Assert.True( hasSecretsGate || prefixes.Any(prefix => method.StartsWith(prefix, StringComparison.Ordinal)), $"Mapped service {type.FullName} method {method} has no authentication interceptor gate.")); if (!hasSecretsGate) { // Negative control uses the real service's methods: removing its gate must be detected. var removed = prefixes.First(p => binder.Methods.Any(m => m.StartsWith(p, StringComparison.Ordinal))); var remaining = prefixes.Where(p => p != removed).ToArray(); Assert.Contains(binder.Methods, method => !remaining.Any(p => method.StartsWith(p, StringComparison.Ordinal))); } } } private static string FindRepositoryRoot() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) if (File.Exists(Path.Combine(directory.FullName, "ZB.MOM.WW.ScadaBridge.slnx"))) return directory.FullName; throw new InvalidOperationException("Repository root not found."); } private sealed class MethodCollector : ServiceBinderBase { public List Methods { get; } = []; public override void AddMethod(Method method, UnaryServerMethod? handler) => Methods.Add(method.FullName); public override void AddMethod(Method method, ClientStreamingServerMethod? handler) => Methods.Add(method.FullName); public override void AddMethod(Method method, ServerStreamingServerMethod? handler) => Methods.Add(method.FullName); public override void AddMethod(Method method, DuplexStreamingServerMethod? handler) => Methods.Add(method.FullName); } }