using System.Net; using System.Security.Cryptography; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Per-cluster mesh Phase 3 (Task 8) — the REAL gRPC artifact-fetch boundary. An in-memory fake /// client (Task 4) proves the fetcher's logic; this proves a real stream crosses a real Kestrel /// h2c listener through the real into the real /// and back through the real /// . /// /// /// The Phase-2 lesson applied: a passing fake client cannot distinguish "the boundary works" from /// "something else supplied the bytes". The wrong-key control (assertion 2) is the falsifier — it /// must return null on the SAME server where the right key succeeds. /// [Trait("Category", "ArtifactBoundary")] public sealed class DeploymentArtifactFetchBoundaryTests { private const string ApiKey = "the-shared-node-key"; [Fact] public async Task RightKey_FetchesAndReassembles_ByteEqual_AndVerifiesAgainstTheRevisionHash() { // >256 KB so the stream really chunks (3 × 128 KiB). var blob = RandomBlob(300 * 1024); var revisionHash = Sha256Hex(blob); var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob); await using var _ = server; var fetcher = Fetcher([$"http://localhost:{port}"], ApiKey); var result = await fetcher.FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken); result.ShouldBe(blob); } [Fact] public async Task WrongKey_ReturnsNull_WhileTheRightKeySucceedsOnTheSameServer() { var blob = RandomBlob(4096); var revisionHash = Sha256Hex(blob); var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob); await using var _ = server; // Falsifiability control: the interceptor rejects a wrong key -> the fetcher gets an RpcException // -> null. Without this, a green right-key fetch cannot prove the boundary is what carries bytes. var wrong = await Fetcher([$"http://localhost:{port}"], "not-the-key") .FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken); wrong.ShouldBeNull(); // Same server, right key: succeeds — proving the null above was the auth gate, not a dead server. var right = await Fetcher([$"http://localhost:{port}"], ApiKey) .FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken); right.ShouldBe(blob); } [Fact] public async Task AnUnknownDeploymentId_ReturnsNull() { var blob = RandomBlob(4096); var (server, _, port) = await StartServerAsync(Sha256Hex(blob), blob); await using var __ = server; var result = await Fetcher([$"http://localhost:{port}"], ApiKey) .FetchAsync(Guid.NewGuid().ToString(), Sha256Hex(blob), TestContext.Current.CancellationToken); result.ShouldBeNull(); } [Fact] public async Task Failover_FromADeadEndpoint_ToTheRealServer_StillReturnsTheBytes() { var blob = RandomBlob(4096); var revisionHash = Sha256Hex(blob); var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob); await using var _ = server; var deadPort = GetFreePort(); // nothing listening var fetcher = Fetcher([$"http://localhost:{deadPort}", $"http://localhost:{port}"], ApiKey); var result = await fetcher.FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken); result.ShouldBe(blob); } // ---- harness -------------------------------------------------------------------------------- /// /// Stands a minimal real Host: an h2c-only Kestrel listener serving the real /// behind the real , /// backed by an in-memory config DB seeded with one sealed deployment. /// private static async Task<(WebApplication Server, string DeploymentId, int Port)> StartServerAsync( string revisionHash, byte[] blob) { var port = GetFreePort(); var id = Guid.NewGuid(); var dbName = $"artboundary-{id:N}"; var builder = WebApplication.CreateBuilder(); builder.Environment.ApplicationName = typeof(DeploymentArtifactFetchBoundaryTests).Assembly.GetName().Name!; builder.Logging.ClearProviders(); builder.Services.Configure(o => o.ApiKey = ApiKey); builder.Services.AddGrpc(o => o.Interceptors.Add()); builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase(dbName)); builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(port, o => o.Protocols = HttpProtocols.Http2)); var app = builder.Build(); app.MapGrpcService(); // Seed the sealed deployment the service will serve. await using (var scope = app.Services.CreateAsyncScope()) { var factory = scope.ServiceProvider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken); db.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = revisionHash, Status = DeploymentStatus.Sealed, CreatedBy = "test", ArtifactBlob = blob, }); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } await app.StartAsync(TestContext.Current.CancellationToken); return (app, id.ToString(), port); } private static GrpcDeploymentArtifactFetcher Fetcher(string[] endpoints, string apiKey) => new( Options.Create(new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeFetchAndCache, CentralFetchEndpoints = endpoints, ApiKey = apiKey, FetchTimeoutSeconds = 15, }), NullLogger.Instance); private static byte[] RandomBlob(int size) { var blob = new byte[size]; for (var i = 0; i < size; i++) blob[i] = (byte)(i * 31 + 7); return blob; } private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); private static int GetFreePort() { using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } }