fix(CLI-40,CLI-41,CLI-44): exact-secret scrub, uniform malformed-reply contract, Go terminal-error mislabel
CLI-40: port the exact-secret credential scrub to Rust/Java/.NET (Go/Python
already did it). AuthenticateUser/WriteSecured(2) helpers now redact the exact
caller-supplied secret from any surfaced error, as defense-in-depth on top of the
by-construction guarantee. Rust hand-writes a redacting Debug (derived Debug would
leak the reply); Java/.NET rebuild the same exception type with the redacted
message and do not carry the secret-bearing original forward (so ToString/stack
traces stay clean too).
CLI-41: uniform malformed-reply contract for AuthenticateUser/ArchestrAUserToId/
AddBufferedItem across all five clients — typed payload, else a present int32
return_value, else a typed malformed-reply error. Fixes Go/Java silent-0, .NET
NRE, and Rust's own internal inconsistency.
CLI-44: the Go event goroutine's Recv-error path now uses a non-blocking
sendTerminalEventResult on the reserved slot, so a genuine terminal stream error
is reported as itself instead of being mislabeled ErrSlowConsumer under overflow.
Riders from the CLI-37/38 review: (a) .NET ToDiagnosticSummary and Python
_mxaccess_message surface the raw success member (diagnostics-only parity with
Rust); (b) the status-conversion fixture carries an independent wantSuccess
boolean and the Go/.NET fixture tests assert against it instead of recomputing
the formula under test.
Shared fixtures (authenticate-user.{echoed-credential,missing-payload,
return-value-only}.reply.json) + manifest + ClientBehaviorFixtures.md +
ClientLibrariesDesign.md updated in the same change. Tracking: CLI-40/41/44 -> Done.
This commit is contained in:
@@ -804,6 +804,93 @@ async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_user_scrubs_exact_caller_credential_echoed_in_diagnostic() {
|
||||
// CLI-40: MXAccess can echo the supplied credential back inside its failure
|
||||
// diagnostic (here in statuses[0].diagnostic_text). The token has no
|
||||
// mxgw_/bearer shape, so the pattern scrub alone cannot catch it — the
|
||||
// exact-secret scrub must replace the caller's password with <redacted>.
|
||||
let credential = "sup3rSecretVerify9f3a2b";
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.invoke_override.lock().await = Some(InvokeOverride::CannedReply(Box::new(
|
||||
command_reply_fixture("authenticate-user.echoed-credential.reply.json"),
|
||||
)));
|
||||
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
|
||||
.authenticate_user(7, "verifier", credential)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::MxAccess(_)),
|
||||
"OK protocol + negative hresult must route to Error::MxAccess: {error:?}"
|
||||
);
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
!rendered.contains(credential),
|
||||
"exact caller credential leaked into the surfaced error: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("<redacted>"),
|
||||
"credential occurrence must be replaced with <redacted>: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_user_maps_missing_payload_reply_to_malformed_reply() {
|
||||
// CLI-41: an OK reply with neither a typed AuthenticateUser payload nor a
|
||||
// return_value is malformed.
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.invoke_override.lock().await = Some(InvokeOverride::CannedReply(Box::new(
|
||||
command_reply_fixture("authenticate-user.missing-payload.reply.json"),
|
||||
)));
|
||||
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
|
||||
.authenticate_user(7, "verifier", "pw")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::MalformedReply { .. }),
|
||||
"missing payload + missing return_value must be MalformedReply, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_user_falls_back_to_return_value_when_typed_payload_absent() {
|
||||
// CLI-41: an OK reply that carries only a return_value (legacy worker) must
|
||||
// resolve the user id from it, mirroring add_buffered_item's fallback.
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.invoke_override.lock().await = Some(InvokeOverride::CannedReply(Box::new(
|
||||
command_reply_fixture("authenticate-user.return-value-only.reply.json"),
|
||||
)));
|
||||
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", "pw")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user_id, 7,
|
||||
"user id must resolve from the int32 return_value"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
@@ -955,6 +1042,11 @@ enum InvokeOverride {
|
||||
/// `AuthenticateUser` rejected by MXAccess) so the client's
|
||||
/// `ensure_mxaccess_success` check is exercised on the typed helper path.
|
||||
MxAccessFailure,
|
||||
/// Reply with a caller-supplied canned [`MxCommandReply`]. Lets a test
|
||||
/// drive a helper with a shared behavior fixture (e.g. the
|
||||
/// echoed-credential / missing-payload / return-value-only
|
||||
/// authenticate-user replies). Boxed to keep the enum small.
|
||||
CannedReply(Box<MxCommandReply>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1057,6 +1149,7 @@ impl MxAccessGateway for FakeGateway {
|
||||
payload: None,
|
||||
..MxCommandReply::default()
|
||||
})),
|
||||
InvokeOverride::CannedReply(reply) => Ok(Response::new(*reply)),
|
||||
InvokeOverride::WriteOk => {
|
||||
// Extract and capture the WriteCommand payload so the test
|
||||
// can assert on server_handle, item_handle, user_id, and value.
|
||||
@@ -1443,6 +1536,18 @@ fn command_reply_fixture(file_name: &str) -> MxCommandReply {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The fixtures that exercise the return_value fallback path carry a typed
|
||||
// `returnValue` (VT_I4). Project it so a canned reply can drive the
|
||||
// helper's payload -> return_value -> MalformedReply precedence.
|
||||
let return_value = fixture.get("returnValue").and_then(|value| {
|
||||
value["int32Value"].as_i64().map(|int32| MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
variant_type: value["variantType"].as_str().unwrap_or("VT_I4").to_owned(),
|
||||
kind: Some(Kind::Int32Value(int32 as i32)),
|
||||
..MxValue::default()
|
||||
})
|
||||
});
|
||||
|
||||
MxCommandReply {
|
||||
session_id: fixture["sessionId"].as_str().unwrap_or_default().to_owned(),
|
||||
correlation_id: fixture["correlationId"]
|
||||
@@ -1452,6 +1557,7 @@ fn command_reply_fixture(file_name: &str) -> MxCommandReply {
|
||||
protocol_status: Some(ok_status("command ok")),
|
||||
hresult: fixture["hresult"].as_i64().map(|hresult| hresult as i32),
|
||||
statuses,
|
||||
return_value,
|
||||
..MxCommandReply::default()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user