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:" 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) } } // 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) } }