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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user