Compare commits

...

2 Commits

Author SHA1 Message Date
Joseph Doherty
a6f53e5b22 FOCAS Tier-C PR B — Driver.FOCAS.Host net48 x86 skeleton + pipe server. Second PR of the 5-PR #220 split. Stands up the Windows Service entry point + named-pipe scaffolding so PR C has a place to move the Fwlib32 calls into. New net48 x86 project (Fwlib32.dll is 32-bit-only, same bitness constraint as Galaxy.Host/MXAccess); references Driver.FOCAS.Shared for framing + DTOs so the wire format Proxy<->Host stays a single type system. Ships four files: PipeAcl creates a PipeSecurity where only the configured server principal SID gets ReadWrite+Synchronize + LocalSystem/BuiltinAdministrators are explicitly denied (so a compromised service account on the same host can't escalate via the pipe); IFrameHandler abstracts the dispatch surface with HandleAsync + AttachConnection for server-push event sinks; PipeServer accepts one connection at a time, verifies the peer SID via RunAsClient, reads the first Hello frame + matches the shared-secret and protocol major version, sends HelloAck, then hands off to the handler until EOF or cancel; StubFrameHandler fully handles Heartbeat/HeartbeatAck so a future supervisor's liveness detector stays happy, and returns ErrorResponse{Code=not-implemented,Message="Kind X is stubbed - Fwlib32 lift lands in PR C"} for every data-plane request. Program.cs mirrors the Galaxy.Host startup exactly: reads OTOPCUA_FOCAS_PIPE / OTOPCUA_ALLOWED_SID / OTOPCUA_FOCAS_SECRET from the env (supervisor passes these at spawn time), builds Serilog rolling-file logger into %ProgramData%\OtOpcUa\focas-host-*.log, constructs the pipe server with StubFrameHandler, loops on RunAsync until SIGINT. Log messages mark the backend as "stub" so it's visible in logs that Fwlib32 isn't actually connected yet. Driver.FOCAS.Host.Tests (net48 x86) ships three integration tests mirroring IpcHandshakeIntegrationTests from Galaxy.Host: correct-secret handshake + heartbeat round-trip, wrong-secret rejection with UnauthorizedAccessException, and a new test that sends a ReadRequest and asserts the StubFrameHandler returns ErrorResponse{not-implemented} mentioning PR C in the message so the wiring between frame dispatch + kind → error mapping is locked. Tests follow the same is-Administrator skip guard as Galaxy because PipeAcl denies BuiltinAdministrators. No changes to existing driver code; FOCAS unit tests still at 165/165 + Shared tests at 24/24. PR C wires the real Fwlib32 backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:00:56 -04:00
b968496471 Merge pull request 'FOCAS Tier-C PR A � Driver.FOCAS.Shared MessagePack contracts' (#169) from focas-tier-c-pr-a-shared into v2 2026-04-20 13:57:45 -04:00
9 changed files with 557 additions and 0 deletions

View File

@@ -15,6 +15,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
@@ -43,6 +44,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>

View File

@@ -0,0 +1,31 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
/// state and translate request DTOs into Fwlib32 calls.
/// </summary>
public interface IFrameHandler
{
Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
/// <summary>
/// Called once per accepted connection after the Hello handshake. Lets the handler
/// attach server-pushed event sinks (data-change notifications, runtime-status
/// changes) to the connection's <paramref name="writer"/>. Returns an
/// <see cref="IDisposable"/> the pipe server disposes when the connection closes —
/// backends use it to unsubscribe from their push sources.
/// </summary>
IDisposable AttachConnection(FrameWriter writer);
public sealed class NoopAttachment : IDisposable
{
public static readonly NoopAttachment Instance = new();
public void Dispose() { }
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Builds the <see cref="PipeSecurity"/> for the FOCAS Host pipe. Same pattern as
/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
/// <c>ReadWrite | Synchronize</c>; LocalSystem + Administrators are explicitly denied
/// so a compromised service account on the same host can't escalate via the pipe.
/// </summary>
public static class PipeAcl
{
public static PipeSecurity Create(SecurityIdentifier allowedSid)
{
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
allowedSid,
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
AccessControlType.Allow));
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
if (allowedSid != localSystem)
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
if (allowedSid != admins)
security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny));
security.SetOwner(allowedSid);
return security;
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
/// strict ACL from <see cref="PipeAcl"/>. Verifies the peer SID + per-process shared
/// secret before any RPC frame is accepted. Mirrors the Galaxy.Host pipe server byte for
/// byte — different MessageKind enum, same negotiation semantics.
/// </summary>
public sealed class PipeServer : IDisposable
{
private readonly string _pipeName;
private readonly SecurityIdentifier _allowedSid;
private readonly string _sharedSecret;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _current;
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
{
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
var acl = PipeAcl.Create(_allowedSid);
_current = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: 64 * 1024,
outBufferSize: 64 * 1024,
pipeSecurity: acl);
try
{
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
if (!VerifyCaller(_current, out var reason))
{
_logger.Warning("FOCAS IPC caller rejected: {Reason}", reason);
_current.Disconnect();
return;
}
using var reader = new FrameReader(_current, leaveOpen: true);
using var writer = new FrameWriter(_current, leaveOpen: true);
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (first is null || first.Value.Kind != FocasMessageKind.Hello)
{
_logger.Warning("FOCAS IPC first frame was not Hello; dropping");
return;
}
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
{
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
linked.Token).ConfigureAwait(false);
_logger.Warning("FOCAS IPC Hello rejected: shared-secret-mismatch");
return;
}
if (hello.ProtocolMajor != Hello.CurrentMajor)
{
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck
{
Accepted = false,
RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}",
},
linked.Token).ConfigureAwait(false);
_logger.Warning("FOCAS IPC Hello rejected: major mismatch peer={Peer} server={Server}",
hello.ProtocolMajor, Hello.CurrentMajor);
return;
}
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = true, HostName = Environment.MachineName },
linked.Token).ConfigureAwait(false);
using var attachment = handler.AttachConnection(writer);
while (!linked.Token.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (frame is null) break;
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
}
}
finally
{
_current.Dispose();
_current = null;
}
}
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error(ex, "FOCAS IPC connection loop error — accepting next"); }
}
}
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
{
try
{
pipe.RunAsClient(() =>
{
using var wi = WindowsIdentity.GetCurrent();
if (wi.User is null)
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
if (wi.User != _allowedSid)
throw new UnauthorizedAccessException(
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
});
reason = string.Empty;
return true;
}
catch (Exception ex) { reason = ex.Message; return false; }
}
public void Dispose()
{
_cts.Cancel();
_current?.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
/// <summary>
/// Placeholder handler that returns <c>ErrorResponse{Code=not-implemented}</c> for every
/// FOCAS data-plane request. Exists so PR B can ship the pipe server + ACL + handshake
/// plumbing before PR C moves the Fwlib32 calls. Heartbeats are handled fully so the
/// supervisor's liveness detector stays happy.
/// </summary>
public sealed class StubFrameHandler : IFrameHandler
{
public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
{
if (kind == FocasMessageKind.Heartbeat)
{
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
return writer.WriteAsync(FocasMessageKind.HeartbeatAck,
new HeartbeatAck
{
MonotonicTicks = hb.MonotonicTicks,
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}, ct);
}
return writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse
{
Code = "not-implemented",
Message = $"Kind {kind} is stubbed — Fwlib32 lift lands in PR C",
},
ct);
}
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Security.Principal;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host;
/// <summary>
/// Entry point for the <c>OtOpcUaFocasHost</c> Windows service / console host. The
/// supervisor (Proxy-side) spawns this process per FOCAS driver instance and passes the
/// pipe name, allowed-SID, and per-process shared secret as environment variables. In
/// PR B the backend is <see cref="StubFrameHandler"/> — PR C swaps in the real
/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
/// driver.
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
@"%ProgramData%\OtOpcUa\focas-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_PIPE") ?? "OtOpcUaFocas";
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
?? throw new InvalidOperationException(
"OTOPCUA_ALLOWED_SID not set — the FOCAS Proxy supervisor must pass the server principal SID");
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_SECRET")
?? throw new InvalidOperationException(
"OTOPCUA_FOCAS_SECRET not set — the FOCAS Proxy supervisor must pass the per-process secret at spawn time");
var allowedSid = new SecurityIdentifier(allowedSidValue);
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
pipeName, allowedSidValue);
var handler = new StubFrameHandler();
Log.Warning("OtOpcUaFocasHost backend=stub — Fwlib32 lift lands in PR C");
server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
Log.Information("OtOpcUaFocasHost stopped cleanly");
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "OtOpcUaFocasHost fatal");
return 2;
}
finally { Log.CloseAndFlush(); }
}
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- Fwlib32.dll is 32-bit only — x86 target is mandatory. Matches the Galaxy.Host
bitness constraint but for a different native library. -->
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host</RootNamespace>
<AssemblyName>OtOpcUa.Driver.FOCAS.Host</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
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.FOCAS.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
{
/// <summary>
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
/// the BuiltinAdministrators group.
/// </summary>
[Trait("Category", "Integration")]
public sealed class IpcHandshakeIntegrationTests
{
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
{
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await stream.ConnectAsync(5_000, ct);
var reader = new FrameReader(stream, leaveOpen: true);
var writer = new FrameWriter(stream, leaveOpen: true);
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
var ack = await reader.ReadFrameAsync(ct);
if (ack is null) throw new EndOfStreamException("no HelloAck");
if (ack.Value.Kind != FocasMessageKind.HelloAck)
throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind);
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
return (stream, reader, writer);
}
[Fact]
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
const string secret = "test-secret-2026";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
using (stream)
using (reader)
using (writer)
{
await writer.WriteAsync(FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = 42 }, cts.Token);
var hbAck = await reader.ReadFrameAsync(cts.Token);
hbAck.HasValue.ShouldBeTrue();
hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck);
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L);
}
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
[Fact]
public async Task Handshake_with_wrong_secret_is_rejected()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, "real-secret", log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
{
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
s.Dispose();
r.Dispose();
w.Dispose();
});
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
[Fact]
public async Task Stub_handler_returns_not_implemented_for_data_plane_request()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
const string secret = "stub-test";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
using (stream)
using (reader)
using (writer)
{
await writer.WriteAsync(FocasMessageKind.ReadRequest,
new ReadRequest
{
SessionId = 1,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
DataType = FocasDataTypeCode.Int32,
},
cts.Token);
var resp = await reader.ReadFrameAsync(cts.Token);
resp.HasValue.ShouldBeTrue();
resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse);
var err = MessagePackSerializer.Deserialize<ErrorResponse>(resp.Value.Body);
err.Code.ShouldBe("not-implemented");
err.Message.ShouldContain("PR C");
}
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>