From 6c1b7e8c5169912d35db81ac21821a7913f28b1e Mon Sep 17 00:00:00 2001 From: Jeffrey Zeng <133815805+jzeng-bw@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:24:57 -0400 Subject: [PATCH] fix(sip): resolve short realm names through realm list --- AGENTS.md | 2 + README.md | 2 + cmd/sip/credential_create_test.go | 5 ++ internal/sip/realm_resolution_test.go | 77 +++++++++++++++++++++++++++ internal/sip/service.go | 31 +++++++++-- internal/sip/service_test.go | 12 +++++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 internal/sip/realm_resolution_test.go diff --git a/AGENTS.md b/AGENTS.md index 98d7308..a7ace41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2208,6 +2208,8 @@ band sip realm get vapi --plain `get` accepts a realm ID, name, or FQDN. +This resolution is shared by realm get/update/delete and credential `--realm` flags. On a short-name 404, the CLI lists realms, matches the name case-insensitively, then fetches the canonical ID. Numeric references remain IDs (use the FQDN for an all-numeric name). No match preserves exit 3; list failures retain their error classification, and ambiguous names require an ID or FQDN (exit 6). Permission and server errors do not trigger fallback. + ### Update a realm Two fields are updatable: `--default=true` and `--description`. Pass either or both; an omitted field is preserved (the update reads the realm first, because the API's `PUT` is a full replace). diff --git a/README.md b/README.md index f8ada9f..300dce9 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,8 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir ### SIP trunk authentication +Realm references accept an ID, short name, or FQDN. If a short-name lookup returns 404, the CLI lists realms, matches the name case-insensitively, and fetches the matching ID. Numeric references are treated as IDs; use the FQDN for an all-numeric realm name. Permission and server errors are returned without a name-lookup fallback. + | Command | What it does | |---------|-------------| | `band sip realm create --name --default=` | Create a SIP realm (`--description`, `--if-not-exists`; async — add `--wait` and optionally `--timeout `) | diff --git a/cmd/sip/credential_create_test.go b/cmd/sip/credential_create_test.go index 482c1c4..653c045 100644 --- a/cmd/sip/credential_create_test.go +++ b/cmd/sip/credential_create_test.go @@ -28,6 +28,11 @@ func credentialCreateStubServer(t *testing.T, appID string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/realms/vapi"): + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/realms"): + w.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.com`)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/realms/1103"): w.Write([]byte(`1103` + `vapi-3efeaa.auth.bandwidth.comACTIVE` + ``)) diff --git a/internal/sip/realm_resolution_test.go b/internal/sip/realm_resolution_test.go new file mode 100644 index 0000000..3e7d5f0 --- /dev/null +++ b/internal/sip/realm_resolution_test.go @@ -0,0 +1,77 @@ +package sip + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func TestGetRealmResolution(t *testing.T) { + const base = "/accounts/9901361/realms" + const host = "my-vapi-3efeaa.auth.bandwidth.com" + const record = `1103` + host + `ACTIVEfull detail` + for _, tc := range []struct { + name string + ref string + status int + listStatus int + list string + wantExit int + paths []string + }{ + {"name", "my-vapi", 404, 200, record, 0, []string{base + "/my-vapi", base, base + "/1103"}}, + {"case insensitive", "MY-VAPI", 404, 200, record, 0, []string{base + "/MY-VAPI", base, base + "/1103"}}, + {"ID", "1103", 200, 0, "", 0, []string{base + "/1103"}}, + {"FQDN", host, 200, 0, "", 0, []string{base + "/" + host}}, + {"direct name supported", "my-vapi", 200, 0, "", 0, []string{base + "/my-vapi"}}, + {"missing name", "missing", 404, 200, record, 3, []string{base + "/missing", base}}, + {"missing ID", "9999", 404, 0, "", 3, []string{base + "/9999"}}, + {"missing FQDN", host, 404, 0, "", 3, []string{base + "/" + host}}, + {"unauthorized", "my-vapi", 401, 0, "", 2, []string{base + "/my-vapi"}}, + {"forbidden", "my-vapi", 403, 0, "", 2, []string{base + "/my-vapi"}}, + {"server failure", "my-vapi", 500, 0, "", 1, []string{base + "/my-vapi"}}, + {"list forbidden", "my-vapi", 404, 403, "", 2, []string{base + "/my-vapi", base}}, + {"ambiguous name", "my-vapi", 404, 200, record + record, 6, []string{base + "/my-vapi", base}}, + } { + t.Run(tc.name, func(t *testing.T) { + var paths []string + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.Method != http.MethodGet { + t.Errorf("unexpected method %s", r.Method) + } + status := tc.status + body := `` + record + `` + if r.URL.Path == base { + status = tc.listStatus + body = `` + tc.list + `` + } else if len(paths) == 3 && r.URL.Path == base+"/1103" { + status = 200 + } + if status == 0 { + status = 500 + } + w.WriteHeader(status) + if status != 200 { + body = `request failed` + } + fmt.Fprint(w, body) + }) + defer done() + realm, err := svc.GetRealm(context.Background(), tc.ref) + if got := cmdutil.ExitCodeForError(err); got != tc.wantExit { + t.Fatalf("exit = %d, want %d; error = %v", got, tc.wantExit, err) + } + if err == nil && (realm.ID != "1103" || realm.Hostname != host || realm.Description != "full detail") { + t.Errorf("unexpected realm: %+v", realm) + } + if !reflect.DeepEqual(paths, tc.paths) { + t.Errorf("requests = %v, want %v", paths, tc.paths) + } + }) + } +} diff --git a/internal/sip/service.go b/internal/sip/service.go index 690dc87..3beab4c 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -3,6 +3,7 @@ package sip import ( "context" "encoding/xml" + "errors" "fmt" "io" "net/url" @@ -192,10 +193,34 @@ func (s *Service) CreateRealm(ctx context.Context, name, description string, isD return toRealm(resp.Realm), nil } -// GetRealm fetches one realm. ref may be an ID or a name. +// GetRealm fetches one realm by ID, short name, or FQDN. Some accounts only +// accept IDs and FQDNs on the detail endpoint, so resolve short names through +// the list endpoint after a 404. Numeric refs remain IDs, including when absent. func (s *Service) GetRealm(ctx context.Context, ref string) (*Realm, error) { body, err := s.do(ctx, "GET", s.base()+"/realms/"+url.PathEscape(ref), nil) if err != nil { + var apiErr *api.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 || + ValidateRealmName(ref) != nil || strings.Trim(ref, "0123456789") == "" { + return nil, err + } + realms, listErr := s.ListRealms(ctx) + if listErr != nil { + return nil, fmt.Errorf("resolving realm %q: %w", ref, listErr) + } + var match *Realm + for i := range realms { + if strings.EqualFold(realms[i].Name, ref) { + if match != nil { + return nil, cmdutil.NewFlagError(fmt.Sprintf("multiple realms match %q — use a realm ID or FQDN", ref)) + } + match = &realms[i] + } + } + if match != nil { + // Fetch the detail record so callers retain the same complete view. + return s.GetRealm(ctx, match.ID) + } return nil, err } var resp realmResponse @@ -248,7 +273,7 @@ func (s *Service) UpdateRealm(ctx context.Context, ref string, promoteDefault bo if description != nil { desc = *description } - body, err := s.do(ctx, "PUT", s.base()+"/realms/"+url.PathEscape(ref), realmRequest{ + body, err := s.do(ctx, "PUT", s.base()+"/realms/"+url.PathEscape(current.ID), realmRequest{ Realm: current.Name, Description: desc, Default: current.Default || promoteDefault, }) if err != nil { @@ -259,7 +284,7 @@ func (s *Service) UpdateRealm(ctx context.Context, ref string, promoteDefault bo return nil, fmt.Errorf("decoding realm response: %w", err) } if resp.Realm == nil { - return s.GetRealm(ctx, ref) + return s.GetRealm(ctx, current.ID) } return toRealm(resp.Realm), nil } diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go index 4f93690..17be235 100644 --- a/internal/sip/service_test.go +++ b/internal/sip/service_test.go @@ -3,6 +3,7 @@ package sip import ( "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -641,6 +642,17 @@ func TestUpdateRealm_ReadModifyWritePreservesUnspecifiedFields(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var sent string svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/accounts/9901361/realms/vapi" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.URL.Path == "/accounts/9901361/realms" { + fmt.Fprint(w, `1103vapi-3efeaa.auth.bandwidth.com`) + return + } + if r.URL.Path != "/accounts/9901361/realms/1103" { + t.Errorf("unexpected path %s", r.URL.Path) + } if r.Method == http.MethodPut { b, _ := io.ReadAll(r.Body) sent = string(b)