Merge stream L (proto reserved/propagation comments + regen, stale docs, gateway test gaps)
This commit is contained in:
@@ -170,7 +170,7 @@ the active alarms the gateway's central monitor currently holds),
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
|
||||
writes the comment to the alarm's `AckMsg`, so the Historian records the
|
||||
operator). All three accept a cancellation
|
||||
operator). The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. All three accept a cancellation
|
||||
token and pass through the `MxGateway:Alarms` configuration on the
|
||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||
stream rather than failing.
|
||||
|
||||
@@ -167,7 +167,7 @@ snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
|
||||
optional `OperatorDomain`) so the gateway authenticates as the operator and the
|
||||
Historian records that operator; an empty password is allowed, and the password
|
||||
is scrubbed from any returned error. The streaming
|
||||
is scrubbed from any returned error. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. The streaming
|
||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||
stream. All three pass straight through to the gateway's central alarm
|
||||
monitor.
|
||||
|
||||
@@ -142,7 +142,7 @@ yields alarm-feed messages from the gateway's central monitor), and
|
||||
The ack carries operator credentials: `operator_user` is required, and
|
||||
`operator_password` / `operator_domain` let the gateway authenticate as that
|
||||
operator so the Historian records the real operator. An empty password is
|
||||
allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream.
|
||||
allowed. The password is scrubbed from exceptions the client throws. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. Close the subscription to cancel the underlying gRPC stream.
|
||||
|
||||
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
|
||||
@@ -116,7 +116,7 @@ comment). The ack is made as the operator: set `operator_user` (required) and
|
||||
`operator_password` (may be empty) and optionally `operator_domain` on
|
||||
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
|
||||
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
|
||||
password is redacted from any raised error.
|
||||
password is redacted from any raised error. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account.
|
||||
|
||||
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
|
||||
stream.
|
||||
|
||||
@@ -127,7 +127,7 @@ returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client.
|
||||
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
|
||||
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
|
||||
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
records them. The operator's full name is not sent by the client and is not propagated to MXAccess (`operator_full_name` on the worker commands is accepted but ignored); the Historian takes it from the authenticated Galaxy account. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
|
||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
|
||||
@@ -107,6 +107,7 @@ Request fields that identify the acknowledging operator:
|
||||
- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded.
|
||||
- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged.
|
||||
- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`.
|
||||
- `operator_full_name`: not a field on `AcknowledgeAlarmRequest`. The worker commands (`AcknowledgeAlarmCommand` field 6, `AcknowledgeAlarmByNameCommand` field 8) carry one, but it is accepted and **not propagated** to MXAccess: the 8-argument `AlarmAckByName` that carried it returns -55 (see [Alarm Client Discovery](./AlarmClientDiscovery.md)). `operator_domain` is used.
|
||||
|
||||
The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `<TagName>.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
|
||||
@@ -1019,10 +1019,10 @@ and relies on the gateway to kill the process.
|
||||
|
||||
## Fault Handling
|
||||
|
||||
Worker fault categories:
|
||||
Worker fault categories (`InvalidArguments` and `GatewayAuthenticationFailed`
|
||||
are reserved and never emitted: bootstrap failures exit via `WorkerExitCode`
|
||||
before the pipe exists):
|
||||
|
||||
- `InvalidArguments`
|
||||
- `GatewayAuthenticationFailed`
|
||||
- `ProtocolMismatch`
|
||||
- `ProtocolViolation`
|
||||
- `PipeDisconnected`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Deferred Follow-ups Implementation Plan
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Status:** D1 executed (commit 4af24b9 — `mxgateway.alarms.provider_switches` emitted in `DashboardSnapshotService.cs:198`). D2 resolved as no-op (see resolution section below). D3–D5 remain pending (ops/validation, no code).
|
||||
**Status:** D1 executed (commit 4af24b9 — `mxgateway.alarms.provider_switches` emitted in `DashboardSnapshotService.cs:198`). D2 resolved as no-op (see resolution section below). D3 (ForceSubtag validation) was exercised through the forced-subtag work (`2026-06-15-forced-subtag-mode-fix.md`); D4 (windev backup prune) has no completion record in the repo and is treated as pending; D5 (wonder redeploy) was superseded by later wonder deployments. D3–D5 are ops/validation with no code.
|
||||
**Context:** After the alarm-subtag-fallback cleanup (merged `5976770`) and its redeploy to
|
||||
windev (10.100.0.48), five items remain deferred. This plan handles all five. They are
|
||||
independent — execute in any order, or cherry-pick. Items D1–D2 are code (branch off `main`);
|
||||
|
||||
@@ -727,6 +727,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
[pbr::OriginalName("ALARM_TRANSITION_KIND_RAISE")] Raise = 1,
|
||||
[pbr::OriginalName("ALARM_TRANSITION_KIND_ACKNOWLEDGE")] Acknowledge = 2,
|
||||
[pbr::OriginalName("ALARM_TRANSITION_KIND_CLEAR")] Clear = 3,
|
||||
/// <summary>
|
||||
/// Reserved: never produced by the gateway or worker. Retained so the numeric
|
||||
/// value is not reused.
|
||||
/// </summary>
|
||||
[pbr::OriginalName("ALARM_TRANSITION_KIND_RETRIGGER")] Retrigger = 4,
|
||||
}
|
||||
|
||||
@@ -12714,6 +12718,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// <summary>Field number for the "operator_full_name" field.</summary>
|
||||
public const int OperatorFullNameFieldNumber = 6;
|
||||
private string operatorFullName_ = "";
|
||||
/// <summary>
|
||||
/// Accepted for wire compatibility but NOT propagated to MXAccess: the 8-arg
|
||||
/// AlarmAckByName that carried it returns -55. operator_domain IS used (the
|
||||
/// AuthenticateUser identity is "<operator_domain>\<operator_user>").
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string OperatorFullName {
|
||||
@@ -13384,6 +13393,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// <summary>Field number for the "operator_full_name" field.</summary>
|
||||
public const int OperatorFullNameFieldNumber = 8;
|
||||
private string operatorFullName_ = "";
|
||||
/// <summary>
|
||||
/// Accepted for wire compatibility but NOT propagated to MXAccess: the 8-arg
|
||||
/// AlarmAckByName that carried it returns -55. operator_domain IS used (the
|
||||
/// AuthenticateUser identity is "<operator_domain>\<operator_user>").
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string OperatorFullName {
|
||||
@@ -17527,6 +17541,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// <summary>Field number for the "correlation_id" field.</summary>
|
||||
public const int CorrelationIdFieldNumber = 2;
|
||||
private string correlationId_ = "";
|
||||
/// <summary>
|
||||
/// Echoes the request's client_correlation_id.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string CorrelationId {
|
||||
@@ -26125,7 +26142,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// <summary>
|
||||
/// When the alarm originally entered the active state. Preserved across
|
||||
/// acknowledge transitions so the Part 9 condition keeps the original raise
|
||||
/// time. Unset on retrigger from a previously-cleared condition.
|
||||
/// time. Unset when the raise time is unknown (RETRIGGER is reserved and never produced).
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
|
||||
@@ -132,6 +132,12 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
|
||||
public enum WorkerFaultCategory {
|
||||
[pbr::OriginalName("WORKER_FAULT_CATEGORY_UNSPECIFIED")] Unspecified = 0,
|
||||
/// <summary>
|
||||
/// Values 1 and 2 are reserved: never emitted. Bootstrap failures (bad
|
||||
/// arguments, gateway authentication) exit via WorkerExitCode before the pipe
|
||||
/// exists, so there is no channel to carry a fault. Names kept so the numbers
|
||||
/// are not reused.
|
||||
/// </summary>
|
||||
[pbr::OriginalName("WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS")] InvalidArguments = 1,
|
||||
[pbr::OriginalName("WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED")] GatewayAuthenticationFailed = 2,
|
||||
[pbr::OriginalName("WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH")] ProtocolMismatch = 3,
|
||||
|
||||
@@ -405,6 +405,9 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_user = 3;
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
// Accepted for wire compatibility but NOT propagated to MXAccess: the 8-arg
|
||||
// AlarmAckByName that carried it returns -55. operator_domain IS used (the
|
||||
// AuthenticateUser identity is "<operator_domain>\<operator_user>").
|
||||
string operator_full_name = 6;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
@@ -435,6 +438,9 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_user = 5;
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
// Accepted for wire compatibility but NOT propagated to MXAccess: the 8-arg
|
||||
// AlarmAckByName that carried it returns -55. operator_domain IS used (the
|
||||
// AuthenticateUser identity is "<operator_domain>\<operator_user>").
|
||||
string operator_full_name = 8;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
@@ -549,6 +555,7 @@ message ShutdownWorkerCommand {
|
||||
|
||||
message MxCommandReply {
|
||||
string session_id = 1;
|
||||
// Echoes the request's client_correlation_id.
|
||||
string correlation_id = 2;
|
||||
MxCommandKind kind = 3;
|
||||
ProtocolStatus protocol_status = 4;
|
||||
@@ -880,7 +887,7 @@ message OnAlarmTransitionEvent {
|
||||
|
||||
// When the alarm originally entered the active state. Preserved across
|
||||
// acknowledge transitions so the Part 9 condition keeps the original raise
|
||||
// time. Unset on retrigger from a previously-cleared condition.
|
||||
// time. Unset when the raise time is unknown (RETRIGGER is reserved and never produced).
|
||||
google.protobuf.Timestamp original_raise_timestamp = 6;
|
||||
|
||||
// When this specific transition occurred (raise time on Raise, ack time on
|
||||
@@ -930,6 +937,8 @@ enum AlarmTransitionKind {
|
||||
ALARM_TRANSITION_KIND_RAISE = 1;
|
||||
ALARM_TRANSITION_KIND_ACKNOWLEDGE = 2;
|
||||
ALARM_TRANSITION_KIND_CLEAR = 3;
|
||||
// Reserved: never produced by the gateway or worker. Retained so the numeric
|
||||
// value is not reused.
|
||||
ALARM_TRANSITION_KIND_RETRIGGER = 4;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,10 @@ enum WorkerState {
|
||||
|
||||
enum WorkerFaultCategory {
|
||||
WORKER_FAULT_CATEGORY_UNSPECIFIED = 0;
|
||||
// Values 1 and 2 are reserved: never emitted. Bootstrap failures (bad
|
||||
// arguments, gateway authentication) exit via WorkerExitCode before the pipe
|
||||
// exists, so there is no channel to carry a fault. Names kept so the numbers
|
||||
// are not reused.
|
||||
WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS = 1;
|
||||
WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED = 2;
|
||||
WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH = 3;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Direct tests of the shared path-validation primitives so the rules are pinned independently of
|
||||
/// the options validators that call them.
|
||||
/// </summary>
|
||||
public sealed class GatewayConfigPathRulesTests
|
||||
{
|
||||
private const string Message = "path rule failed";
|
||||
|
||||
private static readonly string Root = Path.Combine(Path.GetTempPath(), "mxgw-app");
|
||||
|
||||
/// <summary>Verifies blank values are ignored by every rule.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void BlankValue_RecordsNoFailure(string? value)
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfNotRooted(value, Message, builder);
|
||||
GatewayConfigPathRules.AddIfInvalidPath(value, Message, builder);
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot(value, Root, Message, builder);
|
||||
|
||||
Assert.True(builder.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a relative path is rejected as not rooted.</summary>
|
||||
[Fact]
|
||||
public void AddIfNotRooted_RecordsFailure_ForRelativePath()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfNotRooted(Path.Combine("data", "auth.db"), Message, builder);
|
||||
|
||||
Assert.Equal([Message], builder.Failures);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a rooted path passes.</summary>
|
||||
[Fact]
|
||||
public void AddIfNotRooted_Passes_ForRootedPath()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfNotRooted(Path.Combine(Root, "auth.db"), Message, builder);
|
||||
|
||||
Assert.True(builder.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a NUL character makes the path invalid.</summary>
|
||||
[Fact]
|
||||
public void AddIfInvalidPath_RecordsFailure_ForEmbeddedNul()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfInvalidPath("a\0b", Message, builder);
|
||||
|
||||
Assert.Equal([Message], builder.Failures);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an ordinary path is valid.</summary>
|
||||
[Fact]
|
||||
public void AddIfInvalidPath_Passes_ForOrdinaryPath()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfInvalidPath(Path.Combine(Root, "auth.db"), Message, builder);
|
||||
|
||||
Assert.True(builder.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies paths inside the content root, and the root itself, are rejected.</summary>
|
||||
[Fact]
|
||||
public void AddIfUnderContentRoot_RecordsFailure_ForPathInsideRoot()
|
||||
{
|
||||
ValidationBuilder inside = new();
|
||||
ValidationBuilder equal = new();
|
||||
ValidationBuilder trailing = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot(Path.Combine(Root, "sub", "auth.db"), Root, Message, inside);
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot(Root, Root, Message, equal);
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot(
|
||||
Path.Combine(Root, "auth.db"),
|
||||
Root + Path.DirectorySeparatorChar,
|
||||
Message,
|
||||
trailing);
|
||||
|
||||
Assert.Equal([Message], inside.Failures);
|
||||
Assert.Equal([Message], equal.Failures);
|
||||
Assert.Equal([Message], trailing.Failures);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a sibling directory sharing the root's name prefix is not treated as inside it.</summary>
|
||||
[Fact]
|
||||
public void AddIfUnderContentRoot_Passes_ForSiblingWithSharedPrefix()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot(
|
||||
Root + "-data" + Path.DirectorySeparatorChar + "auth.db",
|
||||
Root,
|
||||
Message,
|
||||
builder);
|
||||
|
||||
Assert.True(builder.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a malformed value reports nothing here, leaving it to the path-validity rule.</summary>
|
||||
[Fact]
|
||||
public void AddIfUnderContentRoot_Passes_ForMalformedValue()
|
||||
{
|
||||
ValidationBuilder builder = new();
|
||||
|
||||
GatewayConfigPathRules.AddIfUnderContentRoot("a\0b", Root, Message, builder);
|
||||
|
||||
Assert.True(builder.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||
|
||||
/// <summary>Tests for <see cref="DashboardRedactor"/>.</summary>
|
||||
public sealed class DashboardRedactorTests
|
||||
{
|
||||
/// <summary>Verifies null, empty and whitespace values pass through unchanged.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Redact_ReturnsBlankValueUnchanged(string? value)
|
||||
{
|
||||
Assert.Equal(value, DashboardRedactor.Redact(value));
|
||||
}
|
||||
|
||||
/// <summary>Verifies harmless text is returned unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_ReturnsOrdinaryTextUnchanged()
|
||||
{
|
||||
Assert.Equal("Session opened for Plant1", DashboardRedactor.Redact("Session opened for Plant1"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies text containing a sensitive marker is replaced whole, case-insensitively.</summary>
|
||||
[Theory]
|
||||
[InlineData("apikey=abc")]
|
||||
[InlineData("API_KEY abc")]
|
||||
[InlineData("Authorization: Basic abc")]
|
||||
[InlineData("user credential rejected")]
|
||||
[InlineData("bad Password supplied")]
|
||||
[InlineData("client SECRET")]
|
||||
[InlineData("hub Token minted")]
|
||||
public void Redact_ReplacesTextContainingSensitiveMarker(string value)
|
||||
{
|
||||
Assert.Equal(GatewayLogRedactor.RedactedValue, DashboardRedactor.Redact(value));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a gateway API key goes through client-identity redaction and its secret never survives.</summary>
|
||||
[Fact]
|
||||
public void Redact_DelegatesGatewayKeysToClientIdentityRedaction()
|
||||
{
|
||||
const string Value = "Bearer mxgw_dev_topsecretvalue";
|
||||
|
||||
string? result = DashboardRedactor.Redact(Value);
|
||||
|
||||
Assert.Equal(GatewayLogRedactor.RedactClientIdentity(Value), result);
|
||||
Assert.DoesNotContain("topsecretvalue", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Sessions;
|
||||
|
||||
/// <summary>Tests for the <see cref="EventSubscriberReplayAttachment"/> value shape.</summary>
|
||||
public sealed class EventSubscriberReplayAttachmentTests
|
||||
{
|
||||
/// <summary>Verifies the constructor exposes every component unchanged.</summary>
|
||||
[Fact]
|
||||
public void Constructor_ExposesComponents()
|
||||
{
|
||||
StubLease lease = new();
|
||||
MxEvent[] replay = [new MxEvent { WorkerSequence = 5 }, new MxEvent { WorkerSequence = 6 }];
|
||||
|
||||
EventSubscriberReplayAttachment attachment = new(lease, replay, true, 5, 6);
|
||||
|
||||
Assert.Same(lease, attachment.Lease);
|
||||
Assert.Same(replay, attachment.ReplayedEvents);
|
||||
Assert.True(attachment.Gap);
|
||||
Assert.Equal(5UL, attachment.OldestAvailableSequence);
|
||||
Assert.Equal(6UL, attachment.LiveResumeSequence);
|
||||
}
|
||||
|
||||
/// <summary>Verifies value equality holds for identical components and breaks when a watermark differs.</summary>
|
||||
[Fact]
|
||||
public void Equality_ComparesComponents()
|
||||
{
|
||||
StubLease lease = new();
|
||||
IReadOnlyList<MxEvent> replay = [];
|
||||
|
||||
EventSubscriberReplayAttachment a = new(lease, replay, false, 0, 10);
|
||||
EventSubscriberReplayAttachment same = new(lease, replay, false, 0, 10);
|
||||
EventSubscriberReplayAttachment other = a with { LiveResumeSequence = 11 };
|
||||
|
||||
Assert.Equal(a, same);
|
||||
Assert.NotEqual(a, other);
|
||||
}
|
||||
|
||||
/// <summary>Verifies disposing the lease through the attachment releases the subscriber.</summary>
|
||||
[Fact]
|
||||
public void Lease_DisposeIsObservableThroughAttachment()
|
||||
{
|
||||
StubLease lease = new();
|
||||
EventSubscriberReplayAttachment attachment = new(lease, [], false, 0, 0);
|
||||
|
||||
attachment.Lease.Dispose();
|
||||
|
||||
Assert.True(lease.Disposed);
|
||||
}
|
||||
|
||||
private sealed class StubLease : IEventSubscriberLease
|
||||
{
|
||||
private readonly Channel<MxEvent> channel = Channel.CreateUnbounded<MxEvent>();
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public ChannelReader<MxEvent> Reader => channel.Reader;
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -42,10 +42,9 @@ Items are graded by what they actually are, because most "pending" surface in th
|
||||
- **Location:** metric emitted in `Alarms/GatewayAlarmMonitor.cs` (failover/failback path); residual recorded in `docs/plans/2026-06-14-deferred-followups.md:124-125`.
|
||||
- **Evidence:** *"that counter's live exercise remains the one gap; record it explicitly rather than claiming coverage."* Unit-tested (Tests-032) but the dev rig can't drive a real alarmmgr→subtag failover (`project_rig_alarms_object_driven`), so the counter's `reason` tagging is unproven in production.
|
||||
|
||||
### 🟠 1.4 — Worker 8-arg alarm ack silently discards operator domain / full name
|
||||
- **Location:** `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:261-278` (`_ = ackOperatorDomain; _ = ackOperatorFullName;`).
|
||||
- **Evidence:** *"the IwwAlarmConsumer2 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like a stub) … fields are accepted by the proto for forward-compat but are not propagated to AVEVA today."*
|
||||
- **Impact:** two contract fields are accepted on the wire and silently not delivered. Root cause is the vendor stub (see 3.5), but the drop is currently invisible to callers.
|
||||
### 🟡 1.4 — Operator full name is accepted but not propagated (domain is now consumed)
|
||||
- **Status (2026-10-22):** half-resolved. Acks now authenticate as the operator and write `<alarm>.AckMsg` (`Worker/MxAccess/AlarmAckOperator.cs`), and **`operator_domain` is consumed** (`domain\user` for `AuthenticateUser`). The older `WnWrapAlarmConsumer` 8-arg path that discarded both fields (`_ = ackOperatorDomain; _ = ackOperatorFullName;`) is no longer the ack path.
|
||||
- **Remaining:** `operator_full_name` on `AcknowledgeAlarmCommand` (field 6) / `AcknowledgeAlarmByNameCommand` (field 8) is still accepted and not propagated to MXAccess; the 8-arg `AlarmAckByName` that carried it returns -55 (`docs/AlarmClientDiscovery.md:743-748`). The public `AcknowledgeAlarmRequest` has no such field. The proto comments and `docs/Grpc.md` now state this.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,8 +75,8 @@ Intentionally incomplete, awaiting a live capture or an AVEVA fix; raw payload/m
|
||||
- 🟡 **3.1 `OperationComplete` native trigger condition unknown** — modeled and emitted only from the real event (no synthesis), but the runtime condition that fires it isn't captured. `docs/DesignDecisions.md:280-289`, `gateway.md:1094`, `docs/MxAccessWorkerInstanceDesign.md:341,366`.
|
||||
- 🟡 **3.2 `OnBufferedDataChange` multi-sample conversion unvalidated — STILL OPEN (residual after B8)** — `AddBufferedItem`/`SetBufferedUpdateInterval` are now implemented and live-confirmed (§1.1), and the live B8 test (`f7ada90`) confirms the worker receives and cleanly converts the empty `NoData` bootstrap `OnBufferedDataChange` (no crash, no dropped payload). But the rig's object logic does not drive a multi-sample buffered batch on demand (same limitation as the alarm rig), so a real parallel quality/timestamp sample array (length > 1) has never been observed live — it is exercised only by the B-bundle unit tests against a fake `IMxAccessServer`. Re-run `GatewaySession_WithLiveWorker_BufferedItem_*` against a fast-changing simulated tag to close this. `docs/DesignDecisions.md:291-297`.
|
||||
- 🟡 **3.3 Completion-only status → `MXSTATUS_PROXY[]` mapping unproven** — completion-only operation-status bytes are kept as raw diagnostic metadata until the analysis proves an exact mapping. `docs/DesignDecisions.md:299-306`.
|
||||
- 🟡 **3.4 `AlarmAckByGUID` is `E_NOTIMPL` on this AVEVA build** — throws `NotImplementedException`; all acks route through `AlarmAckByName`. Proto/worker keep the path for forward-compat but it is dead today. `docs/AlarmClientDiscovery.md:750-763`.
|
||||
- 🟡 **3.5 8-arg `AlarmAckByName` v2 is a vendor stub (returns -55)** — worker uses the 6-arg method; the 8-arg `domain`/`full_name` fields are carried for forward-compat only (see 1.4). `docs/AlarmClientDiscovery.md:743-748`.
|
||||
- 🟡 **3.4 `AlarmAckByGUID` is `E_NOTIMPL` on this AVEVA build (historical; note corrected 2026-10-22)** — it throws `NotImplementedException`, but the worker no longer calls it. `MxCommandKind.AcknowledgeAlarm` has live dispatch (`Worker/MxAccess/MxAccessCommandExecutor.cs` `ExecuteAcknowledgeAlarm`, ~895-936): it parses `alarm_guid` and calls `alarmCommandHandler.Acknowledge(guid, comment, AlarmAckOperator)`, which acknowledges by writing `AckMsg` as the authenticated operator. A GUID ack therefore works; only the vendor `AlarmAckByGUID` call is dead. `docs/AlarmClientDiscovery.md:750-763`.
|
||||
- 🟡 **3.5 8-arg `AlarmAckByName` v2 is a vendor stub (returns -55)** — no longer on the ack path (acks write `AckMsg`); `operator_full_name` is carried for forward-compat only and is not propagated (see 1.4). `docs/AlarmClientDiscovery.md:743-748`.
|
||||
- 🟡 **3.6 Subtag degraded-mode fidelity limits** — `category`, `description`, `alarm_type_name`, operator fields, and `retrigger` are not populated/synthesized in subtag fallback (no subtag exists for them). Documented, by design. `docs/AlarmClientDiscovery.md:913-931`, `docs/plans/2026-06-13-alarm-subtag-fallback-design.md:292-298`.
|
||||
- 🟡 **3.7 Subtag `Clear` transition unvalidated live** — Raise/Ack/AckMsg are live-confirmed; Clear is externally undrivable on the rig (object logic owns alarm state). Environmental, not code. (`project_alarm_subtag_fallback`, `project_rig_alarms_object_driven`.)
|
||||
|
||||
@@ -132,7 +131,7 @@ No placeholder/empty/`Assert.True(true)` tests were found anywhere.
|
||||
|
||||
## 7. Stale docs, dead code, accepted gaps
|
||||
|
||||
- 📄 **7.1 D1 plan header stale** — `docs/plans/2026-06-14-deferred-followups.md:4` still says *"Plan only — NOT yet executed,"* but D1 is **done** (`Dashboard/DashboardSnapshotService.cs:198`, commit `4af24b9`). Update the plan status.
|
||||
- ✅ **7.1 D1 plan header stale — RESOLVED** (2026-10-22): `docs/plans/2026-06-14-deferred-followups.md` status line now records D1 executed (`4af24b9`) and D2 resolved as no-op, and no longer claims the plan is unexecuted.
|
||||
- 📄 **7.2 `AlarmClientDiscovery.md` STA "production fix needed" prose is stale** — `docs/AlarmClientDiscovery.md:765-774` reads as a pending follow-up, but alarms now run through the worker STA / `GatewayAlarmMonitor` (merged). Re-check against current code.
|
||||
- 📄 **7.3 EventsHub "publisher side is a follow-up" comment is stale** — `Dashboard/Hubs/EventsHub.cs:9-17`; the `DashboardEventBroadcaster` exists, is DI-registered (`Dashboard/DashboardServiceCollectionExtensions.cs:47`), runs in the live loop (`Grpc/EventStreamService.cs:133`), and `SessionDetailsPage.razor` renders the feed.
|
||||
- ✅ **7.4 CLAUDE.md project-name drift — RESOLVED** (verified 2026-08-07): CLAUDE.md now uses the actual `src/ZB.MOM.WW.MxGateway.*` project paths throughout.
|
||||
@@ -161,7 +160,7 @@ No placeholder/empty/`Assert.True(true)` tests were found anywhere.
|
||||
|
||||
**Still open (all deliberate or environment/vendor-gated):**
|
||||
- **§1.3** — `provider_switches` counter still only unit-tested; the dev rig can't drive a real alarmmgr→subtag failover, so live `reason`-tag coverage remains a recorded residual.
|
||||
- **§1.4 / §3.4 / §3.5** — the AVEVA 8-arg `AlarmAckByName` is a vendor stub (−55) and `AlarmAckByGUID` is `E_NOTIMPL`; the `domain`/`full_name` fields stay forward-compat-only until AVEVA implements them.
|
||||
- **§1.4 / §3.4 / §3.5** — the AVEVA 8-arg `AlarmAckByName` is a vendor stub (−55) and `AlarmAckByGUID` is `E_NOTIMPL`; `operator_domain` is consumed by the `AckMsg` path; `operator_full_name` stays forward-compat-only (not propagated).
|
||||
- **§3.2** — buffered commands work and the empty bootstrap converts cleanly live, but a multi-sample buffered batch is undrivable on the rig (unit-tested only).
|
||||
- **§3.1 / §3.3 / §3.6 / §3.7** — await live MXAccess captures.
|
||||
- **§2** — deliberate v1 scope. Multi-subscriber fan-out, reconnect, and the per-session dashboard ACL are resolved; orphan-worker reattach remains deliberately deferred and not planned. **§5** — opt-in verification gates. **§7.6** — accepted `Won't Fix` review findings.
|
||||
|
||||
Reference in New Issue
Block a user