Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/ProtoDescriptorContractTests.cs
T
Joseph Doherty 9a87d7a328 Resolve all 33 medium-severity architecture review findings
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
2026-09-09 22:05:30 -04:00

97 lines
5.1 KiB
C#

using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using Google.Protobuf.Reflection;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
/// <summary>Compiles source descriptors and compares the complete vendored wire contract.</summary>
public class ProtoDescriptorContractTests
{
[Fact]
public async Task XC4_SourceDescriptorsMatchVendoredMessagesAndServices()
{
var compiled = await CompileAsync();
foreach (var generated in Generated())
Assert.Equal(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
}
[Theory]
[InlineData("string site_identifier = 1;", "string renamed_site = 1;")]
[InlineData("string site_identifier = 1;", "bytes site_identifier = 1;")]
[InlineData("rpc Heartbeat(HeartbeatDto)", "rpc RenamedHeartbeat(HeartbeatDto)")]
public async Task GuardDetectsNameTypeAndServiceDrift(string before, string after)
{
var compiled = await CompileAsync(before, after);
var generated = CentralControlReflection.Descriptor;
Assert.NotEqual(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
}
// C# embeds descriptors with implicit JSON names omitted; descriptor_set_out expands
// them. Compare effective names so explicit json_name drift remains detectable too.
private static FileDescriptorProto CanonicalGenerated(FileDescriptor file)
{
var proto = file.ToProto();
foreach (var message in proto.MessageType)
FillJsonNames(message, file.MessageTypes.Single(m => m.Name == message.Name));
return proto;
}
private static void FillJsonNames(DescriptorProto proto, MessageDescriptor descriptor)
{
foreach (var field in proto.Field)
field.JsonName = descriptor.FindFieldByNumber(field.Number).JsonName;
foreach (var nested in proto.NestedType)
FillJsonNames(nested, descriptor.NestedTypes.Single(m => m.Name == nested.Name));
}
private static FileDescriptor[] Generated() =>
[SitestreamReflection.Descriptor, SiteCommandReflection.Descriptor, CentralControlReflection.Descriptor];
private static async Task<FileDescriptorSet> CompileAsync(string? before = null, string? after = null)
{
var tools = typeof(ProtoDescriptorContractTests).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(a => a.Key == "GrpcToolsRoot").Value!;
var platform = OperatingSystem.IsMacOS() ? "macosx_x64"
: OperatingSystem.IsWindows() ? (RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "windows_x86" : "windows_x64")
: RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux_arm64"
: RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "linux_x86" : "linux_x64";
var executable = Path.Combine(tools, "tools", platform, OperatingSystem.IsWindows() ? "protoc.exe" : "protoc");
var directory = Path.Combine(Path.GetTempPath(), "scadabridge-proto-contract-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(directory, "Protos"));
try
{
foreach (var file in Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "ProtoSources", "Protos"), "*.proto"))
{
var source = await File.ReadAllTextAsync(file);
if (before is not null && Path.GetFileName(file) == "central_control.proto")
{
Assert.Contains(before, source);
source = source.Replace(before, after, StringComparison.Ordinal);
}
await File.WriteAllTextAsync(Path.Combine(directory, "Protos", Path.GetFileName(file)), source);
}
var output = Path.Combine(directory, "descriptor.bin");
var start = new ProcessStartInfo(executable)
{
WorkingDirectory = directory, RedirectStandardError = true, RedirectStandardOutput = true,
UseShellExecute = false, CreateNoWindow = true
};
start.ArgumentList.Add("--proto_path=" + directory);
start.ArgumentList.Add("--proto_path=" + Path.Combine(tools, "build", "native", "include"));
start.ArgumentList.Add("--descriptor_set_out=" + output);
foreach (var file in Directory.GetFiles(Path.Combine(directory, "Protos"), "*.proto").Order())
start.ArgumentList.Add("Protos/" + Path.GetFileName(file));
using var process = Process.Start(start)!;
var stderr = process.StandardError.ReadToEndAsync();
var stdout = process.StandardOutput.ReadToEndAsync();
try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); }
catch { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); throw; }
Assert.True(process.ExitCode == 0, await stderr + await stdout);
return FileDescriptorSet.Parser.ParseFrom(await File.ReadAllBytesAsync(output));
}
finally { Directory.Delete(directory, recursive: true); }
}
}