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()) } } // 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)) } }