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, "") { t.Fatalf("surfaced error missing redaction marker: %q", message) } } // TestAuthenticateUserScrubsEchoedCredentialFromStructuredReply is the CLI-40 // follow-up: redacting only the rendered Error() string is not enough. The typed // *MxAccessError still carries the raw command reply, whose ProtocolStatus.Message, // DiagnosticMessage, and Statuses[].DiagnosticText echo the credential verbatim. A // logger dumping structured fields would reintroduce the leak, so the reply the // typed error carries must be a scrubbed clone. Both the OK+negative-HRESULT and the // MXACCESS_FAILURE fixtures route to *MxAccessError (via EnsureProtocolSuccess), so // both must be scrubbed identically. func TestAuthenticateUserScrubsEchoedCredentialFromStructuredReply(t *testing.T) { const credential = "sup3rSecretVerify9f3a2b" fixtures := []string{ "authenticate-user.echoed-credential.reply.json", "authenticate-user.echoed-credential-mxaccess-failure.reply.json", } for _, fixture := range fixtures { t.Run(fixture, func(t *testing.T) { fake := &fakeGatewayServer{ invokeReply: loadCommandReplyFixture(t, fixture), } 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") } var mxErr *MxAccessError if !errors.As(err, &mxErr) { t.Fatalf("AuthenticateUser() error = %v (%T), want *MxAccessError", err, err) } reply := mxErr.Reply if reply == nil { t.Fatal("MxAccessError.Reply is nil, want the scrubbed command reply") } if got := reply.GetProtocolStatus().GetMessage(); strings.Contains(got, credential) { t.Fatalf("MxAccessError.Reply.ProtocolStatus.Message leaked the credential: %q", got) } if got := reply.GetDiagnosticMessage(); strings.Contains(got, credential) { t.Fatalf("MxAccessError.Reply.DiagnosticMessage leaked the credential: %q", got) } for i, status := range reply.GetStatuses() { if got := status.GetDiagnosticText(); strings.Contains(got, credential) { t.Fatalf("MxAccessError.Reply.Statuses[%d].DiagnosticText leaked the credential: %q", i, got) } } // The wrapped CommandError's status/reply must be scrubbed too. if mxErr.Command != nil { if got := mxErr.Command.Status.GetMessage(); strings.Contains(got, credential) { t.Fatalf("MxAccessError.Command.Status.Message leaked the credential: %q", got) } if cmdReply := mxErr.Command.Reply; cmdReply != nil { if got := cmdReply.GetDiagnosticMessage(); strings.Contains(got, credential) { t.Fatalf("MxAccessError.Command.Reply.DiagnosticMessage leaked the credential: %q", got) } } } if got := err.Error(); strings.Contains(got, credential) { t.Fatalf("rendered error leaked the credential: %q", got) } }) } }