dc7fd16dd5
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.
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
package mxgateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
)
|
|
|
|
// loadCommandReplyFixture parses a shared command-reply fixture into an
|
|
// MxCommandReply so the Go client can be driven through the same wire shapes the
|
|
// other language clients exercise.
|
|
func loadCommandReplyFixture(t *testing.T, name string) *pb.MxCommandReply {
|
|
t.Helper()
|
|
path := filepath.Join("..", "..", "proto", "fixtures", "behavior", "command-replies", name)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read fixture %s: %v", name, err)
|
|
}
|
|
var reply pb.MxCommandReply
|
|
if err := protojson.Unmarshal(data, &reply); err != nil {
|
|
t.Fatalf("parse fixture %s: %v", name, err)
|
|
}
|
|
return &reply
|
|
}
|
|
|
|
func TestAuthenticateUserMissingPayloadReturnsMalformedReplyError(t *testing.T) {
|
|
fake := &fakeGatewayServer{
|
|
invokeReply: loadCommandReplyFixture(t, "authenticate-user.missing-payload.reply.json"),
|
|
}
|
|
client, cleanup := newBufconnClient(t, fake)
|
|
defer cleanup()
|
|
session := NewSessionForID(client, "session-1")
|
|
|
|
_, err := session.AuthenticateUser(context.Background(), 12, "operator", "secret")
|
|
var malformed *MalformedReplyError
|
|
if !errors.As(err, &malformed) {
|
|
t.Fatalf("AuthenticateUser() error = %v (%T), want *MalformedReplyError", err, err)
|
|
}
|
|
if malformed.Op != "authenticate user" {
|
|
t.Fatalf("MalformedReplyError.Op = %q, want %q", malformed.Op, "authenticate user")
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateUserReturnValueOnlyUsesInt32ReturnValue(t *testing.T) {
|
|
fake := &fakeGatewayServer{
|
|
invokeReply: loadCommandReplyFixture(t, "authenticate-user.return-value-only.reply.json"),
|
|
}
|
|
client, cleanup := newBufconnClient(t, fake)
|
|
defer cleanup()
|
|
session := NewSessionForID(client, "session-1")
|
|
|
|
userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "secret")
|
|
if err != nil {
|
|
t.Fatalf("AuthenticateUser() error = %v", err)
|
|
}
|
|
if userID != 7 {
|
|
t.Fatalf("AuthenticateUser() = %d, want 7", userID)
|
|
}
|
|
}
|
|
|
|
// AddBufferedItem shares the prefer-payload / int32-return-value / malformed
|
|
// fallback code path; cover both branches for one of the siblings.
|
|
func TestAddBufferedItemFallbackHonoursReturnValueAndReportsMalformed(t *testing.T) {
|
|
t.Run("return-value-only", func(t *testing.T) {
|
|
fake := &fakeGatewayServer{
|
|
invokeReply: loadCommandReplyFixture(t, "authenticate-user.return-value-only.reply.json"),
|
|
}
|
|
client, cleanup := newBufconnClient(t, fake)
|
|
defer cleanup()
|
|
session := NewSessionForID(client, "session-1")
|
|
|
|
itemHandle, err := session.AddBufferedItem(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
|
|
if err != nil {
|
|
t.Fatalf("AddBufferedItem() error = %v", err)
|
|
}
|
|
if itemHandle != 7 {
|
|
t.Fatalf("AddBufferedItem() = %d, want 7", itemHandle)
|
|
}
|
|
})
|
|
|
|
t.Run("missing-payload", func(t *testing.T) {
|
|
fake := &fakeGatewayServer{
|
|
invokeReply: loadCommandReplyFixture(t, "authenticate-user.missing-payload.reply.json"),
|
|
}
|
|
client, cleanup := newBufconnClient(t, fake)
|
|
defer cleanup()
|
|
session := NewSessionForID(client, "session-1")
|
|
|
|
_, err := session.AddBufferedItem(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
|
|
var malformed *MalformedReplyError
|
|
if !errors.As(err, &malformed) {
|
|
t.Fatalf("AddBufferedItem() error = %v (%T), want *MalformedReplyError", err, err)
|
|
}
|
|
if malformed.Op != "add buffered item" {
|
|
t.Fatalf("MalformedReplyError.Op = %q, want %q", malformed.Op, "add buffered item")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAuthenticateUserScrubsEchoedCredentialFromError is the CLI-40 regression:
|
|
// a gateway diagnostic that echoes the raw credential back must never reach the
|
|
// caller's surfaced error text.
|
|
func TestAuthenticateUserScrubsEchoedCredentialFromError(t *testing.T) {
|
|
const credential = "sup3rSecretVerify9f3a2b"
|
|
fake := &fakeGatewayServer{
|
|
invokeReply: loadCommandReplyFixture(t, "authenticate-user.echoed-credential.reply.json"),
|
|
}
|
|
client, cleanup := newBufconnClient(t, fake)
|
|
defer cleanup()
|
|
session := NewSessionForID(client, "session-1")
|
|
|
|
_, err := session.AuthenticateUser(context.Background(), 12, "operator", credential)
|
|
if err == nil {
|
|
t.Fatal("AuthenticateUser() error = nil, want an MXAccess failure")
|
|
}
|
|
message := err.Error()
|
|
if strings.Contains(message, credential) {
|
|
t.Fatalf("surfaced error leaked the credential: %q", message)
|
|
}
|
|
if !strings.Contains(message, "<redacted>") {
|
|
t.Fatalf("surfaced error missing redaction marker: %q", message)
|
|
}
|
|
}
|