diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs new file mode 100644 index 00000000..f28df672 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs @@ -0,0 +1,181 @@ +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; + } +}