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
This commit is contained in:
+181
@@ -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;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user