feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients

Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:41:43 -04:00
parent 4090a478c8
commit bde042b4d4
21 changed files with 3496 additions and 97 deletions
+22 -12
View File
@@ -192,26 +192,36 @@ but still need the write attributed to a user id, you must first advise the
item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`un_advise` but not supervisory advise, so send it
through the generic command channel:
The session exposes a typed `advise_supervisory` helper alongside
`advise`/`un_advise`:
```rust
session
.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
session.advise_supervisory(server_handle, item_handle).await?;
session.write(server_handle, item_handle, value, user_id).await?;
```
The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`.
### Verified / secured writes and user resolution
The verified path has typed session helpers too: `authenticate_user` (returns
the resolved MXAccess user id), `archestra_user_to_id`, and
`write_secured` / `write_secured2`. MXAccess parity is preserved — a
`write_secured` issued before the required `authenticate_user` +
`advise_supervisory` (or before a value-bearing body) fails natively and the
failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials
passed to `authenticate_user` (and secured write payloads) are placed only on
the wire — the client never logs them and never embeds them in an `Error`'s
`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
messages and reply diagnostics) is scrubbed by the credential-redaction seam.
The CLI mirrors these as `authenticate-user` (password via `--password` or the
`--password-env` env var, never echoed) and `write-secured`.
The remaining single-item command helpers round out MXAccess parity:
`unregister`, `suspend` / `activate` (each returns the operation's
`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`.
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
+178 -14
View File
@@ -21,11 +21,10 @@ use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
WriteSecuredBulkEntry,
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
@@ -118,6 +117,67 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Release a `ServerHandle` (and the items advised under it) via
/// MXAccess `Unregister`.
Unregister {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
json: bool,
},
/// Resolve an MXAccess user id from a credential via `AuthenticateUser`.
/// The password is read from `--password` or, if omitted, from the
/// environment variable named by `--password-env`; it is never echoed to
/// stdout/stderr.
AuthenticateUser {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
verify_user: String,
/// Verifier password. Prefer `--password-env` so the secret never
/// appears in the process command line.
#[arg(long)]
password: Option<String>,
/// Name of the environment variable holding the verifier password.
/// Used only when `--password` is not supplied.
#[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")]
password_env: String,
#[arg(long)]
json: bool,
},
/// Single credential-verified write via MXAccess `WriteSecured`.
///
/// Parity note: this fails natively unless the session has first run
/// `authenticate-user` + `advise-supervisory` and the item carries a
/// value-bearing body — the native failure is surfaced, not hidden.
WriteSecured {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
session_id: String,
#[arg(long)]
server_handle: i32,
#[arg(long)]
item_handle: i32,
#[arg(long, value_enum)]
value_type: CliValueType,
#[arg(long)]
value: String,
#[arg(long, default_value_t = 0)]
current_user_id: i32,
#[arg(long, default_value_t = 0)]
verifier_user_id: i32,
#[arg(long)]
json: bool,
},
SubscribeBulk {
#[command(flatten)]
connection: ConnectionArgs,
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
} => {
let session = session_for(connection, session_id).await?;
session
.invoke(
MxCommandKind::AdviseSupervisory,
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
AdviseSupervisoryCommand {
server_handle,
item_handle,
},
),
)
.advise_supervisory(server_handle, item_handle)
.await?;
print_ok("advise-supervisory", json);
}
Command::Unregister {
connection,
session_id,
server_handle,
json,
} => {
let session = session_for(connection, session_id).await?;
session.unregister(server_handle).await?;
print_ok("unregister", json);
}
Command::AuthenticateUser {
connection,
session_id,
server_handle,
verify_user,
password,
password_env,
json,
} => {
// Resolve the credential from --password or the named env var.
// The password is passed straight to the typed helper and is never
// echoed to stdout/stderr or embedded in an error message.
let verify_user_password = password
.or_else(|| env::var(&password_env).ok())
.ok_or_else(|| Error::InvalidArgument {
name: "password".to_owned(),
detail: format!(
"supply --password or set the environment variable `{password_env}`"
),
})?;
let session = session_for(connection, session_id).await?;
let user_id = session
.authenticate_user(server_handle, &verify_user, &verify_user_password)
.await?;
print_handle("userId", user_id, json);
}
Command::WriteSecured {
connection,
session_id,
server_handle,
item_handle,
value_type,
value,
current_user_id,
verifier_user_id,
json,
} => {
let session = session_for(connection, session_id).await?;
session
.write_secured(
server_handle,
item_handle,
current_user_id,
verifier_user_id,
parse_value(value_type, &value)?,
)
.await?;
print_ok("write-secured", json);
}
Command::SubscribeBulk {
connection,
session_id,
@@ -2489,6 +2600,59 @@ mod tests {
assert_eq!(value["workerProtocolVersion"], 1);
}
#[test]
fn parses_authenticate_user_command_with_password_env() {
let parsed = Cli::try_parse_from([
"mxgw",
"authenticate-user",
"--session-id",
"session-1",
"--server-handle",
"7",
"--verify-user",
"verifier",
"--password-env",
"MY_PW_VAR",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_write_secured_command() {
let parsed = Cli::try_parse_from([
"mxgw",
"write-secured",
"--session-id",
"session-1",
"--server-handle",
"12",
"--item-handle",
"34",
"--value-type",
"int32",
"--value",
"5",
"--current-user-id",
"1",
"--verifier-user-id",
"2",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_unregister_command() {
let parsed = Cli::try_parse_from([
"mxgw",
"unregister",
"--session-id",
"session-1",
"--server-handle",
"12",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_stream_alarms_command() {
let parsed = Cli::try_parse_from([
+357 -9
View File
@@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::MxValue;
use crate::value::{MxStatus, MxValue};
const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply)
}
/// Run MXAccess `Unregister` to release the given `ServerHandle` and the
/// items advised under it. Mirrors [`Session::register`]; the worker
/// returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] if the worker reports a non-OK protocol
/// status and [`Error::MxAccess`] if MXAccess itself rejects the
/// unregister (negative `hresult` / non-success status), plus the usual
/// transport/status errors.
pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
self.invoke(
MxCommandKind::Unregister,
Payload::Unregister(UnregisterCommand { server_handle }),
)
.await?;
Ok(())
}
/// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`.
///
@@ -230,6 +252,133 @@ impl Session {
Ok(())
}
/// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
/// notifications for the given item. Mirrors [`Session::advise`]; the
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status and
/// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
/// non-success status, plus the usual transport/status errors.
pub async fn advise_supervisory(
&self,
server_handle: i32,
item_handle: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
Ok(())
}
/// Run MXAccess `Suspend` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation.
///
/// A top-level MXAccess failure (negative `hresult` or a non-success
/// top-level status) still surfaces as [`Error::MxAccess`] via the shared
/// reply validation; the returned [`MxStatus`] is the per-operation status
/// carried in the `SuspendReply` payload.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] on an MXAccess-level failure, and
/// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
/// plus the usual transport/status errors.
pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Suspend,
Payload::Suspend(SuspendCommand {
server_handle,
item_handle,
}),
)
.await?;
suspend_status(reply)
}
/// Run MXAccess `Activate` on the given item and return the native
/// `MXSTATUS_PROXY` the worker reports for the operation. See
/// [`Session::suspend`] for the status/error contract.
///
/// # Errors
///
/// Same conditions as [`Session::suspend`] (with the `Activate` payload).
pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
let reply = self
.invoke(
MxCommandKind::Activate,
Payload::Activate(ActivateCommand {
server_handle,
item_handle,
}),
)
.await?;
activate_status(reply)
}
/// Run MXAccess `AddBufferedItem` against `server_handle` and return the
/// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
/// item carries a caller-supplied context string.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
/// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
/// lacks the item handle, plus the usual transport/status errors.
pub async fn add_buffered_item(
&self,
server_handle: i32,
item_definition: &str,
item_context: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AddBufferedItem,
Payload::AddBufferedItem(AddBufferedItemCommand {
server_handle,
item_definition: item_definition.to_owned(),
item_context: item_context.to_owned(),
}),
)
.await?;
add_buffered_item_handle(&reply)
}
/// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
/// worker returns no payload, so the call resolves to `()` on success.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or an MXAccess-level failure, plus the usual transport/status
/// errors.
pub async fn set_buffered_update_interval(
&self,
server_handle: i32,
update_interval_milliseconds: i32,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::SetBufferedUpdateInterval,
Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
server_handle,
update_interval_milliseconds,
}),
)
.await?;
Ok(())
}
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector.
///
@@ -628,6 +777,142 @@ impl Session {
Ok(())
}
/// Run MXAccess `WriteSecured` (single credential-verified write, no
/// caller-supplied timestamp).
///
/// **MXAccess parity:** `WriteSecured` failing before a prior
/// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
/// before a value-bearing body, is the native contract, not a client bug —
/// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
/// **not** smoothed over. The `value` is credential-sensitive: it is placed
/// only in the wire command and is never logged or embedded in an error
/// message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
/// usual transport/status errors.
pub async fn write_secured(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured,
Payload::WriteSecured(WriteSecuredCommand {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `WriteSecured2` (credential-verified write with a
/// caller-supplied timestamp). See [`Session::write_secured`] for the
/// parity and credential-handling contract.
///
/// # Errors
///
/// Same conditions as [`Session::write_secured`].
pub async fn write_secured2(
&self,
server_handle: i32,
item_handle: i32,
current_user_id: i32,
verifier_user_id: i32,
value: MxValue,
timestamp_value: MxValue,
) -> Result<(), Error> {
self.invoke(
MxCommandKind::WriteSecured2,
Payload::WriteSecured2(WriteSecured2Command {
server_handle,
item_handle,
current_user_id,
verifier_user_id,
value: Some(value.into_proto()),
timestamp_value: Some(timestamp_value.into_proto()),
}),
)
.await?;
Ok(())
}
/// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
/// id.
///
/// **Credential handling:** `verify_user_password` is a raw MXAccess
/// credential. It is placed only in the wire command; this helper never
/// logs it and never embeds it in an [`Error`] — the only error text that
/// can surface comes from `tonic::Status` messages and the reply's
/// diagnostic fields, both of which are scrubbed by the client's
/// credential-redaction seam (see [`crate::error`]). The reply itself
/// carries no echo of the credential.
///
/// **MXAccess parity:** a failed authentication is a native outcome, not a
/// client error to paper over — it surfaces as [`Error::MxAccess`]
/// (negative `hresult`) with the credential absent from the message.
///
/// # Errors
///
/// Returns [`Error::Command`] for a non-OK protocol status,
/// [`Error::MxAccess`] when MXAccess rejects the credential,
/// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
/// usual transport/status errors.
pub async fn authenticate_user(
&self,
server_handle: i32,
verify_user: &str,
verify_user_password: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::AuthenticateUser,
Payload::AuthenticateUser(AuthenticateUserCommand {
server_handle,
verify_user: verify_user.to_owned(),
verify_user_password: verify_user_password.to_owned(),
}),
)
.await?;
authenticate_user_id(&reply)
}
/// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
/// its MXAccess user id.
///
/// # Errors
///
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
/// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
/// reply lacks the user id, plus the usual transport/status errors.
pub async fn archestra_user_to_id(
&self,
server_handle: i32,
user_id_guid: &str,
) -> Result<i32, Error> {
let reply = self
.invoke(
MxCommandKind::ArchestraUserToId,
Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
server_handle,
user_id_guid: user_id_guid.to_owned(),
}),
)
.await?;
archestra_user_id(&reply)
}
/// Open the per-session event stream from the beginning.
///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
@@ -769,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
}
}
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
Ok(add_buffered.item_handle)
}
_ => reply
.return_value
.as_ref()
.and_then(int32_reply_value)
.ok_or_else(|| Error::MalformedReply {
detail:
"add_buffered_item reply lacked an item_handle payload or int32 return_value"
.to_owned(),
}),
}
}
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
_ => Err(Error::MalformedReply {
detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
}),
}
}
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
match reply.payload.as_ref() {
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
_ => Err(Error::MalformedReply {
detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
}),
}
}
fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "suspend reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "suspend reply did not carry a Suspend payload".to_owned(),
}),
}
}
fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
match reply.payload {
Some(mx_command_reply::Payload::Activate(activate)) => activate
.status
.map(MxStatus::from_proto)
.ok_or_else(|| Error::MalformedReply {
detail: "activate reply payload lacked a status entry".to_owned(),
}),
_ => Err(Error::MalformedReply {
detail: "activate reply did not carry an Activate payload".to_owned(),
}),
}
}
enum BulkReplyKind {
AddItem,
AdviseItem,
+183 -4
View File
@@ -23,10 +23,11 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
@@ -624,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32));
}
#[tokio::test]
async fn advise_supervisory_round_trips_and_sends_advise_supervisory_kind() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
session.advise_supervisory(12, 34).await.unwrap();
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::AdviseSupervisory as i32));
}
#[tokio::test]
async fn unregister_round_trips_and_sends_unregister_kind() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
session.unregister(12).await.unwrap();
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::Unregister as i32));
}
#[tokio::test]
async fn write_secured_surfaces_native_mxaccess_failure_and_redacts_diagnostic() {
// MXAccess parity: WriteSecured failing (e.g. before authenticate +
// advise-supervisory) is a native outcome, surfaced — not smoothed over.
// The scripted reply carries an Ok protocol envelope but a negative
// hresult, so this also proves the typed helper runs ensure_mxaccess_success.
let state = Arc::new(FakeState::default());
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let error = session
.write_secured(12, 34, 0, 0, ClientMxValue::int32(1))
.await
.unwrap_err();
let Error::MxAccess(mx_access) = &error else {
panic!("write_secured must surface the native failure as Error::MxAccess: {error:?}");
};
assert_eq!(mx_access.reply().hresult, Some(-2_147_217_900));
let rendered = error.to_string();
assert!(rendered.contains("<redacted>"), "diagnostic: {rendered}");
assert!(
!rendered.contains("leaked_secret"),
"credential-shaped diagnostic must be scrubbed: {rendered}"
);
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured as i32));
}
#[tokio::test]
async fn authenticate_user_returns_user_id_and_transmits_credential_on_wire() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let user_id = session
.authenticate_user(7, "verifier", "sup3r-s3cret-pw")
.await
.unwrap();
assert_eq!(user_id, 4242);
let captured = state
.last_authenticate_user
.lock()
.await
.take()
.expect("fake should have captured an AuthenticateUserCommand");
assert_eq!(captured.server_handle, 7);
assert_eq!(captured.verify_user, "verifier");
// The credential must reach the wire so authentication can succeed...
assert_eq!(captured.verify_user_password, "sup3r-s3cret-pw");
let last_command = state.last_command_kind.lock().await;
assert_eq!(*last_command, Some(MxCommandKind::AuthenticateUser as i32));
}
#[tokio::test]
async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() {
// ...but a native authentication failure must never leak the credential
// into the error's Display or Debug rendering.
let state = Arc::new(FakeState::default());
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let session = client.session("session-fixture");
let password = "unique-credential-9f3b2";
let error = session
.authenticate_user(7, "verifier", password)
.await
.unwrap_err();
assert!(
matches!(error, Error::MxAccess(_)),
"native auth failure should surface as Error::MxAccess: {error:?}"
);
let display = error.to_string();
let debug = format!("{error:?}");
assert!(
!display.contains(password),
"credential leaked into Display: {display}"
);
assert!(
!debug.contains(password),
"credential leaked into Debug: {debug}"
);
}
#[tokio::test]
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
let state = Arc::new(FakeState::default());
@@ -732,6 +860,10 @@ struct FakeState {
/// Captures the last `WriteCommand` payload received, populated when the
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
last_write_command: Mutex<Option<WriteCommand>>,
/// Captures the last `AuthenticateUserCommand` payload received, populated
/// by the `AuthenticateUser` happy-path handler so a test can confirm the
/// credential reaches the wire (but never a surfaced error).
last_authenticate_user: Mutex<Option<AuthenticateUserCommand>>,
stream_dropped: Arc<AtomicBool>,
/// Optional per-test override that pins the fake's `Invoke` handler to
/// a specific reply shape (or `Err(Status)`). The default of `None`
@@ -765,6 +897,12 @@ enum InvokeOverride {
/// and capture the decoded `WriteCommand` in
/// `FakeState::last_write_command` for inspection.
WriteOk,
/// Reply with an `Ok` protocol envelope but a negative `hresult` and a
/// non-success status entry carrying a credential-shaped diagnostic. This
/// mimics the worker's COMException path (e.g. `WriteSecured` /
/// `AuthenticateUser` rejected by MXAccess) so the client's
/// `ensure_mxaccess_success` check is exercised on the typed helper path.
MxAccessFailure,
}
#[derive(Clone)]
@@ -846,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
..MxCommandReply::default()
})),
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)),
InvokeOverride::MxAccessFailure => Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
// Protocol envelope succeeds; MXAccess itself failed.
protocol_status: Some(ok_status("command ok")),
// 0x80040E14 (a COM failure) as a signed 32-bit value.
hresult: Some(-2_147_217_900),
statuses: vec![MxStatusProxy {
success: 0,
category: MxStatusCategory::SecurityError as i32,
detected_by: MxStatusSource::RespondingLmx as i32,
detail: 123,
// A credential-shaped token that must be scrubbed from
// any surfaced diagnostic text.
diagnostic_text: "denied for mxgw_leaked_secret".to_owned(),
..MxStatusProxy::default()
}],
payload: None,
..MxCommandReply::default()
})),
InvokeOverride::WriteOk => {
// Extract and capture the WriteCommand payload so the test
// can assert on server_handle, item_handle, user_id, and value.
@@ -977,6 +1136,26 @@ impl MxAccessGateway for FakeGateway {
)));
}
if kind == MxCommandKind::AuthenticateUser as i32 {
// Capture the transmitted command so a test can confirm the
// credential reaches the wire but never an error message.
if let Some(mx_command::Payload::AuthenticateUser(auth)) =
request.command.and_then(|command| command.payload)
{
*self.state.last_authenticate_user.lock().await = Some(auth);
}
return Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
protocol_status: Some(ok_status("command ok")),
payload: Some(mx_command_reply::Payload::AuthenticateUser(
AuthenticateUserReply { user_id: 4242 },
)),
..MxCommandReply::default()
}));
}
Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),