diff --git a/pkg/cmd/credential/update/update.go b/pkg/cmd/credential/update/update.go index 50f534d..28d7b90 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,47 @@ func actionRun(opts *Options) error { } pl := make(map[string]interface{}) - if opts.PluginsJSON != "" { + 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) } } 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..ab4c968 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) @@ -79,9 +106,22 @@ 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{} + 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..db4eec2 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,50 @@ 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 != "" { + 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 current api.GatewayGroup + if err := json.Unmarshal(currentBody, ¤t); err != nil { + return fmt.Errorf("failed to decode current gateway group: %w", err) + } + + 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 } - if opts.Description != "" { + if opts.DescriptionSet { request["description"] = opts.Description } - if opts.Prefix != "" { + if opts.PrefixSet { request["prefix"] = opts.Prefix } - if len(opts.Labels) > 0 { + 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..2a6c82b --- /dev/null +++ b/pkg/cmd/gateway-group/update/update_test.go @@ -0,0 +1,102 @@ +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 "http://api.local" } +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 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 + } + 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) diff --git a/test/e2e/credential_test.go b/test/e2e/credential_test.go index 28ac4f5..61c9c2c 100644 --- a/test/e2e/credential_test.go +++ b/test/e2e/credential_test.go @@ -93,17 +93,24 @@ 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) - 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: %v", actualID, cleanupErr) + } + }) var credential map[string]interface{} runA7JSON(t, env, &credential, "credential", "get", actualID, @@ -112,10 +119,71 @@ 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) { + 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, _, 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, "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: %v", actualID, cleanupErr) + } + }) + + stdout, _, err = runA7WithEnv(env, "credential", "update", actualID, + "--consumer", username, + "--desc", "updated credential desc", + "-g", gatewayGroup, + "-o", "json") + 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") + 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") + 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") + 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") + keyAuth = requireJSONObject(t, plugins["key-auth"], "credential.plugins.key-auth") + 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) { 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..e84c6cf 100644 --- a/test/e2e/proto_test.go +++ b/test/e2e/proto_test.go @@ -68,6 +68,22 @@ 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. 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, + "-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"]) + 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") + // 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)