37cb3b0df8
All five client CLIs now share one credential contract for `authenticate-user`: flags `--password` / `--password-env` (Go: `-password` / `-password-env`) with default env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved credential that is missing *or empty* is a usage error naming the flag and the variable. The value is never echoed and never reaches the wire. Go and Java previously sent an empty credential when the variable was unset, turning a misconfigured environment into a real MXAccess authentication attempt. Go now returns the guard error before dialing; Java throws a picocli ParameterException instead of falling back to "". Python's `--password-env` gained the canonical default and its UsageError names the resolved variable. Rust treats an empty flag or env value as missing, with the resolution extracted into a testable `resolve_verify_user_password`. .NET adopts the canonical flags and keeps `--verify-user-password`, `--verify-user-password-env`, and MXGATEWAY_VERIFY_USER_PASSWORD as deprecated aliases for one release. Docs same commit: CrossLanguageSmokeMatrix.md gains the credential contract and the per-CLI subcommand-coverage table (the documented-not-fixed half of the finding); all five READMEs name the canonical variable and the fail-fast rule, and the .NET README carries the deprecation note. Tracking flipped to Done in both remediation registers with a change-log row. No .proto changed; no generated code regenerated.
800 lines
29 KiB
Go
800 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
func TestRunVersionJSON(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
var output versionOutput
|
|
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
|
t.Fatalf("parse JSON: %v", err)
|
|
}
|
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
|
}
|
|
}
|
|
|
|
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
|
options, err := (&commonOptions{
|
|
Endpoint: "localhost:5000",
|
|
APIKey: "mxgw_super_secret",
|
|
Plaintext: true,
|
|
CallTimeout: "2s",
|
|
}).resolved()
|
|
if err != nil {
|
|
t.Fatalf("resolved() error = %v", err)
|
|
}
|
|
|
|
data, err := json.Marshal(options)
|
|
if err != nil {
|
|
t.Fatalf("marshal options: %v", err)
|
|
}
|
|
if strings.Contains(string(data), "super_secret") {
|
|
t.Fatalf("redacted JSON leaked API key: %s", data)
|
|
}
|
|
if !strings.Contains(string(data), "mxgw") {
|
|
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
in := strings.NewReader("version --json\n")
|
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
|
|
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
|
|
}
|
|
|
|
idx := strings.Index(out, batchEOR)
|
|
if idx <= 0 {
|
|
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
|
|
}
|
|
payload := out[:idx]
|
|
var output versionOutput
|
|
if err := json.Unmarshal([]byte(payload), &output); err != nil {
|
|
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
|
|
}
|
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
|
}
|
|
}
|
|
|
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
|
value, err := parseValue("int32", "123")
|
|
if err != nil {
|
|
t.Fatalf("parseValue() error = %v", err)
|
|
}
|
|
if got := value.GetInt32Value(); got != 123 {
|
|
t.Fatalf("int32 value = %d, want 123", got)
|
|
}
|
|
}
|
|
|
|
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
|
|
// secured-only flags must be unavailable on non-secured variants, and
|
|
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
|
|
// but not defined" error instead of silently no-op'ing.
|
|
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{
|
|
name: "write-bulk-rejects-current-user-id",
|
|
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
},
|
|
{
|
|
name: "write-bulk-rejects-verifier-user-id",
|
|
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
},
|
|
{
|
|
name: "write2-bulk-rejects-current-user-id",
|
|
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
},
|
|
{
|
|
name: "write-secured-bulk-rejects-user-id",
|
|
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
},
|
|
{
|
|
name: "write-secured2-bulk-rejects-user-id",
|
|
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatalf("runWithIO(%v) returned no error", tc.args)
|
|
}
|
|
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
|
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
|
|
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
|
|
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
|
|
// driver) short-circuits the bench instead of spinning failing ReadBulk
|
|
// calls until the wall-clock deadline elapses.
|
|
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := grpc.NewServer()
|
|
fake := &benchFakeGateway{}
|
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
defer server.Stop()
|
|
defer listener.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Long warm-up + duration, so if the ctx.Err() guard were missing the
|
|
// loops would run for ~10s. With the guard, the cancel below short-
|
|
// circuits both loops within ~one ReadBulk iteration.
|
|
args := []string{
|
|
"bench-read-bulk",
|
|
"-endpoint", listener.Addr().String(),
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-warmup-seconds", "5",
|
|
"-duration-seconds", "5",
|
|
"-bulk-size", "1",
|
|
"-timeout-ms", "100",
|
|
}
|
|
|
|
// Cancel after a brief delay — far less than warmup+duration (10s).
|
|
go func() {
|
|
time.Sleep(150 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
start := time.Now()
|
|
err = runWithIO(ctx, args, &stdout, &stderr)
|
|
elapsed := time.Since(start)
|
|
|
|
// With the ctx.Err() guard, the loops exit well before the wall-clock
|
|
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
|
|
// CI noise but assert clearly less than the un-guarded worst case.
|
|
if elapsed > 4*time.Second {
|
|
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestRunPingPlainText verifies the ping subcommand round-trips through the
|
|
// fake gateway and prints the echo (diagnostic_message) in plain-text mode.
|
|
func TestRunPingPlainText(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := grpc.NewServer()
|
|
fake := &pingFakeGateway{}
|
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
|
go func() { _ = server.Serve(listener) }()
|
|
defer server.Stop()
|
|
defer listener.Close()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
args := []string{
|
|
"ping",
|
|
"-endpoint", listener.Addr().String(),
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-session-id", "test-session",
|
|
"-message", "hello",
|
|
}
|
|
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
got := strings.TrimSpace(stdout.String())
|
|
if got != "pong:hello" {
|
|
t.Fatalf("ping plain-text output = %q, want %q", got, "pong:hello")
|
|
}
|
|
}
|
|
|
|
// TestRunPingJSON verifies the ping subcommand emits valid JSON in --json mode.
|
|
func TestRunPingJSON(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := grpc.NewServer()
|
|
fake := &pingFakeGateway{}
|
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
|
go func() { _ = server.Serve(listener) }()
|
|
defer server.Stop()
|
|
defer listener.Close()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
args := []string{
|
|
"ping",
|
|
"-endpoint", listener.Addr().String(),
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-session-id", "test-session",
|
|
"-message", "hello",
|
|
"-json",
|
|
}
|
|
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
var out commandReplyOutput
|
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
|
t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
|
|
}
|
|
if out.Command != "ping" {
|
|
t.Fatalf("command = %q, want %q", out.Command, "ping")
|
|
}
|
|
// The fake gateway echoes "pong:<message>" in diagnostic_message; verify the
|
|
// echo appears in the serialised reply so a future regression that wired
|
|
// PingRaw to the wrong proto field would be caught here.
|
|
replyStr := string(out.Reply)
|
|
if !strings.Contains(replyStr, "pong:hello") {
|
|
t.Fatalf("ping JSON reply missing echoed message %q; reply = %s", "pong:hello", replyStr)
|
|
}
|
|
}
|
|
|
|
// TestRunPingRequiresSessionID verifies the ping subcommand rejects missing session-id.
|
|
func TestRunPingRequiresSessionID(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"ping", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatalf("runWithIO(ping without --session-id) returned no error")
|
|
}
|
|
if !strings.Contains(err.Error(), "session-id is required") {
|
|
t.Fatalf("error = %v; want 'session-id is required'", err)
|
|
}
|
|
}
|
|
|
|
// pingFakeGateway handles Invoke for MX_COMMAND_KIND_PING by echoing the
|
|
// message back in the diagnostic_message field so the CLI plain-text path
|
|
// has a deterministic, non-empty string to assert on.
|
|
type pingFakeGateway struct {
|
|
pb.UnimplementedMxAccessGatewayServer
|
|
}
|
|
|
|
func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
|
echo := "pong:" + req.GetCommand().GetPing().GetMessage()
|
|
return &pb.MxCommandReply{
|
|
SessionId: req.GetSessionId(),
|
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING,
|
|
DiagnosticMessage: echo,
|
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
}, nil
|
|
}
|
|
|
|
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
|
|
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
|
|
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
|
|
type benchFakeGateway struct {
|
|
pb.UnimplementedMxAccessGatewayServer
|
|
}
|
|
|
|
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
|
return &pb.OpenSessionReply{
|
|
SessionId: "bench-session",
|
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
}, nil
|
|
}
|
|
|
|
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
|
return &pb.CloseSessionReply{
|
|
SessionId: req.GetSessionId(),
|
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
}, nil
|
|
}
|
|
|
|
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
|
kind := req.GetCommand().GetKind()
|
|
reply := &pb.MxCommandReply{
|
|
SessionId: req.GetSessionId(),
|
|
Kind: kind,
|
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
|
}
|
|
switch kind {
|
|
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
|
|
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
|
|
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
|
|
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
|
|
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
|
|
}}
|
|
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
|
|
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
|
|
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
|
|
}}
|
|
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
|
|
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
|
|
}
|
|
return reply, nil
|
|
}
|
|
|
|
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
|
|
// positivity checks so they cannot drift while resolving the cancellation finding.
|
|
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
|
|
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
|
|
}
|
|
}
|
|
|
|
// browseFakeGalaxy implements BrowseChildren for the galaxy-browse subcommand
|
|
// tests. It returns two root objects when no parent is supplied (the first
|
|
// flagged as having children), and one child when the first root's gobject id
|
|
// is supplied as the parent. The recorded last request lets a test assert the
|
|
// CLI forwarded the parent and filter fields onto the wire.
|
|
type browseFakeGalaxy struct {
|
|
pb.UnimplementedGalaxyRepositoryServer
|
|
lastRequest *pb.BrowseChildrenRequest
|
|
}
|
|
|
|
func (g *browseFakeGalaxy) BrowseChildren(_ context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
|
|
g.lastRequest = req
|
|
if req.GetParentGobjectId() == 10 {
|
|
return &pb.BrowseChildrenReply{
|
|
Children: []*pb.GalaxyObject{
|
|
{GobjectId: 11, TagName: "Area1.Tank", BrowseName: "Tank"},
|
|
},
|
|
ChildHasChildren: []bool{false},
|
|
}, nil
|
|
}
|
|
return &pb.BrowseChildrenReply{
|
|
Children: []*pb.GalaxyObject{
|
|
{GobjectId: 10, TagName: "Area1", BrowseName: "Area1"},
|
|
{GobjectId: 20, TagName: "Area2", BrowseName: "Area2"},
|
|
},
|
|
ChildHasChildren: []bool{true, false},
|
|
}, nil
|
|
}
|
|
|
|
func startBrowseFakeGalaxy(t *testing.T) (addr string, fake *browseFakeGalaxy) {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := grpc.NewServer()
|
|
fake = &browseFakeGalaxy{}
|
|
pb.RegisterGalaxyRepositoryServer(server, fake)
|
|
go func() { _ = server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
server.Stop()
|
|
_ = listener.Close()
|
|
})
|
|
return listener.Addr().String(), fake
|
|
}
|
|
|
|
// TestRunGalaxyBrowseTextTree verifies the galaxy-browse subcommand issues
|
|
// BrowseChildren for the root walk, eagerly expands one level when --depth is
|
|
// set, and renders an indented tree.
|
|
func TestRunGalaxyBrowseTextTree(t *testing.T) {
|
|
addr, _ := startBrowseFakeGalaxy(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
args := []string{
|
|
"galaxy-browse",
|
|
"-endpoint", addr,
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-depth", "1",
|
|
}
|
|
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
// Both roots present; the first root's eagerly-expanded child appears
|
|
// indented beneath it.
|
|
for _, want := range []string{"Area1", "Area2", "Tank"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("galaxy-browse text output missing %q; got:\n%s", want, out)
|
|
}
|
|
}
|
|
if !strings.Contains(out, " ") {
|
|
t.Fatalf("galaxy-browse text output not indented for children; got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestRunGalaxyBrowseJSON verifies the galaxy-browse subcommand emits valid
|
|
// nested JSON and forwards filter options onto the BrowseChildren request.
|
|
func TestRunGalaxyBrowseJSON(t *testing.T) {
|
|
addr, fake := startBrowseFakeGalaxy(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
args := []string{
|
|
"galaxy-browse",
|
|
"-endpoint", addr,
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-depth", "1",
|
|
"-tag-name-glob", "Area%",
|
|
"-alarm-bearing-only",
|
|
"-json",
|
|
}
|
|
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
|
|
}
|
|
if payload["command"] != "galaxy-browse" {
|
|
t.Fatalf("command = %v, want galaxy-browse", payload["command"])
|
|
}
|
|
nodes, ok := payload["nodes"].([]any)
|
|
if !ok || len(nodes) != 2 {
|
|
t.Fatalf("nodes = %v, want 2 root nodes", payload["nodes"])
|
|
}
|
|
// Filter fields must have reached the wire.
|
|
if got := fake.lastRequest.GetTagNameGlob(); got != "Area%" {
|
|
t.Fatalf("BrowseChildren TagNameGlob = %q, want %q", got, "Area%")
|
|
}
|
|
if !fake.lastRequest.GetAlarmBearingOnly() {
|
|
t.Fatalf("BrowseChildren AlarmBearingOnly = false, want true")
|
|
}
|
|
}
|
|
|
|
// TestRunGalaxyBrowseParentSingleLevel verifies that passing --parent fetches a
|
|
// single level of children for that parent via the parent-scoped request.
|
|
func TestRunGalaxyBrowseParentSingleLevel(t *testing.T) {
|
|
addr, fake := startBrowseFakeGalaxy(t)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
args := []string{
|
|
"galaxy-browse",
|
|
"-endpoint", addr,
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-parent", "10",
|
|
}
|
|
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Tank") {
|
|
t.Fatalf("galaxy-browse -parent output missing child %q; got:\n%s", "Tank", stdout.String())
|
|
}
|
|
if got := fake.lastRequest.GetParentGobjectId(); got != 10 {
|
|
t.Fatalf("BrowseChildren ParentGobjectId = %d, want 10", got)
|
|
}
|
|
}
|
|
|
|
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
|
|
// a blank line in the middle of a batch session must NOT terminate the loop —
|
|
// only stdin EOF ends the session.
|
|
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
// version -> blank -> version (a stray blank line in the middle of a
|
|
// programmatic session).
|
|
in := strings.NewReader("version --json\n\nversion --json\n")
|
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
// Both version commands must have produced a result before the EOR sentinel.
|
|
if count := strings.Count(out, batchEOR); count != 2 {
|
|
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
|
|
}
|
|
}
|
|
|
|
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
|
|
// line longer than the default bufio.Scanner token size (64 KiB) must not
|
|
// abort the batch session.
|
|
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
// Build a single command line larger than 64 KiB. The command itself is
|
|
// invalid (no real session) but runBatch must still emit an EOR sentinel
|
|
// and continue to the next command rather than dropping the line on the
|
|
// floor with a bufio.ErrTooLong from the outer return.
|
|
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
|
|
line := "subscribe-bulk -session-id none -items " + huge
|
|
if len(line) <= 64*1024 {
|
|
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
|
|
}
|
|
in := strings.NewReader(line + "\nversion --json\n")
|
|
|
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
// Both commands must produce an EOR sentinel — the long line should be a
|
|
// per-command error (still emitted with EOR), then the version command
|
|
// should run normally.
|
|
if count := strings.Count(out, batchEOR); count != 2 {
|
|
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
|
|
}
|
|
}
|
|
|
|
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the -duration-seconds
|
|
// positivity guard so the bench window cannot be configured to zero/negative.
|
|
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-duration-seconds", "0"}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "duration-seconds must be positive") {
|
|
t.Fatalf("bench-read-bulk -duration-seconds 0 error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunStreamEventsRequiresSessionID pins the session-id guard so stream-events
|
|
// fails fast before dialing when no session id is supplied.
|
|
func TestRunStreamEventsRequiresSessionID(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"stream-events", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
|
t.Fatalf("stream-events without -session-id error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunAdviseSupervisoryRequiresSessionID pins the session-id guard so
|
|
// advise-supervisory fails fast before dialing when no session id is supplied.
|
|
func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"advise-supervisory", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
|
t.Fatalf("advise-supervisory without -session-id error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
|
|
// it fails fast before dialing.
|
|
func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
|
t.Fatalf("write-secured without -session-id error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
|
|
// (before dialing) when the user is absent, and never echoes any credential in
|
|
// the guard error.
|
|
func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{
|
|
"authenticate-user",
|
|
"-session-id", "s1",
|
|
"-password", "hunter2-password",
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
|
|
t.Fatalf("authenticate-user without -verify-user error = %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "hunter2-password") {
|
|
t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunAuthenticateUserRejectsEmptyPassword pins the CLI-45 fail-fast contract:
|
|
// an unresolved credential must abort before dialing rather than authenticating
|
|
// with an empty password, and the usage error must name both -password and the
|
|
// canonical environment variable without echoing any value.
|
|
func TestRunAuthenticateUserRejectsEmptyPassword(t *testing.T) {
|
|
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{
|
|
"authenticate-user",
|
|
"-session-id", "s1",
|
|
"-verify-user", "operator",
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
}, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatal("authenticate-user without a credential must fail before dialing")
|
|
}
|
|
if !strings.Contains(err.Error(), "-password") {
|
|
t.Fatalf("error must name the -password flag: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "MXGATEWAY_VERIFY_PASSWORD") {
|
|
t.Fatalf("error must name the canonical environment variable: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunAuthenticateUserReadsPasswordFromCanonicalEnv pins that the default
|
|
// -password-env is MXGATEWAY_VERIFY_PASSWORD: with it set the credential guard
|
|
// passes and the command proceeds past it to the dial, which fails against an
|
|
// unused port under a short context — proving the guard was cleared without
|
|
// needing a live gateway.
|
|
func TestRunAuthenticateUserReadsPasswordFromCanonicalEnv(t *testing.T) {
|
|
t.Setenv("MXGATEWAY_VERIFY_PASSWORD", "env-sourced-credential")
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
|
defer cancel()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(ctx, []string{
|
|
"authenticate-user",
|
|
"-session-id", "s1",
|
|
"-verify-user", "operator",
|
|
"-endpoint", "127.0.0.1:1",
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-call-timeout", "1s",
|
|
}, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatal("expected the dial/RPC to fail against an unused port")
|
|
}
|
|
if strings.Contains(err.Error(), "flag provided but not defined") {
|
|
t.Fatalf("test invoked an unknown flag, so it never reached the guard: %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "a password is required") {
|
|
t.Fatalf("credential guard must be satisfied from %s: %v", "MXGATEWAY_VERIFY_PASSWORD", err)
|
|
}
|
|
if strings.Contains(err.Error(), "env-sourced-credential") ||
|
|
strings.Contains(stdout.String(), "env-sourced-credential") ||
|
|
strings.Contains(stderr.String(), "env-sourced-credential") {
|
|
t.Fatal("the resolved credential must never be echoed")
|
|
}
|
|
}
|
|
|
|
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
|
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
|
// before any dial.
|
|
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
err := runWithIO(t.Context(), []string{
|
|
"write-bulk",
|
|
"-session-id", "s1",
|
|
"-server-handle", "1",
|
|
"-item-handles", "1,2",
|
|
"-values", "10",
|
|
"-type", "int32",
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
}, &stdout, &stderr)
|
|
if err == nil || !strings.Contains(err.Error(), "does not match values count") {
|
|
t.Fatalf("write-bulk mismatched handles/values error = %v", err)
|
|
}
|
|
}
|
|
|
|
// replayGapFakeGateway streams the gateway's reconnect-replay sentinel (an MxEvent
|
|
// carrying replay_gap, family UNSPECIFIED, body unset) followed by one normal data
|
|
// event — exactly what a resume whose cursor predates the retained replay ring sees.
|
|
type replayGapFakeGateway struct {
|
|
pb.UnimplementedMxAccessGatewayServer
|
|
}
|
|
|
|
func (g *replayGapFakeGateway) StreamEvents(
|
|
req *pb.StreamEventsRequest,
|
|
stream grpc.ServerStreamingServer[pb.MxEvent],
|
|
) error {
|
|
sentinel := &pb.MxEvent{
|
|
SessionId: req.GetSessionId(),
|
|
Family: pb.MxEventFamily_MX_EVENT_FAMILY_UNSPECIFIED,
|
|
ReplayGap: &pb.ReplayGap{
|
|
RequestedAfterSequence: 7,
|
|
OldestAvailableSequence: 42,
|
|
},
|
|
}
|
|
if err := stream.Send(sentinel); err != nil {
|
|
return err
|
|
}
|
|
return stream.Send(&pb.MxEvent{
|
|
SessionId: req.GetSessionId(),
|
|
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
|
WorkerSequence: 43,
|
|
})
|
|
}
|
|
|
|
func startReplayGapGateway(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
server := grpc.NewServer()
|
|
pb.RegisterMxAccessGatewayServer(server, &replayGapFakeGateway{})
|
|
go func() { _ = server.Serve(listener) }()
|
|
t.Cleanup(func() {
|
|
server.Stop()
|
|
_ = listener.Close()
|
|
})
|
|
return listener.Addr().String()
|
|
}
|
|
|
|
// TestRunStreamEventsPrintsReplayGap pins CLI-36: the CLI must render the typed
|
|
// ReplayGap signal in both output modes instead of formatting the library's
|
|
// cleared Event field (which printed "0 MX_EVENT_FAMILY_UNSPECIFIED" in text mode
|
|
// and an empty object in JSON mode, destroying the resume cursors).
|
|
func TestRunStreamEventsPrintsReplayGap(t *testing.T) {
|
|
endpoint := startReplayGapGateway(t)
|
|
|
|
baseArgs := []string{
|
|
"stream-events",
|
|
"-endpoint", endpoint,
|
|
"-plaintext",
|
|
"-api-key", "test",
|
|
"-session-id", "gap-session",
|
|
"-after-worker-sequence", "7",
|
|
"-limit", "2",
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if err := runWithIO(t.Context(), baseArgs, &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
text := stdout.String()
|
|
if !strings.Contains(text, "REPLAY_GAP requested_after=7 oldest_available=42") {
|
|
t.Fatalf("stream-events text output missing typed gap row: %q", text)
|
|
}
|
|
if strings.Contains(text, "0 MX_EVENT_FAMILY_UNSPECIFIED") {
|
|
t.Fatalf("stream-events text output destroyed the gap into a zero row: %q", text)
|
|
}
|
|
if !strings.Contains(text, "43 MX_EVENT_FAMILY_ON_DATA_CHANGE") {
|
|
t.Fatalf("stream-events text output dropped the normal event: %q", text)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if err := runWithIO(t.Context(), append(baseArgs, "-json"), &stdout, &stderr); err != nil {
|
|
t.Fatalf("runWithIO(-json) error = %v; stderr = %s", err, stderr.String())
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("stream-events -json emitted %d rows, want 2: %q", len(lines), stdout.String())
|
|
}
|
|
|
|
// The cursors must decode as JSON numbers, not the strings the proto3 JSON
|
|
// mapping would produce for 64-bit fields: the Rust and Python CLIs emit
|
|
// numbers, and the cross-language matrix compares these rows across clients.
|
|
var gapRow struct {
|
|
ReplayGap *struct {
|
|
RequestedAfterSequence uint64 `json:"requestedAfterSequence"`
|
|
OldestAvailableSequence uint64 `json:"oldestAvailableSequence"`
|
|
} `json:"replayGap"`
|
|
}
|
|
if err := json.Unmarshal([]byte(lines[0]), &gapRow); err != nil {
|
|
t.Fatalf("parse gap row: %v\nrow: %s", err, lines[0])
|
|
}
|
|
if gapRow.ReplayGap == nil {
|
|
t.Fatalf("stream-events -json first row is not a replayGap row: %s", lines[0])
|
|
}
|
|
if gapRow.ReplayGap.RequestedAfterSequence != 7 || gapRow.ReplayGap.OldestAvailableSequence != 42 {
|
|
t.Fatalf("stream-events -json gap cursors = %+v, want 7/42", *gapRow.ReplayGap)
|
|
}
|
|
// Belt and braces on the value type: a protojson-rendered `"7"` already
|
|
// fails the decode above (encoding/json rejects a JSON string for an
|
|
// untagged uint64 field), but assert the raw bytes so a regression names
|
|
// the real problem instead of surfacing as an opaque unmarshal error.
|
|
if !strings.Contains(lines[0], `"requestedAfterSequence":7`) ||
|
|
!strings.Contains(lines[0], `"oldestAvailableSequence":42`) {
|
|
t.Fatalf("stream-events -json gap cursors must be JSON numbers, got: %s", lines[0])
|
|
}
|
|
}
|