using Google.Protobuf; using Grpc.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc; /// /// The serve side of Phase 3: streams a sealed deployment's bytes, and collapses unknown / /// not-sealed / empty into one indistinguishable NotFound (existence-hiding + #485). /// public class DeploymentArtifactServiceTests { private static IDbContextFactory Factory(string name) => new NamedFactory(name); private static DeploymentArtifactGrpcService Service(string dbName) => new(Factory(dbName), NullLogger.Instance); private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob) { var id = Guid.NewGuid(); using var db = new OtOpcUaConfigDbContext( new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options); db.Deployments.Add(new Deployment { DeploymentId = id, RevisionHash = new string('a', 64), Status = status, CreatedBy = "test", ArtifactBlob = blob, }); db.SaveChanges(); return id; } [Fact] public async Task A_sealed_non_empty_blob_streams_back_byte_equal_and_chunk_bounded() { var dbName = $"artsvc-{Guid.NewGuid():N}"; // ~300 KB > 2 * 128 KiB -> exactly 3 chunks. var blob = new byte[300 * 1024]; for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251); var id = Seed(dbName, DeploymentStatus.Sealed, blob); var writer = new CollectingStreamWriter(); await Service(dbName).Fetch( new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext()); writer.Chunks.Count.ShouldBe(3); writer.Chunks[0].Data.Length.ShouldBe(128 * 1024); writer.Chunks[1].Data.Length.ShouldBe(128 * 1024); writer.Reassembled().ShouldBe(blob); } [Fact] public async Task An_unknown_id_throws_NotFound() { var dbName = $"artsvc-{Guid.NewGuid():N}"; Seed(dbName, DeploymentStatus.Sealed, [1, 2, 3]); var ex = await Should.ThrowAsync(() => Service(dbName).Fetch( new FetchRequest { DeploymentId = Guid.NewGuid().ToString() }, new CollectingStreamWriter(), new FakeServerCallContext())); ex.StatusCode.ShouldBe(StatusCode.NotFound); } [Fact] public async Task A_non_sealed_deployment_throws_NotFound() { var dbName = $"artsvc-{Guid.NewGuid():N}"; var id = Seed(dbName, DeploymentStatus.AwaitingApplyAcks, [1, 2, 3]); var ex = await Should.ThrowAsync(() => Service(dbName).Fetch( new FetchRequest { DeploymentId = id.ToString() }, new CollectingStreamWriter(), new FakeServerCallContext())); ex.StatusCode.ShouldBe(StatusCode.NotFound); } [Fact] public async Task A_zero_length_blob_throws_NotFound() { // The serve-side #485 guard: never stream empty bytes as if they were a valid empty config. var dbName = $"artsvc-{Guid.NewGuid():N}"; var id = Seed(dbName, DeploymentStatus.Sealed, []); var ex = await Should.ThrowAsync(() => Service(dbName).Fetch( new FetchRequest { DeploymentId = id.ToString() }, new CollectingStreamWriter(), new FakeServerCallContext())); ex.StatusCode.ShouldBe(StatusCode.NotFound); } [Fact] public async Task A_non_guid_id_throws_NotFound() { var ex = await Should.ThrowAsync(() => Service($"artsvc-{Guid.NewGuid():N}").Fetch( new FetchRequest { DeploymentId = "not-a-guid" }, new CollectingStreamWriter(), new FakeServerCallContext())); ex.StatusCode.ShouldBe(StatusCode.NotFound); } private sealed class CollectingStreamWriter : IServerStreamWriter { public List Chunks { get; } = []; public WriteOptions? WriteOptions { get; set; } public Task WriteAsync(ArtifactChunk message) { Chunks.Add(message); return Task.CompletedTask; } public byte[] Reassembled() { using var ms = new MemoryStream(); foreach (var c in Chunks) c.Data.WriteTo(ms); return ms.ToArray(); } } private sealed class NamedFactory(string name) : IDbContextFactory { public OtOpcUaConfigDbContext CreateDbContext() => new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); public Task CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext()); } /// Minimal — the service reads only the cancellation token. private sealed class FakeServerCallContext : ServerCallContext { protected override string MethodCore => "/deployment_artifact.v1.DeploymentArtifactService/Fetch"; protected override string HostCore => "localhost"; protected override string PeerCore => "ipv4:127.0.0.1:0"; protected override DateTime DeadlineCore => DateTime.MaxValue; protected override Metadata RequestHeadersCore => []; protected override CancellationToken CancellationTokenCore => CancellationToken.None; protected override Metadata ResponseTrailersCore { get; } = []; protected override Status StatusCore { get; set; } protected override WriteOptions? WriteOptionsCore { get; set; } protected override AuthContext AuthContextCore => new(null, new Dictionary>()); protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => throw new NotSupportedException(); protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } }