44b8e37900
An empty replay ring reported oldest_available_sequence = 0 even when gap was
true. Clients follow the documented after_worker_sequence = oldest - 1 formula,
so an unsigned client computed ulong.MaxValue: the follow-up resume replayed
nothing, reported no gap, and the live filter dropped every subsequent event —
a silently dead stream in the headline detach-and-resume scenario, reachable on
default config once ReplayRetentionSeconds (300) age-evicts the ring.
GWC-25: SessionEventDistributor.RegisterWithReplay's empty-ring branch now
reports _highestSequenceSeen + 1 — the next sequence that can possibly be
delivered — when gap is true, so oldest - 1 lands exactly on the highest
observed sequence and the resume delivers everything newer. Still 0 when there
is no gap, where the field is meaningless and never emitted. Nothing is lost:
the evicted interval was unrecoverable either way, and the sentinel's job is to
say "re-snapshot".
CLI-35: the Python CLI fed every stream item into MessageToDict, which raised on
the ReplayGap dataclass and aborted the command after consuming the stream. A
new _event_row helper renders a gap as {"replayGap": {...}} — the same camelCase
shape the Rust CLI emits — and leaves proto events on the existing path.
CLI-36: the Go CLI formatted result.Event on every row, but the library
deliberately clears Event on a gap, so text mode printed
"0 MX_EVENT_FAMILY_UNSPECIFIED" and JSON mode an empty object, discarding the
resume cursors. The loop now branches on result.IsReplayGap() and renders the
typed row in both modes, counting it toward -limit like any other row. The JSON
row's cursors are typed by hand rather than marshalled with protojson: the
proto3 JSON mapping renders 64-bit integers as strings ("7") while the Rust and
Python CLIs emit numbers (7), so going through protojson would have made Go the
only canonical CLI with a different value type.
Docs in the same change: docs/Sessions.md documents the empty-ring sentinel
value and that oldest - 1 is the universal resume formula in both the retained
and fully-evicted cases; docs/CrossLanguageSmokeMatrix.md gains a per-CLI
gap-rendering table covering both client findings, and records exactly what is
and is not comparable across CLIs (same keys and numeric cursors for Rust/Go/
Python; quoted cursors for .NET/Java; differing key order, whitespace, and
container), so a matrix runner compares parsed values rather than raw bytes.
Tests, all written red first and each reproducing its defect verbatim:
- SessionEventDistributorTests: RegisterWithReplayReportsNextDeliverableSequence
WhenRingEmptiedByAge, ...WithRetentionDisabled, and
ResumeUsingSentinelFormulaAfterEmptyRingGapDeliversLiveEvents.
- GatewayEndToEndReconnectReplayTests.ReconnectAfterFullAgeEvictionResumesWith
SentinelFormula — fake-worker e2e resume walk on a fake clock; the fixture now
takes a retention window and a TimeProvider.
- clients/python test_stream_events_renders_replay_gap.
- clients/go TestRunStreamEventsPrintsReplayGap.
GWC-25's ReplayGap.oldest_available_sequence proto-comment amendment is
deliberately deferred to the later codegen wave (see the tracker change log): it
is comment-only but triggers the full five-client regen fan-out.
737 lines
26 KiB
Go
737 lines
26 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)
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
}
|