fix(CLI-37,CLI-38): make status/HRESULT reply validation conformant across all five clients

One cross-client conformance pass; also closes first-cycle CLI-08.

CLI-37: an MxStatusProxy entry is a failure iff `category !=
MX_STATUS_CATEGORY_OK`. The proto contract has always said so — `success` is
the raw 16-bit COM member carried verbatim for diagnostics, not a boolean — but
four clients branched on `success` alone and .NET required both, so the same
gateway reply produced opposite verdicts per language. An absent entry stays
success; a present entry with an UNSPECIFIED category is a failure, because the
worker always maps a category and an unmapped one is not proven OK.

CLI-38: a reply fails on HRESULT iff `hresult` is present and negative, so
positive COM success codes such as S_FALSE (1) pass. .NET/Go/Java used `!= 0`,
which errored on a parity-preserving S_FALSE that Python and Rust accepted.
This makes the existing ClientLibrariesDesign.md claim true rather than
rewriting the doc to describe the divergence.

Four shared fixtures pin both rules cross-client, and each language suite also
carries a table test for the two edges a fixture cannot express (absent entry,
UNSPECIFIED category). A Java test fake that built a status with a bare
`setSuccess(1)` and no category is fixed — under the category rule that reply
was never a success.
This commit is contained in:
Joseph Doherty
2026-08-07 06:00:58 -04:00
parent cf66ebbcfb
commit d6b2f24c3f
30 changed files with 631 additions and 40 deletions
+6 -1
View File
@@ -139,7 +139,12 @@ commands, so you do not need to build raw `MxCommand` messages:
All of them run the same MXAccess reply validation as the bulk helpers (protocol
status plus HRESULT/`MxStatusProxy` check) via the shared `invoke` path, so an
MXAccess COM-side failure surfaces as `MxAccessException`.
MXAccess COM-side failure surfaces as `MxAccessException`. That validation
follows COM semantics: only a **negative** HRESULT is a failure, so positive
success codes such as `S_FALSE` (1) pass. `MxStatuses.succeeded` judges each
entry by its category — an entry fails when its category is not
`MX_STATUS_CATEGORY_OK`, and the raw `success` member is a diagnostic that never
decides the verdict. A `null` entry is success.
**Secret redaction.** Credentials passed to `authenticateUser` (and the
credential-sensitive values passed to `writeSecured`/`writeSecured2`) travel
@@ -47,7 +47,9 @@ final class MxGatewayErrors {
if (reply == null) {
return;
}
if (reply.hasHresult() && reply.getHresult() != 0) {
// COM semantics: only a negative HRESULT is a failure. Positive success
// codes such as S_FALSE (1) pass.
if (reply.hasHresult() && reply.getHresult() < 0) {
throw new MxAccessException(operation, reply);
}
for (var status : reply.getStatusesList()) {
@@ -8,8 +8,11 @@ import mxaccess_gateway.v1.MxaccessGateway.MxStatusSource;
* Helpers for inspecting {@link MxStatusProxy} values returned by the gateway.
*
* <p>An {@code MxStatusProxy} mirrors the MXAccess COM {@code MXSTATUS_PROXY}
* struct. The success flag uses the MXAccess convention where any non-zero
* value indicates success.
* struct. Per the wire contract, {@code category} is the authoritative verdict:
* an entry succeeds only when its category is
* {@code MX_STATUS_CATEGORY_OK}. The {@code success} member carries the raw
* 16-bit COM value verbatim for diagnostics and is not a boolean, so it never
* decides success or failure.
*/
public final class MxStatuses {
private MxStatuses() {
@@ -18,12 +21,17 @@ public final class MxStatuses {
/**
* Returns whether the supplied status proxy reports success.
*
* <p>A {@code null} status is success because nothing was reported. A
* present entry whose category is {@code MX_STATUS_CATEGORY_UNSPECIFIED}
* is a failure: the worker always maps a category, so an unmapped one is
* not proven OK.
*
* @param status the status proxy, may be {@code null}
* @return {@code true} if {@code status} is {@code null} or its success
* flag is non-zero, {@code false} otherwise
* @return {@code true} if {@code status} is {@code null} or its category is
* {@code MX_STATUS_CATEGORY_OK}, {@code false} otherwise
*/
public static boolean succeeded(MxStatusProxy status) {
return status == null || status.getSuccess() != 0;
return status == null || status.getCategory() == MxStatusCategory.MX_STATUS_CATEGORY_OK;
}
/**
@@ -44,9 +52,11 @@ public final class MxStatuses {
*/
public record MxStatusView(MxStatusProxy raw) {
/**
* Returns the raw success flag (non-zero indicates success).
* Returns the raw {@code success} member exactly as MXAccess reported
* it. This is a diagnostic value, not a verdict — use
* {@link MxStatuses#succeeded(MxStatusProxy)} to decide success.
*
* @return the success flag value
* @return the raw success member
*/
public int success() {
return raw.getSuccess();
@@ -701,14 +701,17 @@ final class MxGatewayClientSessionTests {
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok());
// `category` is the authoritative success indicator, so the fake
// must set it — a bare non-zero `success` is not a success.
var okStatus = mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)
.setCategory(mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory.MX_STATUS_CATEGORY_OK);
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_SUSPEND) {
reply.setSuspend(mxaccess_gateway.v1.MxaccessGateway.SuspendReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
.setStatus(okStatus));
} else if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ACTIVATE) {
reply.setActivate(mxaccess_gateway.v1.MxaccessGateway.ActivateReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
.setStatus(okStatus));
}
responseObserver.onNext(reply.build());
responseObserver.onCompleted();
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonArray;
@@ -20,6 +21,8 @@ import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
final class MxGatewayFixtureTests {
@Test
@@ -89,6 +92,50 @@ final class MxGatewayFixtureTests {
throw new AssertionError("expected MxAccessException");
}
@ParameterizedTest
@CsvSource({
"register.ok.reply.json,false",
"write.status-category-error-success-set.reply.json,true",
"write.status-category-ok-success-zero.reply.json,false",
"write.hresult-s-false.reply.json,false",
"write.hresult-e-fail.reply.json,true",
})
void replyValidationFixturesBranchOnCategoryAndNegativeHresult(String fixture, boolean expectFailure)
throws Exception {
MxCommandReply.Builder builder = MxCommandReply.newBuilder();
JsonFormat.parser().merge(
Files.readString(fixtureRoot().resolve("command-replies/" + fixture)),
builder);
MxCommandReply reply = builder.build();
if (expectFailure) {
assertThrows(MxAccessException.class, () -> MxGatewayErrors.ensureMxAccessSuccess("write", reply));
} else {
MxGatewayErrors.ensureMxAccessSuccess("write", reply);
}
}
@ParameterizedTest
@CsvSource({
"MX_STATUS_CATEGORY_OK,0,true",
"MX_STATUS_CATEGORY_OK,1,true",
"MX_STATUS_CATEGORY_COMMUNICATION_ERROR,1,false",
"MX_STATUS_CATEGORY_UNSPECIFIED,1,false",
})
void statusEntryVerdictIgnoresTheRawSuccessMember(String category, int success, boolean expectSucceeded) {
MxStatusProxy status = MxStatusProxy.newBuilder()
.setCategory(MxStatusCategory.valueOf(category))
.setSuccess(success)
.build();
assertEquals(expectSucceeded, MxStatuses.succeeded(status));
}
@Test
void absentStatusEntryIsSuccess() {
assertTrue(MxStatuses.succeeded(null));
}
@Test
void grpcAuthErrorsAreClassifiedAndRedacted() {
RuntimeException authError = MxGatewayErrors.fromGrpc(