using System; using System.IO.Pipes; using System.Security.Principal; using System.Threading; using System.Threading.Tasks; using MessagePack; using Serilog; using Serilog.Core; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc; /// /// Driver.Historian.Wonderware-007 regression. The two other rejection paths /// (shared-secret-mismatch and major-version-mismatch) both write a /// with Accepted=false before disconnecting; the caller-SID-mismatch path used to /// just disconnect abruptly, leaving the client to time out instead of learning why. /// The fix sends a symmetric caller-sid-mismatch ack before disconnecting. /// /// The test uses the internal test-seam constructor so the verifier rejects without /// needing to actually relax the pipe ACL (which would block the test client itself). /// public sealed class PipeServerSidRejectTests { private static readonly ILogger Quiet = Logger.None; [Fact] public async Task Caller_SID_mismatch_sends_HelloAck_with_reject_reason_before_disconnect() { // The pipe ACL must allow the current process to connect — so wire up the pipe // with the current user's SID. Then have the verifier seam simulate the SID // mismatch by returning false. This isolates the "what does the server do on a // rejected caller" question from the (separate) "is the ACL correct" question. var current = WindowsIdentity.GetCurrent().User ?? throw new InvalidOperationException("WindowsIdentity.GetCurrent().User was null — cannot run test"); var pipeName = $"otopcua-hist-sidreject-test-{Guid.NewGuid():N}"; PipeServer.CallerVerifier rejecting = (NamedPipeServerStream _, SecurityIdentifier _, out string reason) => { reason = "synthetic-mismatch"; return false; }; using var server = new PipeServer(pipeName, current, "secret", Quiet, rejecting); var serverTask = Task.Run(() => server.RunOneConnectionAsync(new NoopHandler(), CancellationToken.None)); using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); await client.ConnectAsync(5_000); using var writer = new FrameWriter(client, leaveOpen: true); using var reader = new FrameReader(client, leaveOpen: true); var hello = new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test", SharedSecret = "secret" }; await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None); // Read the rejecting HelloAck the server is expected to send before disconnecting. var frame = await reader.ReadFrameAsync(CancellationToken.None); frame.ShouldNotBeNull("server must send a HelloAck on caller-SID rejection, not just disconnect"); frame!.Value.Kind.ShouldBe(MessageKind.HelloAck); var ack = MessagePackSerializer.Deserialize(frame.Value.Body); ack.Accepted.ShouldBeFalse(); ack.RejectReason.ShouldNotBeNullOrEmpty(); ack.RejectReason!.ShouldContain("caller-sid-mismatch", Case.Insensitive, "reject reason must match the documented caller-sid-mismatch tag so clients can diagnose"); await serverTask; } /// Handler that asserts it is never called — the connection must be rejected at Hello. private sealed class NoopHandler : IFrameHandler { public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) { throw new InvalidOperationException( $"Handler must not be reached on a rejected caller; got frame {kind}"); } } }