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); } } }