using System.Security.Cryptography;
using Google.Protobuf;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache;
///
/// Per-cluster mesh Phase 3 (Task 4) — the node-side gRPC artifact fetcher's logic: reassembly,
/// SHA-256 verification, endpoint failover, and the null-on-any-failure (#485) contract. The real
/// gRPC stream across a real Kestrel h2c listener is the Task 8 boundary test; here the client is
/// a fake streaming canned chunks, so only the fetcher's own logic is under test.
///
public sealed class GrpcDeploymentArtifactFetcherTests
{
private const string DeploymentId = "11111111-1111-1111-1111-111111111111";
private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
private static GrpcDeploymentArtifactFetcher Fetcher(
string[] endpoints, Func factory)
=> new(
Options.Create(new ConfigSourceOptions
{
Mode = ConfigSourceOptions.ModeFetchAndCache,
CentralFetchEndpoints = endpoints,
ApiKey = "k",
FetchTimeoutSeconds = 30,
}),
NullLogger.Instance,
factory);
[Fact]
public async Task Chunks_reassembling_to_the_expected_hash_return_the_exact_bytes()
{
var blob = new byte[300 * 1024];
for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
var factory = SingleClient(StreamOf(blob, chunkSize: 128 * 1024));
var result = await Fetcher(["http://central-1:4055"], factory)
.FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);
result.ShouldBe(blob);
}
[Fact]
public async Task A_hash_mismatch_returns_null()
{
var blob = new byte[] { 1, 2, 3, 4 };
var factory = SingleClient(StreamOf(blob, chunkSize: 2));
var result = await Fetcher(["http://central-1:4055"], factory)
.FetchAsync(DeploymentId, Sha256Hex([9, 9, 9, 9]), CancellationToken.None);
result.ShouldBeNull();
}
[Fact]
public async Task An_empty_stream_returns_null()
{
var factory = SingleClient(StreamOf([], chunkSize: 128 * 1024));
var result = await Fetcher(["http://central-1:4055"], factory)
.FetchAsync(DeploymentId, Sha256Hex([]), CancellationToken.None);
result.ShouldBeNull();
}
[Fact]
public async Task When_the_first_endpoint_is_unavailable_it_fails_over_to_the_second()
{
var blob = new byte[] { 10, 20, 30, 40, 50 };
var consulted = new List();
var factory = (string endpoint) =>
{
consulted.Add(endpoint);
return endpoint.Contains("central-1")
? (DeploymentArtifactService.DeploymentArtifactServiceClient)
new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")))
: new FakeClient(StreamOf(blob, chunkSize: 2));
};
var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
.FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);
result.ShouldBe(blob);
// The failover must have actually consulted the second endpoint.
consulted.ShouldBe(["http://central-1:4055", "http://central-2:4055"]);
}
[Fact]
public async Task When_every_endpoint_throws_it_returns_null()
{
var factory = (string _) =>
(DeploymentArtifactService.DeploymentArtifactServiceClient)
new ThrowingClient(new RpcException(new Status(StatusCode.NotFound, "unknown deployment")));
var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
.FetchAsync(DeploymentId, Sha256Hex([1]), CancellationToken.None);
result.ShouldBeNull();
}
// ---- fakes --------------------------------------------------------------------------------
private static Func SingleClient(
IReadOnlyList chunks)
=> _ => new FakeClient(chunks);
private static IReadOnlyList StreamOf(byte[] blob, int chunkSize)
{
var chunks = new List();
for (var offset = 0; offset < blob.Length; offset += chunkSize)
{
var len = Math.Min(chunkSize, blob.Length - offset);
chunks.Add(new ArtifactChunk { Data = ByteString.CopyFrom(blob, offset, len) });
}
return chunks;
}
/// A generated client double whose Fetch streams canned chunks.
private sealed class FakeClient(IReadOnlyList chunks)
: DeploymentArtifactService.DeploymentArtifactServiceClient
{
public override AsyncServerStreamingCall Fetch(FetchRequest request, CallOptions options)
=> new(
new ListStreamReader(chunks),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => [],
() => { });
}
/// A generated client double whose Fetch throws (endpoint down / NotFound).
private sealed class ThrowingClient(RpcException toThrow)
: DeploymentArtifactService.DeploymentArtifactServiceClient
{
public override AsyncServerStreamingCall Fetch(FetchRequest request, CallOptions options)
=> throw toThrow;
}
private sealed class ListStreamReader(IReadOnlyList chunks) : IAsyncStreamReader
{
private int _index = -1;
public ArtifactChunk Current => chunks[_index];
public Task MoveNext(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_index++;
return Task.FromResult(_index < chunks.Count);
}
}
}