Merge branch 'fix/archreview-p2' into main (P2 tier: completeness & polish)
# Conflicts: # archreview/remediation/00-tracking.md # clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs # clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs # src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs
This commit is contained in:
+63
-12
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
|
||||
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
|
||||
returns the unvalidated reply.
|
||||
|
||||
## Event Streaming And Reconnect-Replay Gaps
|
||||
|
||||
`session.events()` / `session.events_after(after_worker_sequence)` (and the
|
||||
lower-level `client.stream_events`) return an `EventStream` that yields
|
||||
`EventItem` values, not bare `MxEvent`s:
|
||||
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::EventItem;
|
||||
|
||||
let mut stream = session.events_after(cursor).await?;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item? {
|
||||
EventItem::Event(event) => { /* apply the MXAccess change */ }
|
||||
EventItem::ReplayGap(gap) => {
|
||||
// Recent history was evicted — discard local state and re-snapshot,
|
||||
// then resume without provoking another gap:
|
||||
let resume = gap.oldest_available_sequence.saturating_sub(1);
|
||||
stream = session.events_after(resume).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
|
||||
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
|
||||
client does not synthesize it. The gateway emits the sentinel at most once, at
|
||||
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
|
||||
when the requested sequence is older than the oldest event still retained in the
|
||||
session's replay ring: events in the open interval
|
||||
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
|
||||
be replayed. A `ReplayGap` therefore means "you missed events — discard any
|
||||
local state and re-snapshot." To resume without a second gap, reconnect with
|
||||
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
|
||||
first still-retained event. A stream opened from the beginning
|
||||
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
|
||||
|
||||
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
|
||||
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
|
||||
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
|
||||
object under `--json` / `--jsonl`).
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -151,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
|
||||
|
||||
@@ -21,15 +21,14 @@ 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, GalaxyClient, GatewayClient, MxValue,
|
||||
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
|
||||
MxValue, MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
const MAX_AGGREGATE_EVENTS: usize = 10_000;
|
||||
@@ -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,
|
||||
@@ -886,17 +997,43 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut event_count = 0usize;
|
||||
while event_count < max_events {
|
||||
let Some(event) = stream.next().await else {
|
||||
let Some(item) = stream.next().await else {
|
||||
break;
|
||||
};
|
||||
let event = event?;
|
||||
let item = item?;
|
||||
event_count += 1;
|
||||
if jsonl {
|
||||
println!("{}", event_to_json(&event));
|
||||
} else if json {
|
||||
events.push(event_to_json(&event));
|
||||
} else {
|
||||
println!("{} {}", event.worker_sequence, event.family);
|
||||
match item {
|
||||
EventItem::Event(event) => {
|
||||
if jsonl {
|
||||
println!("{}", event_to_json(&event));
|
||||
} else if json {
|
||||
events.push(event_to_json(&event));
|
||||
} else {
|
||||
println!("{} {}", event.worker_sequence, event.family);
|
||||
}
|
||||
}
|
||||
// Reconnect-replay gap sentinel: recent history was evicted
|
||||
// before this resumed stream could replay it. Render it as a
|
||||
// distinct row so the caller can re-snapshot and resume with
|
||||
// `oldest_available_sequence - 1`.
|
||||
EventItem::ReplayGap(gap) => {
|
||||
let value = json!({
|
||||
"replayGap": {
|
||||
"requestedAfterSequence": gap.requested_after_sequence,
|
||||
"oldestAvailableSequence": gap.oldest_available_sequence,
|
||||
}
|
||||
});
|
||||
if jsonl {
|
||||
println!("{value}");
|
||||
} else if json {
|
||||
events.push(value);
|
||||
} else {
|
||||
println!(
|
||||
"REPLAY_GAP requested_after={} oldest_available={}",
|
||||
gap.requested_after_sequence, gap.oldest_available_sequence
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if json {
|
||||
@@ -2463,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([
|
||||
|
||||
+121
-8
@@ -18,7 +18,7 @@ use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGa
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, ReplayGap, StreamAlarmsRequest,
|
||||
StreamEventsRequest,
|
||||
};
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
@@ -28,11 +28,120 @@ use crate::session::Session;
|
||||
/// [`GatewayClient`] uses internally.
|
||||
pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||
|
||||
/// Pinned, boxed [`MxEvent`] stream returned by
|
||||
/// [`GatewayClient::stream_events`]. Errors are pre-mapped from
|
||||
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
||||
/// One item yielded by the per-session event stream returned by
|
||||
/// [`GatewayClient::stream_events`].
|
||||
///
|
||||
/// Almost every item is an ordinary MXAccess event ([`EventItem::Event`]).
|
||||
/// The one exception is the reconnect-replay gap sentinel
|
||||
/// ([`EventItem::ReplayGap`]): the gateway emits it at most once, at the head
|
||||
/// of a stream that was *resumed* via
|
||||
/// [`Session::events_after`](crate::session::Session::events_after)
|
||||
/// (`StreamEventsRequest.after_worker_sequence`) when the requested sequence is
|
||||
/// older than the oldest event still retained in the session replay ring — i.e.
|
||||
/// events were evicted and cannot be replayed.
|
||||
///
|
||||
/// The client does **not** synthesize this signal: it faithfully forwards the
|
||||
/// gateway's sentinel `MxEvent` (whose `replay_gap` field is set), and only
|
||||
/// makes it a distinct, typed variant so consumers can `match` on it instead of
|
||||
/// inspecting a field on a value that otherwise looks like a normal event.
|
||||
///
|
||||
/// # Reacting to a gap
|
||||
///
|
||||
/// A [`EventItem::ReplayGap`] means "you missed events — discard any local
|
||||
/// state and re-snapshot." The events in the open interval
|
||||
/// `(requested_after_sequence, oldest_available_sequence)` are gone. To resume
|
||||
/// the stream without provoking another gap, reconnect with
|
||||
/// [`Session::events_after`](crate::session::Session::events_after) passing
|
||||
/// `oldest_available_sequence - 1`, which replays starting at the first still
|
||||
/// retained event (`oldest_available_sequence`):
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use zb_mom_ww_mxgateway_client::{EventItem, Session};
|
||||
/// # use futures_util::StreamExt;
|
||||
/// # async fn run(session: Session, cursor: u64) -> Result<(), zb_mom_ww_mxgateway_client::Error> {
|
||||
/// let mut stream = session.events_after(cursor).await?;
|
||||
/// while let Some(item) = stream.next().await {
|
||||
/// match item? {
|
||||
/// EventItem::Event(event) => {
|
||||
/// let _ = event; // apply the change
|
||||
/// }
|
||||
/// EventItem::ReplayGap(gap) => {
|
||||
/// // Local state is stale — re-snapshot, then resume without a gap.
|
||||
/// let resume_cursor = gap.oldest_available_sequence.saturating_sub(1);
|
||||
/// stream = session.events_after(resume_cursor).await?;
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
// The `Event` variant is the hot path (nearly every stream item) and is the
|
||||
// large one; the rare `ReplayGap` sentinel is small. Boxing `Event` to equalize
|
||||
// the variants would add a heap allocation to every streamed event — a
|
||||
// regression versus the prior `Result<MxEvent, Error>` surface, which already
|
||||
// moved `MxEvent` by value. Keep the common path allocation-free.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EventItem {
|
||||
/// A normal MXAccess event forwarded from the worker.
|
||||
Event(MxEvent),
|
||||
/// The reconnect-replay gap sentinel — recent event history was evicted
|
||||
/// before this resumed stream could replay it. See [`EventItem`] for how
|
||||
/// to react.
|
||||
ReplayGap(ReplayGap),
|
||||
}
|
||||
|
||||
impl EventItem {
|
||||
/// Classify an incoming `MxEvent` into the typed stream item.
|
||||
///
|
||||
/// A present `replay_gap` promotes the event to [`EventItem::ReplayGap`];
|
||||
/// otherwise it is an ordinary [`EventItem::Event`]. The sentinel is never
|
||||
/// dropped and never surfaced as a normal event.
|
||||
fn from_event(mut event: MxEvent) -> Self {
|
||||
match event.replay_gap.take() {
|
||||
Some(gap) => EventItem::ReplayGap(gap),
|
||||
None => EventItem::Event(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the inner [`MxEvent`] when this item is a normal event, or
|
||||
/// `None` when it is the [`EventItem::ReplayGap`] sentinel.
|
||||
#[must_use]
|
||||
pub fn as_event(&self) -> Option<&MxEvent> {
|
||||
match self {
|
||||
EventItem::Event(event) => Some(event),
|
||||
EventItem::ReplayGap(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the [`ReplayGap`] when this item is the reconnect-replay gap
|
||||
/// sentinel, or `None` for a normal event.
|
||||
#[must_use]
|
||||
pub fn replay_gap(&self) -> Option<&ReplayGap> {
|
||||
match self {
|
||||
EventItem::ReplayGap(gap) => Some(gap),
|
||||
EventItem::Event(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the item and return the inner [`MxEvent`] when it is a normal
|
||||
/// event, or `None` for the [`EventItem::ReplayGap`] sentinel.
|
||||
#[must_use]
|
||||
pub fn into_event(self) -> Option<MxEvent> {
|
||||
match self {
|
||||
EventItem::Event(event) => Some(event),
|
||||
EventItem::ReplayGap(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned, boxed [`EventItem`] stream returned by
|
||||
/// [`GatewayClient::stream_events`]. Each item is either a normal
|
||||
/// [`EventItem::Event`] or the [`EventItem::ReplayGap`] reconnect-replay
|
||||
/// sentinel. Errors are pre-mapped from `tonic::Status` to [`Error`]; dropping
|
||||
/// the stream cancels the call.
|
||||
pub type EventStream =
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<EventItem, Error>> + Send + 'static>>;
|
||||
|
||||
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
|
||||
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
|
||||
@@ -190,8 +299,12 @@ impl GatewayClient {
|
||||
|
||||
/// Open the server-streaming `StreamEvents` RPC.
|
||||
///
|
||||
/// The returned [`EventStream`] yields `MxEvent` messages as the worker
|
||||
/// produces them. Dropping the stream cancels the gRPC call cooperatively.
|
||||
/// The returned [`EventStream`] yields [`EventItem`] values as the worker
|
||||
/// produces them: ordinary MXAccess events as [`EventItem::Event`], and the
|
||||
/// gateway's reconnect-replay gap sentinel — set only on resumed streams
|
||||
/// whose requested sequence predates the retained replay history — as
|
||||
/// [`EventItem::ReplayGap`]. Dropping the stream cancels the gRPC call
|
||||
/// cooperatively.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -201,7 +314,7 @@ impl GatewayClient {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.stream_events(self.stream_request(request)).await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
result.map(EventItem::from_event).map_err(Error::from)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
|
||||
@@ -24,12 +24,14 @@ pub mod version;
|
||||
#[doc(inline)]
|
||||
pub use auth::{ApiKey, AuthInterceptor};
|
||||
#[doc(inline)]
|
||||
pub use client::{AlarmFeedStream, EventStream, GatewayClient};
|
||||
pub use client::{AlarmFeedStream, EventItem, EventStream, GatewayClient};
|
||||
#[doc(inline)]
|
||||
pub use error::{CommandError, Error, MxAccessError};
|
||||
#[doc(inline)]
|
||||
pub use galaxy::{DeployEventStream, GalaxyClient};
|
||||
#[doc(inline)]
|
||||
pub use generated::mxaccess_gateway::v1::ReplayGap;
|
||||
#[doc(inline)]
|
||||
pub use options::ClientOptions;
|
||||
#[doc(inline)]
|
||||
pub use session::{next_correlation_id, Session};
|
||||
|
||||
+373
-9
@@ -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,8 +777,151 @@ 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)
|
||||
/// values — normal MXAccess events as
|
||||
/// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
|
||||
/// beginning never produces a
|
||||
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
|
||||
/// only appears on a resumed stream (see [`Session::events_after`]).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the `tonic::Status` mapped through [`Error::from`] when the
|
||||
@@ -642,6 +934,15 @@ impl Session {
|
||||
/// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
|
||||
/// to receive every buffered event.
|
||||
///
|
||||
/// If `after_worker_sequence` predates the oldest event still retained in
|
||||
/// the gateway's replay ring, the stream opens with a single
|
||||
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
|
||||
/// history was evicted and cannot be replayed, so the caller must discard
|
||||
/// any local state and re-snapshot. To resume without provoking another
|
||||
/// gap, call this method again with
|
||||
/// `gap.oldest_available_sequence - 1`. See
|
||||
/// [`EventItem`](crate::EventItem) for the full contract.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::events`].
|
||||
@@ -753,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,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! The protocol versions track the values the gateway and worker negotiate on
|
||||
//! `OpenSession` and let test harnesses cross-check the wire contract.
|
||||
|
||||
/// Semantic version of this Rust client crate. Mirrors `Cargo.toml`.
|
||||
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||
/// Semantic version of this Rust client crate. Sourced from `Cargo.toml` at
|
||||
/// compile time so the two cannot drift.
|
||||
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Public gateway gRPC protocol version this client targets.
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
@@ -23,17 +23,18 @@ 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,
|
||||
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest,
|
||||
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteCommand,
|
||||
WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||
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,
|
||||
};
|
||||
use zb_mom_ww_mxgateway_client::{
|
||||
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus,
|
||||
MxValue as ClientMxValue, MxValueProjection,
|
||||
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, EventItem, GatewayClient,
|
||||
MxStatus, MxValue as ClientMxValue, MxValueProjection,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -128,8 +129,28 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 1);
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 2);
|
||||
assert_eq!(
|
||||
stream
|
||||
.next()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_event()
|
||||
.unwrap()
|
||||
.worker_sequence,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
stream
|
||||
.next()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_event()
|
||||
.unwrap()
|
||||
.worker_sequence,
|
||||
2
|
||||
);
|
||||
|
||||
drop(stream);
|
||||
for _ in 0..20 {
|
||||
@@ -142,6 +163,55 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
|
||||
assert!(state.stream_dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_gap_sentinel_surfaces_as_typed_event_item() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
// Script a resumed stream: the reconnect-replay gap sentinel at the head
|
||||
// (family UNSPECIFIED, no body, `replay_gap` set) followed by a normal
|
||||
// event. The client must promote the sentinel to `EventItem::ReplayGap`
|
||||
// and leave the following event as a normal `EventItem::Event`.
|
||||
*state.stream_events_script.lock().await = Some(vec![
|
||||
MxEvent {
|
||||
replay_gap: Some(ReplayGap {
|
||||
requested_after_sequence: 5,
|
||||
oldest_available_sequence: 42,
|
||||
}),
|
||||
..MxEvent::default()
|
||||
},
|
||||
event(42),
|
||||
]);
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stream = client
|
||||
.stream_events(StreamEventsRequest {
|
||||
session_id: "session-fixture".to_owned(),
|
||||
after_worker_sequence: 5,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// First item is the typed gap sentinel, not a normal event.
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
match &first {
|
||||
EventItem::ReplayGap(gap) => {
|
||||
assert_eq!(gap.requested_after_sequence, 5);
|
||||
assert_eq!(gap.oldest_available_sequence, 42);
|
||||
}
|
||||
EventItem::Event(_) => panic!("expected a ReplayGap sentinel, got a normal event"),
|
||||
}
|
||||
// Accessor helpers reflect the variant.
|
||||
assert!(first.as_event().is_none());
|
||||
assert_eq!(first.replay_gap().unwrap().oldest_available_sequence, 42);
|
||||
|
||||
// The normal event that follows is unaffected.
|
||||
let second = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(second.as_event().unwrap().worker_sequence, 42);
|
||||
assert!(second.replay_gap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_alarm_returns_reply_with_native_status() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
@@ -555,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());
|
||||
@@ -663,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`
|
||||
@@ -672,6 +873,11 @@ struct FakeState {
|
||||
/// handler to emit a synthetic ConditionRefresh -> snapshot_complete
|
||||
/// -> transition sequence.
|
||||
stream_alarms_script: Mutex<Option<Vec<AlarmFeedMessage>>>,
|
||||
/// Optional per-test override that pins the fake's `StreamEvents`
|
||||
/// handler to emit a scripted `MxEvent` sequence (e.g. a `replay_gap`
|
||||
/// sentinel followed by a normal event). When `None`, the handler falls
|
||||
/// back to the default `event(1)` / `event(2)` pair.
|
||||
stream_events_script: Mutex<Option<Vec<MxEvent>>>,
|
||||
}
|
||||
|
||||
/// Per-test override for the fake's `Invoke` handler.
|
||||
@@ -691,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)]
|
||||
@@ -772,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.
|
||||
@@ -903,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(),
|
||||
@@ -921,9 +1174,12 @@ impl MxAccessGateway for FakeGateway {
|
||||
&self,
|
||||
_request: Request<StreamEventsRequest>,
|
||||
) -> Result<Response<Self::StreamEventsStream>, Status> {
|
||||
let (sender, receiver) = mpsc::channel(4);
|
||||
sender.send(Ok(event(1))).await.unwrap();
|
||||
sender.send(Ok(event(2))).await.unwrap();
|
||||
let script = self.state.stream_events_script.lock().await.take();
|
||||
let events = script.unwrap_or_else(|| vec![event(1), event(2)]);
|
||||
let (sender, receiver) = mpsc::channel(events.len().max(1));
|
||||
for event in events {
|
||||
sender.send(Ok(event)).await.unwrap();
|
||||
}
|
||||
|
||||
Ok(Response::new(DropAwareStream {
|
||||
inner: ReceiverStream::new(receiver),
|
||||
|
||||
Reference in New Issue
Block a user