Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --default=<bool>` | Create a SIP realm (`--description`, `--if-not-exists`; async — add `--wait` and optionally `--timeout <seconds>`) |
Expand Down
5 changes: 5 additions & 0 deletions cmd/sip/credential_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<RealmsResponse><Realms><Realm><Id>1103</Id>` +
`<Realm>vapi-3efeaa.auth.bandwidth.com</Realm></Realm></Realms></RealmsResponse>`))
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/realms/1103"):
w.Write([]byte(`<RealmResponse><Realm><Id>1103</Id>` +
`<Realm>vapi-3efeaa.auth.bandwidth.com</Realm><Status>ACTIVE</Status>` +
`</Realm></RealmResponse>`))
Expand Down
77 changes: 77 additions & 0 deletions internal/sip/realm_resolution_test.go
Original file line number Diff line number Diff line change
@@ -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 = `<Realm><Id>1103</Id><Realm>` + host + `</Realm><Status>ACTIVE</Status><Description>full detail</Description></Realm>`
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 := `<RealmResponse>` + record + `</RealmResponse>`
if r.URL.Path == base {
status = tc.listStatus
body = `<RealmsResponse><Realms>` + tc.list + `</Realms></RealmsResponse>`
} else if len(paths) == 3 && r.URL.Path == base+"/1103" {
status = 200
}
if status == 0 {
status = 500
}
w.WriteHeader(status)
if status != 200 {
body = `<RealmResponse><ResponseStatus><Description>request failed</Description></ResponseStatus></RealmResponse>`
}
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)
}
})
}
}
31 changes: 28 additions & 3 deletions internal/sip/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sip
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/url"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions internal/sip/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sip
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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, `<RealmsResponse><Realms><Realm><Id>1103</Id><Realm>vapi-3efeaa.auth.bandwidth.com</Realm></Realm></Realms></RealmsResponse>`)
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)
Expand Down
Loading