From e2794da410e40078a3ab6746a2b4e7e4538f981b Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sat, 9 May 2026 08:39:06 +0800 Subject: [PATCH 1/8] fix partial update payloads --- pkg/cmd/credential/update/update.go | 40 +++++++--- pkg/cmd/credential/update/update_test.go | 32 +++++++- pkg/cmd/gateway-group/update/update.go | 69 ++++++++++------ pkg/cmd/gateway-group/update/update_test.go | 88 +++++++++++++++++++++ pkg/cmd/proto/update/update.go | 46 ++++++++--- pkg/cmd/proto/update/update_test.go | 31 +++++++- 6 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 pkg/cmd/gateway-group/update/update_test.go diff --git a/pkg/cmd/credential/update/update.go b/pkg/cmd/credential/update/update.go index 50f534d..26433e9 100644 --- a/pkg/cmd/credential/update/update.go +++ b/pkg/cmd/credential/update/update.go @@ -28,6 +28,9 @@ type Options struct { Desc string PluginsJSON string Labels []string + DescSet bool + PluginsSet bool + LabelsSet bool } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -41,6 +44,9 @@ func NewCmd(f *cmd.Factory) *cobra.Command { opts.Output, _ = c.Flags().GetString("output") opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") opts.Consumer, _ = c.Flags().GetString("consumer") + opts.DescSet = c.Flags().Changed("desc") + opts.PluginsSet = c.Flags().Changed("plugins-json") + opts.LabelsSet = c.Flags().Changed("labels") return actionRun(opts) }, } @@ -97,30 +103,44 @@ func actionRun(opts *Options) error { } pl := make(map[string]interface{}) - if opts.PluginsJSON != "" { + if opts.PluginsSet { if err := json.Unmarshal([]byte(opts.PluginsJSON), &pl); err != nil { return fmt.Errorf("invalid --plugins-json: %w", err) } } labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) + if opts.LabelsSet { + for _, label := range opts.Labels { + parts := strings.SplitN(label, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid label %q, expected key=value", label) + } + labels[parts[0]] = parts[1] } - labels[parts[0]] = parts[1] } - bodyReq := api.Credential{ID: opts.ID, Desc: opts.Desc} - if len(pl) > 0 { + client := api.NewClient(httpClient, cfg.BaseURL()) + currentBody, err := client.Get(path, nil) + if err != nil { + return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) + } + + var bodyReq api.Credential + if err := json.Unmarshal(currentBody, &bodyReq); err != nil { + return fmt.Errorf("failed to decode current credential: %w", err) + } + bodyReq.ID = opts.ID + if opts.DescSet { + bodyReq.Desc = opts.Desc + } + if opts.PluginsSet { bodyReq.Plugins = pl } - if len(labels) > 0 { + if opts.LabelsSet { bodyReq.Labels = labels } - client := api.NewClient(httpClient, cfg.BaseURL()) body, err := client.Put(path, bodyReq) if err != nil { return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) diff --git a/pkg/cmd/credential/update/update_test.go b/pkg/cmd/credential/update/update_test.go index af84580..6035d9e 100644 --- a/pkg/cmd/credential/update/update_test.go +++ b/pkg/cmd/credential/update/update_test.go @@ -2,6 +2,7 @@ package update import ( "encoding/json" + "io" "net/http" "strings" "testing" @@ -34,11 +35,37 @@ func (m *mockConfig) Save() error { return n func TestUpdateCredential_JSONOutput(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/consumers/alice/credentials/cred1", httpmock.JSONResponse(`{"id":"cred1","desc":"updated"}`)) + registry.Register(http.MethodGet, "/apisix/admin/consumers/alice/credentials/cred1", httpmock.JSONResponse(`{ + "id":"cred1", + "name":"apikey", + "desc":"old", + "plugins":{"key-auth":{"key":"old-key"}}, + "labels":{"env":"dev"} + }`)) + registry.RegisterResponder(http.MethodPut, "/apisix/admin/consumers/alice/credentials/cred1", func(r *http.Request) (httpmock.Response, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return httpmock.Response{}, err + } + var payload api.Credential + if err := json.Unmarshal(body, &payload); err != nil { + return httpmock.Response{}, err + } + if payload.Name != "apikey" { + t.Fatalf("expected existing credential name to be preserved, got %q", payload.Name) + } + if payload.Plugins["key-auth"] == nil { + t.Fatalf("expected existing plugins to be preserved: %+v", payload.Plugins) + } + if payload.Desc != "updated" { + t.Fatalf("expected desc to be updated, got %q", payload.Desc) + } + return httpmock.JSONResponse(`{"id":"cred1","name":"apikey","desc":"updated","plugins":{"key-auth":{"key":"old-key"}}}`), nil + }) opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil - }, Consumer: "alice", ID: "cred1", GatewayGroup: "gg1", Desc: "updated"} + }, Consumer: "alice", ID: "cred1", GatewayGroup: "gg1", Desc: "updated", DescSet: true} if err := actionRun(opts); err != nil { t.Fatalf("actionRun failed: %v", err) @@ -82,6 +109,7 @@ func TestUpdateCredential_MissingConsumer(t *testing.T) { func TestUpdateCredential_APIError(t *testing.T) { ios, _, _, _ := iostreams.Test() registry := &httpmock.Registry{} + registry.Register(http.MethodGet, "/apisix/admin/consumers/alice/credentials/cred1", httpmock.JSONResponse(`{"id":"cred1","name":"apikey","plugins":{"key-auth":{}}}`)) registry.Register(http.MethodPut, "/apisix/admin/consumers/alice/credentials/cred1", httpmock.StringResponse(http.StatusInternalServerError, `{"message":"boom"}`)) opts := &Options{IO: ios, Client: func() (*http.Client, error) { return registry.GetClient(), nil }, Config: func() (config.Config, error) { diff --git a/pkg/cmd/gateway-group/update/update.go b/pkg/cmd/gateway-group/update/update.go index afc0452..6fe1aa6 100644 --- a/pkg/cmd/gateway-group/update/update.go +++ b/pkg/cmd/gateway-group/update/update.go @@ -17,16 +17,20 @@ import ( ) type Options struct { - IO *iostreams.IOStreams - Client func() (*http.Client, error) - Config func() (config.Config, error) - Output string - File string - ID string - Name string - Description string - Labels []string - Prefix string + IO *iostreams.IOStreams + Client func() (*http.Client, error) + Config func() (config.Config, error) + Output string + File string + ID string + Name string + Description string + Labels []string + Prefix string + NameSet bool + DescriptionSet bool + LabelsSet bool + PrefixSet bool } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -42,6 +46,10 @@ func NewCmd(f *cmd.Factory) *cobra.Command { RunE: func(c *cobra.Command, args []string) error { opts.ID = args[0] opts.Output, _ = c.Flags().GetString("output") + opts.NameSet = c.Flags().Changed("name") + opts.DescriptionSet = c.Flags().Changed("description") + opts.LabelsSet = c.Flags().Changed("labels") + opts.PrefixSet = c.Flags().Changed("prefix") return updateRun(opts) }, } @@ -84,29 +92,40 @@ func updateRun(opts *Options) error { } labels := map[string]string{} - for _, item := range opts.Labels { - key, value, found := strings.Cut(item, "=") - if !found || key == "" { - return &cmdutil.FlagError{Err: fmt.Errorf("invalid --labels value %q, expected key=value", item)} + if opts.LabelsSet { + for _, item := range opts.Labels { + key, value, found := strings.Cut(item, "=") + if !found || key == "" { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid --labels value %q, expected key=value", item)} + } + labels[key] = value } - labels[key] = value } - request := map[string]interface{}{} - if opts.Name != "" { - request["name"] = opts.Name + client := api.NewClient(httpClient, cfg.BaseURL()) + currentBody, err := client.Get(fmt.Sprintf("/api/gateway_groups/%s", opts.ID), nil) + if err != nil { + return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) + } + + var request api.GatewayGroup + if err := json.Unmarshal(currentBody, &request); err != nil { + return fmt.Errorf("failed to decode current gateway group: %w", err) + } + request.ID = opts.ID + if opts.NameSet { + request.Name = opts.Name } - if opts.Description != "" { - request["description"] = opts.Description + if opts.DescriptionSet { + request.Description = opts.Description } - if opts.Prefix != "" { - request["prefix"] = opts.Prefix + if opts.PrefixSet { + request.Prefix = opts.Prefix } - if len(opts.Labels) > 0 { - request["labels"] = labels + if opts.LabelsSet { + request.Labels = labels } - client := api.NewClient(httpClient, cfg.BaseURL()) body, err := client.Put(fmt.Sprintf("/api/gateway_groups/%s", opts.ID), request) if err != nil { return fmt.Errorf("failed to update gateway group: %s", cmdutil.FormatAPIError(err)) diff --git a/pkg/cmd/gateway-group/update/update_test.go b/pkg/cmd/gateway-group/update/update_test.go new file mode 100644 index 0000000..0dfed6c --- /dev/null +++ b/pkg/cmd/gateway-group/update/update_test.go @@ -0,0 +1,88 @@ +package update + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/api7/a7/internal/config" + "github.com/api7/a7/pkg/api" + "github.com/api7/a7/pkg/httpmock" + "github.com/api7/a7/pkg/iostreams" +) + +type mockConfig struct{} + +func (m *mockConfig) BaseURL() string { return "" } +func (m *mockConfig) Token() string { return "" } +func (m *mockConfig) GatewayGroup() string { return "" } +func (m *mockConfig) TLSSkipVerify() bool { return false } +func (m *mockConfig) CACert() string { return "" } +func (m *mockConfig) CurrentContext() string { return "" } +func (m *mockConfig) Contexts() []config.Context { return nil } +func (m *mockConfig) GetContext(name string) (*config.Context, error) { return nil, nil } +func (m *mockConfig) AddContext(ctx config.Context) error { return nil } +func (m *mockConfig) RemoveContext(name string) error { return nil } +func (m *mockConfig) SetCurrentContext(name string) error { return nil } +func (m *mockConfig) Save() error { return nil } + +func TestUpdateGatewayGroup_PreservesRequiredFields(t *testing.T) { + ios, _, out, _ := iostreams.Test() + registry := &httpmock.Registry{} + registry.Register(http.MethodGet, "/api/gateway_groups/gg1", httpmock.JSONResponse(`{ + "id":"gg1", + "name":"default", + "description":"old description", + "prefix":"/old", + "labels":{"env":"old"}, + "status":1 + }`)) + registry.RegisterResponder(http.MethodPut, "/api/gateway_groups/gg1", func(r *http.Request) (httpmock.Response, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return httpmock.Response{}, err + } + var payload api.GatewayGroup + if err := json.Unmarshal(body, &payload); err != nil { + return httpmock.Response{}, err + } + if payload.Name != "default" { + t.Fatalf("expected existing name to be preserved, got %q", payload.Name) + } + if payload.Description != "new description" { + t.Fatalf("expected description to be updated, got %q", payload.Description) + } + if payload.Prefix != "/old" { + t.Fatalf("expected existing prefix to be preserved, got %q", payload.Prefix) + } + if payload.Labels["env"] != "new" { + t.Fatalf("expected labels to be replaced from flags, got %+v", payload.Labels) + } + return httpmock.JSONResponse(`{"id":"gg1","name":"default","description":"new description","prefix":"/old","labels":{"env":"new"},"status":1}`), nil + }) + + opts := &Options{ + IO: ios, + Client: func() (*http.Client, error) { return registry.GetClient(), nil }, + Config: func() (config.Config, error) { return &mockConfig{}, nil }, + Output: "json", + ID: "gg1", + Description: "new description", + DescriptionSet: true, + Labels: []string{"env=new"}, + LabelsSet: true, + } + if err := updateRun(opts); err != nil { + t.Fatalf("updateRun failed: %v", err) + } + + var item api.GatewayGroup + if err := json.Unmarshal(out.Bytes(), &item); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + if item.Name != "default" || item.Description != "new description" { + t.Fatalf("unexpected gateway group output: %+v", item) + } + registry.Verify(t) +} diff --git a/pkg/cmd/proto/update/update.go b/pkg/cmd/proto/update/update.go index 7647cfe..a753573 100644 --- a/pkg/cmd/proto/update/update.go +++ b/pkg/cmd/proto/update/update.go @@ -24,9 +24,12 @@ type Options struct { GatewayGroup string ID string - Desc string - Content string - Labels []string + Desc string + Content string + Labels []string + DescSet bool + ContentSet bool + LabelsSet bool } func NewCmd(f *cmd.Factory) *cobra.Command { @@ -39,6 +42,9 @@ func NewCmd(f *cmd.Factory) *cobra.Command { opts.ID = args[0] opts.Output, _ = c.Flags().GetString("output") opts.GatewayGroup, _ = c.Flags().GetString("gateway-group") + opts.DescSet = c.Flags().Changed("desc") + opts.ContentSet = c.Flags().Changed("content") + opts.LabelsSet = c.Flags().Changed("labels") return actionRun(opts) }, } @@ -88,23 +94,37 @@ func actionRun(opts *Options) error { } labels := make(map[string]string) - for _, label := range opts.Labels { - parts := strings.SplitN(label, "=", 2) - if len(parts) != 2 || parts[0] == "" { - return fmt.Errorf("invalid label %q, expected key=value", label) + if opts.LabelsSet { + for _, label := range opts.Labels { + parts := strings.SplitN(label, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid label %q, expected key=value", label) + } + labels[parts[0]] = parts[1] } - labels[parts[0]] = parts[1] } - bodyReq := api.Proto{ - Desc: opts.Desc, - Content: opts.Content, + client := api.NewClient(httpClient, cfg.BaseURL()) + currentBody, err := client.Get("/apisix/admin/protos/"+opts.ID, map[string]string{"gateway_group_id": ggID}) + if err != nil { + return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) + } + + var bodyReq api.Proto + if err := json.Unmarshal(currentBody, &bodyReq); err != nil { + return fmt.Errorf("failed to decode current proto: %w", err) + } + bodyReq.ID = opts.ID + if opts.DescSet { + bodyReq.Desc = opts.Desc } - if len(labels) > 0 { + if opts.ContentSet { + bodyReq.Content = opts.Content + } + if opts.LabelsSet { bodyReq.Labels = labels } - client := api.NewClient(httpClient, cfg.BaseURL()) body, err := client.Put("/apisix/admin/protos/"+opts.ID+"?gateway_group_id="+ggID, bodyReq) if err != nil { return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) diff --git a/pkg/cmd/proto/update/update_test.go b/pkg/cmd/proto/update/update_test.go index e3453ec..51588c7 100644 --- a/pkg/cmd/proto/update/update_test.go +++ b/pkg/cmd/proto/update/update_test.go @@ -2,6 +2,7 @@ package update import ( "encoding/json" + "io" "net/http" "testing" @@ -33,7 +34,32 @@ func (m *mockConfig) Save() error { return n func TestUpdateProto_JSON(t *testing.T) { ios, _, out, _ := iostreams.Test() registry := &httpmock.Registry{} - registry.Register(http.MethodPut, "/apisix/admin/protos/p1", httpmock.JSONResponse(`{"id":"p1","desc":"d2","content":"message X {}"}`)) + registry.Register(http.MethodGet, "/apisix/admin/protos/p1", httpmock.JSONResponse(`{ + "id":"p1", + "desc":"d1", + "content":"syntax = \"proto3\"; message X {}", + "labels":{"env":"old"} + }`)) + registry.RegisterResponder(http.MethodPut, "/apisix/admin/protos/p1", func(r *http.Request) (httpmock.Response, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return httpmock.Response{}, err + } + var payload api.Proto + if err := json.Unmarshal(body, &payload); err != nil { + return httpmock.Response{}, err + } + if payload.Content != `syntax = "proto3"; message X {}` { + t.Fatalf("expected existing proto content to be preserved, got %q", payload.Content) + } + if payload.Desc != "d2" { + t.Fatalf("expected desc to be updated, got %q", payload.Desc) + } + if payload.Labels["env"] != "dev" { + t.Fatalf("expected labels to be replaced from flags, got %+v", payload.Labels) + } + return httpmock.JSONResponse(`{"id":"p1","desc":"d2","content":"syntax = \"proto3\"; message X {}","labels":{"env":"dev"}}`), nil + }) opts := &Options{ IO: ios, @@ -44,8 +70,9 @@ func TestUpdateProto_JSON(t *testing.T) { GatewayGroup: "gg1", ID: "p1", Desc: "d2", - Content: "message X {}", + DescSet: true, Labels: []string{"env=dev"}, + LabelsSet: true, } if err := actionRun(opts); err != nil { t.Fatalf("actionRun failed: %v", err) From 0d77de2e063c4c091adb0ffd89b2734b4b5428a0 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sat, 9 May 2026 09:53:46 +0800 Subject: [PATCH 2/8] avoid response fields in gateway group update --- pkg/cmd/gateway-group/update/update.go | 24 +++++++++++++++------ pkg/cmd/gateway-group/update/update_test.go | 14 ++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/gateway-group/update/update.go b/pkg/cmd/gateway-group/update/update.go index 6fe1aa6..db4eec2 100644 --- a/pkg/cmd/gateway-group/update/update.go +++ b/pkg/cmd/gateway-group/update/update.go @@ -108,22 +108,32 @@ func updateRun(opts *Options) error { return fmt.Errorf("%s", cmdutil.FormatAPIError(err)) } - var request api.GatewayGroup - if err := json.Unmarshal(currentBody, &request); err != nil { + var current api.GatewayGroup + if err := json.Unmarshal(currentBody, ¤t); err != nil { return fmt.Errorf("failed to decode current gateway group: %w", err) } - request.ID = opts.ID + + request := map[string]interface{}{ + "name": current.Name, + "description": current.Description, + } + if current.Prefix != "" { + request["prefix"] = current.Prefix + } + if current.Labels != nil { + request["labels"] = current.Labels + } if opts.NameSet { - request.Name = opts.Name + request["name"] = opts.Name } if opts.DescriptionSet { - request.Description = opts.Description + request["description"] = opts.Description } if opts.PrefixSet { - request.Prefix = opts.Prefix + request["prefix"] = opts.Prefix } if opts.LabelsSet { - request.Labels = labels + request["labels"] = labels } body, err := client.Put(fmt.Sprintf("/api/gateway_groups/%s", opts.ID), request) diff --git a/pkg/cmd/gateway-group/update/update_test.go b/pkg/cmd/gateway-group/update/update_test.go index 0dfed6c..3859848 100644 --- a/pkg/cmd/gateway-group/update/update_test.go +++ b/pkg/cmd/gateway-group/update/update_test.go @@ -43,6 +43,20 @@ func TestUpdateGatewayGroup_PreservesRequiredFields(t *testing.T) { if err != nil { return httpmock.Response{}, err } + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return httpmock.Response{}, err + } + if _, ok := raw["status"]; ok { + t.Fatalf("request must not include response-only field status: %s", string(body)) + } + if _, ok := raw["created_at"]; ok { + t.Fatalf("request must not include response-only field created_at: %s", string(body)) + } + if _, ok := raw["updated_at"]; ok { + t.Fatalf("request must not include response-only field updated_at: %s", string(body)) + } + var payload api.GatewayGroup if err := json.Unmarshal(body, &payload); err != nil { return httpmock.Response{}, err From a73c4741cb701fbc93cbe6be16ce0c2fd932e8c9 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 21:06:26 +0800 Subject: [PATCH 3/8] add e2e coverage for partial update fixes --- test/e2e/credential_test.go | 46 ++++++++++++++++++++++++++++++++++ test/e2e/gateway_group_test.go | 12 +++++++++ test/e2e/proto_test.go | 14 +++++++++++ 3 files changed, 72 insertions(+) diff --git a/test/e2e/credential_test.go b/test/e2e/credential_test.go index 28ac4f5..789eaf2 100644 --- a/test/e2e/credential_test.go +++ b/test/e2e/credential_test.go @@ -118,6 +118,52 @@ func TestCredential_CreateWithPositionalID(t *testing.T) { require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) } +func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { + env := setupEnv(t) + username := uniqueResourceID("e2e-cred-update-consumer") + credName := uniqueResourceID("e2e-cred-update") + t.Cleanup(func() { deleteConsumerViaAdmin(t, username) }) + + createTestConsumerViaCLI(t, env, username) + + stdout, stderr, err := runA7WithEnv(env, "credential", "create", credName, + "--consumer", username, + "--plugins-json", `{"key-auth":{"key":"e2e-update-key-12345"}}`, + "--desc", "old credential desc", + "-g", gatewayGroup) + require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + + var created map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &created), "credential create should return JSON") + actualID, ok := created["id"].(string) + require.True(t, ok && actualID != "", "credential create response should contain generated id: %v", created) + + stdout, stderr, err = runA7WithEnv(env, "credential", "update", actualID, + "--consumer", username, + "--desc", "updated credential desc", + "-g", gatewayGroup, + "-o", "json") + require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + + var credential map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &credential), "credential update should return JSON") + assert.Equal(t, "updated credential desc", credential["desc"]) + assert.Equal(t, credName, credential["name"]) + plugins := requireJSONObject(t, credential["plugins"], "credential.plugins") + assert.Contains(t, plugins, "key-auth") + + runA7JSON(t, env, &credential, "credential", "get", actualID, + "--consumer", username, "-g", gatewayGroup, "-o", "json") + assert.Equal(t, "updated credential desc", credential["desc"]) + assert.Equal(t, credName, credential["name"]) + plugins = requireJSONObject(t, credential["plugins"], "credential.plugins") + assert.Contains(t, plugins, "key-auth") + + stdout, stderr, err = runA7WithEnv(env, "credential", "delete", actualID, + "--consumer", username, "--force", "-g", gatewayGroup) + require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) +} + func TestCredential_RequiresConsumerFlag(t *testing.T) { env := setupEnv(t) diff --git a/test/e2e/gateway_group_test.go b/test/e2e/gateway_group_test.go index a699e0b..52f33a2 100644 --- a/test/e2e/gateway_group_test.go +++ b/test/e2e/gateway_group_test.go @@ -170,6 +170,18 @@ func TestGatewayGroup_CRUD(t *testing.T) { require.NoError(t, err, stderr) assert.Contains(t, stdout, ggName) + // Update via flags and verify required fields from the current resource are preserved. + stdout, stderr, err = runA7WithEnv(env, "gateway-group", "update", ggID, + "--description", "Updated by e2e tests", + "-o", "json") + require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + + var updated map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &updated), "gateway-group update should return JSON") + assert.Equal(t, ggID, updated["id"]) + assert.Equal(t, ggName, updated["name"]) + assert.Equal(t, "Updated by e2e tests", updated["description"]) + // Delete stdout, stderr, err = runA7WithEnv(env, "gateway-group", "delete", ggID, "--force") require.NoError(t, err, stderr) diff --git a/test/e2e/proto_test.go b/test/e2e/proto_test.go index ee5e191..9d95079 100644 --- a/test/e2e/proto_test.go +++ b/test/e2e/proto_test.go @@ -68,6 +68,20 @@ func TestProto_CRUD(t *testing.T) { require.True(t, ok, "proto.content should be a string") assert.Contains(t, content, "helloworld") + // Update via flags and verify required content is preserved. + stdout, stderr, err = runA7WithEnv(env, "proto", "update", protoID, + "--desc", "updated e2e test proto", + "-g", gatewayGroup, + "-o", "json") + require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + + runA7JSON(t, env, &proto, "proto", "get", protoID, "-g", gatewayGroup, "-o", "json") + assert.Equal(t, protoID, proto["id"]) + assert.Equal(t, "updated e2e test proto", proto["desc"]) + content, ok = proto["content"].(string) + require.True(t, ok, "proto.content should be a string") + assert.Contains(t, content, "helloworld") + // Export (use get -o json; export is batch-only with cobra.NoArgs) runA7JSON(t, env, &proto, "proto", "get", protoID, "-g", gatewayGroup, "-o", "json") content, ok = proto["content"].(string) From c6e84967243ae7a8026e1c7d231c838c166bfd08 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 21:15:02 +0800 Subject: [PATCH 4/8] address e2e review feedback --- pkg/cmd/gateway-group/update/update_test.go | 2 +- test/e2e/credential_test.go | 36 +++++++++++++-------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/gateway-group/update/update_test.go b/pkg/cmd/gateway-group/update/update_test.go index 3859848..2a6c82b 100644 --- a/pkg/cmd/gateway-group/update/update_test.go +++ b/pkg/cmd/gateway-group/update/update_test.go @@ -14,7 +14,7 @@ import ( type mockConfig struct{} -func (m *mockConfig) BaseURL() string { return "" } +func (m *mockConfig) BaseURL() string { return "http://api.local" } func (m *mockConfig) Token() string { return "" } func (m *mockConfig) GatewayGroup() string { return "" } func (m *mockConfig) TLSSkipVerify() bool { return false } diff --git a/test/e2e/credential_test.go b/test/e2e/credential_test.go index 789eaf2..c534b40 100644 --- a/test/e2e/credential_test.go +++ b/test/e2e/credential_test.go @@ -97,13 +97,20 @@ func TestCredential_CreateWithPositionalID(t *testing.T) { "--consumer", username, "--plugins-json", `{"key-auth":{"key":"e2e-positional-key-12345"}}`, "-g", gatewayGroup) - require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + require.NoError(t, err, "credential create failed") var created map[string]interface{} require.NoError(t, json.Unmarshal([]byte(stdout), &created), "credential create should return JSON") actualID, ok := created["id"].(string) require.True(t, ok && actualID != "", "credential create response should contain generated id: %v", created) assert.Equal(t, credID, created["name"]) + t.Cleanup(func() { + _, _, cleanupErr := runA7WithEnv(env, "credential", "delete", actualID, + "--consumer", username, "--force", "-g", gatewayGroup) + if cleanupErr != nil { + t.Logf("credential cleanup failed for %s", actualID) + } + }) var credential map[string]interface{} runA7JSON(t, env, &credential, "credential", "get", actualID, @@ -112,10 +119,6 @@ func TestCredential_CreateWithPositionalID(t *testing.T) { assert.Equal(t, credID, credential["name"]) plugins := requireJSONObject(t, credential["plugins"], "credential.plugins") assert.Contains(t, plugins, "key-auth") - - stdout, stderr, err = runA7WithEnv(env, "credential", "delete", actualID, - "--consumer", username, "--force", "-g", gatewayGroup) - require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) } func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { @@ -126,24 +129,31 @@ func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { createTestConsumerViaCLI(t, env, username) - stdout, stderr, err := runA7WithEnv(env, "credential", "create", credName, + stdout, _, err := runA7WithEnv(env, "credential", "create", credName, "--consumer", username, "--plugins-json", `{"key-auth":{"key":"e2e-update-key-12345"}}`, "--desc", "old credential desc", "-g", gatewayGroup) - require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + require.NoError(t, err, "credential create failed") var created map[string]interface{} require.NoError(t, json.Unmarshal([]byte(stdout), &created), "credential create should return JSON") actualID, ok := created["id"].(string) require.True(t, ok && actualID != "", "credential create response should contain generated id: %v", created) + t.Cleanup(func() { + _, _, cleanupErr := runA7WithEnv(env, "credential", "delete", actualID, + "--consumer", username, "--force", "-g", gatewayGroup) + if cleanupErr != nil { + t.Logf("credential cleanup failed for %s", actualID) + } + }) - stdout, stderr, err = runA7WithEnv(env, "credential", "update", actualID, + stdout, _, err = runA7WithEnv(env, "credential", "update", actualID, "--consumer", username, "--desc", "updated credential desc", "-g", gatewayGroup, "-o", "json") - require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + require.NoError(t, err, "credential update failed") var credential map[string]interface{} require.NoError(t, json.Unmarshal([]byte(stdout), &credential), "credential update should return JSON") @@ -151,6 +161,8 @@ func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { assert.Equal(t, credName, credential["name"]) plugins := requireJSONObject(t, credential["plugins"], "credential.plugins") assert.Contains(t, plugins, "key-auth") + keyAuth := requireJSONObject(t, plugins["key-auth"], "credential.plugins.key-auth") + assert.Equal(t, "e2e-update-key-12345", keyAuth["key"]) runA7JSON(t, env, &credential, "credential", "get", actualID, "--consumer", username, "-g", gatewayGroup, "-o", "json") @@ -158,10 +170,8 @@ func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { assert.Equal(t, credName, credential["name"]) plugins = requireJSONObject(t, credential["plugins"], "credential.plugins") assert.Contains(t, plugins, "key-auth") - - stdout, stderr, err = runA7WithEnv(env, "credential", "delete", actualID, - "--consumer", username, "--force", "-g", gatewayGroup) - require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) + keyAuth = requireJSONObject(t, plugins["key-auth"], "credential.plugins.key-auth") + assert.Equal(t, "e2e-update-key-12345", keyAuth["key"]) } func TestCredential_RequiresConsumerFlag(t *testing.T) { From 3cd4f4df25f18e19d54a3dd06af554ac76de5237 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 21:17:23 +0800 Subject: [PATCH 5/8] fix credential e2e compile --- test/e2e/credential_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/credential_test.go b/test/e2e/credential_test.go index c534b40..5a0f1a6 100644 --- a/test/e2e/credential_test.go +++ b/test/e2e/credential_test.go @@ -93,7 +93,7 @@ func TestCredential_CreateWithPositionalID(t *testing.T) { createTestConsumerViaCLI(t, env, username) - stdout, stderr, err := runA7WithEnv(env, "credential", "create", credID, + stdout, _, err := runA7WithEnv(env, "credential", "create", credID, "--consumer", username, "--plugins-json", `{"key-auth":{"key":"e2e-positional-key-12345"}}`, "-g", gatewayGroup) From 398d75e989c4f81660f538bee8e9683455b24683 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 21:23:26 +0800 Subject: [PATCH 6/8] validate empty credential plugins payload --- pkg/cmd/credential/update/update.go | 3 +++ pkg/cmd/credential/update/update_test.go | 12 ++++++++++++ test/e2e/credential_test.go | 16 ++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/credential/update/update.go b/pkg/cmd/credential/update/update.go index 26433e9..28d7b90 100644 --- a/pkg/cmd/credential/update/update.go +++ b/pkg/cmd/credential/update/update.go @@ -104,6 +104,9 @@ func actionRun(opts *Options) error { pl := make(map[string]interface{}) if opts.PluginsSet { + if strings.TrimSpace(opts.PluginsJSON) == "" { + return fmt.Errorf("--plugins-json cannot be empty; pass {} to clear plugins") + } if err := json.Unmarshal([]byte(opts.PluginsJSON), &pl); err != nil { return fmt.Errorf("invalid --plugins-json: %w", err) } diff --git a/pkg/cmd/credential/update/update_test.go b/pkg/cmd/credential/update/update_test.go index 6035d9e..ab4c968 100644 --- a/pkg/cmd/credential/update/update_test.go +++ b/pkg/cmd/credential/update/update_test.go @@ -106,6 +106,18 @@ func TestUpdateCredential_MissingConsumer(t *testing.T) { } } +func TestUpdateCredential_EmptyPluginsJSON(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &Options{IO: ios, Client: func() (*http.Client, error) { return (&httpmock.Registry{}).GetClient(), nil }, Config: func() (config.Config, error) { + return &mockConfig{baseURL: "http://api.local", gatewayGroup: "gg1"}, nil + }, Consumer: "alice", ID: "cred1", GatewayGroup: "gg1", PluginsSet: true, PluginsJSON: " \t"} + + err := actionRun(opts) + if err == nil || !strings.Contains(err.Error(), "--plugins-json cannot be empty") || !strings.Contains(err.Error(), "{}") { + t.Fatalf("expected empty plugins-json error with clear hint, got: %v", err) + } +} + func TestUpdateCredential_APIError(t *testing.T) { ios, _, _, _ := iostreams.Test() registry := &httpmock.Registry{} diff --git a/test/e2e/credential_test.go b/test/e2e/credential_test.go index 5a0f1a6..61c9c2c 100644 --- a/test/e2e/credential_test.go +++ b/test/e2e/credential_test.go @@ -108,7 +108,7 @@ func TestCredential_CreateWithPositionalID(t *testing.T) { _, _, cleanupErr := runA7WithEnv(env, "credential", "delete", actualID, "--consumer", username, "--force", "-g", gatewayGroup) if cleanupErr != nil { - t.Logf("credential cleanup failed for %s", actualID) + t.Logf("credential cleanup failed for %s: %v", actualID, cleanupErr) } }) @@ -144,7 +144,7 @@ func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { _, _, cleanupErr := runA7WithEnv(env, "credential", "delete", actualID, "--consumer", username, "--force", "-g", gatewayGroup) if cleanupErr != nil { - t.Logf("credential cleanup failed for %s", actualID) + t.Logf("credential cleanup failed for %s: %v", actualID, cleanupErr) } }) @@ -174,6 +174,18 @@ func TestCredential_UpdateFlagsPreserveExistingFields(t *testing.T) { assert.Equal(t, "e2e-update-key-12345", keyAuth["key"]) } +func TestCredential_UpdateRejectsEmptyPluginsJSON(t *testing.T) { + env := setupEnv(t) + + _, stderr, err := runA7WithEnv(env, "credential", "update", "missing-credential", + "--consumer", "missing-consumer", + "--plugins-json", "", + "-g", gatewayGroup) + require.Error(t, err) + assert.Contains(t, stderr, "--plugins-json cannot be empty") + assert.Contains(t, stderr, "{}") +} + func TestCredential_RequiresConsumerFlag(t *testing.T) { env := setupEnv(t) From 41fcd9a69b36560661b2cde50246ff52e84d092b Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 21:48:53 +0800 Subject: [PATCH 7/8] stabilize proto e2e update assertion --- test/e2e/proto_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/e2e/proto_test.go b/test/e2e/proto_test.go index 9d95079..f7526bd 100644 --- a/test/e2e/proto_test.go +++ b/test/e2e/proto_test.go @@ -68,16 +68,19 @@ func TestProto_CRUD(t *testing.T) { require.True(t, ok, "proto.content should be a string") assert.Contains(t, content, "helloworld") - // Update via flags and verify required content is preserved. + // Update via flags and verify required content is preserved. The runtime + // proto API does not consistently persist desc, so use labels for a + // server-observable mutation. stdout, stderr, err = runA7WithEnv(env, "proto", "update", protoID, - "--desc", "updated e2e test proto", + "--labels", "e2e=updated", "-g", gatewayGroup, "-o", "json") require.NoError(t, err, "stdout=%s stderr=%s", stdout, stderr) runA7JSON(t, env, &proto, "proto", "get", protoID, "-g", gatewayGroup, "-o", "json") assert.Equal(t, protoID, proto["id"]) - assert.Equal(t, "updated e2e test proto", proto["desc"]) + labels := requireJSONObject(t, proto["labels"], "proto.labels") + assert.Equal(t, "updated", labels["e2e"]) content, ok = proto["content"].(string) require.True(t, ok, "proto.content should be a string") assert.Contains(t, content, "helloworld") From a3779d825d4aacffade51bb2f8c466bebfc49d82 Mon Sep 17 00:00:00 2001 From: Qi Guo <979918879@qq.com> Date: Sun, 10 May 2026 22:04:35 +0800 Subject: [PATCH 8/8] clarify proto e2e update intent --- test/e2e/proto_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/e2e/proto_test.go b/test/e2e/proto_test.go index f7526bd..e84c6cf 100644 --- a/test/e2e/proto_test.go +++ b/test/e2e/proto_test.go @@ -68,9 +68,8 @@ func TestProto_CRUD(t *testing.T) { require.True(t, ok, "proto.content should be a string") assert.Contains(t, content, "helloworld") - // Update via flags and verify required content is preserved. The runtime - // proto API does not consistently persist desc, so use labels for a - // server-observable mutation. + // Update via flags and verify required content is preserved. Labels provide + // a server-observable mutation without depending on optional display fields. stdout, stderr, err = runA7WithEnv(env, "proto", "update", protoID, "--labels", "e2e=updated", "-g", gatewayGroup,