Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs
T
Joseph Doherty 7d7de482b7 feat(mesh): central DeploymentArtifactService — stream the sealed artifact, NotFound-hide the rest
Streams a sealed deployment's ArtifactBlob to a fetching driver node in
128-KiB chunks (matching the LocalDb cache chunk size). Unknown id, a
non-sealed deployment, and a zero-length blob all collapse to one
indistinguishable NotFound — existence-hiding plus the #485 serve-side guard
(never stream empty bytes as a valid empty config). The impl is named
DeploymentArtifactGrpcService to avoid colliding with the generated
DeploymentArtifactService container type.

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

160 lines
6.3 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
public class DeploymentArtifactServiceTests
{
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
private static DeploymentArtifactGrpcService Service(string dbName) =>
new(Factory(dbName), NullLogger<DeploymentArtifactGrpcService>.Instance);
private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob)
{
var id = Guid.NewGuid();
using var db = new OtOpcUaConfigDbContext(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<ArtifactChunk>
{
public List<ArtifactChunk> 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<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
Task.FromResult(CreateDbContext());
}
/// <summary>Minimal <see cref="ServerCallContext"/> — the service reads only the cancellation token.</summary>
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<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
}
}