From b2fcbc2c2ca9932c9924bbfe6eda53006642be43 Mon Sep 17 00:00:00 2001 From: Alexandre Bergere Date: Thu, 8 Oct 2026 18:09:50 +0200 Subject: [PATCH] fix: send the slug on update so the API can refuse a rename Customers, deployment zones, entitlements, entitlement groups, licenses and service accounts are never renamed. Their PUT accepts the current slug and refuses any other with a 422, .SlugNotRenameable. Their update payloads dropped Slug, so a rename returned success and left the old slug in place without a word. The six update payloads now send Slug when it is set, so the refusal reaches the caller as an *Error carrying the code. The current slug is still accepted, so writing back what was read keeps working, and an input with no slug still renders none. Signed-off-by: Alexandre Bergere --- README.md | 5 ++ inputs.go | 79 ++++++++++++++++--------- inputs_test.go | 155 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 198 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 1cb4c73..5c78f1f 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ err = client.Instances.UpdateStatus(ctx, *instance.Slug, sdk.InstanceStatusHealt `InstanceInput.Slug` is generated when nil on create and renames the instance on update; a slug another instance holds is a 409. +Customers, deployment zones, entitlements, entitlement groups, licenses and service +accounts are never renamed. Their `Update` sends `Slug` for the API to check: the +current slug is accepted, and any other is a 422 whose `*sdk.Error` carries the code +`.SlugNotRenameable` (for example `UpdateEntitlement.SlugNotRenameable`). + ### Licenses and entitlements A license is one version of a family; `LicenseFamilies` reads the family with its diff --git a/inputs.go b/inputs.go index 4ff9a62..e012356 100644 --- a/inputs.go +++ b/inputs.go @@ -71,7 +71,11 @@ type ComponentInput struct { type CustomerInput struct { Name string ExternalCustomerID *string - Slug *string + + // Slug is generated when nil on create. Update sends it for the API to check: the + // current slug is accepted, and any other is refused with a 422, + // UpdateCustomer.SlugNotRenameable. + Slug *string } // DeploymentZoneInput is the input for creating or updating a deployment zone. @@ -81,7 +85,11 @@ type DeploymentZoneInput struct { Description string Metadata map[string]any ReleaseID *string - Slug *string + + // Slug is generated when nil on create. Update sends it for the API to check: the + // current slug is accepted, and any other is refused with a 422, + // UpdateDeploymentZone.SlugNotRenameable. + Slug *string } // EnsureOrganizationInput is the input for ensuring a platform organization exists. @@ -99,7 +107,11 @@ type EnsureOrganizationInput struct { type EntitlementGroupInput struct { Name string Description *string - Slug *string + + // Slug is generated when nil on create. Update sends it for the API to check: the + // current slug is accepted, and any other is refused with a 422, + // UpdateEntitlementGroup.SlugNotRenameable. + Slug *string } // EntitlementInput is the input for creating or updating an entitlement. @@ -117,7 +129,11 @@ type EntitlementInput struct { Type *EntitlementType AggregationMethod *EntitlementAggregationMethod GroupSlugs []string - Slug *string + + // Slug is generated when nil on create. Update sends it for the API to check: the + // current slug is accepted, and any other is refused with a 422, + // UpdateEntitlement.SlugNotRenameable. + Slug *string // Icon is a provider-namespaced token such as "lucide:rocket", deliberately not tied // to any one icon library. @@ -177,8 +193,9 @@ type InstanceInput struct { // next number from there, so the create body declares no such field at all. // Update sends it only as an echo of what was read, since a version's slug is // built from its number. -// - Slug is accepted on create and refused on update. A slug is the license's stable -// identity once assigned. +// - Slug is chosen on create and never changed. Update sends it for the API to check +// against the path: the current slug is accepted, and any other is a 422, +// UpdateLicense.SlugNotRenameable. // - FamilySlug is accepted on create and refused on update, so updatePayload never // renders it. FamilyID is accepted on both, but on update only as the license's // own family. @@ -203,8 +220,10 @@ type LicenseInput struct { VersionName *string IsDefault bool - // Slug is the license's URL-friendly identity, auto-generated when nil -- create only. - // Update refuses it. + // Slug is the license's URL-friendly identity, auto-generated when nil on create. + // Update sends it for the API to check against the path: the current slug is + // accepted, so a License read back can be written back, and any other is a 422, + // UpdateLicense.SlugNotRenameable. Slug *string // FamilySlug names, by slug, the family this license becomes the next version of @@ -273,6 +292,10 @@ type ReleaseInput struct { // ServiceAccountInput is the input for creating or updating a service account. type ServiceAccountInput struct { Name string + + // Slug is generated when nil on create. Update sends it for the API to check: the + // current slug is accepted, and any other is refused with a 422, + // UpdateServiceAccount.SlugNotRenameable. Slug *string } @@ -378,13 +401,11 @@ func (in CustomerInput) createPayload() customerCreatePayload { type customerUpdatePayload struct { Name string `json:"name"` ExternalCustomerID *string `json:"externalCustomerId,omitempty"` + Slug *string `json:"slug,omitempty"` } func (in CustomerInput) updatePayload() customerUpdatePayload { - return customerUpdatePayload{ - Name: in.Name, - ExternalCustomerID: in.ExternalCustomerID, - } + return customerUpdatePayload(in) } type deploymentZoneCreatePayload struct { @@ -413,6 +434,7 @@ type deploymentZoneUpdatePayload struct { Description string `json:"description"` Metadata map[string]any `json:"metadata,omitempty"` ReleaseID *string `json:"releaseId,omitempty"` + Slug *string `json:"slug,omitempty"` } func (in DeploymentZoneInput) updatePayload() deploymentZoneUpdatePayload { @@ -422,6 +444,7 @@ func (in DeploymentZoneInput) updatePayload() deploymentZoneUpdatePayload { Description: in.Description, Metadata: cloneMap(in.Metadata), ReleaseID: in.ReleaseID, + Slug: in.Slug, } } @@ -442,13 +465,11 @@ func (in EntitlementGroupInput) createPayload() entitlementGroupCreatePayload { type entitlementGroupUpdatePayload struct { Name string `json:"name"` Description *string `json:"description"` + Slug *string `json:"slug,omitempty"` } func (in EntitlementGroupInput) updatePayload() entitlementGroupUpdatePayload { - return entitlementGroupUpdatePayload{ - Name: in.Name, - Description: in.Description, - } + return entitlementGroupUpdatePayload(in) } type entitlementPayload struct { @@ -481,14 +502,13 @@ func (in EntitlementInput) createPayload() entitlementPayload { } } -// entitlementUpdatePayload is entitlementPayload without the slug: an entitlement's slug -// is its identity in the path, and UpdateEntitlementBody does not publish one. type entitlementUpdatePayload struct { Name string `json:"name"` Description *string `json:"description"` Type *EntitlementType `json:"type,omitempty"` AggregationMethod *EntitlementAggregationMethod `json:"aggregationMethod,omitempty"` GroupSlugs *[]string `json:"groupSlugs,omitempty"` + Slug *string `json:"slug,omitempty"` Icon *string `json:"icon,omitempty"` UnitSingular *string `json:"unitSingular,omitempty"` UnitPlural *string `json:"unitPlural,omitempty"` @@ -503,6 +523,7 @@ func (in EntitlementInput) updatePayload() entitlementUpdatePayload { Type: in.Type, AggregationMethod: in.AggregationMethod, GroupSlugs: cloneStringSlice(in.GroupSlugs), + Slug: in.Slug, Icon: in.Icon, UnitSingular: in.UnitSingular, UnitPlural: in.UnitPlural, @@ -614,14 +635,13 @@ func (in LicenseInput) createPayload() licenseCreatePayload { } } -// licenseUpdatePayload drops Slug, FamilySlug and LifecycleState. The slug is the -// license's identity in the path. familySlug is create-only: PUT refuses it with -// UpdateLicense.FamilyNotReassignable rather than ignore it, so rendering it from an -// input filled once for both calls would fail every update. lifecycleState moves -// through Licenses.Publish, Archive and Unarchive: PUT accepts the stored state and -// refuses any other with UpdateLicense.LifecycleStateNotSettable, so the state an -// input was created with would fail every update once the version has moved on. -// FamilyID stays, as the value a read-modify-write caller carries back. +// licenseUpdatePayload drops FamilySlug and LifecycleState. familySlug is create-only: +// PUT refuses it with UpdateLicense.FamilyNotReassignable rather than ignore it, so +// rendering it from an input filled once for both calls would fail every update. +// lifecycleState moves through Licenses.Publish, Archive and Unarchive: PUT accepts the +// stored state and refuses any other with UpdateLicense.LifecycleStateNotSettable, so the +// state an input was created with would fail every update once the version has moved on. +// Slug and FamilyID stay, as the values a read-modify-write caller carries back. type licenseUpdatePayload struct { Name string `json:"name"` Description string `json:"description"` @@ -629,6 +649,7 @@ type licenseUpdatePayload struct { Version string `json:"version"` VersionName *string `json:"versionName,omitempty"` IsDefault bool `json:"isDefault"` + Slug *string `json:"slug,omitempty"` FamilyID *string `json:"familyId,omitempty"` } @@ -640,6 +661,7 @@ func (in LicenseInput) updatePayload() licenseUpdatePayload { Version: in.Version, VersionName: in.VersionName, IsDefault: in.IsDefault, + Slug: in.Slug, FamilyID: in.FamilyID, } } @@ -704,11 +726,12 @@ func (in ServiceAccountInput) createPayload() serviceAccountCreatePayload { } type serviceAccountUpdatePayload struct { - Name string `json:"name"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` } func (in ServiceAccountInput) updatePayload() serviceAccountUpdatePayload { - return serviceAccountUpdatePayload{Name: in.Name} + return serviceAccountUpdatePayload(in) } // Scopes renders even when nil, as JSON null: CreateServiceAccountTokenBody declares it diff --git a/inputs_test.go b/inputs_test.go index 4830d78..b996ec5 100644 --- a/inputs_test.go +++ b/inputs_test.go @@ -5,7 +5,11 @@ package sdk import ( "context" + "encoding/json" + "errors" + "fmt" "net/http" + "path" "slices" "testing" "time" @@ -22,8 +26,8 @@ import ( // every call, with no type error to catch it. // // The expected sets below are transcribed from app/openapi.yaml's Create*/Update* bodies. -// A create body and its update body routinely differ -- a slug is assigned once and then -// immutable, a license's lifecycle state is chosen on create and moved by its own +// A create body and its update body routinely differ -- a component's predecessor is +// linked once, a license's lifecycle state is chosen on create and moved by its own // operations afterwards -- so each direction is listed separately rather than assumed // symmetric. func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { @@ -68,7 +72,7 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.Customers.Update(ctx, name, CustomerInput{Name: name, ExternalCustomerID: &name, Slug: &name}) }, - expected: []string{"externalCustomerId", "name"}, + expected: []string{"externalCustomerId", "name", "slug"}, }, { name: "create deployment zone", @@ -83,7 +87,7 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.DeploymentZones.Update(ctx, name, DeploymentZoneInput{Name: name, Type: "production", Description: "d", Metadata: map[string]any{"region": "eu-west-1"}, ReleaseID: &name, Slug: &name}) }, - expected: []string{"description", "metadata", "name", "releaseId", "type"}, + expected: []string{"description", "metadata", "name", "releaseId", "slug", "type"}, }, { name: "create entitlement group", @@ -99,7 +103,7 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.EntitlementGroups.Update(ctx, name, EntitlementGroupInput{Name: name, Slug: &name}) }, - expected: []string{"description", "name"}, + expected: []string{"description", "name", "slug"}, }, { name: "create entitlement", @@ -114,7 +118,7 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.Entitlements.Update(ctx, name, EntitlementInput{Name: name, Slug: &name, GroupSlugs: []string{"platform"}}) }, - expected: []string{"description", "groupSlugs", "name"}, + expected: []string{"description", "groupSlugs", "name", "slug"}, }, { name: "create instance", @@ -170,12 +174,12 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.Licenses.Update(ctx, name, LicenseInput{Name: name, Description: "d", Type: LicenseType("COMMUNITY"), Version: "1", VersionName: &name, IsDefault: true, Slug: &name, FamilySlug: &name, FamilyID: &name, LifecycleState: &draft}) }, - // Version renders as the read-back echo the server accepts. Slug and - // familySlug are refused and lifecycleState moves through Publish, - // Archive and Unarchive, so none of the three renders even though all - // were set; familyId does, as the value a read-modify-write caller - // carries back. - expected: []string{"description", "familyId", "isDefault", "name", "type", "version", "versionName"}, + // Version renders as the read-back echo the server accepts. familySlug is + // refused whatever its value and lifecycleState moves through Publish, + // Archive and Unarchive, so neither renders even though both were set; + // familyId and slug do, as the values a read-modify-write caller carries + // back. + expected: []string{"description", "familyId", "isDefault", "name", "slug", "type", "version", "versionName"}, }, { name: "create service account", @@ -190,7 +194,7 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { call: func(ctx context.Context, c *Client) error { return c.ServiceAccounts.Update(ctx, name, ServiceAccountInput{Name: name, Slug: &name}) }, - expected: []string{"name"}, + expected: []string{"name", "slug"}, }, { name: "create service account token", @@ -228,6 +232,131 @@ func TestRequestBodiesMatchTheirSpecBodies(t *testing.T) { } } +// TestUpdateLetsTheAPIRefuseARename pins the six updates that never rename. +// +// Their PUTs check a slug in the body against the path: the current one is accepted and +// any other is a 422, .SlugNotRenameable. The update payloads used to drop +// Slug, so a caller asking for a rename got a success and kept the old slug without being +// told. The responder applies the API's rule, so this test fails on a payload that drops +// the slug again. The omitted half matters too: an input with no slug renders none. +func TestUpdateLetsTheAPIRefuseARename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code string + update func(ctx context.Context, c *Client, slug *string) error + }{ + { + name: "customer", + code: "UpdateCustomer.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.Customers.Update(ctx, "current", CustomerInput{Name: "n", Slug: slug}) + }, + }, + { + name: "deployment zone", + code: "UpdateDeploymentZone.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.DeploymentZones.Update(ctx, "current", DeploymentZoneInput{Name: "n", Type: "production", Slug: slug}) + }, + }, + { + name: "entitlement group", + code: "UpdateEntitlementGroup.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.EntitlementGroups.Update(ctx, "current", EntitlementGroupInput{Name: "n", Slug: slug}) + }, + }, + { + name: "entitlement", + code: "UpdateEntitlement.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.Entitlements.Update(ctx, "current", EntitlementInput{Name: "n", Slug: slug}) + }, + }, + { + name: "license", + code: "UpdateLicense.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.Licenses.Update(ctx, "current", LicenseInput{Name: "n", Type: LicenseType("COMMUNITY"), Slug: slug}) + }, + }, + { + name: "service account", + code: "UpdateServiceAccount.SlugNotRenameable", + update: func(ctx context.Context, c *Client, slug *string) error { + return c.ServiceAccounts.Update(ctx, "current", ServiceAccountInput{Name: "n", Slug: slug}) + }, + }, + } + + current, renamed := "current", "renamed" + + for _, test := range tests { + t.Run(test.name+" refuses another slug", func(t *testing.T) { + t.Parallel() + + client, _ := givenClient(t, refuseRenames(test.code)) + + err := test.update(t.Context(), client, &renamed) + + var apiErr *Error + if !errors.As(err, &apiErr) { + t.Fatalf("Update() error = %v, want the API's refusal", err) + } + + if apiErr.StatusCode != http.StatusUnprocessableEntity || apiErr.Code != test.code { + t.Errorf("Update() error = %d %s, want 422 %s", apiErr.StatusCode, apiErr.Code, test.code) + } + }) + + t.Run(test.name+" accepts the current slug", func(t *testing.T) { + t.Parallel() + + client, _ := givenClient(t, refuseRenames(test.code)) + + if err := test.update(t.Context(), client, ¤t); err != nil { + t.Fatalf("Update() error = %v", err) + } + }) + + t.Run(test.name+" renders no slug when none is set", func(t *testing.T) { + t.Parallel() + + client, sent := givenClient(t, refuseRenames(test.code)) + + if err := test.update(t.Context(), client, nil); err != nil { + t.Fatalf("Update() error = %v", err) + } + + if _, ok := sent.only(t).decodeBody(t)["slug"]; ok { + t.Errorf("request body = %s, want no slug key", sent.only(t).body) + } + }) + } +} + +// refuseRenames answers an update the way the PUTs that never rename do: a slug in the +// body other than the path's last segment is a 422 carrying code, anything else a 204. +func refuseRenames(code string) responder { + return func(req *http.Request) (*http.Response, error) { + var body struct { + Slug string `json:"slug"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return nil, err + } + + if body.Slug != "" && body.Slug != path.Base(req.URL.Path) { + return jsonResponse(req, http.StatusUnprocessableEntity, + fmt.Sprintf(`{"title":"Unprocessable Entity","status":422,"code":%q,"detail":"slug cannot be changed through this endpoint; omit it or send the current slug"}`, code)), nil + } + + return jsonResponse(req, http.StatusNoContent, ""), nil + } +} + // respondToWrite answers a write the way the listener does: creates answer 201 and // updates 200, which is what the generated client matches on to fill in its typed // response fields. The body is the smallest one every wrapper here can decode.