103 lines
6.2 KiB
C#
103 lines
6.2 KiB
C#
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.DependencyInjection;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>Every actually mapped generated gRPC method must have an authentication gate.</summary>
|
|
public class MappedGrpcAuthenticationCoverageTests
|
|
{
|
|
[Fact]
|
|
public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Services.AddGrpc();
|
|
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<GenericNameSyntax>())
|
|
.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; this package hides its MapGrpcService call.
|
|
Assert.Contains(hostSources.SelectMany(s => s.DescendantNodes().OfType<InvocationExpressionSyntax>()),
|
|
n => n.Expression.ToString().EndsWith(".MapZbLocalDbSync", StringComparison.Ordinal));
|
|
app.MapZbLocalDbSync();
|
|
var metadata = ((IEndpointRouteBuilder)app).DataSources.SelectMany(s => s.Endpoints)
|
|
.Select(e => e.Metadata.GetMetadata<GrpcMethodMetadata>()).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 + 1, "The external LocalDb mapping must also be exercised.");
|
|
|
|
// The secrets hub — which used to install an all-method interceptor on its own service
|
|
// options and so needed a separate gate check here — is retired (Task 11): there is no
|
|
// longer a mapped gRPC service whose only gate is anything other than one of these prefixes.
|
|
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<BindServiceMethodAttribute>(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());
|
|
|
|
Assert.All(binder.Methods, method => Assert.True(
|
|
prefixes.Any(prefix => method.StartsWith(prefix, StringComparison.Ordinal)),
|
|
$"Mapped service {type.FullName} method {method} has no authentication interceptor gate."));
|
|
|
|
// 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<string> Methods { get; } = [];
|
|
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, UnaryServerMethod<TRequest, TResponse>? handler)
|
|
=> Methods.Add(method.FullName);
|
|
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ClientStreamingServerMethod<TRequest, TResponse>? handler)
|
|
=> Methods.Add(method.FullName);
|
|
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ServerStreamingServerMethod<TRequest, TResponse>? handler)
|
|
=> Methods.Add(method.FullName);
|
|
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, DuplexStreamingServerMethod<TRequest, TResponse>? handler)
|
|
=> Methods.Add(method.FullName);
|
|
}
|
|
}
|