Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs
T
Joseph Doherty ffbcaa93f2 test(mesh): real two-Host gRPC artifact-fetch boundary test with wrong-key control
Phase 3 Task 8. Stands a real h2c-only Kestrel listener serving the real
DeploymentArtifactGrpcService behind the real ConfigServeAuthInterceptor (in-memory
config DB seeded with one sealed deployment), and drives the real
GrpcDeploymentArtifactFetcher across it — proving a stream crosses the real boundary,
not just that the fetcher's logic is right (Task 4's fakes). Asserts: (1) right key +
>256 KB blob reassembles byte-equal and verifies against RevisionHash; (2) FALSIFIABILITY
CONTROL — wrong key returns null while the right key succeeds on the SAME server (so the
null is the auth gate, not a dead server); (3) unknown id -> null (NotFound-hidden);
(4) [dead-port, real-port] failover still returns the bytes. h2c GrpcChannel over http://
works out of the box in .NET 10. Tagged [Trait Category=ArtifactBoundary].

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-22 19:57:48 -04:00

182 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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 <see cref="ConfigServeAuthInterceptor"/> into the real
/// <see cref="DeploymentArtifactGrpcService"/> and back through the real
/// <see cref="GrpcDeploymentArtifactFetcher"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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 --------------------------------------------------------------------------------
/// <summary>
/// Stands a minimal real Host: an h2c-only Kestrel listener serving the real
/// <see cref="DeploymentArtifactGrpcService"/> behind the real <see cref="ConfigServeAuthInterceptor"/>,
/// backed by an in-memory config DB seeded with one sealed deployment.
/// </summary>
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<ConfigServeOptions>(o => o.ApiKey = ApiKey);
builder.Services.AddGrpc(o => o.Interceptors.Add<ConfigServeAuthInterceptor>());
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase(dbName));
builder.WebHost.ConfigureKestrel(k =>
k.ListenAnyIP(port, o => o.Protocols = HttpProtocols.Http2));
var app = builder.Build();
app.MapGrpcService<DeploymentArtifactGrpcService>();
// Seed the sealed deployment the service will serve.
await using (var scope = app.Services.CreateAsyncScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
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<GrpcDeploymentArtifactFetcher>.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;
}
}