742ced7970
TestRunPingJSON now verifies the fake gateway's echoed text appears in the serialised reply body, catching any future wiring regression that maps PingRaw to the wrong proto field. runPing gains a one-line comment explaining why DiagnosticMessage carries the echo, why the kind-string fallback exists, and why writeCommandOutput is not reused on the plain-text path.
400 lines
14 KiB
Go
400 lines
14 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)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|