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:
@@ -3,10 +3,68 @@ package mxgateway
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
)
|
||||
|
||||
// redactedSecretMarker is the placeholder substituted for credential material in
|
||||
// surfaced error text. It matches the marker used by RedactAPIKey so the client
|
||||
// presents one consistent redaction shape everywhere secrets could otherwise
|
||||
// leak.
|
||||
const redactedSecretMarker = "<redacted>"
|
||||
|
||||
// secretRedactingError wraps a typed error so any occurrence of a known
|
||||
// credential in the underlying message is replaced with redactedSecretMarker in
|
||||
// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
|
||||
// errors.Is continue to reach the underlying MxAccessError, CommandError, or
|
||||
// GatewayError. This is the seam that keeps AuthenticateUser credentials and
|
||||
// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
|
||||
// even if a gateway diagnostic message were to echo them back.
|
||||
type secretRedactingError struct {
|
||||
err error
|
||||
secrets []string
|
||||
}
|
||||
|
||||
// Error returns the wrapped error's message with every non-empty secret redacted.
|
||||
func (e *secretRedactingError) Error() string {
|
||||
if e == nil || e.err == nil {
|
||||
return ""
|
||||
}
|
||||
message := e.err.Error()
|
||||
for _, secret := range e.secrets {
|
||||
if secret != "" {
|
||||
message = strings.ReplaceAll(message, secret, redactedSecretMarker)
|
||||
}
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped error so typed-error inspection still works through
|
||||
// the redaction wrapper.
|
||||
func (e *secretRedactingError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced
|
||||
// message is redacted, while errors.As / errors.Is still reach the wrapped typed
|
||||
// error. It returns nil unchanged and skips wrapping when no non-empty secret is
|
||||
// supplied, so non-secret-bearing calls keep their original error verbatim.
|
||||
func redactSecrets(err error, secrets ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
if secret != "" {
|
||||
return &secretRedactingError{err: err, secrets: secrets}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
|
||||
// (cancel-when-full) path when the buffered results channel overflows because
|
||||
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
||||
|
||||
@@ -706,6 +706,277 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32,
|
||||
})
|
||||
}
|
||||
|
||||
// AdviseSupervisory invokes MXAccess AdviseSupervisory, advising an item on the
|
||||
// supervisory (as opposed to runtime) data path.
|
||||
func (s *Session) AdviseSupervisory(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||
_, err := s.AdviseSupervisoryRaw(ctx, serverHandle, itemHandle)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdviseSupervisoryRaw invokes MXAccess AdviseSupervisory and returns the raw reply.
|
||||
func (s *Session) AdviseSupervisoryRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
Payload: &pb.MxCommand_AdviseSupervisory{
|
||||
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WriteSecured invokes MXAccess WriteSecured (secured single-item write).
|
||||
//
|
||||
// The value is credential-sensitive: callers must not log it, and any error this
|
||||
// call surfaces has the value's string content redacted (see WriteSecuredRaw).
|
||||
// MXAccess parity is preserved — WriteSecured legitimately fails when it is not
|
||||
// preceded by a matching AuthenticateUser + AdviseSupervisory or when the body
|
||||
// carries no value; that native failure is surfaced as-is, never pre-empted or
|
||||
// reordered by this client.
|
||||
func (s *Session) WriteSecured(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) error {
|
||||
_, err := s.WriteSecuredRaw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteSecuredRaw invokes MXAccess WriteSecured and returns the raw reply. Any
|
||||
// surfaced error is routed through the client's secret-redaction seam so a string
|
||||
// write value never appears in error text.
|
||||
func (s *Session) WriteSecuredRaw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) (*MxCommandReply, error) {
|
||||
if value == nil {
|
||||
return nil, errors.New("mxgateway: write-secured value is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
|
||||
Payload: &pb.MxCommand_WriteSecured{
|
||||
WriteSecured: &pb.WriteSecuredCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
CurrentUserId: currentUserID,
|
||||
VerifierUserId: verifierUserID,
|
||||
Value: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, stringSecrets(value)...)
|
||||
}
|
||||
|
||||
// WriteSecured2 invokes MXAccess WriteSecured2 (secured, timestamped single-item write).
|
||||
//
|
||||
// Like WriteSecured, the value is credential-sensitive and its string content is
|
||||
// scrubbed from any surfaced error. Native parity failures (missing prior
|
||||
// AuthenticateUser/AdviseSupervisory, value-less body) are surfaced unchanged.
|
||||
func (s *Session) WriteSecured2(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) error {
|
||||
_, err := s.WriteSecured2Raw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value, timestampValue)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteSecured2Raw invokes MXAccess WriteSecured2 and returns the raw reply. Any
|
||||
// surfaced error is routed through the client's secret-redaction seam.
|
||||
func (s *Session) WriteSecured2Raw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) (*MxCommandReply, error) {
|
||||
if value == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 value is required")
|
||||
}
|
||||
if timestampValue == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 timestamp value is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2,
|
||||
Payload: &pb.MxCommand_WriteSecured2{
|
||||
WriteSecured2: &pb.WriteSecured2Command{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
CurrentUserId: currentUserID,
|
||||
VerifierUserId: verifierUserID,
|
||||
Value: value,
|
||||
TimestampValue: timestampValue,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, stringSecrets(value)...)
|
||||
}
|
||||
|
||||
// AuthenticateUser invokes MXAccess AuthenticateUser and returns the resolved
|
||||
// MXAccess user id.
|
||||
//
|
||||
// verifyUserPassword is a raw MXAccess credential: this client never logs it and
|
||||
// scrubs it from any error it surfaces (see AuthenticateUserRaw). Callers must
|
||||
// likewise keep it out of their own logs, metrics, and diagnostics.
|
||||
func (s *Session) AuthenticateUser(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (int32, error) {
|
||||
reply, err := s.AuthenticateUserRaw(ctx, serverHandle, verifyUser, verifyUserPassword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetAuthenticateUser() != nil {
|
||||
return reply.GetAuthenticateUser().GetUserId(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// AuthenticateUserRaw invokes MXAccess AuthenticateUser and returns the raw
|
||||
// reply. The credential is scrubbed from any surfaced error via the client's
|
||||
// secret-redaction seam, so even a gateway diagnostic echoing the password back
|
||||
// cannot leak it through this call's error.
|
||||
func (s *Session) AuthenticateUserRaw(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (*MxCommandReply, error) {
|
||||
if verifyUser == "" {
|
||||
return nil, errors.New("mxgateway: verify user is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
Payload: &pb.MxCommand_AuthenticateUser{
|
||||
AuthenticateUser: &pb.AuthenticateUserCommand{
|
||||
ServerHandle: serverHandle,
|
||||
VerifyUser: verifyUser,
|
||||
VerifyUserPassword: verifyUserPassword,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, verifyUserPassword)
|
||||
}
|
||||
|
||||
// ArchestrAUserToId invokes MXAccess ArchestrAUserToId, resolving an ArchestrA
|
||||
// user GUID to its MXAccess integer user id.
|
||||
func (s *Session) ArchestrAUserToId(ctx context.Context, serverHandle int32, userIDGuid string) (int32, error) {
|
||||
reply, err := s.ArchestrAUserToIdRaw(ctx, serverHandle, userIDGuid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetArchestraUserToId() != nil {
|
||||
return reply.GetArchestraUserToId().GetUserId(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// ArchestrAUserToIdRaw invokes MXAccess ArchestrAUserToId and returns the raw reply.
|
||||
func (s *Session) ArchestrAUserToIdRaw(ctx context.Context, serverHandle int32, userIDGuid string) (*MxCommandReply, error) {
|
||||
if userIDGuid == "" {
|
||||
return nil, errors.New("mxgateway: user id GUID is required")
|
||||
}
|
||||
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
|
||||
Payload: &pb.MxCommand_ArchestraUserToId{
|
||||
ArchestraUserToId: &pb.ArchestrAUserToIdCommand{
|
||||
ServerHandle: serverHandle,
|
||||
UserIdGuid: userIDGuid,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AddBufferedItem invokes MXAccess AddBufferedItem and returns the item handle.
|
||||
func (s *Session) AddBufferedItem(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
|
||||
reply, err := s.AddBufferedItemRaw(ctx, serverHandle, itemDefinition, itemContext)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetAddBufferedItem() != nil {
|
||||
return reply.GetAddBufferedItem().GetItemHandle(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// AddBufferedItemRaw invokes MXAccess AddBufferedItem and returns the raw reply.
|
||||
func (s *Session) AddBufferedItemRaw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
|
||||
if itemDefinition == "" {
|
||||
return nil, errors.New("mxgateway: item definition is required")
|
||||
}
|
||||
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
|
||||
Payload: &pb.MxCommand_AddBufferedItem{
|
||||
AddBufferedItem: &pb.AddBufferedItemCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemDefinition: itemDefinition,
|
||||
ItemContext: itemContext,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SetBufferedUpdateInterval invokes MXAccess SetBufferedUpdateInterval.
|
||||
func (s *Session) SetBufferedUpdateInterval(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) error {
|
||||
_, err := s.SetBufferedUpdateIntervalRaw(ctx, serverHandle, updateIntervalMilliseconds)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetBufferedUpdateIntervalRaw invokes MXAccess SetBufferedUpdateInterval and returns the raw reply.
|
||||
func (s *Session) SetBufferedUpdateIntervalRaw(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
|
||||
Payload: &pb.MxCommand_SetBufferedUpdateInterval{
|
||||
SetBufferedUpdateInterval: &pb.SetBufferedUpdateIntervalCommand{
|
||||
ServerHandle: serverHandle,
|
||||
UpdateIntervalMilliseconds: updateIntervalMilliseconds,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Suspend invokes MXAccess Suspend and returns the resulting item status.
|
||||
func (s *Session) Suspend(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
|
||||
reply, err := s.SuspendRaw(ctx, serverHandle, itemHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetSuspend().GetStatus(), nil
|
||||
}
|
||||
|
||||
// SuspendRaw invokes MXAccess Suspend and returns the raw reply.
|
||||
func (s *Session) SuspendRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
|
||||
Payload: &pb.MxCommand_Suspend{
|
||||
Suspend: &pb.SuspendCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Activate invokes MXAccess Activate and returns the resulting item status.
|
||||
func (s *Session) Activate(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
|
||||
reply, err := s.ActivateRaw(ctx, serverHandle, itemHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetActivate().GetStatus(), nil
|
||||
}
|
||||
|
||||
// ActivateRaw invokes MXAccess Activate and returns the raw reply.
|
||||
func (s *Session) ActivateRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ACTIVATE,
|
||||
Payload: &pb.MxCommand_Activate{
|
||||
Activate: &pb.ActivateCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// stringSecrets collects the non-empty string content of the given values so it
|
||||
// can be scrubbed from surfaced errors. Only string-typed MxValues carry
|
||||
// scrubable text; non-string values (numbers, timestamps, arrays) contribute
|
||||
// nothing.
|
||||
func stringSecrets(values ...*MxValue) []string {
|
||||
var secrets []string
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if stringValue, ok := value.GetKind().(*pb.MxValue_StringValue); ok && stringValue.StringValue != "" {
|
||||
secrets = append(secrets, stringValue.StringValue)
|
||||
}
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
// Events streams ordered session events until the server ends the stream,
|
||||
// context cancellation stops Recv, or a terminal error is sent.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
)
|
||||
|
||||
// TestAdviseSupervisoryBuildsCommandAndExposesRawReply pins that the promoted
|
||||
// typed helper emits an ADVISE_SUPERVISORY command carrying the server/item
|
||||
// handles and returns the raw reply.
|
||||
func TestAdviseSupervisoryBuildsCommandAndExposesRawReply(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
reply, err := session.AdviseSupervisoryRaw(context.Background(), 12, 34)
|
||||
if err != nil {
|
||||
t.Fatalf("AdviseSupervisoryRaw() error = %v", err)
|
||||
}
|
||||
if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
|
||||
t.Fatalf("reply kind = %s", reply.GetKind())
|
||||
}
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetAdviseSupervisory().GetServerHandle() != 12 || cmd.GetAdviseSupervisory().GetItemHandle() != 34 {
|
||||
t.Fatalf("advise-supervisory handles = (%d, %d), want (12, 34)",
|
||||
cmd.GetAdviseSupervisory().GetServerHandle(), cmd.GetAdviseSupervisory().GetItemHandle())
|
||||
}
|
||||
|
||||
// The error-returning wrapper drops the reply but must not error on success.
|
||||
if err := session.AdviseSupervisory(context.Background(), 12, 34); err != nil {
|
||||
t.Fatalf("AdviseSupervisory() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate pins MXAccess
|
||||
// parity: WriteSecured issued without a preceding AuthenticateUser is rejected
|
||||
// natively, and the client surfaces that failure as a typed MxAccessError rather
|
||||
// than pre-validating or reordering. The write value is also kept out of the
|
||||
// surfaced error text.
|
||||
func TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate(t *testing.T) {
|
||||
hresult := int32(-2147024891) // E_ACCESSDENIED
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
|
||||
Hresult: &hresult,
|
||||
DiagnosticMessage: "WriteSecured requires a prior AuthenticateUser",
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
Message: "MXAccess failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
securedValue := "supersecret-payload"
|
||||
err := session.WriteSecured(context.Background(), 12, 34, 0, 0, StringValue(securedValue))
|
||||
|
||||
var mxErr *MxAccessError
|
||||
if !errors.As(err, &mxErr) {
|
||||
t.Fatalf("error %T does not support errors.As(*MxAccessError); err = %v", err, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), securedValue) {
|
||||
t.Fatalf("surfaced error leaked the secured payload: %q", err.Error())
|
||||
}
|
||||
|
||||
// The command must carry the secured fields verbatim (parity: unaltered).
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetWriteSecured().GetValue().GetStringValue() != securedValue {
|
||||
t.Fatalf("wire value = %q, want %q", cmd.GetWriteSecured().GetValue().GetStringValue(), securedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteSecuredRejectsNilValueWithoutRoundTrip pins the client-side required
|
||||
// guard, which never echoes the (absent) value.
|
||||
func TestWriteSecuredRejectsNilValue(t *testing.T) {
|
||||
fake := &fakeGatewayServer{}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
if err := session.WriteSecured(context.Background(), 1, 2, 0, 0, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "write-secured value is required") {
|
||||
t.Fatalf("WriteSecured(nil value) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserReturnsUserIDOnHappyPath pins the typed helper unpacking of
|
||||
// AuthenticateUserReply.user_id and that the credential is carried on the wire
|
||||
// but never surfaced.
|
||||
func TestAuthenticateUserReturnsUserIDOnHappyPath(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
Payload: &pb.MxCommandReply_AuthenticateUser{
|
||||
AuthenticateUser: &pb.AuthenticateUserReply{UserId: 4242},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "hunter2-password")
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateUser() error = %v", err)
|
||||
}
|
||||
if userID != 4242 {
|
||||
t.Fatalf("user id = %d, want 4242", userID)
|
||||
}
|
||||
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetAuthenticateUser().GetVerifyUser() != "operator" {
|
||||
t.Fatalf("verify user = %q, want operator", cmd.GetAuthenticateUser().GetVerifyUser())
|
||||
}
|
||||
if cmd.GetAuthenticateUser().GetVerifyUserPassword() != "hunter2-password" {
|
||||
t.Fatalf("password not carried to the wire verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserScrubsCredentialFromSurfacedError proves the redaction
|
||||
// seam: even when the gateway diagnostic message echoes the credential back, the
|
||||
// surfaced error redacts it while the typed MxAccessError remains reachable via
|
||||
// errors.As.
|
||||
func TestAuthenticateUserScrubsCredentialFromSurfacedError(t *testing.T) {
|
||||
password := "hunter2-password"
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
DiagnosticMessage: "authentication failed for password " + password,
|
||||
// Message is intentionally left empty so MxAccessError.Error() falls
|
||||
// through to the diagnostic message — the free-text field that could
|
||||
// otherwise echo the credential back to a caller's log.
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
_, err := session.AuthenticateUser(context.Background(), 12, "operator", password)
|
||||
if err == nil {
|
||||
t.Fatal("AuthenticateUser() returned no error on native failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), password) {
|
||||
t.Fatalf("surfaced error leaked the credential: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), redactedSecretMarker) {
|
||||
t.Fatalf("surfaced error missing redaction marker: %q", err.Error())
|
||||
}
|
||||
var mxErr *MxAccessError
|
||||
if !errors.As(err, &mxErr) {
|
||||
t.Fatalf("redaction wrapper broke errors.As(*MxAccessError); err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserRequiresVerifyUser pins the client-side required guard.
|
||||
func TestAuthenticateUserRequiresVerifyUser(t *testing.T) {
|
||||
fake := &fakeGatewayServer{}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
if _, err := session.AuthenticateUser(context.Background(), 12, "", "pw"); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify user is required") {
|
||||
t.Fatalf("AuthenticateUser(empty user) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspendActivateReturnStatus covers two Phase 2 helpers that unpack an
|
||||
// MxStatusProxy from their dedicated reply arms.
|
||||
func TestSuspendActivateReturnStatus(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
Payload: &pb.MxCommandReply_Suspend{
|
||||
Suspend: &pb.SuspendReply{Status: &pb.MxStatusProxy{Success: 1, DiagnosticText: "suspended"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
status, err := session.Suspend(context.Background(), 12, 34)
|
||||
if err != nil {
|
||||
t.Fatalf("Suspend() error = %v", err)
|
||||
}
|
||||
if status.GetDiagnosticText() != "suspended" {
|
||||
t.Fatalf("status diagnostic = %q, want suspended", status.GetDiagnosticText())
|
||||
}
|
||||
if fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle() != 34 {
|
||||
t.Fatalf("suspend item handle = %d, want 34", fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user