From be83b65d4a70e34de1a247fcc7c7e024e72b759c Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:17:26 -0400 Subject: [PATCH 01/25] feat(sip): add digest hash, password generation, and validation primitives Co-Authored-By: Claude Opus 5 (1M context) --- internal/sip/sip.go | 80 ++++++++++++++++++++++++++++++++++++++++ internal/sip/sip_test.go | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 internal/sip/sip.go create mode 100644 internal/sip/sip_test.go diff --git a/internal/sip/sip.go b/internal/sip/sip.go new file mode 100644 index 0000000..90e996f --- /dev/null +++ b/internal/sip/sip.go @@ -0,0 +1,80 @@ +// Package sip provides domain logic for Bandwidth SIP realm and credential +// provisioning: digest-hash computation, password generation, and validation. +package sip + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "math/big" + "regexp" + "strings" +) + +// passwordAlphabet is intentionally alphanumeric-only: SIP configuration files +// and shell round-trips mangle punctuation, and 62^22 still yields ~131 bits. +const passwordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +const passwordLength = 22 + +// realmNameRe enforces a DNS label. The two alternatives exist because a single +// pattern permitting a trailing hyphen would emit an invalid hostname label. +var realmNameRe = regexp.MustCompile(`^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,28}[A-Za-z0-9])$`) + +// ComputeHashes returns the SIP digest hashes Bandwidth requires when creating +// or rotating a credential. Bandwidth does not compute these server-side. +// +// realmFQDN must be the realm's full hostname exactly as returned by the API +// (e.g. "vapi-3efeaa.auth.bandwidth.com"), not the short realm name — the SIP +// server presents the FQDN in its digest challenge. +func ComputeHashes(username, realmFQDN, password string) (hash1 string, hash1b string) { + sum := func(s string) string { + h := md5.Sum([]byte(s)) + return hex.EncodeToString(h[:]) + } + hash1 = sum(username + ":" + realmFQDN + ":" + password) + hash1b = sum(username + "@" + realmFQDN + ":" + realmFQDN + ":" + password) + return hash1, hash1b +} + +// GeneratePassword returns a cryptographically random alphanumeric password. +func GeneratePassword() (string, error) { + max := big.NewInt(int64(len(passwordAlphabet))) + var b strings.Builder + b.Grow(passwordLength) + for i := 0; i < passwordLength; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("generating password: %w", err) + } + b.WriteByte(passwordAlphabet[n.Int64()]) + } + return b.String(), nil +} + +// ValidateRealmName checks that name is a usable DNS label. The realm name +// becomes the first label of the realm's FQDN. +func ValidateRealmName(name string) error { + if name == "" { + return fmt.Errorf("realm name is required") + } + if len(name) > 30 { + return fmt.Errorf("realm name %q is %d characters; maximum is 30", name, len(name)) + } + if !realmNameRe.MatchString(name) { + return fmt.Errorf("realm name %q must be alphanumeric with internal hyphens only (a-z, A-Z, 0-9, -)", name) + } + return nil +} + +// ValidateUsername rejects usernames that would corrupt digest-hash construction. +func ValidateUsername(username string) error { + if username == "" { + return fmt.Errorf("username is required") + } + if strings.ContainsAny(username, ":@") { + return fmt.Errorf("username %q must not contain ':' or '@'", username) + } + return nil +} diff --git a/internal/sip/sip_test.go b/internal/sip/sip_test.go new file mode 100644 index 0000000..3a1b371 --- /dev/null +++ b/internal/sip/sip_test.go @@ -0,0 +1,78 @@ +package sip + +import ( + "regexp" + "strings" + "testing" +) + +func TestComputeHashes_KnownVector(t *testing.T) { + // Recipe validated live: Hash1 = MD5(user:realm:pass), + // Hash1b = MD5(user@realm:realm:pass). Realm is the full FQDN. + h1, h1b := ComputeHashes("clitest", "bwclitest-3efeaa.auth.bandwidth.com", "Tb7xQ2mK9rL4vN8s") + + if h1 != "1be6abcaa8e9956021d30f33a3925b99" { + t.Errorf("Hash1 = %q, want 1be6abcaa8e9956021d30f33a3925b99", h1) + } + if h1b != "e028e6577a0bb1b90a33d30a110dbdfe" { + t.Errorf("Hash1b = %q, want e028e6577a0bb1b90a33d30a110dbdfe", h1b) + } +} + +func TestComputeHashes_LowercaseHex(t *testing.T) { + h1, h1b := ComputeHashes("u", "r.auth.bandwidth.com", "p") + for name, h := range map[string]string{"Hash1": h1, "Hash1b": h1b} { + if len(h) != 32 { + t.Errorf("%s length = %d, want 32", name, len(h)) + } + if h != strings.ToLower(h) { + t.Errorf("%s = %q, want lowercase hex", name, h) + } + } +} + +func TestGeneratePassword_Shape(t *testing.T) { + seen := map[string]bool{} + re := regexp.MustCompile(`^[A-Za-z0-9]{22}$`) + for i := 0; i < 50; i++ { + pw, err := GeneratePassword() + if err != nil { + t.Fatalf("GeneratePassword() error = %v", err) + } + if !re.MatchString(pw) { + t.Fatalf("password %q does not match ^[A-Za-z0-9]{22}$", pw) + } + if seen[pw] { + t.Fatalf("duplicate password generated: %q", pw) + } + seen[pw] = true + } +} + +func TestValidateRealmName(t *testing.T) { + valid := []string{"a", "vapi", "vapi-test", "a1", "abc123def456ghi789jkl012mno34"} + for _, n := range valid { + if err := ValidateRealmName(n); err != nil { + t.Errorf("ValidateRealmName(%q) = %v, want nil", n, err) + } + } + invalid := []string{"", "-vapi", "vapi-", "va pi", "vapi.test", "VAPI_TEST", + "abcdefghij0123456789abcdefghij0"} // 31 chars + for _, n := range invalid { + if err := ValidateRealmName(n); err == nil { + t.Errorf("ValidateRealmName(%q) = nil, want error", n) + } + } +} + +func TestValidateUsername(t *testing.T) { + if err := ValidateUsername("vapi-agent"); err != nil { + t.Errorf("ValidateUsername(vapi-agent) = %v, want nil", err) + } + // ':' and '@' would corrupt hash construction; empty is meaningless. + for _, u := range []string{"", "user:name", "user@name"} { + if err := ValidateUsername(u); err == nil { + t.Errorf("ValidateUsername(%q) = nil, want error", u) + } + } +} From bf3ea7f6e6b5b2b9ae325db00241e4f308140c5e Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:22:48 -0400 Subject: [PATCH 02/25] feat(api): add response-aware request primitive and same-origin redirect policy --- internal/api/client.go | 8 +-- internal/api/response.go | 95 +++++++++++++++++++++++++++++++++++ internal/api/response_test.go | 92 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 internal/api/response.go create mode 100644 internal/api/response_test.go diff --git a/internal/api/client.go b/internal/api/client.go index 5c5eb08..2c2d1b6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -65,7 +65,7 @@ type Client struct { func NewClient(baseURL string, tm *auth.TokenManager) *Client { return &Client{ BaseURL: baseURL, - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: sameOriginRedirect}, tm: tm, contentType: "json", } @@ -75,7 +75,7 @@ func NewClient(baseURL string, tm *auth.TokenManager) *Client { func NewXMLClient(baseURL string, tm *auth.TokenManager) *Client { return &Client{ BaseURL: baseURL, - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: sameOriginRedirect}, tm: tm, contentType: "xml", } @@ -85,7 +85,7 @@ func NewXMLClient(baseURL string, tm *auth.TokenManager) *Client { func NewBasicAuthClient(baseURL, username, password string) *Client { return &Client{ BaseURL: baseURL, - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: sameOriginRedirect}, contentType: "json", basicUser: username, basicPassword: password, @@ -96,7 +96,7 @@ func NewBasicAuthClient(baseURL, username, password string) *Client { func NewClientNoAuth(baseURL string) *Client { return &Client{ BaseURL: baseURL, - httpClient: &http.Client{Timeout: 30 * time.Second}, + httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: sameOriginRedirect}, contentType: "json", } } diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..60c7b67 --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,95 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" +) + +const maxRedirectHops = 10 + +// RawResponse is a complete HTTP response. Unlike doRaw, it preserves status, +// headers, and the post-redirect URL — required for APIs that signal via 303 +// and paginate through headers. +type RawResponse struct { + StatusCode int + Header http.Header + FinalURL *url.URL + Body []byte +} + +// sameOriginRedirect permits a redirect only when it stays on the same origin +// (scheme + host + effective port) as the request that triggered it. Go follows +// redirects automatically, so this must reject a hop *before* it is followed. +func sameOriginRedirect(req *http.Request, via []*http.Request) error { + if len(via) > maxRedirectHops { + return fmt.Errorf("too many redirects (>%d)", maxRedirectHops) + } + if len(via) == 0 { + return nil + } + prev := via[len(via)-1].URL + if !sameOrigin(prev, req.URL) { + return fmt.Errorf("refusing cross-origin redirect from %s to %s", prev.Host, req.URL.Host) + } + return nil +} + +func sameOrigin(a, b *url.URL) bool { + return a.Scheme == b.Scheme && a.Hostname() == b.Hostname() && port(a) == port(b) +} + +func port(u *url.URL) string { + if p := u.Port(); p != "" { + return p + } + if u.Scheme == "https" { + return "443" + } + return "80" +} + +// DoRawResponse executes a request and returns the full response, including +// non-2xx statuses (the caller decides how to interpret them). Transport +// failures still return an error. +func (c *Client) DoRawResponse(method, path string, body []byte) (*RawResponse, error) { + var r io.Reader + if body != nil { + r = bytes.NewReader(body) + } + req, err := c.newRequest(method, path, r) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", c.contentTypeHeader()) + } + req.Header.Set("Accept", c.contentTypeHeader()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + return &RawResponse{ + StatusCode: resp.StatusCode, + Header: resp.Header, + FinalURL: resp.Request.URL, + Body: data, + }, nil +} + +// contentTypeHeader maps the client's mode to a MIME type. +func (c *Client) contentTypeHeader() string { + if c.contentType == "xml" { + return "application/xml" + } + return "application/json" +} diff --git a/internal/api/response_test.go b/internal/api/response_test.go new file mode 100644 index 0000000..95cd6f5 --- /dev/null +++ b/internal/api/response_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestDoRawResponse_ExposesStatusAndHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "application/xml" { + t.Errorf("Accept = %q, want application/xml", r.Header.Get("Accept")) + } + w.Header().Set("X-Test", "yes") + w.WriteHeader(200) + w.Write([]byte("")) + })) + defer srv.Close() + + c := NewXMLClient(srv.URL, nil) + resp, err := c.DoRawResponse("GET", "/realms", nil) + if err != nil { + t.Fatalf("DoRawResponse() error = %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", resp.StatusCode) + } + if resp.Header.Get("X-Test") != "yes" { + t.Errorf("header X-Test = %q, want yes", resp.Header.Get("X-Test")) + } + if string(resp.Body) != "" { + t.Errorf("Body = %q, want ", resp.Body) + } +} + +func TestDoRawResponse_FollowsSameOriginRedirectAndReportsFinalURL(t *testing.T) { + // Mirrors the live behavior: an empty credential list 303s to ?page=1&size=500. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "" { + http.Redirect(w, r, r.URL.Path+"?page=1&size=500", http.StatusSeeOther) + return + } + w.Write([]byte("")) + })) + defer srv.Close() + + c := NewXMLClient(srv.URL, nil) + resp, err := c.DoRawResponse("GET", "/realms/1/sipcredentials", nil) + if err != nil { + t.Fatalf("DoRawResponse() error = %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", resp.StatusCode) + } + if got := resp.FinalURL.Query().Get("page"); got != "1" { + t.Errorf("FinalURL page = %q, want 1", got) + } +} + +func TestSameOriginRedirect_RefusesOffHost(t *testing.T) { + mk := func(raw string) *http.Request { + u, _ := url.Parse(raw) + return &http.Request{URL: u} + } + via := []*http.Request{mk("https://api.bandwidth.com/api/v2/realms")} + + if err := sameOriginRedirect(mk("https://api.bandwidth.com/api/v2/realms?page=1"), via); err != nil { + t.Errorf("same origin redirect refused: %v", err) + } + for _, target := range []string{ + "https://evil.example.com/realms", // different host + "http://api.bandwidth.com/realms", // HTTPS -> HTTP downgrade + } { + if err := sameOriginRedirect(mk(target), via); err == nil { + t.Errorf("redirect to %s allowed, want refusal", target) + } + } +} + +func TestSameOriginRedirect_HopLimit(t *testing.T) { + u, _ := url.Parse("https://api.bandwidth.com/a") + via := make([]*http.Request, 11) + for i := range via { + via[i] = &http.Request{URL: u} + } + err := sameOriginRedirect(&http.Request{URL: u}, via) + if err == nil || !strings.Contains(err.Error(), "too many redirects") { + t.Errorf("err = %v, want too many redirects", err) + } +} From 344e6243e4fa08ef7b4cb0f14218c5639bd989f4 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:30:47 -0400 Subject: [PATCH 03/25] test(api): cover redirect wiring per constructor via httpClient.Do() Adds TestConstructors_RefuseCrossOriginRedirect, which drives a real cross-origin redirect through NewClient/NewXMLClient/NewBasicAuthClient/ NewClientNoAuth's actual httpClient.Do() call rather than only exercising sameOriginRedirect directly. Closes the gap where a future edit dropping CheckRedirect from one constructor would go undetected. --- internal/api/response_test.go | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/api/response_test.go b/internal/api/response_test.go index 95cd6f5..aedf0e2 100644 --- a/internal/api/response_test.go +++ b/internal/api/response_test.go @@ -90,3 +90,48 @@ func TestSameOriginRedirect_HopLimit(t *testing.T) { t.Errorf("err = %v, want too many redirects", err) } } + +// TestConstructors_RefuseCrossOriginRedirect drives a real cross-origin redirect +// through httpClient.Do() for a client built by each of the four constructors. +// It exists to catch the exact regression a pure-function test can't: if a +// future edit to client.go silently dropped `CheckRedirect: sameOriginRedirect` +// from one constructor's http.Client literal, this test would fail for that +// constructor even though TestSameOriginRedirect_RefusesOffHost (which never +// touches httpClient.Do) would keep passing. +func TestConstructors_RefuseCrossOriginRedirect(t *testing.T) { + // A second server on a different port is a different origin even though + // the hostname is the same ("localhost"), matching how Go's http.Client + // treats it: scheme+host(with port) differ, so this is a genuine + // cross-origin hop, not a same-host same-port coincidence. + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("")) + })) + defer target.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/elsewhere", http.StatusFound) + })) + defer origin.Close() + + tests := []struct { + name string + client *Client + }{ + {"NewClient", NewClient(origin.URL, nil)}, + {"NewXMLClient", NewXMLClient(origin.URL, nil)}, + {"NewBasicAuthClient", NewBasicAuthClient(origin.URL, "user", "pass")}, + {"NewClientNoAuth", NewClientNoAuth(origin.URL)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.client.DoRawResponse("GET", "/redirect", nil) + if err == nil { + t.Fatalf("DoRawResponse() error = nil, want refusal of cross-origin redirect") + } + if !strings.Contains(err.Error(), "refusing cross-origin redirect") { + t.Errorf("err = %v, want message containing %q", err, "refusing cross-origin redirect") + } + }) + } +} From eb10d90f6448a0f609bf1ee9df7dc01290c7815f Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:34:45 -0400 Subject: [PATCH 04/25] feat(output): redact SIP digest hashes from structured output and raw bodies Co-Authored-By: Claude Opus 5 (1M context) --- internal/output/redact.go | 61 ++++++++++++++++++++ internal/output/redact_test.go | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 internal/output/redact.go create mode 100644 internal/output/redact_test.go diff --git a/internal/output/redact.go b/internal/output/redact.go new file mode 100644 index 0000000..9b945fe --- /dev/null +++ b/internal/output/redact.go @@ -0,0 +1,61 @@ +package output + +import ( + "regexp" + "strings" +) + +// redactedKeys are password-equivalent in SIP digest auth. Matching ignores +// case and any XML namespace prefix. +var redactedKeys = map[string]bool{ + "hash1": true, + "hash1b": true, +} + +// hashElementRe matches / , with or without +// a namespace prefix, case-insensitively. +var hashElementRe = regexp.MustCompile(`(?is)<((?:\w+:)?hash1b?)>.*?`) + +func isRedactedKey(k string) bool { + if i := strings.LastIndex(k, ":"); i >= 0 { + k = k[i+1:] + } + return redactedKeys[strings.ToLower(k)] +} + +// RedactSecrets returns a copy of data with digest-hash keys removed at every +// depth. The generated "password" field is deliberately preserved: it is the +// intended output of credential create/rotate. +func RedactSecrets(data interface{}) interface{} { + switch v := data.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(v)) + for k, val := range v { + if isRedactedKey(k) { + continue + } + out[k] = RedactSecrets(val) + } + return out + case []interface{}: + out := make([]interface{}, len(v)) + for i, val := range v { + out[i] = RedactSecrets(val) + } + return out + default: + return data + } +} + +// RedactAndPrint writes data through RedactSecrets. Commands that can handle +// secret-bearing payloads must print through this rather than StdoutAuto. +func RedactAndPrint(format string, plain bool, data interface{}) error { + return StdoutAuto(format, plain, RedactSecrets(data)) +} + +// ScrubHashes removes digest-hash values from a raw XML body while preserving +// the surrounding diagnostic content (error codes, usernames). +func ScrubHashes(s string) string { + return hashElementRe.ReplaceAllString(s, "<$1>[REDACTED]") +} diff --git a/internal/output/redact_test.go b/internal/output/redact_test.go new file mode 100644 index 0000000..848a60a --- /dev/null +++ b/internal/output/redact_test.go @@ -0,0 +1,100 @@ +package output + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestRedactSecrets_RemovesHashesCaseInsensitively(t *testing.T) { + in := map[string]interface{}{ + "id": "870874", + "Hash1": "1be6abcaa8e9956021d30f33a3925b99", + "hash1b": "e028e6577a0bb1b90a33d30a110dbdfe", + "ns:Hash1": "deadbeef", + } + out, ok := RedactSecrets(in).(map[string]interface{}) + if !ok { + t.Fatalf("RedactSecrets returned %T, want map", RedactSecrets(in)) + } + for _, k := range []string{"Hash1", "hash1b", "ns:Hash1"} { + if _, present := out[k]; present { + t.Errorf("key %q survived redaction", k) + } + } + if out["id"] != "870874" { + t.Errorf("id = %v, want 870874", out["id"]) + } +} + +func TestRedactSecrets_PreservesGeneratedPassword(t *testing.T) { + // The generated password is the deliverable — generic secret-key redaction + // must not eat it. + in := map[string]interface{}{"username": "u", "password": "Tb7xQ2mK9rL4vN8s"} + out := RedactSecrets(in).(map[string]interface{}) + if out["password"] != "Tb7xQ2mK9rL4vN8s" { + t.Errorf("password = %v, want it preserved", out["password"]) + } +} + +func TestRedactSecrets_Nested(t *testing.T) { + in := map[string]interface{}{ + "creds": []interface{}{ + map[string]interface{}{"UserName": "u", "Hash1": "abc"}, + }, + } + out := RedactSecrets(in).(map[string]interface{}) + first := out["creds"].([]interface{})[0].(map[string]interface{}) + if _, present := first["Hash1"]; present { + t.Error("nested Hash1 survived redaction") + } + if first["UserName"] != "u" { + t.Errorf("UserName = %v, want u", first["UserName"]) + } +} + +func TestRedactAndPrint_ScrubsBeforeWriting(t *testing.T) { + // The real guarantee: output written by a command carries no hashes, even + // when the command hands over a raw map. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + err := RedactAndPrint("json", true, map[string]interface{}{ + "id": "870874", "Hash1": "1be6abcaa8e9956021d30f33a3925b99", + }) + w.Close() + os.Stdout = old + if err != nil { + t.Fatalf("RedactAndPrint() error = %v", err) + } + var buf bytes.Buffer + buf.ReadFrom(r) + got := buf.String() + if strings.Contains(got, "1be6abcaa8e9956021d30f33a3925b99") || strings.Contains(got, "Hash1") { + t.Errorf("hash reached stdout: %s", got) + } + if !strings.Contains(got, "870874") { + t.Errorf("non-secret field was lost: %s", got) + } +} + +func TestScrubHashes_XMLErrorBody(t *testing.T) { + body := `23026` + + `clitest` + + `1be6abcaa8e9956021d30f33a3925b99` + + `e028e6577a0bb1b90a33d30a110dbdfe` + + `` + + got := ScrubHashes(body) + if strings.Contains(got, "1be6abcaa8e9956021d30f33a3925b99") || + strings.Contains(got, "e028e6577a0bb1b90a33d30a110dbdfe") { + t.Errorf("hash value survived scrubbing: %s", got) + } + if !strings.Contains(got, "23026") { + t.Errorf("error code was lost: %s", got) + } + if !strings.Contains(got, "clitest") { + t.Errorf("username was lost: %s", got) + } +} From c402f53da3ef86e1a4d710c826fca92125a0ec67 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:42:23 -0400 Subject: [PATCH 05/25] fix(output): harden hash regex for attributes; document RedactSecrets precondition - Update hashElementRe to match value patterns - Add test coverage for attributed hash elements - Document that RedactSecrets only redacts within maps/slices, not typed structs Co-Authored-By: Claude Opus 5 (1M context) --- internal/output/redact.go | 16 ++++++++++++---- internal/output/redact_test.go | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/internal/output/redact.go b/internal/output/redact.go index 9b945fe..84fb10b 100644 --- a/internal/output/redact.go +++ b/internal/output/redact.go @@ -13,8 +13,8 @@ var redactedKeys = map[string]bool{ } // hashElementRe matches / , with or without -// a namespace prefix, case-insensitively. -var hashElementRe = regexp.MustCompile(`(?is)<((?:\w+:)?hash1b?)>.*?`) +// a namespace prefix and optional attributes, case-insensitively. +var hashElementRe = regexp.MustCompile(`(?is)<((?:\w+:)?hash1b?)(?:\s[^>]*)?>.*?`) func isRedactedKey(k string) bool { if i := strings.LastIndex(k, ":"); i >= 0 { @@ -24,8 +24,16 @@ func isRedactedKey(k string) bool { } // RedactSecrets returns a copy of data with digest-hash keys removed at every -// depth. The generated "password" field is deliberately preserved: it is the -// intended output of credential create/rotate. +// depth within maps and slices. The generated "password" field is deliberately +// preserved: it is the intended output of credential create/rotate. +// +// NOTE: RedactSecrets only redacts within map[string]interface{} and +// []interface{} structures. It does not scrub digest-hash fields from typed +// structs: a struct field named Hash1 or Hash1b will pass through unchanged. +// Callers must ensure that struct types carrying hash fields are not routed +// through this function. (The typical path decodes API responses to +// map[string]interface{} via XMLToMap, and domain structs deliberately omit +// hash fields, so this is safe in practice.) func RedactSecrets(data interface{}) interface{} { switch v := data.(type) { case map[string]interface{}: diff --git a/internal/output/redact_test.go b/internal/output/redact_test.go index 848a60a..4c2a810 100644 --- a/internal/output/redact_test.go +++ b/internal/output/redact_test.go @@ -98,3 +98,26 @@ func TestScrubHashes_XMLErrorBody(t *testing.T) { t.Errorf("username was lost: %s", got) } } + +func TestScrubHashes_AttributedHashElement(t *testing.T) { + // Hash elements may carry attributes (xmlns, namespace declarations, etc.). + // The regex must handle these to ensure secrets don't leak. + body := `23026` + + `` + + `1be6abcaa8e9956021d30f33a3925b99` + + `e028e6577a0bb1b90a33d30a110dbdfe` + + `testuser` + + `` + + got := ScrubHashes(body) + if strings.Contains(got, "1be6abcaa8e9956021d30f33a3925b99") || + strings.Contains(got, "e028e6577a0bb1b90a33d30a110dbdfe") { + t.Errorf("hash value survived scrubbing (attributed element): %s", got) + } + if !strings.Contains(got, "23026") { + t.Errorf("error code was lost: %s", got) + } + if !strings.Contains(got, "testuser") { + t.Errorf("username was lost: %s", got) + } +} From 9197edc90d312f8aab036dd253fedf90d789604d Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 15:49:04 -0400 Subject: [PATCH 06/25] feat(sip): add typed XML models and SIP realm/credential service --- internal/sip/service.go | 274 +++++++++++++++++++++++++++++++++++ internal/sip/service_test.go | 157 ++++++++++++++++++++ internal/sip/types.go | 112 ++++++++++++++ 3 files changed, 543 insertions(+) create mode 100644 internal/sip/service.go create mode 100644 internal/sip/service_test.go create mode 100644 internal/sip/types.go diff --git a/internal/sip/service.go b/internal/sip/service.go new file mode 100644 index 0000000..89260a9 --- /dev/null +++ b/internal/sip/service.go @@ -0,0 +1,274 @@ +package sip + +import ( + "encoding/xml" + "fmt" + "net/url" + "strings" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/output" +) + +// APIFault is a structured Bandwidth error (ErrorCode + Description). Commands +// branch on Code to map documented failures onto exit codes. +type APIFault struct { + Code string + Description string + StatusCode int +} + +func (e *APIFault) Error() string { + return fmt.Sprintf("%s (error %s)", e.Description, e.Code) +} + +// Service wraps the Dashboard SIP endpoints for one account. +type Service struct { + client *api.Client + accountID string +} + +// NewService returns a Service bound to an account. c must be an XML client +// (see cmdutil.DashboardClient). +func NewService(c *api.Client, accountID string) *Service { + return &Service{client: c, accountID: accountID} +} + +func (s *Service) base() string { + return "/accounts/" + url.PathEscape(s.accountID) +} + +// do issues a request and returns the response body, converting documented +// error envelopes into *APIFault. Bodies are scrubbed of digest hashes before +// ever being placed in an error. +func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { + var payload []byte + if reqBody != nil { + b, err := xml.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("encoding request: %w", err) + } + payload = append([]byte(xml.Header), b...) + } + + resp, err := s.client.DoRawResponse(method, path, payload) + if err != nil { + return nil, err + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp.Body, nil + } + if fault := parseFault(resp.Body, resp.StatusCode); fault != nil { + return nil, fault + } + return nil, &api.APIError{StatusCode: resp.StatusCode, Body: output.ScrubHashes(string(resp.Body))} +} + +// parseFault extracts ResponseStatus or the first Errors entry. Returns nil if +// the body carries neither. +func parseFault(body []byte, status int) *APIFault { + var probe struct { + ResponseStatus *responseStatus `xml:"ResponseStatus"` + Errors []wireError `xml:"Errors>Error"` + } + if err := xml.Unmarshal(body, &probe); err != nil { + // Unparseable body: discard it entirely rather than risk leaking hashes. + return &APIFault{Code: "", Description: "unreadable error response", StatusCode: status} + } + if probe.ResponseStatus != nil && probe.ResponseStatus.ErrorCode != "" { + return &APIFault{ + Code: probe.ResponseStatus.ErrorCode, + Description: probe.ResponseStatus.Description, + StatusCode: status, + } + } + if len(probe.Errors) > 0 { + return &APIFault{ + Code: probe.Errors[0].ErrorCode, + Description: probe.Errors[0].Description, + StatusCode: status, + } + } + return nil +} + +// shortName derives the realm's short name from its FQDN. The API returns the +// FQDN in the same element used to submit the short name. +func shortName(fqdn string) string { + if i := strings.Index(fqdn, "."); i >= 0 { + label := fqdn[:i] + if j := strings.LastIndex(label, "-"); j >= 0 { + return label[:j] // strip the "-" suffix + } + return label + } + return fqdn +} + +func toRealm(w *realmWire) *Realm { + return &Realm{ + ID: w.ID, + Name: shortName(w.Realm), + Hostname: w.Realm, + Description: w.Description, + Default: w.Default, + Status: w.Status, + CredentialCount: w.SipCredentialCount, + } +} + +func toCredential(w *credentialWire) *Credential { + return &Credential{ + ID: w.ID, + RealmID: w.RealmID, + Username: w.UserName, + Hostname: w.Realm, + AppID: w.AppID, + } +} + +// CreateRealm creates a realm. isDefault is always transmitted: the API rejects +// the request without it (error 1003). +func (s *Service) CreateRealm(name, description string, isDefault bool) (*Realm, error) { + body, err := s.do("POST", s.base()+"/realms", realmRequest{ + Realm: name, Description: description, Default: isDefault, + }) + if err != nil { + return nil, err + } + var resp realmResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding realm response: %w", err) + } + if resp.Realm == nil { + return nil, fmt.Errorf("realm response contained no realm") + } + return toRealm(resp.Realm), nil +} + +// GetRealm fetches one realm. ref may be an ID or a name. +func (s *Service) GetRealm(ref string) (*Realm, error) { + body, err := s.do("GET", s.base()+"/realms/"+url.PathEscape(ref), nil) + if err != nil { + return nil, err + } + var resp realmResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding realm response: %w", err) + } + if resp.Realm == nil { + return nil, fmt.Errorf("realm %q not found", ref) + } + return toRealm(resp.Realm), nil +} + +// ListRealms returns every realm on the account, always as a non-nil slice. +func (s *Service) ListRealms() ([]Realm, error) { + body, err := s.do("GET", s.base()+"/realms", nil) + if err != nil { + return nil, err + } + var resp realmsResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding realms response: %w", err) + } + out := make([]Realm, 0, len(resp.Realms)) + for i := range resp.Realms { + out = append(out, *toRealm(&resp.Realms[i])) + } + return out, nil +} + +// DeleteRealm submits an async delete (the API returns 202). +func (s *Service) DeleteRealm(ref string) error { + _, err := s.do("DELETE", s.base()+"/realms/"+url.PathEscape(ref), nil) + return err +} + +func (s *Service) credentialsPath(realmID string) string { + return s.base() + "/realms/" + url.PathEscape(realmID) + "/sipcredentials" +} + +// CreateCredential creates one credential. A 201 carrying an Errors entry is +// treated as failure, not success. +func (s *Service) CreateCredential(realmID, username, hash1, hash1b, appID string) (*Credential, error) { + req := credentialCreateRequest{Credentials: []credentialCreateOne{{ + UserName: username, Hash1: hash1, Hash1b: hash1b, AppID: appID, + }}} + body, err := s.do("POST", s.credentialsPath(realmID), req) + if err != nil { + return nil, err + } + var resp credentialsResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding credential response: %w", err) + } + if len(resp.Errors) > 0 { + return nil, &APIFault{Code: resp.Errors[0].ErrorCode, Description: resp.Errors[0].Description, StatusCode: 201} + } + for i := range resp.Valid { + if resp.Valid[i].UserName == username { + return toCredential(&resp.Valid[i]), nil + } + } + return nil, fmt.Errorf("credential %q was not returned in ValidSipCredentials", username) +} + +// RotateCredential replaces a credential's hashes. The credential ID is stable +// across rotation. UserName must not be sent (see credentialRotateRequest). +func (s *Service) RotateCredential(realmID, credentialID, hash1, hash1b string) (*Credential, error) { + req := credentialRotateRequest{RealmID: realmID, Hash1: hash1, Hash1b: hash1b} + body, err := s.do("PUT", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), req) + if err != nil { + return nil, err + } + var resp credentialResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding credential response: %w", err) + } + if resp.Credential == nil { + return nil, fmt.Errorf("rotate response contained no credential") + } + return toCredential(resp.Credential), nil +} + +// ListCredentials returns a realm's credentials, always as a non-nil slice. +// The API answers an unpaginated request with a 303 to ?page=1&size=500, which +// the client follows. +func (s *Service) ListCredentials(realmID string) ([]Credential, error) { + body, err := s.do("GET", s.credentialsPath(realmID), nil) + if err != nil { + return nil, err + } + var resp credentialsResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding credentials response: %w", err) + } + out := make([]Credential, 0, len(resp.Credentials)) + for i := range resp.Credentials { + out = append(out, *toCredential(&resp.Credentials[i])) + } + return out, nil +} + +// GetCredential fetches one credential. +func (s *Service) GetCredential(realmID, credentialID string) (*Credential, error) { + body, err := s.do("GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) + if err != nil { + return nil, err + } + var resp credentialResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding credential response: %w", err) + } + if resp.Credential == nil { + return nil, fmt.Errorf("credential %q not found", credentialID) + } + return toCredential(resp.Credential), nil +} + +// DeleteCredential removes a credential. +func (s *Service) DeleteCredential(realmID, credentialID string) error { + _, err := s.do("DELETE", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) + return err +} diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go new file mode 100644 index 0000000..6c3c3b6 --- /dev/null +++ b/internal/sip/service_test.go @@ -0,0 +1,157 @@ +package sip + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/api" +) + +func newTestService(t *testing.T, h http.HandlerFunc) (*Service, func()) { + t.Helper() + srv := httptest.NewServer(h) + c := api.NewXMLClient(srv.URL, nil) + return NewService(c, "9901361"), srv.Close +} + +func TestCreateRealm_ParsesFQDNAndStatus(t *testing.T) { + var gotBody string + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + r.Body.Read(b) + gotBody = string(b) + w.WriteHeader(201) + w.Write([]byte(`` + + `1103bwclitest-3efeaa.auth.bandwidth.com` + + `dfalse` + + `0CREATE_PENDING` + + ``)) + }) + defer done() + + r, err := svc.CreateRealm("bwclitest", "d", false) + if err != nil { + t.Fatalf("CreateRealm() error = %v", err) + } + if r.ID != "1103" { + t.Errorf("ID = %q, want 1103", r.ID) + } + // The API returns the FQDN in ; the short name is derived, never built. + if r.Hostname != "bwclitest-3efeaa.auth.bandwidth.com" { + t.Errorf("Hostname = %q", r.Hostname) + } + if r.Name != "bwclitest" { + t.Errorf("Name = %q, want bwclitest", r.Name) + } + if r.Status != "CREATE_PENDING" { + t.Errorf("Status = %q, want CREATE_PENDING", r.Status) + } + if r.Default { + t.Error("Default = true, want false") + } + // Default is required by the API; it must always be sent. + if !strings.Contains(gotBody, "false") { + t.Errorf("request body missing Default element: %s", gotBody) + } +} + +func TestCreateRealm_ReturnsAPIFault(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Write([]byte(`33004` + + `Your account isn't setup for Sip Credentials.` + + ``)) + }) + defer done() + + _, err := svc.CreateRealm("x", "", false) + var fault *APIFault + if !errorsAs(err, &fault) { + t.Fatalf("error = %v (%T), want *APIFault", err, err) + } + if fault.Code != "33004" { + t.Errorf("Code = %q, want 33004", fault.Code) + } +} + +func TestListCredentials_FollowsRedirectAndAlwaysReturnsSlice(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "" { + http.Redirect(w, r, r.URL.Path+"?page=1&size=500", http.StatusSeeOther) + return + } + w.Write([]byte(``)) + }) + defer done() + + creds, err := svc.ListCredentials("1103") + if err != nil { + t.Fatalf("ListCredentials() error = %v", err) + } + if creds == nil { + t.Fatal("ListCredentials() = nil, want empty non-nil slice") + } + if len(creds) != 0 { + t.Errorf("len = %d, want 0", len(creds)) + } +} + +func TestRotateCredential_SendsRealmIDAndOmitsUserName(t *testing.T) { + // Live-verified: RealmId is required in the body even though it is in the + // path (23031 without it), and sending UserName fails with 23030 even when + // the value is unchanged. + var gotBody string + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + r.Body.Read(b) + gotBody = string(b) + w.Write([]byte(`` + + `8708801105rotuser` + + `rottest-3efeaa.auth.bandwidth.com` + + ``)) + }) + defer done() + + c, err := svc.RotateCredential("1105", "870880", "h1", "h1b") + if err != nil { + t.Fatalf("RotateCredential() error = %v", err) + } + if !strings.Contains(gotBody, "1105") { + t.Errorf("body missing RealmId: %s", gotBody) + } + if strings.Contains(gotBody, "UserName") { + t.Errorf("body must not contain UserName: %s", gotBody) + } + if c.ID != "870880" { + t.Errorf("ID = %q, want 870880 (must be stable across rotation)", c.ID) + } + if c.Username != "rotuser" { + t.Errorf("Username = %q, want rotuser", c.Username) + } +} + +func TestCreateCredential_PartialSuccessIsFailure(t *testing.T) { + // A 201 can still carry an Errors array; the requested credential must be + // present in ValidSipCredentials or the command fails. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Write([]byte(`` + + `23026does already exist` + + ``)) + }) + defer done() + + _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + var fault *APIFault + if !errorsAs(err, &fault) || fault.Code != "23026" { + t.Fatalf("error = %v, want APIFault 23026", err) + } +} + +// errorsAs keeps the assertions above readable. +func errorsAs(err error, target interface{}) bool { + return errors.As(err, target) +} diff --git a/internal/sip/types.go b/internal/sip/types.go new file mode 100644 index 0000000..d2b4d0f --- /dev/null +++ b/internal/sip/types.go @@ -0,0 +1,112 @@ +package sip + +import "encoding/xml" + +// --- Wire types (XML) ------------------------------------------------------- +// These mirror the Dashboard API exactly, including element order, which the +// generic map-based XML helper cannot express. + +type realmRequest struct { + XMLName xml.Name `xml:"Realm"` + Realm string `xml:"Realm"` + Description string `xml:"Description,omitempty"` + Default bool `xml:"Default"` // required by the API; never omitempty +} + +type realmWire struct { + ID string `xml:"Id"` + Realm string `xml:"Realm"` // the FQDN on read responses + Description string `xml:"Description"` + Default bool `xml:"Default"` + SipCredentialCount int `xml:"SipCredentialCount"` + Status string `xml:"Status"` +} + +type realmResponse struct { + XMLName xml.Name `xml:"RealmResponse"` + Realm *realmWire `xml:"Realm"` + ResponseStatus *responseStatus `xml:"ResponseStatus"` +} + +type realmsResponse struct { + XMLName xml.Name `xml:"RealmsResponse"` + Realms []realmWire `xml:"Realms>Realm"` + ResponseStatus *responseStatus `xml:"ResponseStatus"` +} + +// credentialCreateRequest is the bulk create body. The CLI always sends one. +type credentialCreateRequest struct { + XMLName xml.Name `xml:"SipCredentials"` + Credentials []credentialCreateOne `xml:"SipCredential"` +} + +type credentialCreateOne struct { + UserName string `xml:"UserName"` + Hash1 string `xml:"Hash1"` + Hash1b string `xml:"Hash1b"` + AppID string `xml:"HttpVoiceV2AppId,omitempty"` +} + +// credentialRotateRequest deliberately has no UserName field: including the +// element fails with 23030 even when the value is unchanged. +type credentialRotateRequest struct { + XMLName xml.Name `xml:"SipCredential"` + RealmID string `xml:"RealmId"` // required in the body despite being in the path + Hash1 string `xml:"Hash1"` + Hash1b string `xml:"Hash1b"` +} + +type credentialWire struct { + ID string `xml:"Id"` + RealmID string `xml:"RealmId"` + UserName string `xml:"UserName"` + Realm string `xml:"Realm"` // FQDN + AppID string `xml:"HttpVoiceV2AppId"` +} + +type credentialsResponse struct { + XMLName xml.Name `xml:"SipCredentialsResponse"` + Valid []credentialWire `xml:"ValidSipCredentials>SipCredential"` + Credentials []credentialWire `xml:"SipCredentials>SipCredential"` + Errors []wireError `xml:"Errors>Error"` + ResponseStatus *responseStatus `xml:"ResponseStatus"` +} + +type credentialResponse struct { + XMLName xml.Name `xml:"SipCredentialResponse"` + Credential *credentialWire `xml:"SipCredential"` + ResponseStatus *responseStatus `xml:"ResponseStatus"` +} + +type responseStatus struct { + ErrorCode string `xml:"ErrorCode"` + Description string `xml:"Description"` +} + +type wireError struct { + ErrorCode string `xml:"ErrorCode"` + Description string `xml:"Description"` +} + +// --- Domain types ----------------------------------------------------------- +// Deliberately carry no hash fields, so digest material cannot reach output. + +// Realm is a SIP authentication realm. +type Realm struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Description string `json:"description"` + Default bool `json:"default"` + Status string `json:"status"` + CredentialCount int `json:"credentialCount"` +} + +// Credential is a SIP digest credential belonging to a realm. +type Credential struct { + ID string `json:"id"` + RealmID string `json:"realmId"` + Username string `json:"username"` + Hostname string `json:"hostname"` + AppID string `json:"appId"` +} From 73e569cc32406d029ab7e8105f9f5dbd7a84e1de Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 16:05:32 -0400 Subject: [PATCH 07/25] fix(sip): map faults onto exit codes, fault 2xx envelopes, fix hash/case edge cases Addresses code review findings on the SIP service layer, backed by live probes against the Dashboard API: - *APIFault now implements Unwrap() so cmdutil.ExitCodeForError maps documented SIP failures (ErrorCode-bearing) onto the real exit-code taxonomy instead of always falling back to exit 1. - do() now probes 2xx bodies for an error envelope too (a partially successful bulk operation can return 200/201 with Errors/ResponseStatus), with an explicit empty-body guard so a 202 delete with no body is not misread as unparseable. - parseFault returns nil (not a bogus empty-Code APIFault) on unparseable bodies, falling through to the existing scrubbed api.APIError path. - CreateCredential distinguishes "credential never created" from "created under a different-case username, so its digest hashes are unusable." - Rewrote the partial-success test to actually hit the 2xx+Errors branch, and added coverage for hash scrubbing, shortName's FQDN parsing, and the 2xx empty-body/error-envelope split. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmdutil/exitcodes_test.go | 6 + internal/sip/service.go | 34 +++++- internal/sip/service_test.go | 171 +++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 10 deletions(-) diff --git a/internal/cmdutil/exitcodes_test.go b/internal/cmdutil/exitcodes_test.go index 72736a4..8996499 100644 --- a/internal/cmdutil/exitcodes_test.go +++ b/internal/cmdutil/exitcodes_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/sip" ) func TestExitCodeForError(t *testing.T) { @@ -27,6 +28,11 @@ func TestExitCodeForError(t *testing.T) { {"feature limit precedence beats raw 401", NewFeatureLimit("nope", &api.APIError{StatusCode: 401}), ExitConflict}, {"wrapped 429 keeps rate limit", fmt.Errorf("wrap: %w", &api.APIError{StatusCode: 429}), ExitRateLimit}, {"wrapped ErrPollTimeout", fmt.Errorf("timed out: %w", ErrPollTimeout), ExitTimeout}, + // *sip.APIFault must unwrap to *api.APIError so a documented SIP + // failure (one that carries an ErrorCode) maps onto the CLI's + // exit-code taxonomy instead of falling back to ExitGeneral. + {"sip APIFault 404", &sip.APIFault{Code: "33010", Description: "not found", StatusCode: 404}, ExitNotFound}, + {"sip APIFault 409", &sip.APIFault{Code: "23026", Description: "does already exist", StatusCode: 409}, ExitConflict}, } for _, tt := range tests { diff --git a/internal/sip/service.go b/internal/sip/service.go index 89260a9..77b9b3d 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -22,6 +22,13 @@ func (e *APIFault) Error() string { return fmt.Sprintf("%s (error %s)", e.Description, e.Code) } +// Unwrap lets errors.As match *api.APIError, so cmdutil.ExitCodeForError maps +// SIP faults onto the CLI's exit-code taxonomy by StatusCode (401/403->2, +// 404->3, 409->4, 429->7) instead of every documented failure exiting 1. +func (e *APIFault) Unwrap() error { + return &api.APIError{StatusCode: e.StatusCode, Body: e.Description} +} + // Service wraps the Dashboard SIP endpoints for one account. type Service struct { client *api.Client @@ -56,6 +63,16 @@ func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { return nil, err } if resp.StatusCode >= 200 && resp.StatusCode < 300 { + // An empty body (e.g. a 202 delete) is unambiguous success: there is + // nothing to unmarshal and nothing to fault. + if len(resp.Body) == 0 { + return resp.Body, nil + } + // A 2xx can still carry an error envelope (e.g. a partially successful + // bulk credential create), so probe it the same as a non-2xx body. + if fault := parseFault(resp.Body, resp.StatusCode); fault != nil { + return nil, fault + } return resp.Body, nil } if fault := parseFault(resp.Body, resp.StatusCode); fault != nil { @@ -65,15 +82,16 @@ func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { } // parseFault extracts ResponseStatus or the first Errors entry. Returns nil if -// the body carries neither. +// the body carries neither, including when the body is not parseable XML — the +// caller falls through to the api.APIError path, which already scrubs hashes +// and substitutes a status-text placeholder when the body is empty. func parseFault(body []byte, status int) *APIFault { var probe struct { ResponseStatus *responseStatus `xml:"ResponseStatus"` Errors []wireError `xml:"Errors>Error"` } if err := xml.Unmarshal(body, &probe); err != nil { - // Unparseable body: discard it entirely rather than risk leaking hashes. - return &APIFault{Code: "", Description: "unreadable error response", StatusCode: status} + return nil } if probe.ResponseStatus != nil && probe.ResponseStatus.ErrorCode != "" { return &APIFault{ @@ -211,6 +229,16 @@ func (s *Service) CreateCredential(realmID, username, hash1, hash1b, appID strin return toCredential(&resp.Valid[i]), nil } } + // A case-insensitive match means the API echoed a different case than what + // ComputeHashes used to build the digest: the credential exists server-side + // but its hashes are for the wrong username and it can never authenticate. + for i := range resp.Valid { + if strings.EqualFold(resp.Valid[i].UserName, username) { + return nil, fmt.Errorf( + "API returned username %q, expected %q; the credential exists but its digest hashes are invalid — delete it and retry", + resp.Valid[i].UserName, username) + } + } return nil, fmt.Errorf("credential %q was not returned in ValidSipCredentials", username) } diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go index 6c3c3b6..3fa20ed 100644 --- a/internal/sip/service_test.go +++ b/internal/sip/service_test.go @@ -2,6 +2,7 @@ package sip import ( "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -20,8 +21,7 @@ func newTestService(t *testing.T, h http.HandlerFunc) (*Service, func()) { func TestCreateRealm_ParsesFQDNAndStatus(t *testing.T) { var gotBody string svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { - b := make([]byte, r.ContentLength) - r.Body.Read(b) + b, _ := io.ReadAll(r.Body) gotBody = string(b) w.WriteHeader(201) w.Write([]byte(`` + @@ -105,8 +105,7 @@ func TestRotateCredential_SendsRealmIDAndOmitsUserName(t *testing.T) { // the value is unchanged. var gotBody string svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { - b := make([]byte, r.ContentLength) - r.Body.Read(b) + b, _ := io.ReadAll(r.Body) gotBody = string(b) w.Write([]byte(`` + `8708801105rotuser` + @@ -134,10 +133,12 @@ func TestRotateCredential_SendsRealmIDAndOmitsUserName(t *testing.T) { } func TestCreateCredential_PartialSuccessIsFailure(t *testing.T) { - // A 201 can still carry an Errors array; the requested credential must be - // present in ValidSipCredentials or the command fails. + // Live-verified: a 201 can still carry an Errors array; the requested + // credential must be present in ValidSipCredentials or the command fails. + // The status is 201 (not 400) so this actually exercises the 2xx+Errors + // branch instead of the ordinary non-2xx fault path. svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(400) + w.WriteHeader(201) w.Write([]byte(`` + `23026does already exist` + ``)) @@ -151,6 +152,162 @@ func TestCreateCredential_PartialSuccessIsFailure(t *testing.T) { } } +func TestCreateCredential_NotInValidListIsFailure(t *testing.T) { + // A 201 with ValidSipCredentials present, but missing the requested + // username entirely, must fail rather than report success for a + // credential that does not exist as requested. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + w.Write([]byte(`` + + `1someoneelse` + + ``)) + }) + defer done() + + _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + if err == nil { + t.Fatal("CreateCredential() error = nil, want error") + } + var fault *APIFault + if errorsAs(err, &fault) { + t.Fatalf("error = %v, want plain error (no matching or case-folded username), not *APIFault", err) + } +} + +func TestCreateCredential_CaseMismatchReportsUnusableCredential(t *testing.T) { + // The API echoed a different case than what was submitted. ComputeHashes + // used the submitted case to build the digest, so the credential that + // exists server-side can never authenticate — the error must say so + // distinctly from "not returned at all". + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + w.Write([]byte(`` + + `1CliTest` + + ``)) + }) + defer done() + + _, err := svc.CreateCredential("1103", "clitest", "h1", "h1b", "") + if err == nil { + t.Fatal("CreateCredential() error = nil, want error") + } + if !strings.Contains(err.Error(), "digest hashes are invalid") { + t.Errorf("error = %q, want mention of invalid digest hashes", err.Error()) + } +} + +func TestDo_2xxEmptyBodyIsSuccess(t *testing.T) { + // Live-verified: DELETE /realms/{id} returns 202 with a completely empty + // body. That must not be misread as an unparseable/faulted response. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(202) + }) + defer done() + + if err := svc.DeleteRealm("1103"); err != nil { + t.Fatalf("DeleteRealm() error = %v, want nil for empty 202 body", err) + } +} + +func TestDo_2xxErrorEnvelopeIsFailure(t *testing.T) { + // A 200 carrying a ResponseStatus/ErrorCode is not success, even though the + // status code says so. This guards ListRealms and friends from reporting + // "no realms" when the true state is "not authorized". + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`12666` + + `not authorized`)) + }) + defer done() + + _, err := svc.ListRealms() + var fault *APIFault + if !errorsAs(err, &fault) || fault.Code != "12666" { + t.Fatalf("error = %v, want APIFault 12666", err) + } +} + +func TestAPIFault_UnwrapsToAPIErrorForExitCodeMapping(t *testing.T) { + // cmdutil.ExitCodeForError maps exit codes via errors.As(err, &apiErr) on + // *api.APIError. Without Unwrap, every documented SIP failure (any fault + // that carries an ErrorCode) would exit 1 regardless of HTTP status. + fault := &APIFault{Code: "33004", Description: "conflict", StatusCode: 409} + var apiErr *api.APIError + if !errorsAs(fault, &apiErr) { + t.Fatalf("errors.As(%v, &apiErr) = false, want true", fault) + } + if apiErr.StatusCode != 409 { + t.Errorf("StatusCode = %d, want 409", apiErr.StatusCode) + } +} + +func TestParseFault_UnparseableBodyFallsThroughToAPIError(t *testing.T) { + // A non-2xx body that isn't well-formed XML (or is empty) must not become + // an APIFault with an empty Code — that renders as the useless + // "(error )" and never carries a body for api.APIError to work with. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte("not xml at all")) + }) + defer done() + + _, err := svc.GetRealm("1103") + var fault *APIFault + if errorsAs(err, &fault) { + t.Fatalf("error = %v, want *api.APIError, not *APIFault", err) + } + var apiErr *api.APIError + if !errorsAs(err, &apiErr) { + t.Fatalf("error = %v (%T), want *api.APIError", err, err) + } + if apiErr.StatusCode != 500 { + t.Errorf("StatusCode = %d, want 500", apiErr.StatusCode) + } +} + +func TestDo_ScrubsHashesFromUnfaultedErrorBody(t *testing.T) { + // The single most security-relevant line in this file: output.ScrubHashes + // must run before a raw error body (which can echo Hash1/Hash1b) is ever + // placed on an error that gets printed to stderr. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`deadbeef`)) + }) + defer done() + + _, err := svc.CreateRealm("x", "", false) + if err == nil { + t.Fatal("CreateRealm() error = nil, want error") + } + msg := err.Error() + if !strings.Contains(msg, "[REDACTED]") { + t.Errorf("error message missing [REDACTED]: %s", msg) + } + if strings.Contains(msg, "deadbeef") { + t.Errorf("error message leaked hash value: %s", msg) + } +} + +func TestShortName(t *testing.T) { + tests := []struct { + name string + fqdn string + want string + }{ + {"hyphenated label strips account suffix", "vapi-test-3efeaa.auth.bandwidth.com", "vapi-test"}, + {"no hyphen in label", "vapi3efeaa.auth.bandwidth.com", "vapi3efeaa"}, + {"empty string", "", ""}, + {"bare label with no dot", "vapi-test", "vapi-test"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shortName(tt.fqdn); got != tt.want { + t.Errorf("shortName(%q) = %q, want %q", tt.fqdn, got, tt.want) + } + }) + } +} + // errorsAs keeps the assertions above readable. func errorsAs(err error, target interface{}) bool { return errors.As(err, target) From f09dc911c3e8c9bfdac7a66b717b7a686e89d8df Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 16:11:14 -0400 Subject: [PATCH 08/25] feat(cmdutil): add ExitSecretUnavailable (8) for unrecoverable credentials Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmdutil/exitcodes.go | 13 +++++++++++++ internal/cmdutil/exitcodes_test.go | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/internal/cmdutil/exitcodes.go b/internal/cmdutil/exitcodes.go index e67d370..f263a0d 100644 --- a/internal/cmdutil/exitcodes.go +++ b/internal/cmdutil/exitcodes.go @@ -16,8 +16,17 @@ const ( ExitTimeout = 5 ExitFlagError = 6 ExitRateLimit = 7 + // ExitSecretUnavailable signals that a resource exists but its secret cannot + // be recovered — an agent must not proceed as though provisioning succeeded. + ExitSecretUnavailable = 8 ) +// SecretUnavailableError reports that a credential exists but its password is +// unrecoverable, so the caller cannot use it. +type SecretUnavailableError struct{ Message string } + +func (e *SecretUnavailableError) Error() string { return e.Message } + // ExitCodeForError maps an error to the appropriate exit code. // FeatureLimitError takes precedence over the raw API status code so a // 403 caused by a plan/role limit maps to ExitConflict (4) rather than @@ -35,6 +44,10 @@ func ExitCodeForError(err error) int { if errors.As(err, &fle) { return ExitConflict } + var sue *SecretUnavailableError + if errors.As(err, &sue) { + return ExitSecretUnavailable + } var apiErr *api.APIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { diff --git a/internal/cmdutil/exitcodes_test.go b/internal/cmdutil/exitcodes_test.go index 8996499..b87453a 100644 --- a/internal/cmdutil/exitcodes_test.go +++ b/internal/cmdutil/exitcodes_test.go @@ -44,3 +44,13 @@ func TestExitCodeForError(t *testing.T) { }) } } + +func TestExitCodeForError_SecretUnavailable(t *testing.T) { + err := &SecretUnavailableError{Message: "credential exists but its password cannot be recovered"} + if got := ExitCodeForError(err); got != ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d", got, ExitSecretUnavailable) + } + if ExitSecretUnavailable != 8 { + t.Errorf("ExitSecretUnavailable = %d, want 8", ExitSecretUnavailable) + } +} From c670932191589638258408dda8b45963870b3632 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 16:21:28 -0400 Subject: [PATCH 09/25] feat(sip): add band sip realm create/list/get/update/delete Adds the band sip command group with realm CRUD: async create with --wait/--if-not-exists, list (always emits an array), get, update (promote to default), and delete (async, treats 404-on-poll as success). Adds Service.SetRealmDefault (read-modify-write PUT so Description survives) and registers the group in cmd/root.go. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 48 ++++++++++++++++ README.md | 10 ++++ cmd/root.go | 2 + cmd/sip/realm.go | 37 +++++++++++++ cmd/sip/realm_create.go | 120 ++++++++++++++++++++++++++++++++++++++++ cmd/sip/realm_delete.go | 71 ++++++++++++++++++++++++ cmd/sip/realm_get.go | 29 ++++++++++ cmd/sip/realm_list.go | 27 +++++++++ cmd/sip/realm_update.go | 42 ++++++++++++++ cmd/sip/sip.go | 74 +++++++++++++++++++++++++ cmd/sip/sip_test.go | 44 +++++++++++++++ internal/sip/service.go | 24 ++++++++ 12 files changed, 528 insertions(+) create mode 100644 cmd/sip/realm.go create mode 100644 cmd/sip/realm_create.go create mode 100644 cmd/sip/realm_delete.go create mode 100644 cmd/sip/realm_get.go create mode 100644 cmd/sip/realm_list.go create mode 100644 cmd/sip/realm_update.go create mode 100644 cmd/sip/sip.go create mode 100644 cmd/sip/sip_test.go diff --git a/AGENTS.md b/AGENTS.md index 8c86088..ce2a13f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -707,6 +707,54 @@ band tendlc number +19195551234 --plain band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text "Hello" ``` +## SIP Trunk Authentication + +SIP realms provide the FQDN a SIP peer uses as its outbound address for SIP trunk digest authentication. This is separate from inbound call routing, which is configured with `band vcp`. + +### Create a realm + +```bash +band sip realm create --name vapi --default=false --wait --plain +# → { "id": "...", "name": "vapi", "hostname": "vapi-3efeaa.auth.bandwidth.com", "status": "ACTIVE", ... } +``` + +`--default` is **required** — the API rejects creates without it (error 1003). Creation is asynchronous (`CREATE_PENDING` → `ACTIVE`); use `--wait` to block until the realm is `ACTIVE`, or `--if-not-exists` for a safe retry. + +### List and inspect realms + +```bash +band sip realm list --plain +band sip realm get vapi --plain +``` + +`get` accepts a realm ID, name, or FQDN. + +### Promote a different realm to default + +The API refuses to delete the default realm, and a realm's `default` flag can only be set to `true` (never back to `false`). To retire a default realm, promote another one first: + +```bash +band sip realm update backup-realm --default=true +band sip realm delete old-default-realm --wait +``` + +### Delete a realm + +```bash +band sip realm delete vapi --wait +``` + +Deletion is asynchronous (the API returns 202). A realm cannot be deleted while it still has SIP credentials (error 12666) or while it is the account default (error 33006). + +### Common errors + +| Error code | Message | Cause | Fix | +|---|---|---|---| +| **33006** | Cannot delete the default realm | Realm is the account default | Promote another realm first: `band sip realm update --default=true` | +| **12666** | Realm still has SIP credentials | Realm has credentials attached | Delete the credentials first | +| **33002** | Realm already exists | Name collision | Use `--if-not-exists`, or pick a different `--name` | +| **23022** | Realm is not active yet | Realm hasn't finished provisioning | Retry with `--wait` | + ## Limitations - **Bandwidth Build accounts are voice-only.** Detect via `band auth status --plain` (`build: true`). On a Build account, only voice and app-management commands work — `message send`, `number search`/`order`, `vcp *`, `subaccount *`, `tendlc *`, `tfv *` all exit 4 with a Build-aware message and an upgrade link. Pre-provisioned voice app and number ship with the account; `band number list` doesn't work yet (the number is reachable via the account portal). Build also has runtime limits not surfaced in `auth status` — verified-number-only outbound on Free Trial, a 30-min cap per call, a 5-concurrent-call ceiling. See [dev.bandwidth.com](https://dev.bandwidth.com/docs/voice/programmable-voice/build-free-trial) for current pricing and limits; treat any 402 (exit 4) as "out of credits, escalate" and any 429 (exit 7) as "back off and retry." diff --git a/README.md b/README.md index 237585f..53aed3b 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,16 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band tnoption get ` | Check the status of a TN Option Order | | `band tnoption list` | List TN Option Orders (filter by `--status`, `--tn`) | +### SIP trunk authentication + +| Command | What it does | +|---------|-------------| +| `band sip realm create --name --default=` | Create a SIP realm (async; add `--wait`) | +| `band sip realm list` | List realms | +| `band sip realm get ` | Get one realm, including its FQDN | +| `band sip realm update --default=true` | Promote a realm to account default | +| `band sip realm delete ` | Delete a realm | + ### Other | Command | What it does | diff --git a/cmd/root.go b/cmd/root.go index 4d96cd4..edf1f32 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,7 @@ import ( quickstartcmd "github.com/Bandwidth/cli/cmd/quickstart" recordingcmd "github.com/Bandwidth/cli/cmd/recording" shortcodecmd "github.com/Bandwidth/cli/cmd/shortcode" + sipcmd "github.com/Bandwidth/cli/cmd/sip" sitecmd "github.com/Bandwidth/cli/cmd/site" tendlccmd "github.com/Bandwidth/cli/cmd/tendlc" tfvcmd "github.com/Bandwidth/cli/cmd/tfv" @@ -95,6 +96,7 @@ func init() { rootCmd.AddCommand(authcmd.Cmd) rootCmd.AddCommand(accountcmd.Cmd) rootCmd.AddCommand(sitecmd.Cmd) + rootCmd.AddCommand(sipcmd.Cmd) rootCmd.AddCommand(locationcmd.Cmd) rootCmd.AddCommand(appcmd.Cmd) rootCmd.AddCommand(numbercmd.Cmd) diff --git a/cmd/sip/realm.go b/cmd/sip/realm.go new file mode 100644 index 0000000..64e203e --- /dev/null +++ b/cmd/sip/realm.go @@ -0,0 +1,37 @@ +package sip + +import ( + "github.com/spf13/cobra" + + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +func init() { + Cmd.AddCommand(realmCmd) +} + +var realmCmd = &cobra.Command{ + Use: "realm", + Short: "Manage SIP authentication realms", + Long: "Create and manage SIP authentication realms. Each realm has a generated FQDN that SIP peers use as their outbound address.", +} + +// realmReuseAllowed reports whether an existing realm in this state may be +// returned by --if-not-exists. Terminal-failure and deletion states must not be +// silently reused, and unknown states are never interpreted. +func realmReuseAllowed(status string) bool { + return status == "ACTIVE" || status == "CREATE_PENDING" +} + +// realmStateMatches compares an existing realm against the requested state. +// Only fields the caller specified participate: descriptionSet distinguishes +// "not provided" from "provided as empty". +func realmStateMatches(existing *sipsvc.Realm, wantDefault bool, description string, descriptionSet bool) bool { + if existing.Default != wantDefault { + return false + } + if descriptionSet && existing.Description != description { + return false + } + return true +} diff --git a/cmd/sip/realm_create.go b/cmd/sip/realm_create.go new file mode 100644 index 0000000..5c05006 --- /dev/null +++ b/cmd/sip/realm_create.go @@ -0,0 +1,120 @@ +package sip + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +var ( + realmCreateName string + realmCreateDescription string + realmCreateDefault bool + realmCreateIfNotExists bool + realmCreateWait bool + realmCreateTimeout int +) + +func init() { + realmCmd.AddCommand(realmCreateCmd) + f := realmCreateCmd.Flags() + f.StringVar(&realmCreateName, "name", "", "Realm name — becomes the first label of the realm FQDN (required)") + f.StringVar(&realmCreateDescription, "description", "", "Realm description") + f.BoolVar(&realmCreateDefault, "default", false, "Make this the account's default realm (required — the API rejects creates without it)") + f.BoolVar(&realmCreateIfNotExists, "if-not-exists", false, "Return the existing realm if one with the same name and state exists") + f.BoolVar(&realmCreateWait, "wait", false, "Block until the realm reaches ACTIVE") + f.IntVar(&realmCreateTimeout, "timeout", 60, "Seconds to wait when --wait is set") + realmCreateCmd.MarkFlagRequired("name") + realmCreateCmd.MarkFlagRequired("default") +} + +var realmCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a SIP authentication realm", + Long: "Creates a SIP authentication realm. The response includes the realm's generated FQDN, which is the address a SIP peer uses for outbound calls. Realm creation is asynchronous — use --wait to block until it is ACTIVE.", + Example: ` # Create a non-default realm and wait for it to become active + band sip realm create --name vapi --default=false --wait + + # Idempotent create + band sip realm create --name vapi --default=false --if-not-exists`, + RunE: runRealmCreate, +} + +func runRealmCreate(cmd *cobra.Command, args []string) error { + if err := sipsvc.ValidateRealmName(realmCreateName); err != nil { + return err + } + svc, err := service(cmd) + if err != nil { + return err + } + format, plain := cmdutil.OutputFlags(cmd) + descSet := cmd.Flags().Changed("description") + + if realmCreateIfNotExists { + realms, err := svc.ListRealms() + if err != nil { + return faultExit(err) + } + for i := range realms { + r := realms[i] + if r.Name != realmCreateName { + continue + } + if !realmReuseAllowed(r.Status) { + return fmt.Errorf("realm %q exists but is in state %s — delete it and retry", r.Name, r.Status) + } + if !realmStateMatches(&r, realmCreateDefault, realmCreateDescription, descSet) { + return fmt.Errorf("realm %q exists with different settings (default=%v, description=%q) — update it explicitly", r.Name, r.Default, r.Description) + } + return emit(format, plain, r) + } + } + + realm, err := svc.CreateRealm(realmCreateName, realmCreateDescription, realmCreateDefault) + if err != nil { + return faultExit(err) + } + + if realmCreateWait && realm.Status != "ACTIVE" { + final, err := waitForRealmActive(svc, realm.ID, realmCreateTimeout) + if err != nil { + return err + } + realm = final + } + return emit(format, plain, realm) +} + +// waitForRealmActive polls until the realm is ACTIVE. Terminal-failure states +// stop the loop immediately rather than burning the whole timeout. +func waitForRealmActive(svc *sipsvc.Service, realmID string, timeoutSeconds int) (*sipsvc.Realm, error) { + result, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: time.Duration(timeoutSeconds) * time.Second, + Check: func() (bool, interface{}, error) { + r, err := svc.GetRealm(realmID) + if err != nil { + return false, nil, faultExit(err) + } + switch r.Status { + case "ACTIVE": + return true, r, nil + case "CREATE_FAILED", "DELETE_FAILED", "DELETE_PENDING": + return false, nil, fmt.Errorf("realm %s entered state %s — delete it and retry", r.ID, r.Status) + case "CREATE_PENDING": + return false, nil, nil + default: + return false, nil, fmt.Errorf("realm %s reported unrecognized state %s", r.ID, r.Status) + } + }, + }) + if err != nil { + return nil, err + } + return result.(*sipsvc.Realm), nil +} diff --git a/cmd/sip/realm_delete.go b/cmd/sip/realm_delete.go new file mode 100644 index 0000000..d70db20 --- /dev/null +++ b/cmd/sip/realm_delete.go @@ -0,0 +1,71 @@ +package sip + +import ( + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +var ( + realmDeleteWait bool + realmDeleteTimeout int +) + +func init() { + realmCmd.AddCommand(realmDeleteCmd) + realmDeleteCmd.Flags().BoolVar(&realmDeleteWait, "wait", false, "Block until the realm is fully deleted") + realmDeleteCmd.Flags().IntVar(&realmDeleteTimeout, "timeout", 60, "Seconds to wait when --wait is set") +} + +var realmDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a SIP authentication realm", + Long: "Deletes a realm. Deletion is asynchronous (the API returns 202). A realm that still has SIP credentials, or that is the account default, cannot be deleted.", + Args: cobra.ExactArgs(1), + Example: ` band sip realm delete vapi --wait`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + ref := args[0] + if err := svc.DeleteRealm(ref); err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + result := map[string]interface{}{"id": ref, "deleted": !realmDeleteWait, "accepted": true} + + if realmDeleteWait { + if _, err := cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: time.Duration(realmDeleteTimeout) * time.Second, + Check: func() (bool, interface{}, error) { + _, err := svc.GetRealm(ref) + if err == nil { + return false, nil, nil // still present + } + // 404 means the delete completed. Anything else is a real failure. + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return true, nil, nil + } + var fault *sipsvc.APIFault + if errors.As(err, &fault) && fault.StatusCode == 404 { + return true, nil, nil + } + return false, nil, faultExit(err) + }, + }); err != nil { + return fmt.Errorf("waiting for realm deletion: %w", err) + } + result["deleted"] = true + } + return emit(format, plain, result) + }, +} diff --git a/cmd/sip/realm_get.go b/cmd/sip/realm_get.go new file mode 100644 index 0000000..6d6ec2f --- /dev/null +++ b/cmd/sip/realm_get.go @@ -0,0 +1,29 @@ +package sip + +import ( + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { realmCmd.AddCommand(realmGetCmd) } + +var realmGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a SIP authentication realm", + Long: "Fetches one realm, including its generated FQDN. Accepts a realm ID, name, or FQDN.", + Args: cobra.ExactArgs(1), + Example: ` band sip realm get vapi --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(args[0]) + if err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, realm) + }, +} diff --git a/cmd/sip/realm_list.go b/cmd/sip/realm_list.go new file mode 100644 index 0000000..a41b244 --- /dev/null +++ b/cmd/sip/realm_list.go @@ -0,0 +1,27 @@ +package sip + +import ( + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +func init() { realmCmd.AddCommand(realmListCmd) } + +var realmListCmd = &cobra.Command{ + Use: "list", + Short: "List SIP authentication realms", + Example: ` band sip realm list --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realms, err := svc.ListRealms() + if err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, realms) + }, +} diff --git a/cmd/sip/realm_update.go b/cmd/sip/realm_update.go new file mode 100644 index 0000000..e2354d1 --- /dev/null +++ b/cmd/sip/realm_update.go @@ -0,0 +1,42 @@ +package sip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var realmUpdateDefault bool + +func init() { + realmCmd.AddCommand(realmUpdateCmd) + realmUpdateCmd.Flags().BoolVar(&realmUpdateDefault, "default", false, "Make this realm the account default (only true is supported by the API)") + realmUpdateCmd.MarkFlagRequired("default") +} + +var realmUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Make a realm the account default", + Long: "Sets a realm as the account default. This exists so a default realm can be torn down: " + + "the API refuses to delete the default realm, and 'default' can only be set to true, so another " + + "realm must be promoted first.", + Args: cobra.ExactArgs(1), + Example: ` band sip realm update backup-realm --default=true`, + RunE: func(cmd *cobra.Command, args []string) error { + if !realmUpdateDefault { + return fmt.Errorf("--default=false is not supported by the API; promote a different realm instead") + } + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.SetRealmDefault(args[0]) + if err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, realm) + }, +} diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go new file mode 100644 index 0000000..965e4a3 --- /dev/null +++ b/cmd/sip/sip.go @@ -0,0 +1,74 @@ +// Package sip implements the `band sip` command group: SIP realms and SIP +// credentials used for SIP trunk digest authentication. +package sip + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +// Cmd is the `band sip` parent command. +// +// The Example block enumerates full leaf command paths with --plain, because a +// caller that only runs `band sip --help` would otherwise see group names and +// have to guess the verbs beneath them. +var Cmd = &cobra.Command{ + Use: "sip", + Short: "Manage SIP trunk authentication (realms and credentials)", + Long: "Manage SIP authentication realms and SIP digest credentials. " + + "A realm yields the FQDN a SIP peer uses as its outbound address; credentials " + + "authenticate that peer. Inbound call routing is configured with 'band vcp'.", + Example: ` # Realms + band sip realm create --name vapi --default=false --wait --plain + band sip realm list --plain + band sip realm get vapi --plain + band sip realm update vapi --default=true --plain + band sip realm delete vapi --wait --plain`, +} + +// emit is the single output path for every `band sip` command. Routing all +// output through one helper means the hash-redaction net cannot be bypassed by +// a future subcommand that prints a raw map — typed domain structs already omit +// hashes, and this catches everything else. +func emit(format string, plain bool, data interface{}) error { + return output.RedactAndPrint(format, plain, data) +} + +// service builds a SIP service for the active account. It is a package var, +// not a plain func, so tests can substitute a service pointed at a stub server +// — the same seam pattern as cmdutil.VoiceClient. +var service = func(cmd *cobra.Command) (*sipsvc.Service, error) { + client, acctID, err := cmdutil.DashboardClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return nil, err + } + return sipsvc.NewService(client, acctID), nil +} + +// faultExit converts documented Bandwidth error codes into actionable messages. +// Exit codes follow from the error type via cmdutil.ExitCodeForError. +func faultExit(err error) error { + var fault *sipsvc.APIFault + if !errors.As(err, &fault) { + return err + } + switch fault.Code { + case "33004": + return cmdutil.NewFeatureLimit("this account isn't enabled for SIP credentials — contact Bandwidth support to enable SipCredentialSettings", err) + case "33006": + return fmt.Errorf("cannot delete the default realm — make another realm default first: band sip realm update --default=true") + case "12666": + return fmt.Errorf("cannot delete this realm while it has SIP credentials — delete them first: band sip credential list --realm ") + case "23022": + return fmt.Errorf("realm is not active yet — retry with --wait: %s", fault.Description) + case "33002": + return fmt.Errorf("realm already exists: %s", fault.Description) + } + return err +} diff --git a/cmd/sip/sip_test.go b/cmd/sip/sip_test.go new file mode 100644 index 0000000..1df4a47 --- /dev/null +++ b/cmd/sip/sip_test.go @@ -0,0 +1,44 @@ +package sip + +import ( + "testing" + + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +func TestRealmReuseAllowed(t *testing.T) { + cases := []struct { + status string + wantOK bool + }{ + {"ACTIVE", true}, + {"CREATE_PENDING", true}, + {"CREATE_FAILED", false}, + {"DELETE_PENDING", false}, + {"DELETE_FAILED", false}, + {"SOMETHING_NEW", false}, + } + for _, c := range cases { + if got := realmReuseAllowed(c.status); got != c.wantOK { + t.Errorf("realmReuseAllowed(%q) = %v, want %v", c.status, got, c.wantOK) + } + } +} + +func TestRealmStateMatches(t *testing.T) { + existing := &sipsvc.Realm{Name: "vapi", Default: false, Description: "d"} + + // Only fields the caller specified participate in the comparison. + if !realmStateMatches(existing, false, "d", true) { + t.Error("identical state reported as mismatch") + } + if !realmStateMatches(existing, false, "", false) { + t.Error("unspecified description must not cause a mismatch") + } + if realmStateMatches(existing, true, "", false) { + t.Error("differing Default must be reported as a mismatch") + } + if realmStateMatches(existing, false, "other", true) { + t.Error("differing description must be reported as a mismatch") + } +} diff --git a/internal/sip/service.go b/internal/sip/service.go index 77b9b3d..e9b7123 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -203,6 +203,30 @@ func (s *Service) DeleteRealm(ref string) error { return err } +// SetRealmDefault promotes a realm to the account default. The API only +// supports setting Default to true. +func (s *Service) SetRealmDefault(ref string) (*Realm, error) { + current, err := s.GetRealm(ref) + if err != nil { + return nil, err + } + // Read-modify-write: Description is resent so a full-replace PUT cannot drop it. + body, err := s.do("PUT", s.base()+"/realms/"+url.PathEscape(ref), realmRequest{ + Realm: current.Name, Description: current.Description, Default: true, + }) + if err != nil { + return nil, err + } + var resp realmResponse + if err := xml.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding realm response: %w", err) + } + if resp.Realm == nil { + return s.GetRealm(ref) + } + return toRealm(resp.Realm), nil +} + func (s *Service) credentialsPath(realmID string) string { return s.base() + "/realms/" + url.PathEscape(realmID) + "/sipcredentials" } From 2a39f31e73a8fad34addd438dea93f45c6333e10 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 16:30:32 -0400 Subject: [PATCH 10/25] fix(sip): preserve exit-code signal in faultExit for 33006/12666/23022/33002 The four non-33004 branches wrapped the original *APIFault in a bare fmt.Errorf (no %w), detaching it from errors.As. cmdutil.ExitCodeForError determines exit codes purely by unwrapping, so these documented conflicts (should exit 4) fell through to exit 1 - contradicting the spec's own error-mapping table. Wrap with %w in all four branches and add TestFaultExit_PreservesExitCode to guard the mapping. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/sip/sip.go | 8 ++++---- cmd/sip/sip_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index 965e4a3..fcb6d43 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -62,13 +62,13 @@ func faultExit(err error) error { case "33004": return cmdutil.NewFeatureLimit("this account isn't enabled for SIP credentials — contact Bandwidth support to enable SipCredentialSettings", err) case "33006": - return fmt.Errorf("cannot delete the default realm — make another realm default first: band sip realm update --default=true") + return fmt.Errorf("cannot delete the default realm — make another realm default first: band sip realm update --default=true: %w", err) case "12666": - return fmt.Errorf("cannot delete this realm while it has SIP credentials — delete them first: band sip credential list --realm ") + return fmt.Errorf("cannot delete this realm while it has SIP credentials — delete them first: band sip credential list --realm : %w", err) case "23022": - return fmt.Errorf("realm is not active yet — retry with --wait: %s", fault.Description) + return fmt.Errorf("realm is not active yet — retry with --wait: %s: %w", fault.Description, err) case "33002": - return fmt.Errorf("realm already exists: %s", fault.Description) + return fmt.Errorf("realm already exists: %s: %w", fault.Description, err) } return err } diff --git a/cmd/sip/sip_test.go b/cmd/sip/sip_test.go index 1df4a47..35b6dd0 100644 --- a/cmd/sip/sip_test.go +++ b/cmd/sip/sip_test.go @@ -3,6 +3,7 @@ package sip import ( "testing" + "github.com/Bandwidth/cli/internal/cmdutil" sipsvc "github.com/Bandwidth/cli/internal/sip" ) @@ -42,3 +43,42 @@ func TestRealmStateMatches(t *testing.T) { t.Error("differing description must be reported as a mismatch") } } + +// TestFaultExit_PreservesExitCode guards against a regression where a +// documented error code's branch in faultExit wraps the original *APIFault in +// a plain fmt.Errorf (no %w), silently detaching it from errors.As. Because +// cmdutil.ExitCodeForError determines the process exit code purely by +// unwrapping to *FeatureLimitError / *api.APIError / ErrPollTimeout, a +// dropped wrap makes a documented conflict (should exit 4) fall through to +// exit 1 — indistinguishable from an unexpected failure to an agent branching +// on exit codes. +func TestFaultExit_PreservesExitCode(t *testing.T) { + cases := []struct { + name string + code string + }{ + {"default realm delete conflict", "33006"}, + {"realm has credentials conflict", "12666"}, + {"realm not active yet conflict", "23022"}, + {"realm already exists conflict", "33002"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fault := &sipsvc.APIFault{Code: c.code, Description: "boom", StatusCode: 409} + result := faultExit(fault) + if got := cmdutil.ExitCodeForError(result); got != cmdutil.ExitConflict { + t.Errorf("faultExit(%s) -> ExitCodeForError = %d, want ExitConflict (%d): err = %v", + c.code, got, cmdutil.ExitConflict, result) + } + }) + } + + // 33004 must still map to ExitConflict via FeatureLimitError (regression + // guard for the one branch that was already correct). + fault := &sipsvc.APIFault{Code: "33004", Description: "not enabled", StatusCode: 403} + result := faultExit(fault) + if got := cmdutil.ExitCodeForError(result); got != cmdutil.ExitConflict { + t.Errorf("faultExit(33004) -> ExitCodeForError = %d, want ExitConflict (%d): err = %v", + got, cmdutil.ExitConflict, result) + } +} From aa89f66990c640516923635158bb8add9b60086b Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 27 Jul 2026 16:38:33 -0400 Subject: [PATCH 11/25] feat(sip): add band sip credential create/rotate/list/get/delete Adds the SIP digest credential commands: create (--password-stdin, --password-file, or --generate-password; --if-not-exists for idempotent retries), rotate (password recovery without changing the credential ID), list, get, and delete. Passwords are never accepted via argv or echoed back once known to the caller; a generated password is shown exactly once. --if-not-exists on a duplicate credential with a generated password returns SecretUnavailableError (exit 8) instead of silently succeeding, since the CLI can no longer prove the caller knows the password. Adds Service.FindCredentialByUsername and Service.CredentialHashesMatch to internal/sip to support the --if-not-exists reconciliation path. --- README.md | 5 ++ cmd/sip/credential.go | 26 ++++++++ cmd/sip/credential_create.go | 120 +++++++++++++++++++++++++++++++++++ cmd/sip/credential_delete.go | 42 ++++++++++++ cmd/sip/credential_get.go | 39 ++++++++++++ cmd/sip/credential_list.go | 38 +++++++++++ cmd/sip/credential_rotate.go | 50 +++++++++++++++ cmd/sip/password.go | 70 ++++++++++++++++++++ cmd/sip/password_test.go | 81 +++++++++++++++++++++++ cmd/sip/sip.go | 9 ++- internal/sip/service.go | 56 ++++++++++++++++ 11 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 cmd/sip/credential.go create mode 100644 cmd/sip/credential_create.go create mode 100644 cmd/sip/credential_delete.go create mode 100644 cmd/sip/credential_get.go create mode 100644 cmd/sip/credential_list.go create mode 100644 cmd/sip/credential_rotate.go create mode 100644 cmd/sip/password.go create mode 100644 cmd/sip/password_test.go diff --git a/README.md b/README.md index 53aed3b..6f58833 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,11 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band sip realm get ` | Get one realm, including its FQDN | | `band sip realm update --default=true` | Promote a realm to account default | | `band sip realm delete ` | Delete a realm | +| `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`) | +| `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved) | +| `band sip credential list --realm ` | List a realm's credentials | +| `band sip credential get --realm ` | Get one credential | +| `band sip credential delete --realm ` | Delete a credential | ### Other diff --git a/cmd/sip/credential.go b/cmd/sip/credential.go new file mode 100644 index 0000000..f2a62c9 --- /dev/null +++ b/cmd/sip/credential.go @@ -0,0 +1,26 @@ +package sip + +import "github.com/spf13/cobra" + +func init() { Cmd.AddCommand(credentialCmd) } + +var credentialCmd = &cobra.Command{ + Use: "credential", + Aliases: []string{"cred"}, + Short: "Manage SIP digest credentials", + Long: "Manage the SIP digest credentials a peer uses to authenticate to a Bandwidth realm. Bandwidth stores only MD5 hashes; the CLI computes them, so passwords are never sent to the API.", +} + +// credentialFlags are shared by create and rotate. +type credentialFlags struct { + realm string + stdin bool + file string + generate bool +} + +func addPasswordFlags(cmd *cobra.Command, f *credentialFlags) { + cmd.Flags().BoolVar(&f.stdin, "password-stdin", false, "Read the password from stdin") + cmd.Flags().StringVar(&f.file, "password-file", "", "Read the password from a file") + cmd.Flags().BoolVar(&f.generate, "generate-password", false, "Generate a random password and print it once") +} diff --git a/cmd/sip/credential_create.go b/cmd/sip/credential_create.go new file mode 100644 index 0000000..bd3ffd5 --- /dev/null +++ b/cmd/sip/credential_create.go @@ -0,0 +1,120 @@ +package sip + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +var ( + credCreate credentialFlags + credCreateUsername string + credCreateAppID string + credCreateIfNotExists bool +) + +func init() { + credentialCmd.AddCommand(credentialCreateCmd) + f := credentialCreateCmd.Flags() + f.StringVar(&credCreate.realm, "realm", "", "Realm ID, name, or FQDN (required)") + f.StringVar(&credCreateUsername, "username", "", "SIP username (required)") + f.StringVar(&credCreateAppID, "app-id", "", "Bind the credential to a voice application ID") + f.BoolVar(&credCreateIfNotExists, "if-not-exists", false, "Return the existing credential if one with the same username and settings exists") + addPasswordFlags(credentialCreateCmd, &credCreate) + credentialCreateCmd.MarkFlagRequired("realm") + credentialCreateCmd.MarkFlagRequired("username") +} + +var credentialCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a SIP digest credential", + Long: "Creates a SIP digest credential on a realm. Bandwidth never accepts or returns a password — " + + "the CLI computes the MD5 digest hashes from the realm's FQDN. With --generate-password the password " + + "is printed exactly once and cannot be recovered later; use 'band sip credential rotate' if it is lost.", + Example: ` # Caller-owned secret (recommended for agents and retries) + printf '%s' "$SIP_PASSWORD" | band sip credential create --realm vapi --username vapi-agent --password-stdin + + # Let the CLI generate one (shown once) + band sip credential create --realm vapi --username vapi-agent --generate-password`, + RunE: runCredentialCreate, +} + +func runCredentialCreate(cmd *cobra.Command, args []string) error { + if err := sipsvc.ValidateUsername(credCreateUsername); err != nil { + return err + } + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(credCreate.realm) + if err != nil { + return faultExit(err) + } + if realm.Status != "ACTIVE" { + return fmt.Errorf("realm %s is %s — credentials can only be created on ACTIVE realms; retry after 'band sip realm get %s' reports ACTIVE", realm.Name, realm.Status, realm.Name) + } + + password, generated, err := readPassword(cmd, credCreate.stdin, credCreate.file, credCreate.generate) + if err != nil { + return err + } + hash1, hash1b := sipsvc.ComputeHashes(credCreateUsername, realm.Hostname, password) + + cred, err := svc.CreateCredential(realm.ID, credCreateUsername, hash1, hash1b, credCreateAppID) + if err != nil { + var fault *sipsvc.APIFault + if credCreateIfNotExists && errors.As(err, &fault) && fault.Code == "23026" { + return reuseCredential(cmd, svc, realm, hash1, hash1b, password, generated) + } + return faultExit(err) + } + return emitCredential(cmd, cred, password, generated) +} + +// reuseCredential implements --if-not-exists after a 23026 duplicate. Identity +// is realm + username; desired state is both hashes plus the app binding. +func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Realm, hash1, hash1b, password string, generated bool) error { + existing, err := svc.FindCredentialByUsername(realm.ID, credCreateUsername) + if err != nil { + return faultExit(err) + } + if existing.AppID != credCreateAppID { + return fmt.Errorf("credential %q exists but is bound to a different application (%q) — delete and recreate it", credCreateUsername, existing.AppID) + } + if generated { + return &cmdutil.SecretUnavailableError{ + Message: fmt.Sprintf("credential %q already exists and its password cannot be recovered — rotate it: band sip credential rotate %s --realm %s --generate-password", credCreateUsername, existing.ID, realm.Name), + } + } + match, err := svc.CredentialHashesMatch(realm.ID, existing.ID, hash1, hash1b) + if err != nil { + return faultExit(err) + } + if !match { + return fmt.Errorf("credential %q exists with a different password — rotate it: band sip credential rotate %s --realm %s --password-stdin", credCreateUsername, existing.ID, realm.Name) + } + return emitCredential(cmd, existing, password, false) +} + +// emitCredential prints the credential. A generated password is included exactly +// once; a caller-supplied one is omitted because the caller already has it. +func emitCredential(cmd *cobra.Command, cred *sipsvc.Credential, password string, generated bool) error { + format, plain := cmdutil.OutputFlags(cmd) + out := map[string]interface{}{ + "id": cred.ID, + "realmId": cred.RealmID, + "username": cred.Username, + "hostname": cred.Hostname, + "appId": cred.AppID, + "passwordShownOnce": generated, + } + if generated { + out["password"] = password + } + return emit(format, plain, out) +} diff --git a/cmd/sip/credential_delete.go b/cmd/sip/credential_delete.go new file mode 100644 index 0000000..e4c5af5 --- /dev/null +++ b/cmd/sip/credential_delete.go @@ -0,0 +1,42 @@ +package sip + +import ( + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var credDeleteRealm string + +func init() { + credentialCmd.AddCommand(credentialDeleteCmd) + credentialDeleteCmd.Flags().StringVar(&credDeleteRealm, "realm", "", "Realm ID, name, or FQDN (required)") + credentialDeleteCmd.MarkFlagRequired("realm") +} + +var credentialDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a SIP digest credential", + Long: "Deletes a SIP digest credential from a realm.", + Args: cobra.ExactArgs(1), + Example: ` band sip credential delete 870880 --realm vapi --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(credDeleteRealm) + if err != nil { + return faultExit(err) + } + if err := svc.DeleteCredential(realm.ID, args[0]); err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, map[string]interface{}{ + "id": args[0], + "realmId": realm.ID, + "deleted": true, + }) + }, +} diff --git a/cmd/sip/credential_get.go b/cmd/sip/credential_get.go new file mode 100644 index 0000000..a81cfd7 --- /dev/null +++ b/cmd/sip/credential_get.go @@ -0,0 +1,39 @@ +package sip + +import ( + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var credGetRealm string + +func init() { + credentialCmd.AddCommand(credentialGetCmd) + credentialGetCmd.Flags().StringVar(&credGetRealm, "realm", "", "Realm ID, name, or FQDN (required)") + credentialGetCmd.MarkFlagRequired("realm") +} + +var credentialGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a SIP digest credential", + Long: "Fetches one SIP digest credential. Passwords are never returned — Bandwidth stores only MD5 hashes.", + Args: cobra.ExactArgs(1), + Example: ` band sip credential get 870880 --realm vapi --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(credGetRealm) + if err != nil { + return faultExit(err) + } + cred, err := svc.GetCredential(realm.ID, args[0]) + if err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, cred) + }, +} diff --git a/cmd/sip/credential_list.go b/cmd/sip/credential_list.go new file mode 100644 index 0000000..dc59f30 --- /dev/null +++ b/cmd/sip/credential_list.go @@ -0,0 +1,38 @@ +package sip + +import ( + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" +) + +var credListRealm string + +func init() { + credentialCmd.AddCommand(credentialListCmd) + credentialListCmd.Flags().StringVar(&credListRealm, "realm", "", "Realm ID, name, or FQDN (required)") + credentialListCmd.MarkFlagRequired("realm") +} + +var credentialListCmd = &cobra.Command{ + Use: "list", + Short: "List a realm's SIP credentials", + Long: "Lists the SIP digest credentials on a realm. Passwords are never returned — Bandwidth stores only MD5 hashes.", + Example: ` band sip credential list --realm vapi --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(credListRealm) + if err != nil { + return faultExit(err) + } + creds, err := svc.ListCredentials(realm.ID) + if err != nil { + return faultExit(err) + } + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, creds) + }, +} diff --git a/cmd/sip/credential_rotate.go b/cmd/sip/credential_rotate.go new file mode 100644 index 0000000..0ce6cab --- /dev/null +++ b/cmd/sip/credential_rotate.go @@ -0,0 +1,50 @@ +package sip + +import ( + "github.com/spf13/cobra" + + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +var credRotate credentialFlags + +func init() { + credentialCmd.AddCommand(credentialRotateCmd) + credentialRotateCmd.Flags().StringVar(&credRotate.realm, "realm", "", "Realm ID, name, or FQDN (required)") + addPasswordFlags(credentialRotateCmd, &credRotate) + credentialRotateCmd.MarkFlagRequired("realm") +} + +var credentialRotateCmd = &cobra.Command{ + Use: "rotate ", + Short: "Rotate a SIP credential's password", + Long: "Replaces a credential's digest hashes with ones derived from a new password. The credential ID is " + + "unchanged, so peers referencing it keep working. This is the recovery path when a generated password " + + "was lost. The username and application binding cannot be changed — delete and recreate for that.", + Args: cobra.ExactArgs(1), + Example: ` band sip credential rotate 870880 --realm vapi --generate-password`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + realm, err := svc.GetRealm(credRotate.realm) + if err != nil { + return faultExit(err) + } + existing, err := svc.GetCredential(realm.ID, args[0]) + if err != nil { + return faultExit(err) + } + password, generated, err := readPassword(cmd, credRotate.stdin, credRotate.file, credRotate.generate) + if err != nil { + return err + } + hash1, hash1b := sipsvc.ComputeHashes(existing.Username, realm.Hostname, password) + cred, err := svc.RotateCredential(realm.ID, existing.ID, hash1, hash1b) + if err != nil { + return faultExit(err) + } + return emitCredential(cmd, cred, password, generated) + }, +} diff --git a/cmd/sip/password.go b/cmd/sip/password.go new file mode 100644 index 0000000..6f904c4 --- /dev/null +++ b/cmd/sip/password.go @@ -0,0 +1,70 @@ +package sip + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +const maxPasswordBytes = 1024 + +// readPassword resolves exactly one password source. Secrets are never accepted +// via argv: command-line arguments leak through shell history, process listings, +// CI logs, and agent transcripts. +// +// Returns the password and whether the CLI generated it (callers only print +// generated passwords — a caller-supplied secret is already known to the caller). +func readPassword(cmd *cobra.Command, stdin bool, file string, generate bool) (string, bool, error) { + sources := 0 + for _, on := range []bool{stdin, file != "", generate} { + if on { + sources++ + } + } + if sources != 1 { + return "", false, fmt.Errorf("specify exactly one of --password-stdin, --password-file, or --generate-password") + } + + if generate { + pw, err := sipsvc.GeneratePassword() + return pw, true, err + } + + var raw []byte + var err error + if stdin { + if cmdutil.IsInteractive() { + // The prompt goes to stderr, never stdout — stdout is the + // machine-readable output channel for `band` commands. + fmt.Fprint(cmd.ErrOrStderr(), "SIP password: ") + pw, perr := cmdutil.ReadPassword() + fmt.Fprintln(cmd.ErrOrStderr()) + if perr != nil { + return "", false, fmt.Errorf("reading password: %w", perr) + } + raw = pw + } else { + raw, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxPasswordBytes+1)) + } + } else { + raw, err = os.ReadFile(file) + } + if err != nil { + return "", false, fmt.Errorf("reading password: %w", err) + } + if len(raw) > maxPasswordBytes { + return "", false, fmt.Errorf("password exceeds %d bytes", maxPasswordBytes) + } + + pw := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + if pw == "" { + return "", false, fmt.Errorf("password is empty") + } + return pw, false, nil +} diff --git a/cmd/sip/password_test.go b/cmd/sip/password_test.go new file mode 100644 index 0000000..52ae47e --- /dev/null +++ b/cmd/sip/password_test.go @@ -0,0 +1,81 @@ +package sip + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestReadPassword_FromFileStripsSingleTrailingNewline(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pw") + if err := os.WriteFile(p, []byte("s3cret\n"), 0600); err != nil { + t.Fatal(err) + } + pw, generated, err := readPassword(&cobra.Command{}, false, p, false) + if err != nil { + t.Fatalf("readPassword() error = %v", err) + } + if pw != "s3cret" { + t.Errorf("password = %q, want s3cret", pw) + } + if generated { + t.Error("generated = true, want false for caller-supplied password") + } +} + +func TestReadPassword_FromFileStripsCRLF(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pw") + os.WriteFile(p, []byte("s3cret\r\n"), 0600) + pw, _, err := readPassword(&cobra.Command{}, false, p, false) + if err != nil { + t.Fatalf("readPassword() error = %v", err) + } + if pw != "s3cret" { + t.Errorf("password = %q, want s3cret", pw) + } +} + +func TestReadPassword_RejectsEmpty(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pw") + os.WriteFile(p, []byte("\n"), 0600) + if _, _, err := readPassword(&cobra.Command{}, false, p, false); err == nil { + t.Error("empty password accepted, want error") + } +} + +func TestReadPassword_Generate(t *testing.T) { + pw, generated, err := readPassword(&cobra.Command{}, false, "", true) + if err != nil { + t.Fatalf("readPassword() error = %v", err) + } + if len(pw) != 22 { + t.Errorf("generated password length = %d, want 22", len(pw)) + } + if !generated { + t.Error("generated = false, want true") + } +} + +func TestReadPassword_RequiresExactlyOneSource(t *testing.T) { + if _, _, err := readPassword(&cobra.Command{}, false, "", false); err == nil { + t.Error("no password source accepted, want error") + } + if _, _, err := readPassword(&cobra.Command{}, true, "", true); err == nil { + t.Error("multiple password sources accepted, want error") + } +} + +func TestReadPassword_RejectsOversizedInput(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pw") + os.WriteFile(p, []byte(strings.Repeat("a", 2048)), 0600) + if _, _, err := readPassword(&cobra.Command{}, false, p, false); err == nil { + t.Error("oversized password accepted, want error") + } +} diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index fcb6d43..44eed6c 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -29,7 +29,14 @@ var Cmd = &cobra.Command{ band sip realm list --plain band sip realm get vapi --plain band sip realm update vapi --default=true --plain - band sip realm delete vapi --wait --plain`, + band sip realm delete vapi --wait --plain + + # Credentials + band sip credential create --realm vapi --username agent --password-stdin --plain + band sip credential rotate 870880 --realm vapi --generate-password --plain + band sip credential list --realm vapi --plain + band sip credential get 870880 --realm vapi --plain + band sip credential delete 870880 --realm vapi --plain`, } // emit is the single output path for every `band sip` command. Routing all diff --git a/internal/sip/service.go b/internal/sip/service.go index e9b7123..c802fc7 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "strings" + "time" "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/output" @@ -324,3 +325,58 @@ func (s *Service) DeleteCredential(realmID, credentialID string) error { _, err := s.do("DELETE", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) return err } + +// FindCredentialByUsername returns the single credential with this username in +// a realm. Bounded retry absorbs read-after-write lag following a duplicate error. +func (s *Service) FindCredentialByUsername(realmID, username string) (*Credential, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Second) + } + creds, err := s.ListCredentials(realmID) + if err != nil { + lastErr = err + continue + } + var matches []Credential + for i := range creds { + if creds[i].Username == username { + matches = append(matches, creds[i]) + } + } + switch len(matches) { + case 1: + return &matches[0], nil + case 0: + lastErr = fmt.Errorf("credential %q not found in realm %s", username, realmID) + default: + return nil, fmt.Errorf("found %d credentials named %q in realm %s; delete the duplicates", len(matches), username, realmID) + } + } + return nil, lastErr +} + +// credentialHashWire exists only for equality checks. The public credentialWire +// deliberately carries no hash fields so digest material cannot reach output; +// this private type keeps the comparison inside the service. +type credentialHashWire struct { + Credential struct { + Hash1 string `xml:"Hash1"` + Hash1b string `xml:"Hash1b"` + } `xml:"SipCredential"` +} + +// CredentialHashesMatch reports whether the stored digest hashes equal the +// supplied ones. The hashes never leave this function. +func (s *Service) CredentialHashesMatch(realmID, credentialID, hash1, hash1b string) (bool, error) { + body, err := s.do("GET", s.credentialsPath(realmID)+"/"+url.PathEscape(credentialID), nil) + if err != nil { + return false, err + } + var w credentialHashWire + if err := xml.Unmarshal(body, &w); err != nil { + return false, fmt.Errorf("decoding credential response: %w", err) + } + return w.Credential.Hash1 == hash1 && w.Credential.Hash1b == hash1b, nil +} From 6a4d46af8b2d9dd1728ce349e550bc7d8de75de0 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 28 Jul 2026 11:00:37 -0400 Subject: [PATCH 12/25] fix(sip): harden credential reconciliation logic per code review Addresses 7 reliability defects found in review of the credential commands (secrecy invariants were already verified sound): - CredentialHashesMatch no longer folds "no hashes in response" into "false (different password)" -- pins the expected root element via XMLName and errors explicitly when both hashes are absent, rather than telling an --if-not-exists caller to rotate a credential that may be correct. - FindCredentialByUsername now matches usernames case-insensitively (mirrors CreateCredential's existing fold), matching the API's case-insensitive duplicate-detection semantics. - A generated password lost to a post-write failure (e.g. decode error after a successful POST/PUT) now exits 8 (SecretUnavailableError) instead of a generic, retryable-looking exit 1, in both create and rotate. - The app-binding gate in --if-not-exists re-reads the credential via GetCredential rather than trusting ListCredentials' shape for HttpVoiceV2AppId, and reports an empty existing binding distinctly from a different one. - faultExit gained a 23026 (duplicate credential) branch with actionable remediation, wrapped with %w so exit-code mapping isn't silently dropped. - --password-file now reads bounded (os.Open + io.LimitReader) instead of slurping the whole file before checking size, and the size cap is enforced after trimming the trailing newline so a legitimate max-length password isn't rejected. Adds test coverage for FindCredentialByUsername and CredentialHashesMatch in internal/sip, plus cmd/sip tests for the --if-not-exists + --generate-password exit-8 path, the app-binding mismatch message, emitCredential's password inclusion/omission, and the --password-stdin pipe path. --- cmd/sip/credential_create.go | 22 +++- cmd/sip/credential_create_test.go | 167 ++++++++++++++++++++++++++++++ cmd/sip/credential_rotate.go | 13 +++ cmd/sip/password.go | 31 +++++- cmd/sip/password_test.go | 39 +++++++ cmd/sip/sip.go | 2 + cmd/sip/sip_test.go | 20 ++++ internal/sip/service.go | 23 +++- internal/sip/service_test.go | 159 ++++++++++++++++++++++++++++ 9 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 cmd/sip/credential_create_test.go diff --git a/cmd/sip/credential_create.go b/cmd/sip/credential_create.go index bd3ffd5..8664aea 100644 --- a/cmd/sip/credential_create.go +++ b/cmd/sip/credential_create.go @@ -71,6 +71,16 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { if credCreateIfNotExists && errors.As(err, &fault) && fault.Code == "23026" { return reuseCredential(cmd, svc, realm, hash1, hash1b, password, generated) } + if generated { + // The write may have landed server-side (e.g. a decode failure after + // a successful POST) even though this call reports failure. Since the + // password was never printed, there is no way to know whether a live + // credential now exists with an unrecoverable password — this must + // not exit as a generic, retryable failure. + return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( + "the write may have been applied but the generated password was not printed and cannot be recovered — check 'band sip credential list --realm %s --plain' and rotate the credential if it exists: %v", + realm.Name, err)} + } return faultExit(err) } return emitCredential(cmd, cred, password, generated) @@ -79,11 +89,21 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { // reuseCredential implements --if-not-exists after a 23026 duplicate. Identity // is realm + username; desired state is both hashes plus the app binding. func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Realm, hash1, hash1b, password string, generated bool) error { - existing, err := svc.FindCredentialByUsername(realm.ID, credCreateUsername) + found, err := svc.FindCredentialByUsername(realm.ID, credCreateUsername) + if err != nil { + return faultExit(err) + } + // Re-read the single credential rather than trusting FindCredentialByUsername's + // list-derived AppID: the collection response's app-binding field has not + // been confirmed to round-trip the same shape as the single-item GET. + existing, err := svc.GetCredential(realm.ID, found.ID) if err != nil { return faultExit(err) } if existing.AppID != credCreateAppID { + if existing.AppID == "" { + return fmt.Errorf("credential %q exists but is not bound to an application (wanted %q) — delete and recreate it", credCreateUsername, credCreateAppID) + } return fmt.Errorf("credential %q exists but is bound to a different application (%q) — delete and recreate it", credCreateUsername, existing.AppID) } if generated { diff --git a/cmd/sip/credential_create_test.go b/cmd/sip/credential_create_test.go new file mode 100644 index 0000000..8e1249d --- /dev/null +++ b/cmd/sip/credential_create_test.go @@ -0,0 +1,167 @@ +package sip + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" + "github.com/Bandwidth/cli/internal/testutil" +) + +// credentialCreateStubServer returns a fault-injectable stub for the requests +// runCredentialCreate's --if-not-exists path issues: GetRealm, the duplicate +// CreateCredential, ListCredentials (FindCredentialByUsername), and the +// re-read GetCredential (the app-binding guard added after code review). +func credentialCreateStubServer(t *testing.T, appID string) *httptest.Server { + t.Helper() + 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.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/sipcredentials"): + w.WriteHeader(201) + w.Write([]byte(`` + + `23026does already exist` + + ``)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/sipcredentials"): + w.Write([]byte(`` + + `55agent` + + ``)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/sipcredentials/"): + w.Write([]byte(`` + + `55agent` + appID + `` + + ``)) + default: + w.WriteHeader(404) + } + })) +} + +func withStubService(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := service + t.Cleanup(func() { service = orig }) + service = func(cmd *cobra.Command) (*sipsvc.Service, error) { + return sipsvc.NewService(api.NewXMLClient(srv.URL, nil), "9901361"), nil + } +} + +// TestCredentialCreate_IfNotExistsGeneratedPasswordOnExisting_ExitsSecretUnavailable +// guards the security invariant reviewed as most load-bearing in this +// command: an agent must never be told "success" for a generated password it +// cannot recover. --if-not-exists against an existing credential, combined +// with --generate-password, must exit 8 (ExitSecretUnavailable), not 0. +func TestCredentialCreate_IfNotExistsGeneratedPasswordOnExisting_ExitsSecretUnavailable(t *testing.T) { + srv := credentialCreateStubServer(t, "") + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--generate-password", "--if-not-exists"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want SecretUnavailableError") + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d (ExitSecretUnavailable); err = %v", got, cmdutil.ExitSecretUnavailable, err) + } +} + +// TestCredentialCreate_IfNotExistsAppMismatch_ReportsNotBound covers the +// re-read-before-compare fix: the app-binding gate must not trust +// ListCredentials' shape for HttpVoiceV2AppId, and an empty existing binding +// must be reported distinctly ("not bound") rather than as a generic +// different-application mismatch. +func TestCredentialCreate_IfNotExistsAppMismatch_ReportsNotBound(t *testing.T) { + srv := credentialCreateStubServer(t, "") // existing credential is unbound + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--app-id", "app-123", "--generate-password", "--if-not-exists"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want an app-binding mismatch error") + } + if !strings.Contains(err.Error(), "not bound to an application") { + t.Errorf("error = %q, want mention of the credential not being bound", err.Error()) + } +} + +// TestEmitCredential_OmitsCallerSuppliedPassword is the most security-load-bearing +// assertion in this package: a caller-supplied password must never be echoed +// back, and passwordShownOnce must be false whenever password is absent. +func TestEmitCredential_OmitsCallerSuppliedPassword(t *testing.T) { + cred := &sipsvc.Credential{ID: "1", RealmID: "10", Username: "agent", Hostname: "vapi.example.com"} + wrap := &cobra.Command{ + Use: "wrap", + RunE: func(cmd *cobra.Command, args []string) error { + return emitCredential(cmd, cred, "callerSuppliedSecret", false) + }, + } + root := testutil.NewTestRoot(wrap) + root.SetArgs([]string{"wrap", "--plain"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + var got map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not JSON: %q (%v)", out, err) + } + if _, present := got["password"]; present { + t.Errorf("output contains a \"password\" key for a caller-supplied secret: %q", out) + } + if v, ok := got["passwordShownOnce"].(bool); !ok || v { + t.Errorf("passwordShownOnce = %v, want false", got["passwordShownOnce"]) + } + if strings.Contains(out, "callerSuppliedSecret") { + t.Errorf("output leaked the caller-supplied password: %q", out) + } +} + +// TestEmitCredential_IncludesGeneratedPasswordExactlyOnce is the counterpart +// to the omission test: a generated password must be present exactly when +// generated=true, alongside passwordShownOnce=true. +func TestEmitCredential_IncludesGeneratedPasswordExactlyOnce(t *testing.T) { + cred := &sipsvc.Credential{ID: "1", RealmID: "10", Username: "agent", Hostname: "vapi.example.com"} + wrap := &cobra.Command{ + Use: "wrap", + RunE: func(cmd *cobra.Command, args []string) error { + return emitCredential(cmd, cred, "generatedSecret123", true) + }, + } + root := testutil.NewTestRoot(wrap) + root.SetArgs([]string{"wrap", "--plain"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + var got map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not JSON: %q (%v)", out, err) + } + if got["password"] != "generatedSecret123" { + t.Errorf("password = %v, want generatedSecret123", got["password"]) + } + if v, ok := got["passwordShownOnce"].(bool); !ok || !v { + t.Errorf("passwordShownOnce = %v, want true", got["passwordShownOnce"]) + } +} diff --git a/cmd/sip/credential_rotate.go b/cmd/sip/credential_rotate.go index 0ce6cab..8c0dffb 100644 --- a/cmd/sip/credential_rotate.go +++ b/cmd/sip/credential_rotate.go @@ -1,8 +1,11 @@ package sip import ( + "fmt" + "github.com/spf13/cobra" + "github.com/Bandwidth/cli/internal/cmdutil" sipsvc "github.com/Bandwidth/cli/internal/sip" ) @@ -43,6 +46,16 @@ var credentialRotateCmd = &cobra.Command{ hash1, hash1b := sipsvc.ComputeHashes(existing.Username, realm.Hostname, password) cred, err := svc.RotateCredential(realm.ID, existing.ID, hash1, hash1b) if err != nil { + if generated { + // The PUT may have replaced the hashes server-side even though + // this call reports failure (e.g. a decode error after a + // successful write). The generated password was never printed, + // so a working peer's credential may now be silently dead with + // an unrecoverable password — that must not exit as a generic, + // retryable failure. + return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( + "the write may have been applied but the generated password was not printed and cannot be recovered — rotate the credential again: %v", err)} + } return faultExit(err) } return emitCredential(cmd, cred, password, generated) diff --git a/cmd/sip/password.go b/cmd/sip/password.go index 6f904c4..4778b91 100644 --- a/cmd/sip/password.go +++ b/cmd/sip/password.go @@ -14,6 +14,14 @@ import ( const maxPasswordBytes = 1024 +// readLimit caps how many bytes readPassword ever reads before deciding an +// input is oversized. It allows maxPasswordBytes plus up to two bytes of +// trailing CRLF (so a max-length password with a trailing newline is not +// rejected — the cap is checked AFTER trimming, not before) plus one sentinel +// byte so a genuinely oversized input still exceeds maxPasswordBytes once +// trimmed and is reported as such rather than silently truncated. +const readLimit = maxPasswordBytes + 3 + // readPassword resolves exactly one password source. Secrets are never accepted // via argv: command-line arguments leak through shell history, process listings, // CI logs, and agent transcripts. @@ -50,19 +58,32 @@ func readPassword(cmd *cobra.Command, stdin bool, file string, generate bool) (s } raw = pw } else { - raw, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), maxPasswordBytes+1)) + raw, err = io.ReadAll(io.LimitReader(cmd.InOrStdin(), readLimit)) } } else { - raw, err = os.ReadFile(file) + // Bounded via os.Open + LimitReader rather than os.ReadFile: the latter + // reads the whole file into memory before any size check runs, so + // --password-file /dev/zero (or any oversized/unbounded file) would + // materialize in full before being rejected. + f, oerr := os.Open(file) + if oerr != nil { + return "", false, fmt.Errorf("reading password: %w", oerr) + } + raw, err = io.ReadAll(io.LimitReader(f, readLimit)) + f.Close() } if err != nil { return "", false, fmt.Errorf("reading password: %w", err) } - if len(raw) > maxPasswordBytes { - return "", false, fmt.Errorf("password exceeds %d bytes", maxPasswordBytes) - } + // The size cap is enforced AFTER trimming trailing CRLF: checking the raw + // byte count first would reject a legitimate maxPasswordBytes-length + // password that happens to end in a newline (the common case for both a + // piped secret and a file written by `echo`). pw := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + if len(pw) > maxPasswordBytes { + return "", false, fmt.Errorf("password exceeds %d bytes", maxPasswordBytes) + } if pw == "" { return "", false, fmt.Errorf("password is empty") } diff --git a/cmd/sip/password_test.go b/cmd/sip/password_test.go index 52ae47e..76204fa 100644 --- a/cmd/sip/password_test.go +++ b/cmd/sip/password_test.go @@ -71,6 +71,45 @@ func TestReadPassword_RequiresExactlyOneSource(t *testing.T) { } } +// TestReadPassword_FromStdinPipe covers the flow the create command's Example +// documents as recommended for agents: `printf '%s' "$SIP_PASSWORD" | band ... +// --password-stdin`. IsInteractive() reports false for the piped/redirected +// stdin a `go test` process runs under, so this exercises the same +// io.LimitReader(cmd.InOrStdin(), ...) branch a real pipe would take. +func TestReadPassword_FromStdinPipe(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader("hunter2\n")) + pw, generated, err := readPassword(cmd, true, "", false) + if err != nil { + t.Fatalf("readPassword() error = %v", err) + } + if pw != "hunter2" { + t.Errorf("password = %q, want hunter2", pw) + } + if generated { + t.Error("generated = true, want false for a piped, caller-supplied password") + } +} + +func TestReadPassword_MaxLengthPasswordWithTrailingNewlineIsAccepted(t *testing.T) { + // Regression guard: the size cap must be enforced AFTER trimming the + // trailing newline, not before — otherwise a legitimate maxPasswordBytes + // password written by `echo` (which appends \n) is rejected. + dir := t.TempDir() + p := filepath.Join(dir, "pw") + pwBytes := strings.Repeat("a", maxPasswordBytes) + if err := os.WriteFile(p, []byte(pwBytes+"\n"), 0600); err != nil { + t.Fatal(err) + } + pw, _, err := readPassword(&cobra.Command{}, false, p, false) + if err != nil { + t.Fatalf("readPassword() error = %v, want max-length password with trailing newline accepted", err) + } + if pw != pwBytes { + t.Errorf("password length = %d, want %d", len(pw), maxPasswordBytes) + } +} + func TestReadPassword_RejectsOversizedInput(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "pw") diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index 44eed6c..6145bf0 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -76,6 +76,8 @@ func faultExit(err error) error { return fmt.Errorf("realm is not active yet — retry with --wait: %s: %w", fault.Description, err) case "33002": return fmt.Errorf("realm already exists: %s: %w", fault.Description, err) + case "23026": + return fmt.Errorf("credential already exists — use --if-not-exists to reuse it, or 'band sip credential rotate --realm ' to change its password: %w", err) } return err } diff --git a/cmd/sip/sip_test.go b/cmd/sip/sip_test.go index 35b6dd0..bf17f6d 100644 --- a/cmd/sip/sip_test.go +++ b/cmd/sip/sip_test.go @@ -1,6 +1,7 @@ package sip import ( + "errors" "testing" "github.com/Bandwidth/cli/internal/cmdutil" @@ -82,3 +83,22 @@ func TestFaultExit_PreservesExitCode(t *testing.T) { got, cmdutil.ExitConflict, result) } } + +// TestFaultExit_DuplicateCredentialPreservesAPIFaultWrapping guards the same +// %w regression as TestFaultExit_PreservesExitCode for the 23026 branch. It +// does not assert a specific numeric exit code: CreateCredential's live +// duplicate-credential fault has been observed at both StatusCode 201 (2xx +// body carrying an Errors envelope) and, per code review, potentially 400 — +// neither of which ExitCodeForError's api.APIError switch maps to +// ExitConflict (it only maps 402/409). The 23026 branch here improves the +// remediation message; it does not change the resulting exit code, and this +// test only confirms the branch does not silently drop the *APIFault via a +// bare fmt.Errorf. +func TestFaultExit_DuplicateCredentialPreservesAPIFaultWrapping(t *testing.T) { + fault := &sipsvc.APIFault{Code: "23026", Description: "does already exist", StatusCode: 201} + result := faultExit(fault) + var got *sipsvc.APIFault + if !errors.As(result, &got) { + t.Fatalf("faultExit(23026) = %v, want an error that unwraps to *sipsvc.APIFault", result) + } +} diff --git a/internal/sip/service.go b/internal/sip/service.go index c802fc7..5381ffb 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -327,7 +327,11 @@ func (s *Service) DeleteCredential(realmID, credentialID string) error { } // FindCredentialByUsername returns the single credential with this username in -// a realm. Bounded retry absorbs read-after-write lag following a duplicate error. +// a realm. Matching is case-insensitive because the API treats usernames as a +// case-insensitive duplicate key (see CreateCredential's case-fold branch) — +// a caller invoking this after a 23026 duplicate with a different case than +// what is stored must still find it. Bounded retry absorbs read-after-write +// lag following that duplicate error. func (s *Service) FindCredentialByUsername(realmID, username string) (*Credential, error) { var lastErr error for attempt := 0; attempt < 3; attempt++ { @@ -341,7 +345,7 @@ func (s *Service) FindCredentialByUsername(realmID, username string) (*Credentia } var matches []Credential for i := range creds { - if creds[i].Username == username { + if strings.EqualFold(creds[i].Username, username) { matches = append(matches, creds[i]) } } @@ -349,7 +353,7 @@ func (s *Service) FindCredentialByUsername(realmID, username string) (*Credentia case 1: return &matches[0], nil case 0: - lastErr = fmt.Errorf("credential %q not found in realm %s", username, realmID) + lastErr = fmt.Errorf("the API reported a duplicate but no credential named %q is listed in realm %s — check for a case difference", username, realmID) default: return nil, fmt.Errorf("found %d credentials named %q in realm %s; delete the duplicates", len(matches), username, realmID) } @@ -360,7 +364,13 @@ func (s *Service) FindCredentialByUsername(realmID, username string) (*Credentia // credentialHashWire exists only for equality checks. The public credentialWire // deliberately carries no hash fields so digest material cannot reach output; // this private type keeps the comparison inside the service. +// +// XMLName pins the expected root element: without it, xml.Unmarshal succeeds +// against any document shape, silently zeroing both hash fields on a +// wrong-shaped body and making CredentialHashesMatch report "different +// password" for a credential that may be perfectly correct. type credentialHashWire struct { + XMLName xml.Name `xml:"SipCredentialResponse"` Credential struct { Hash1 string `xml:"Hash1"` Hash1b string `xml:"Hash1b"` @@ -378,5 +388,12 @@ func (s *Service) CredentialHashesMatch(realmID, credentialID, hash1, hash1b str if err := xml.Unmarshal(body, &w); err != nil { return false, fmt.Errorf("decoding credential response: %w", err) } + // A response that decoded cleanly but carries no hashes at all is a + // distinct failure from "hashes present but different" — telling a caller + // to rotate a possibly-correct credential inverts the guarantee + // --if-not-exists exists to provide. + if w.Credential.Hash1 == "" && w.Credential.Hash1b == "" { + return false, fmt.Errorf("credential %s response carried no digest hashes; cannot verify the supplied password matches", credentialID) + } return w.Credential.Hash1 == hash1 && w.Credential.Hash1b == hash1b, nil } diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go index 3fa20ed..d6673c8 100644 --- a/internal/sip/service_test.go +++ b/internal/sip/service_test.go @@ -312,3 +312,162 @@ func TestShortName(t *testing.T) { func errorsAs(err error, target interface{}) bool { return errors.As(err, target) } + +func TestFindCredentialByUsername_ZeroMatches(t *testing.T) { + // The API reported a duplicate (that's the only caller of this method), + // but the list genuinely doesn't contain the username after all retries. + // The error must say so distinctly from "found it, multiple times". + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `1someoneelse` + + ``)) + }) + defer done() + + _, err := svc.FindCredentialByUsername("1103", "missing") + if err == nil { + t.Fatal("FindCredentialByUsername() error = nil, want error") + } + if !strings.Contains(err.Error(), "no credential named") { + t.Errorf("error = %q, want mention of the missing credential", err.Error()) + } +} + +func TestFindCredentialByUsername_OneMatchIsCaseInsensitive(t *testing.T) { + // The API's duplicate check (23026) is case-insensitive (see + // CreateCredential's EqualFold branch); this lookup must match the same + // way or a caller who requested "Agent" will never find the stored + // "agent" and will spuriously report "not found" after a real duplicate. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `42Agent` + + ``)) + }) + defer done() + + cred, err := svc.FindCredentialByUsername("1103", "agent") + if err != nil { + t.Fatalf("FindCredentialByUsername() error = %v", err) + } + if cred.ID != "42" { + t.Errorf("ID = %q, want 42", cred.ID) + } +} + +func TestFindCredentialByUsername_MultipleMatchesIsError(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `1agent` + + `2AGENT` + + ``)) + }) + defer done() + + _, err := svc.FindCredentialByUsername("1103", "agent") + if err == nil { + t.Fatal("FindCredentialByUsername() error = nil, want error for multiple matches") + } + if !strings.Contains(err.Error(), "delete the duplicates") { + t.Errorf("error = %q, want mention of duplicates", err.Error()) + } +} + +func TestFindCredentialByUsername_TransportErrorThenSuccess(t *testing.T) { + // A transient failure on one attempt must not abort the bounded retry — + // only the final attempt's error (or a real 0/N+ match outcome) should + // surface. + var calls int + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.WriteHeader(500) + w.Write([]byte("boom")) + return + } + w.Write([]byte(`` + + `7agent` + + ``)) + }) + defer done() + + cred, err := svc.FindCredentialByUsername("1103", "agent") + if err != nil { + t.Fatalf("FindCredentialByUsername() error = %v", err) + } + if cred.ID != "7" { + t.Errorf("ID = %q, want 7", cred.ID) + } + if calls != 2 { + t.Errorf("calls = %d, want 2 (one failure, then one success)", calls) + } +} + +func TestCredentialHashesMatch_Match(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `870880h1h1b` + + ``)) + }) + defer done() + + match, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + if err != nil { + t.Fatalf("CredentialHashesMatch() error = %v", err) + } + if !match { + t.Error("match = false, want true") + } +} + +func TestCredentialHashesMatch_Mismatch(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `870880otherotherb` + + ``)) + }) + defer done() + + match, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + if err != nil { + t.Fatalf("CredentialHashesMatch() error = %v", err) + } + if match { + t.Error("match = true, want false") + } +} + +func TestCredentialHashesMatch_AbsentHashesIsError(t *testing.T) { + // A response that decodes cleanly but carries no digest hashes at all must + // not silently report "false" (different password) — that would tell an + // --if-not-exists caller to rotate a credential that may be perfectly + // correct, inverting the one contract --if-not-exists exists to provide. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `870880agent` + + ``)) + }) + defer done() + + _, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + if err == nil { + t.Fatal("CredentialHashesMatch() error = nil, want error for a response with no hashes") + } + if !strings.Contains(err.Error(), "no digest hashes") { + t.Errorf("error = %q, want mention of missing hashes", err.Error()) + } +} + +func TestCredentialHashesMatch_WrongShapedBodyIsDecodeError(t *testing.T) { + // credentialHashWire's XMLName pins the expected root element; a + // completely different document shape must fail to decode rather than + // silently unmarshal into a zero-valued struct. + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`bar`)) + }) + defer done() + + _, err := svc.CredentialHashesMatch("1105", "870880", "h1", "h1b") + if err == nil { + t.Fatal("CredentialHashesMatch() error = nil, want decode error for a wrong-shaped body") + } +} From c997684251187a270f5c3995a8bb11804c106eb9 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 28 Jul 2026 16:19:28 -0400 Subject: [PATCH 13/25] test(sip): cover the two untested generated-password-loss branches Re-review found that the exit-8 test added for Finding 3 exercised reuseCredential's pre-existing "if generated" block, not either of the two branches Finding 3 actually introduced: CreateCredential failing with generated==true (credential_create.go) and RotateCredential failing with generated==true (credential_rotate.go). Both were previously untested. Adds a stub-server test pair for each command: a plain 500 on the write call (not the documented 23026 duplicate, so --if-not-exists cannot mask which branch is under test) asserts *cmdutil. SecretUnavailableError / exit 8 when the password was generated, and the identical failure with a caller-supplied (--password-stdin) password asserts the opposite -- proving the branch keys on `generated`, not the failure alone. Verified by mutation: manually deleting each "if generated" block and re-running the corresponding new test reproduces a failure; both files were restored afterward with a clean `git diff`. --- cmd/sip/credential_create_test.go | 79 ++++++++++++++++++++++++++ cmd/sip/credential_rotate_test.go | 93 +++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 cmd/sip/credential_rotate_test.go diff --git a/cmd/sip/credential_create_test.go b/cmd/sip/credential_create_test.go index 8e1249d..3101057 100644 --- a/cmd/sip/credential_create_test.go +++ b/cmd/sip/credential_create_test.go @@ -2,6 +2,7 @@ package sip import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -99,6 +100,84 @@ func TestCredentialCreate_IfNotExistsAppMismatch_ReportsNotBound(t *testing.T) { } } +// credentialCreatePostWriteFailureStubServer simulates a failure on the +// CreateCredential call itself that is NOT the documented 23026 duplicate — +// e.g. a 500 on the response leg of a POST that may already have committed +// server-side. This is the scenario code review flagged: the write may have +// landed, the generated password was never printed, and the CLI must not +// report a generic, retryable-looking failure. +func credentialCreatePostWriteFailureStubServer(t *testing.T) *httptest.Server { + t.Helper() + 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.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/sipcredentials"): + w.WriteHeader(500) + w.Write([]byte("boom")) + default: + w.WriteHeader(404) + } + })) +} + +// TestCredentialCreate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUnavailable +// exercises the branch code review found untested: CreateCredential returning +// an error while generated == true (credential_create.go, inside +// runCredentialCreate's post-CreateCredential error handling, after the +// --if-not-exists/23026 branch is ruled out). The generated password was +// never printed, so this must surface as *cmdutil.SecretUnavailableError +// (exit 8), not a generic failure an agent might blindly retry. +func TestCredentialCreate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUnavailable(t *testing.T) { + srv := credentialCreatePostWriteFailureStubServer(t) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--generate-password"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want *cmdutil.SecretUnavailableError") + } + var sue *cmdutil.SecretUnavailableError + if !errors.As(err, &sue) { + t.Fatalf("error = %v (%T), want *cmdutil.SecretUnavailableError", err, err) + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d (ExitSecretUnavailable)", got, cmdutil.ExitSecretUnavailable) + } +} + +// TestCredentialCreate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThroughToFaultExit +// is the negative pairing: the identical failure with a caller-supplied +// password (--password-stdin) must NOT produce SecretUnavailableError — the +// caller already knows the password, so there is nothing unrecoverable. This +// proves the branch keys on `generated`, not on the failure alone. +func TestCredentialCreate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThroughToFaultExit(t *testing.T) { + srv := credentialCreatePostWriteFailureStubServer(t) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetIn(strings.NewReader("hunter2\n")) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--password-stdin"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want an error for the failed write") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want a plain faultExit error, NOT *cmdutil.SecretUnavailableError, for a caller-supplied password", err) + } + if got := cmdutil.ExitCodeForError(err); got == cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want anything but ExitSecretUnavailable for a caller-supplied password", got) + } +} + // TestEmitCredential_OmitsCallerSuppliedPassword is the most security-load-bearing // assertion in this package: a caller-supplied password must never be echoed // back, and passwordShownOnce must be false whenever password is absent. diff --git a/cmd/sip/credential_rotate_test.go b/cmd/sip/credential_rotate_test.go new file mode 100644 index 0000000..b67ed3b --- /dev/null +++ b/cmd/sip/credential_rotate_test.go @@ -0,0 +1,93 @@ +package sip + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/testutil" +) + +// credentialRotatePostWriteFailureStubServer simulates a failure on the +// RotateCredential (PUT) call itself — e.g. a 500 on the response leg of a +// PUT that may already have replaced the hashes server-side. This is the +// scenario code review flagged: the write may have landed, the generated +// password was never printed, and a working SIP peer's credential may now be +// silently dead with an unrecoverable password. +func credentialRotatePostWriteFailureStubServer(t *testing.T) *httptest.Server { + t.Helper() + 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.Write([]byte(`1105` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/sipcredentials/"): + w.Write([]byte(`` + + `870880rotuser` + + ``)) + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/sipcredentials/"): + w.WriteHeader(500) + w.Write([]byte("boom")) + default: + w.WriteHeader(404) + } + })) +} + +// TestCredentialRotate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUnavailable +// exercises the branch code review found untested: RotateCredential returning +// an error while generated == true (credential_rotate.go's RunE, in the +// post-RotateCredential error handling). The generated password was never +// printed, so this must surface as *cmdutil.SecretUnavailableError (exit 8), +// not a generic, retryable-looking failure. +func TestCredentialRotate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUnavailable(t *testing.T) { + srv := credentialRotatePostWriteFailureStubServer(t) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialRotateCmd) + root.SetArgs([]string{"rotate", "870880", "--realm", "vapi", "--generate-password"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want *cmdutil.SecretUnavailableError") + } + var sue *cmdutil.SecretUnavailableError + if !errors.As(err, &sue) { + t.Fatalf("error = %v (%T), want *cmdutil.SecretUnavailableError", err, err) + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d (ExitSecretUnavailable)", got, cmdutil.ExitSecretUnavailable) + } +} + +// TestCredentialRotate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThroughToFaultExit +// is the negative pairing: the identical failure with a caller-supplied +// password (--password-stdin) must NOT produce SecretUnavailableError — the +// caller already knows the password, so there is nothing unrecoverable. This +// proves the branch keys on `generated`, not on the failure alone. +func TestCredentialRotate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThroughToFaultExit(t *testing.T) { + srv := credentialRotatePostWriteFailureStubServer(t) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialRotateCmd) + root.SetIn(strings.NewReader("hunter2\n")) + root.SetArgs([]string{"rotate", "870880", "--realm", "vapi", "--password-stdin"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want an error for the failed write") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want a plain faultExit error, NOT *cmdutil.SecretUnavailableError, for a caller-supplied password", err) + } + if got := cmdutil.ExitCodeForError(err); got == cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want anything but ExitSecretUnavailable for a caller-supplied password", got) + } +} From b6ea8305bd36186cbee95bf6384d0df81c2a31a1 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 28 Jul 2026 16:31:50 -0400 Subject: [PATCH 14/25] feat(vcp): add origination route-plan flags with replacement guard Adds --route-endpoint/--route-endpoint-type/--route-name/--route-plan-json to `band vcp create` and `band vcp update`, plus --replace-routes on update to guard against silently overwriting an existing origination route plan. `vcp create --if-not-exists` is now state-aware: it compares the requested name, description, app ID, and route plan against any existing match and errors on a mismatch (or on an ambiguous duplicate name) instead of returning the first name match unconditionally. --- README.md | 2 + cmd/vcp/create.go | 72 +++++++++++++++- cmd/vcp/route.go | 192 ++++++++++++++++++++++++++++++++++++++++++ cmd/vcp/route_test.go | 95 +++++++++++++++++++++ cmd/vcp/update.go | 47 ++++++++++- 5 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 cmd/vcp/route.go create mode 100644 cmd/vcp/route_test.go diff --git a/README.md b/README.md index 6f58833..4dcf000 100644 --- a/README.md +++ b/README.md @@ -392,9 +392,11 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | Command | What it does | |---------|-------------| | `band vcp create` | Create a VCP | +| `band vcp create --route-endpoint --route-endpoint-type ` | Create a VCP with an origination route | | `band vcp list` | List all VCPs | | `band vcp get ` | Get VCP details | | `band vcp update ` | Update a VCP (name, description, linked app) | +| `band vcp update --route-endpoint --route-endpoint-type FQDN --replace-routes` | Replace a VCP's origination route plan | | `band vcp delete ` | Delete a VCP | | `band vcp assign ` | Assign (or move) numbers to a VCP | | `band vcp numbers ` | List numbers on a VCP | diff --git a/cmd/vcp/create.go b/cmd/vcp/create.go index b0a67d4..28b852c 100644 --- a/cmd/vcp/create.go +++ b/cmd/vcp/create.go @@ -14,6 +14,11 @@ var ( createDescription string createAppID string createIfNotExists bool + + createRouteEndpoint string + createRouteEndpointType string + createRouteName string + createRoutePlanJSON string ) func init() { @@ -22,6 +27,10 @@ func init() { createCmd.Flags().StringVar(&createDescription, "description", "", "VCP description") createCmd.Flags().StringVar(&createAppID, "app-id", "", "Voice application ID to link") createCmd.Flags().BoolVar(&createIfNotExists, "if-not-exists", false, "Return existing VCP if one with the same name exists") + createCmd.Flags().StringVar(&createRouteEndpoint, "route-endpoint", "", "Origination route endpoint (host, SIP URI, IPv4, or E.164)") + createCmd.Flags().StringVar(&createRouteEndpointType, "route-endpoint-type", "", "Endpoint type: TN, SIP, IP_V4, or FQDN") + createCmd.Flags().StringVar(&createRouteName, "route-name", "", "Route name (default \"primary route\")") + createCmd.Flags().StringVar(&createRoutePlanJSON, "route-plan-json", "", "Full originationRoutePlan object as JSON, or @file") createCmd.MarkFlagRequired("name") } @@ -36,7 +45,10 @@ var createCmd = &cobra.Command{ band vcp create --name "Voice VCP" --app-id abc-123-def # Idempotent create (safe for retries) - band vcp create --name "Voice VCP" --if-not-exists`, + band vcp create --name "Voice VCP" --if-not-exists + + # Create with an origination route + band vcp create --name "Voice VCP" --route-endpoint vapi.example.sip.vapi.ai --route-endpoint-type FQDN`, RunE: runCreate, } @@ -69,10 +81,29 @@ func runCreate(cmd *cobra.Command, args []string) error { format, plain := cmdutil.OutputFlags(cmd) + plan, err := resolveRoutePlan(createRouteEndpoint, createRouteEndpointType, createRouteName, createRoutePlanJSON) + if err != nil { + return err + } + if createIfNotExists { var listResult interface{} if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &listResult); err == nil { - if existing := output.FindByName(listResult, "name", createName); existing != nil { + matches := findAllByName(listResult, "name", createName) + if len(matches) > 1 { + return fmt.Errorf("found %d VCPs named %q; disambiguate by ID with band vcp update ", len(matches), createName) + } + if len(matches) == 1 { + existing := matches[0] + if cmd.Flags().Changed("description") && normalizedField(existing["description"]) != createDescription { + return fmt.Errorf("VCP %q exists with a different description — update it explicitly: band vcp update %v --description ", createName, existing["voiceConfigurationPackageId"]) + } + if cmd.Flags().Changed("app-id") && normalizedField(existing["httpVoiceV2ApplicationId"]) != createAppID { + return fmt.Errorf("VCP %q exists but is linked to a different application — update it explicitly: band vcp update %v --app-id ", createName, existing["voiceConfigurationPackageId"]) + } + if plan != nil && !RoutePlansEqual(existing["originationRoutePlan"], plan) { + return fmt.Errorf("VCP %q exists with a different origination route plan — update it explicitly: band vcp update %v --route-endpoint ... --route-endpoint-type ... --replace-routes", createName, existing["voiceConfigurationPackageId"]) + } return output.StdoutAuto(format, plain, existing) } } @@ -83,6 +114,9 @@ func runCreate(cmd *cobra.Command, args []string) error { Description: createDescription, AppID: createAppID, }) + if plan != nil { + body["originationRoutePlan"] = plan + } var result interface{} if err := client.Post(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), body, &result); err != nil { @@ -91,3 +125,37 @@ func runCreate(cmd *cobra.Command, args []string) error { return output.StdoutAuto(format, plain, result) } + +// findAllByName returns every item in a flattened list response whose field +// matches name. Unlike output.FindByName, callers need the full match count +// to detect ambiguous names before silently acting on the first one found. +func findAllByName(data interface{}, field, name string) []map[string]interface{} { + flat := output.FlattenResponse(data) + var matches []map[string]interface{} + switch v := flat.(type) { + case []interface{}: + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + if val, ok := m[field].(string); ok && val == name { + matches = append(matches, m) + } + } + } + case map[string]interface{}: + if val, ok := v[field].(string); ok && val == name { + matches = append(matches, v) + } + } + return matches +} + +// normalizedField treats a JSON-decoded field value of nil (absent/null) the +// same as an empty string, matching the API's own absent/null equivalence for +// optional scalar fields like description and app ID. +func normalizedField(v interface{}) string { + if v == nil { + return "" + } + s, _ := v.(string) + return s +} diff --git a/cmd/vcp/route.go b/cmd/vcp/route.go new file mode 100644 index 0000000..8718f20 --- /dev/null +++ b/cmd/vcp/route.go @@ -0,0 +1,192 @@ +package vcp + +import ( + "encoding/json" + "fmt" + "net" + "os" + "regexp" + "strings" +) + +// supportedEndpointTypes are the origination-route endpoint types the CLI writes. +// INTEGRATION and BOT are Bandwidth-managed and out of scope. +var supportedEndpointTypes = map[string]bool{"TN": true, "SIP": true, "IP_V4": true, "FQDN": true} + +var ( + e164Re = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) + fqdnRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$`) +) + +// ValidateEndpoint checks an endpoint value against its declared type. +func ValidateEndpoint(endpoint, endpointType string) error { + if !supportedEndpointTypes[endpointType] { + return fmt.Errorf("unsupported --route-endpoint-type %q; use one of TN, SIP, IP_V4, FQDN", endpointType) + } + switch endpointType { + case "TN": + if !e164Re.MatchString(endpoint) { + return fmt.Errorf("endpoint %q must be E.164 (e.g. +19195551234) for type TN", endpoint) + } + case "SIP": + if !strings.HasPrefix(endpoint, "sip:") && !strings.HasPrefix(endpoint, "sips:") { + return fmt.Errorf("endpoint %q must be a SIP URI (e.g. sip:agent@example.com) for type SIP", endpoint) + } + case "IP_V4": + host := endpoint + if i := strings.Index(host, "/"); i >= 0 { + host = host[:i] + } + if ip := net.ParseIP(host); ip == nil || ip.To4() == nil { + return fmt.Errorf("endpoint %q must be an IPv4 address or CIDR for type IP_V4", endpoint) + } + case "FQDN": + if !fqdnRe.MatchString(endpoint) { + return fmt.Errorf("endpoint %q must be a hostname (e.g. host.example.com) for type FQDN", endpoint) + } + } + return nil +} + +// BuildRoutePlan produces a single-route, single-endpoint origination route plan. +// The route's own type is always WEIGHTED; the endpoint carries the kind of +// destination. Priority and weight are not exposed as flags in v1 because +// neither is meaningful with one route and one endpoint. +func BuildRoutePlan(endpoint, endpointType, routeName string) (map[string]interface{}, error) { + if err := ValidateEndpoint(endpoint, endpointType); err != nil { + return nil, err + } + if routeName == "" { + routeName = "primary route" + } + return map[string]interface{}{ + "routes": []map[string]interface{}{{ + "priority": 1, + "name": routeName, + "type": "WEIGHTED", + "endpoints": []map[string]interface{}{{ + "endpoint": endpoint, + "type": endpointType, + "weight": 100, + }}, + }}, + }, nil +} + +// ParseRoutePlanJSON parses the value of --route-plan-json, which is the +// originationRoutePlan object itself (i.e. {"routes":[...]}). +func ParseRoutePlanJSON(s string) (map[string]interface{}, error) { + var plan map[string]interface{} + dec := json.NewDecoder(strings.NewReader(s)) + dec.DisallowUnknownFields() + if err := json.Unmarshal([]byte(s), &plan); err != nil { + return nil, fmt.Errorf("parsing --route-plan-json: %w", err) + } + for k := range plan { + if k != "routes" { + return nil, fmt.Errorf("unknown field %q in --route-plan-json; expected the originationRoutePlan object, e.g. {\"routes\":[...]}", k) + } + } + if _, ok := plan["routes"]; !ok { + return nil, fmt.Errorf("--route-plan-json must contain a \"routes\" array") + } + return plan, nil +} + +// isEmptyPlan treats absent, null, and {"routes":[]} as the same state — the +// API returns null for packages with no plan. +func isEmptyPlan(p interface{}) bool { + if p == nil { + return true + } + m, ok := normalizePlan(p) + if !ok { + return false + } + routes, _ := m["routes"].([]interface{}) + return len(routes) == 0 +} + +// normalizePlan converts any plan representation into a generic map via JSON so +// map[string]interface{} and map[string]any compare consistently. +func normalizePlan(p interface{}) (map[string]interface{}, bool) { + b, err := json.Marshal(p) + if err != nil { + return nil, false + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, false + } + return m, true +} + +// RoutePlansEqual compares two route plans for the purposes of --if-not-exists +// and the --replace-routes guard. Route and endpoint order is significant; +// FQDN endpoint values compare case-insensitively. +func RoutePlansEqual(a, b interface{}) bool { + if isEmptyPlan(a) && isEmptyPlan(b) { + return true + } + if isEmptyPlan(a) != isEmptyPlan(b) { + return false + } + na, okA := normalizePlan(a) + nb, okB := normalizePlan(b) + if !okA || !okB { + return false + } + return canonicalPlanJSON(na) == canonicalPlanJSON(nb) +} + +// canonicalPlanJSON renders only the user-controllable fields, lowercasing FQDN +// endpoints so case differences do not read as changes. +func canonicalPlanJSON(m map[string]interface{}) string { + routes, _ := m["routes"].([]interface{}) + var out []map[string]interface{} + for _, r := range routes { + rm, _ := r.(map[string]interface{}) + var eps []map[string]interface{} + raw, _ := rm["endpoints"].([]interface{}) + for _, e := range raw { + em, _ := e.(map[string]interface{}) + ep, _ := em["endpoint"].(string) + typ, _ := em["type"].(string) + if typ == "FQDN" { + ep = strings.ToLower(ep) + } + eps = append(eps, map[string]interface{}{"endpoint": ep, "type": typ}) + } + name, _ := rm["name"].(string) + out = append(out, map[string]interface{}{"name": name, "endpoints": eps}) + } + b, _ := json.Marshal(out) + return string(b) +} + +// resolveRoutePlan turns the route flags into a plan, or nil when none were set. +// The individual flags and --route-plan-json are mutually exclusive. +func resolveRoutePlan(endpoint, endpointType, routeName, planJSON string) (map[string]interface{}, error) { + usingFlags := endpoint != "" || endpointType != "" + if usingFlags && planJSON != "" { + return nil, fmt.Errorf("--route-plan-json cannot be combined with --route-endpoint/--route-endpoint-type") + } + if planJSON != "" { + s := planJSON + if strings.HasPrefix(s, "@") { + b, err := os.ReadFile(strings.TrimPrefix(s, "@")) + if err != nil { + return nil, fmt.Errorf("reading --route-plan-json file: %w", err) + } + s = string(b) + } + return ParseRoutePlanJSON(s) + } + if !usingFlags { + return nil, nil + } + if endpoint == "" || endpointType == "" { + return nil, fmt.Errorf("--route-endpoint and --route-endpoint-type must be provided together") + } + return BuildRoutePlan(endpoint, endpointType, routeName) +} diff --git a/cmd/vcp/route_test.go b/cmd/vcp/route_test.go new file mode 100644 index 0000000..2646317 --- /dev/null +++ b/cmd/vcp/route_test.go @@ -0,0 +1,95 @@ +package vcp + +import "testing" + +func TestBuildRoutePlan_SingleFQDNEndpoint(t *testing.T) { + plan, err := BuildRoutePlan("vapi.example.sip.vapi.ai", "FQDN", "") + if err != nil { + t.Fatalf("BuildRoutePlan() error = %v", err) + } + routes := plan["routes"].([]map[string]interface{}) + if len(routes) != 1 { + t.Fatalf("routes = %d, want 1", len(routes)) + } + if routes[0]["priority"] != 1 { + t.Errorf("priority = %v, want 1", routes[0]["priority"]) + } + if routes[0]["name"] != "primary route" { + t.Errorf("name = %v, want 'primary route'", routes[0]["name"]) + } + // A route's type is WEIGHTED|ANI; the endpoint carries the TN/SIP/IP_V4/FQDN type. + if routes[0]["type"] != "WEIGHTED" { + t.Errorf("route type = %v, want WEIGHTED", routes[0]["type"]) + } + eps := routes[0]["endpoints"].([]map[string]interface{}) + if eps[0]["endpoint"] != "vapi.example.sip.vapi.ai" || eps[0]["type"] != "FQDN" { + t.Errorf("endpoint = %v", eps[0]) + } +} + +func TestValidateEndpoint(t *testing.T) { + ok := map[string]string{ + "TN": "+19195551234", + "SIP": "sip:agent@example.com", + "IP_V4": "192.0.2.10", + "FQDN": "host.example.com", + } + for typ, ep := range ok { + if err := ValidateEndpoint(ep, typ); err != nil { + t.Errorf("ValidateEndpoint(%q,%q) = %v, want nil", ep, typ, err) + } + } + bad := map[string]string{ + "TN": "19195551234", // missing + + "SIP": "agent@example.com", // missing sip: + "IP_V4": "not-an-ip", + "FQDN": "no spaces allowed", + } + for typ, ep := range bad { + if err := ValidateEndpoint(ep, typ); err == nil { + t.Errorf("ValidateEndpoint(%q,%q) = nil, want error", ep, typ) + } + } + if err := ValidateEndpoint("x", "BOT"); err == nil { + t.Error("unsupported endpoint type accepted, want error") + } +} + +func TestRoutePlansEqual_EmptyNormalization(t *testing.T) { + // Live-verified: the VCP list response returns null for packages with no plan. + empty := []interface{}{nil, map[string]interface{}{"routes": []interface{}{}}} + for _, a := range empty { + for _, b := range empty { + if !RoutePlansEqual(a, b) { + t.Errorf("RoutePlansEqual(%v,%v) = false, want true", a, b) + } + } + } +} + +func TestRoutePlansEqual_DetectsDifference(t *testing.T) { + a, _ := BuildRoutePlan("host-a.example.com", "FQDN", "") + b, _ := BuildRoutePlan("host-b.example.com", "FQDN", "") + if RoutePlansEqual(a, b) { + t.Error("different endpoints reported as equal") + } + // FQDN comparison is case-insensitive. + c, _ := BuildRoutePlan("HOST-A.example.com", "FQDN", "") + if !RoutePlansEqual(a, c) { + t.Error("FQDN case difference reported as unequal") + } +} + +func TestParseRoutePlanJSON(t *testing.T) { + plan, err := ParseRoutePlanJSON(`{"routes":[{"priority":1,"endpoints":[{"endpoint":"h.example.com","type":"FQDN"}]}]}`) + if err != nil { + t.Fatalf("ParseRoutePlanJSON() error = %v", err) + } + if _, ok := plan["routes"]; !ok { + t.Error("parsed plan missing routes") + } + // Unknown top-level fields are rejected rather than silently dropped. + if _, err := ParseRoutePlanJSON(`{"routez":[]}`); err == nil { + t.Error("unknown field accepted, want error") + } +} diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go index e3d6562..9cf5db6 100644 --- a/cmd/vcp/update.go +++ b/cmd/vcp/update.go @@ -13,6 +13,12 @@ var ( updateName string updateDescription string updateAppID string + + updateRouteEndpoint string + updateRouteEndpointType string + updateRouteName string + updateRoutePlanJSON string + updateReplaceRoutes bool ) func init() { @@ -20,6 +26,11 @@ func init() { updateCmd.Flags().StringVar(&updateName, "name", "", "VCP name") updateCmd.Flags().StringVar(&updateDescription, "description", "", "VCP description") updateCmd.Flags().StringVar(&updateAppID, "app-id", "", "Voice application ID to link") + updateCmd.Flags().StringVar(&updateRouteEndpoint, "route-endpoint", "", "Origination route endpoint (host, SIP URI, IPv4, or E.164)") + updateCmd.Flags().StringVar(&updateRouteEndpointType, "route-endpoint-type", "", "Endpoint type: TN, SIP, IP_V4, or FQDN") + updateCmd.Flags().StringVar(&updateRouteName, "route-name", "", "Route name (default \"primary route\")") + updateCmd.Flags().StringVar(&updateRoutePlanJSON, "route-plan-json", "", "Full originationRoutePlan object as JSON, or @file") + updateCmd.Flags().BoolVar(&updateReplaceRoutes, "replace-routes", false, "Confirm replacement of an existing origination route plan. This is a read-then-write check and is best-effort against concurrent modification.") } var updateCmd = &cobra.Command{ @@ -33,7 +44,10 @@ var updateCmd = &cobra.Command{ band vcp update abc-123 --app-id def-456 # Update multiple fields at once - band vcp update abc-123 --name "Updated" --description "New description" --app-id def-456`, + band vcp update abc-123 --name "Updated" --description "New description" --app-id def-456 + + # Replace a VCP's origination route plan + band vcp update abc-123 --route-endpoint vapi.example.sip.vapi.ai --route-endpoint-type FQDN --replace-routes`, Args: cobra.ExactArgs(1), RunE: runUpdate, } @@ -81,16 +95,45 @@ func runUpdate(cmd *cobra.Command, args []string) error { opts.AppID = &updateAppID } - body, err := BuildVCPUpdateBody(opts) + plan, err := resolveRoutePlan(updateRouteEndpoint, updateRouteEndpointType, updateRouteName, updateRoutePlanJSON) if err != nil { return err } + body, err := BuildVCPUpdateBody(opts) + if err != nil { + // A route-only update (no --name/--description/--app-id) is valid; + // BuildVCPUpdateBody only knows about the non-route fields. + if plan == nil { + return err + } + body = make(map[string]interface{}) + } + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) if err != nil { return err } + if plan != nil { + var current map[string]interface{} + if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), ¤t); err != nil { + return fmt.Errorf("reading current VCP: %w", err) + } + existingPlan := current["originationRoutePlan"] + if data, ok := current["data"].(map[string]interface{}); ok { + existingPlan = data["originationRoutePlan"] + } + // Writing a route plan replaces the whole plan. Require explicit consent + // whenever a non-empty plan would be overwritten with something different. + // This is a read-then-write check, so it is best-effort against + // concurrent modification of the VCP between the GET and the PATCH. + if !isEmptyPlan(existingPlan) && !RoutePlansEqual(existingPlan, plan) && !updateReplaceRoutes { + return fmt.Errorf("VCP %s already has an origination route plan; writing a new one replaces it — re-run with --replace-routes to confirm", vcpID) + } + body["originationRoutePlan"] = plan + } + var result interface{} if err := client.Patch(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages/%s", acctID, vcpID), body, &result); err != nil { return fmt.Errorf("updating VCP: %w", err) From 0f7de8702986f8a4407860e318ec2b16042f19f1 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 28 Jul 2026 16:47:01 -0400 Subject: [PATCH 15/25] fix(vcp): close route-plan canonicalization gap and route-name no-op canonicalPlanJSON compared only route name and per-endpoint {endpoint, type}, so --route-plan-json could change route priority, route type (WEIGHTED vs ANI), or endpoint weight and the --replace-routes guard would treat the plans as identical. Now compares those fields too, normalizing priority/weight to float64 so JSON-decoded and hand-built numeric values compare equal. Also: --route-name alone (or combined with --route-plan-json) was a silent no-op; it now participates in resolveRoutePlan's flag/JSON mutual exclusivity like the other route flags. Removed a dead DisallowUnknownFields() decoder in ParseRoutePlanJSON that had no effect. Extracted the --if-not-exists conflict check and the --replace-routes guard condition into pure functions (vcpConflict, requiresRouteReplaceConfirmation) and added unit coverage for the canonicalization gap, the route-name fix, duplicate-name detection, and the guard's transitions. --- cmd/vcp/create.go | 35 ++++++-- cmd/vcp/route.go | 65 ++++++++++++-- cmd/vcp/route_test.go | 202 ++++++++++++++++++++++++++++++++++++++++++ cmd/vcp/update.go | 2 +- 4 files changed, 286 insertions(+), 18 deletions(-) diff --git a/cmd/vcp/create.go b/cmd/vcp/create.go index 28b852c..3135f42 100644 --- a/cmd/vcp/create.go +++ b/cmd/vcp/create.go @@ -95,14 +95,11 @@ func runCreate(cmd *cobra.Command, args []string) error { } if len(matches) == 1 { existing := matches[0] - if cmd.Flags().Changed("description") && normalizedField(existing["description"]) != createDescription { - return fmt.Errorf("VCP %q exists with a different description — update it explicitly: band vcp update %v --description ", createName, existing["voiceConfigurationPackageId"]) - } - if cmd.Flags().Changed("app-id") && normalizedField(existing["httpVoiceV2ApplicationId"]) != createAppID { - return fmt.Errorf("VCP %q exists but is linked to a different application — update it explicitly: band vcp update %v --app-id ", createName, existing["voiceConfigurationPackageId"]) - } - if plan != nil && !RoutePlansEqual(existing["originationRoutePlan"], plan) { - return fmt.Errorf("VCP %q exists with a different origination route plan — update it explicitly: band vcp update %v --route-endpoint ... --route-endpoint-type ... --replace-routes", createName, existing["voiceConfigurationPackageId"]) + if err := vcpConflict(existing, createName, + cmd.Flags().Changed("description"), createDescription, + cmd.Flags().Changed("app-id"), createAppID, + plan); err != nil { + return err } return output.StdoutAuto(format, plain, existing) } @@ -159,3 +156,25 @@ func normalizedField(v interface{}) string { s, _ := v.(string) return s } + +// vcpConflict decides whether a single --if-not-exists name match is +// compatible with the requested create, comparing only the fields the caller +// actually specified (checkDescription/checkAppID reflect whether --description +// and --app-id were set; plan is nil unless route flags/--route-plan-json were +// given). Returns nil when the match is compatible (safe to return as-is), or +// an error naming the exact `band vcp update` remediation otherwise. This is +// pulled out of runCreate so the decision can be unit tested without a live +// (or faked) HTTP client. +func vcpConflict(existing map[string]interface{}, name string, checkDescription bool, description string, checkAppID bool, appID string, plan map[string]interface{}) error { + id := existing["voiceConfigurationPackageId"] + if checkDescription && normalizedField(existing["description"]) != description { + return fmt.Errorf("VCP %q exists with a different description — update it explicitly: band vcp update %v --description ", name, id) + } + if checkAppID && normalizedField(existing["httpVoiceV2ApplicationId"]) != appID { + return fmt.Errorf("VCP %q exists but is linked to a different application — update it explicitly: band vcp update %v --app-id ", name, id) + } + if plan != nil && !RoutePlansEqual(existing["originationRoutePlan"], plan) { + return fmt.Errorf("VCP %q exists with a different origination route plan — update it explicitly: band vcp update %v --route-endpoint ... --route-endpoint-type ... --replace-routes", name, id) + } + return nil +} diff --git a/cmd/vcp/route.go b/cmd/vcp/route.go index 8718f20..1031b0b 100644 --- a/cmd/vcp/route.go +++ b/cmd/vcp/route.go @@ -77,8 +77,6 @@ func BuildRoutePlan(endpoint, endpointType, routeName string) (map[string]interf // originationRoutePlan object itself (i.e. {"routes":[...]}). func ParseRoutePlanJSON(s string) (map[string]interface{}, error) { var plan map[string]interface{} - dec := json.NewDecoder(strings.NewReader(s)) - dec.DisallowUnknownFields() if err := json.Unmarshal([]byte(s), &plan); err != nil { return nil, fmt.Errorf("parsing --route-plan-json: %w", err) } @@ -139,8 +137,13 @@ func RoutePlansEqual(a, b interface{}) bool { return canonicalPlanJSON(na) == canonicalPlanJSON(nb) } -// canonicalPlanJSON renders only the user-controllable fields, lowercasing FQDN -// endpoints so case differences do not read as changes. +// canonicalPlanJSON renders the user-controllable fields — including route +// priority/type and endpoint weight, which a caller can set via +// --route-plan-json even though the individual flags never vary them — so +// the guard and --if-not-exists detect every field a caller can actually +// change. FQDN endpoint values are lowercased so case differences do not +// read as changes; priority and weight are normalized to float64 so a +// JSON-decoded 1.0 and a hand-built int 1 compare equal. func canonicalPlanJSON(m map[string]interface{}) string { routes, _ := m["routes"].([]interface{}) var out []map[string]interface{} @@ -155,21 +158,65 @@ func canonicalPlanJSON(m map[string]interface{}) string { if typ == "FQDN" { ep = strings.ToLower(ep) } - eps = append(eps, map[string]interface{}{"endpoint": ep, "type": typ}) + eps = append(eps, map[string]interface{}{ + "endpoint": ep, + "type": typ, + "weight": toFloat(em["weight"]), + }) } name, _ := rm["name"].(string) - out = append(out, map[string]interface{}{"name": name, "endpoints": eps}) + routeType, _ := rm["type"].(string) + out = append(out, map[string]interface{}{ + "name": name, + "type": routeType, + "priority": toFloat(rm["priority"]), + "endpoints": eps, + }) } b, _ := json.Marshal(out) return string(b) } +// toFloat normalizes a route/endpoint numeric field (priority, weight) to +// float64 so values coming from JSON decoding (always float64) and values +// from a hand-built map (often int, e.g. BuildRoutePlan's literals) compare +// equal. A missing or non-numeric value normalizes to 0. +func toFloat(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case int64: + return float64(n) + case json.Number: + f, _ := n.Float64() + return f + default: + return 0 + } +} + +// requiresRouteReplaceConfirmation reports whether writing plan over +// existingPlan would silently destroy a route plan the caller didn't +// explicitly ask to replace: true only when existingPlan is non-empty AND +// structurally different from plan. An empty existing plan (nothing to +// destroy) or an identical one (no-op write) never requires --replace-routes. +// Pulled out of runUpdate so the four guard transitions are unit testable +// without a live (or faked) HTTP client. +func requiresRouteReplaceConfirmation(existingPlan, plan interface{}) bool { + return !isEmptyPlan(existingPlan) && !RoutePlansEqual(existingPlan, plan) +} + // resolveRoutePlan turns the route flags into a plan, or nil when none were set. // The individual flags and --route-plan-json are mutually exclusive. +// routeName counts as "using flags" too — otherwise a caller who sets only +// --route-name gets no plan and no error, a silent no-op on a flag they +// explicitly set. func resolveRoutePlan(endpoint, endpointType, routeName, planJSON string) (map[string]interface{}, error) { - usingFlags := endpoint != "" || endpointType != "" + usingFlags := endpoint != "" || endpointType != "" || routeName != "" if usingFlags && planJSON != "" { - return nil, fmt.Errorf("--route-plan-json cannot be combined with --route-endpoint/--route-endpoint-type") + return nil, fmt.Errorf("--route-plan-json cannot be combined with --route-endpoint/--route-endpoint-type/--route-name") } if planJSON != "" { s := planJSON @@ -186,7 +233,7 @@ func resolveRoutePlan(endpoint, endpointType, routeName, planJSON string) (map[s return nil, nil } if endpoint == "" || endpointType == "" { - return nil, fmt.Errorf("--route-endpoint and --route-endpoint-type must be provided together") + return nil, fmt.Errorf("--route-endpoint and --route-endpoint-type must be provided together (--route-name alone does not build a route)") } return BuildRoutePlan(endpoint, endpointType, routeName) } diff --git a/cmd/vcp/route_test.go b/cmd/vcp/route_test.go index 2646317..b953506 100644 --- a/cmd/vcp/route_test.go +++ b/cmd/vcp/route_test.go @@ -93,3 +93,205 @@ func TestParseRoutePlanJSON(t *testing.T) { t.Error("unknown field accepted, want error") } } + +// --- Canonicalization gap: route type, priority, and endpoint weight are all +// user-controllable via --route-plan-json (BuildRoutePlan hardcodes them, so +// only the JSON path can vary them). RoutePlansEqual/canonicalPlanJSON must +// treat a difference in any of the three as a real difference, or the +// --replace-routes guard never fires for it. --- + +func mustParsePlan(t *testing.T, s string) map[string]interface{} { + t.Helper() + plan, err := ParseRoutePlanJSON(s) + if err != nil { + t.Fatalf("ParseRoutePlanJSON(%q) error = %v", s, err) + } + return plan +} + +func TestRoutePlansEqual_DetectsRouteTypeDifference(t *testing.T) { + weighted := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + ani := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"ANI","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + if RoutePlansEqual(weighted, ani) { + t.Error("plans differing only in route type (WEIGHTED vs ANI) reported as equal — a real failover-semantics change was missed") + } +} + +func TestRoutePlansEqual_DetectsPriorityDifference(t *testing.T) { + p1 := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + p2 := mustParsePlan(t, `{"routes":[{"priority":2,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + if RoutePlansEqual(p1, p2) { + t.Error("plans differing only in route priority reported as equal") + } +} + +func TestRoutePlansEqual_DetectsWeightDifference(t *testing.T) { + p1 := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + p2 := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":50}]}]}`) + if RoutePlansEqual(p1, p2) { + t.Error("plans differing only in endpoint weight reported as equal") + } +} + +func TestRoutePlansEqual_NumericComparisonNotStringComparison(t *testing.T) { + // BuildRoutePlan hand-builds int(1)/int(100); ParseRoutePlanJSON decodes + // float64(1)/float64(100) via encoding/json. These represent the same + // plan and must compare equal despite the differing Go types. + built, err := BuildRoutePlan("h.example.com", "FQDN", "") + if err != nil { + t.Fatalf("BuildRoutePlan() error = %v", err) + } + parsed := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + if !RoutePlansEqual(built, parsed) { + t.Error("int vs float64 priority/weight representations of the same plan reported as unequal") + } +} + +// --- resolveRoutePlan: --route-name must not be a silent no-op --- + +func TestResolveRoutePlan_RouteNameAloneErrors(t *testing.T) { + // Setting --route-name without --route-endpoint/--route-endpoint-type must + // error, not silently return (nil, nil) as if no route flags were set. + plan, err := resolveRoutePlan("", "", "custom name", "") + if err == nil { + t.Errorf("expected error for --route-name alone, got plan=%v", plan) + } +} + +func TestResolveRoutePlan_RouteNameWithPlanJSONErrors(t *testing.T) { + _, err := resolveRoutePlan("", "", "custom name", `{"routes":[]}`) + if err == nil { + t.Error("expected mutual-exclusivity error for --route-name with --route-plan-json") + } +} + +func TestResolveRoutePlan_MutualExclusivity(t *testing.T) { + _, err := resolveRoutePlan("h.example.com", "FQDN", "", `{"routes":[]}`) + if err == nil { + t.Error("expected mutual-exclusivity error combining individual flags with --route-plan-json") + } +} + +func TestResolveRoutePlan_PartialFlagsError(t *testing.T) { + if _, err := resolveRoutePlan("h.example.com", "", "", ""); err == nil { + t.Error("expected error when --route-endpoint is given without --route-endpoint-type") + } +} + +func TestResolveRoutePlan_NoFlagsReturnsNil(t *testing.T) { + plan, err := resolveRoutePlan("", "", "", "") + if err != nil || plan != nil { + t.Errorf("resolveRoutePlan() = (%v, %v), want (nil, nil)", plan, err) + } +} + +func TestResolveRoutePlan_MissingFile(t *testing.T) { + _, err := resolveRoutePlan("", "", "", "@/no/such/file-for-testing.json") + if err == nil { + t.Error("expected error reading a nonexistent --route-plan-json file") + } +} + +// --- requiresRouteReplaceConfirmation: the four --replace-routes guard +// transitions the update command relies on. --- + +func TestRequiresRouteReplaceConfirmation_EmptyExistingAllowed(t *testing.T) { + plan, _ := BuildRoutePlan("h.example.com", "FQDN", "") + if requiresRouteReplaceConfirmation(nil, plan) { + t.Error("empty existing plan should not require confirmation") + } +} + +func TestRequiresRouteReplaceConfirmation_IdenticalAllowed(t *testing.T) { + existing, _ := BuildRoutePlan("h.example.com", "FQDN", "") + plan, _ := BuildRoutePlan("h.example.com", "FQDN", "") + if requiresRouteReplaceConfirmation(existing, plan) { + t.Error("identical existing plan should not require confirmation") + } +} + +func TestRequiresRouteReplaceConfirmation_DifferentBlocked(t *testing.T) { + existing, _ := BuildRoutePlan("old.example.com", "FQDN", "") + plan, _ := BuildRoutePlan("new.example.com", "FQDN", "") + if !requiresRouteReplaceConfirmation(existing, plan) { + t.Error("different non-empty existing plan should require confirmation") + } +} + +// --- findAllByName: duplicate detection never guesses --- + +func TestFindAllByName_ZeroOneAndMultipleMatches(t *testing.T) { + list := []interface{}{ + map[string]interface{}{"name": "Prod VCP", "voiceConfigurationPackageId": "vcp-1"}, + map[string]interface{}{"name": "Prod VCP", "voiceConfigurationPackageId": "vcp-2"}, + map[string]interface{}{"name": "Other VCP", "voiceConfigurationPackageId": "vcp-3"}, + } + if got := findAllByName(list, "name", "Prod VCP"); len(got) != 2 { + t.Errorf("findAllByName() = %d matches, want 2", len(got)) + } + if got := findAllByName(list, "name", "Other VCP"); len(got) != 1 { + t.Errorf("findAllByName() = %d matches, want 1", len(got)) + } + if got := findAllByName(list, "name", "No Such VCP"); len(got) != 0 { + t.Errorf("findAllByName() = %d matches, want 0", len(got)) + } +} + +// --- vcpConflict: --if-not-exists must compare, not guess --- + +func TestVCPConflict_NoFlagsSpecifiedAlwaysCompatible(t *testing.T) { + existing := map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-1", + "description": "something else entirely", + "httpVoiceV2ApplicationId": "other-app", + } + // Neither --description nor --app-id was passed, and no route plan was + // requested: nothing the caller specified conflicts. + if err := vcpConflict(existing, "Prod VCP", false, "", false, "", nil); err != nil { + t.Errorf("vcpConflict() = %v, want nil when caller specified no comparable fields", err) + } +} + +func TestVCPConflict_DescriptionMismatch(t *testing.T) { + existing := map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-1", + "description": "existing description", + } + if err := vcpConflict(existing, "Prod VCP", true, "requested description", false, "", nil); err == nil { + t.Error("expected conflict error for mismatched description") + } + if err := vcpConflict(existing, "Prod VCP", true, "existing description", false, "", nil); err != nil { + t.Errorf("matching description should not conflict, got %v", err) + } +} + +func TestVCPConflict_AppIDMismatch(t *testing.T) { + existing := map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-1", + "httpVoiceV2ApplicationId": "app-existing", + } + if err := vcpConflict(existing, "Prod VCP", false, "", true, "app-requested", nil); err == nil { + t.Error("expected conflict error for mismatched app ID") + } + if err := vcpConflict(existing, "Prod VCP", false, "", true, "app-existing", nil); err != nil { + t.Errorf("matching app ID should not conflict, got %v", err) + } +} + +func TestVCPConflict_AbsentNullEmptyAreEquivalent(t *testing.T) { + existing := map[string]interface{}{"voiceConfigurationPackageId": "vcp-1"} // description absent + if err := vcpConflict(existing, "Prod VCP", true, "", false, "", nil); err != nil { + t.Errorf("absent existing description vs requested \"\" should not conflict, got %v", err) + } +} + +func TestVCPConflict_RoutePlanMismatch(t *testing.T) { + existing := map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-1", + "originationRoutePlan": nil, + } + plan, _ := BuildRoutePlan("h.example.com", "FQDN", "") + if err := vcpConflict(existing, "Prod VCP", false, "", false, "", plan); err == nil { + t.Error("expected conflict error when existing plan is empty but caller requested a route") + } +} diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go index 9cf5db6..6b77efb 100644 --- a/cmd/vcp/update.go +++ b/cmd/vcp/update.go @@ -128,7 +128,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // whenever a non-empty plan would be overwritten with something different. // This is a read-then-write check, so it is best-effort against // concurrent modification of the VCP between the GET and the PATCH. - if !isEmptyPlan(existingPlan) && !RoutePlansEqual(existingPlan, plan) && !updateReplaceRoutes { + if requiresRouteReplaceConfirmation(existingPlan, plan) && !updateReplaceRoutes { return fmt.Errorf("VCP %s already has an origination route plan; writing a new one replaces it — re-run with --replace-routes to confirm", vcpID) } body["originationRoutePlan"] = plan From 6a86a30598015c6b457b0d189c20e0df59617fe1 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 28 Jul 2026 16:52:13 -0400 Subject: [PATCH 16/25] fix(vcp): fold --replace-routes override into the guard function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requiresRouteReplaceConfirmation previously only captured the "would this destroy data" half of the guard; the "&& !updateReplaceRoutes" override lived as a bare boolean expression in runUpdate, untested and outside the extracted function. Move the override inside the function so the complete decision — including whether --replace-routes was honored — is one unit-tested call, and add the fourth guard transition (non-empty existing plan, different requested plan, --replace-routes set -> write proceeds) that was previously uncovered. --- cmd/vcp/route.go | 21 +++++++++++++-------- cmd/vcp/route_test.go | 22 ++++++++++++++++------ cmd/vcp/update.go | 2 +- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/cmd/vcp/route.go b/cmd/vcp/route.go index 1031b0b..43ba266 100644 --- a/cmd/vcp/route.go +++ b/cmd/vcp/route.go @@ -197,14 +197,19 @@ func toFloat(v interface{}) float64 { } } -// requiresRouteReplaceConfirmation reports whether writing plan over -// existingPlan would silently destroy a route plan the caller didn't -// explicitly ask to replace: true only when existingPlan is non-empty AND -// structurally different from plan. An empty existing plan (nothing to -// destroy) or an identical one (no-op write) never requires --replace-routes. -// Pulled out of runUpdate so the four guard transitions are unit testable -// without a live (or faked) HTTP client. -func requiresRouteReplaceConfirmation(existingPlan, plan interface{}) bool { +// requiresRouteReplaceConfirmation reports whether the write must be refused: +// true only when a non-empty existing plan would be replaced by a different +// one AND the caller has not passed --replace-routes. An empty existing plan +// (nothing to destroy), an identical one (no-op write), or an explicit +// replaceConfirmed=true (the caller's override) all return false. The +// override is folded in here — not left as a separate "&&" at the call +// site — so the complete guard decision, including whether --replace-routes +// was honored, is a single unit-testable function rather than a boolean +// expression split across this file and runUpdate. +func requiresRouteReplaceConfirmation(existingPlan, plan interface{}, replaceConfirmed bool) bool { + if replaceConfirmed { + return false + } return !isEmptyPlan(existingPlan) && !RoutePlansEqual(existingPlan, plan) } diff --git a/cmd/vcp/route_test.go b/cmd/vcp/route_test.go index b953506..8e28b32 100644 --- a/cmd/vcp/route_test.go +++ b/cmd/vcp/route_test.go @@ -193,11 +193,13 @@ func TestResolveRoutePlan_MissingFile(t *testing.T) { } // --- requiresRouteReplaceConfirmation: the four --replace-routes guard -// transitions the update command relies on. --- +// transitions the update command relies on, including the --replace-routes +// override itself (previously a bare "&& !updateReplaceRoutes" left outside +// the function and untested). --- func TestRequiresRouteReplaceConfirmation_EmptyExistingAllowed(t *testing.T) { plan, _ := BuildRoutePlan("h.example.com", "FQDN", "") - if requiresRouteReplaceConfirmation(nil, plan) { + if requiresRouteReplaceConfirmation(nil, plan, false) { t.Error("empty existing plan should not require confirmation") } } @@ -205,16 +207,24 @@ func TestRequiresRouteReplaceConfirmation_EmptyExistingAllowed(t *testing.T) { func TestRequiresRouteReplaceConfirmation_IdenticalAllowed(t *testing.T) { existing, _ := BuildRoutePlan("h.example.com", "FQDN", "") plan, _ := BuildRoutePlan("h.example.com", "FQDN", "") - if requiresRouteReplaceConfirmation(existing, plan) { + if requiresRouteReplaceConfirmation(existing, plan, false) { t.Error("identical existing plan should not require confirmation") } } -func TestRequiresRouteReplaceConfirmation_DifferentBlocked(t *testing.T) { +func TestRequiresRouteReplaceConfirmation_DifferentBlockedWithoutFlag(t *testing.T) { existing, _ := BuildRoutePlan("old.example.com", "FQDN", "") plan, _ := BuildRoutePlan("new.example.com", "FQDN", "") - if !requiresRouteReplaceConfirmation(existing, plan) { - t.Error("different non-empty existing plan should require confirmation") + if !requiresRouteReplaceConfirmation(existing, plan, false) { + t.Error("different non-empty existing plan should require confirmation when --replace-routes is not set") + } +} + +func TestRequiresRouteReplaceConfirmation_DifferentAllowedWithFlag(t *testing.T) { + existing, _ := BuildRoutePlan("old.example.com", "FQDN", "") + plan, _ := BuildRoutePlan("new.example.com", "FQDN", "") + if requiresRouteReplaceConfirmation(existing, plan, true) { + t.Error("--replace-routes=true should allow overwriting a different non-empty existing plan") } } diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go index 6b77efb..cbe1f52 100644 --- a/cmd/vcp/update.go +++ b/cmd/vcp/update.go @@ -128,7 +128,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // whenever a non-empty plan would be overwritten with something different. // This is a read-then-write check, so it is best-effort against // concurrent modification of the VCP between the GET and the PATCH. - if requiresRouteReplaceConfirmation(existingPlan, plan) && !updateReplaceRoutes { + if requiresRouteReplaceConfirmation(existingPlan, plan, updateReplaceRoutes) { return fmt.Errorf("VCP %s already has an origination route plan; writing a new one replaces it — re-run with --replace-routes to confirm", vcpID) } body["originationRoutePlan"] = plan From 429583d64bc2d6f692885d405fa94d42cfe928ff Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 09:56:20 -0400 Subject: [PATCH 17/25] feat(sip): add band sip status probe and tri-state sip capability auth status stays offline and can only ever know whether the SIP Credentials role is present, not whether the account itself is enabled for SIP -- that requires a live probe. Report SIP as a separate {"status","reason"} object (sip) alongside the existing boolean capabilities map rather than overloading it, and add `band sip status` as the explicit, non-persisted probe that resolves the offline `unknown`. --- AGENTS.md | 30 ++++++++++++- README.md | 1 + cmd/auth/auth_test.go | 13 ++++++ cmd/auth/status.go | 45 ++++++++++++++++++- cmd/sip/sip.go | 5 ++- cmd/sip/status.go | 44 +++++++++++++++++++ cmd/sip/status_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 cmd/sip/status.go create mode 100644 cmd/sip/status_test.go diff --git a/AGENTS.md b/AGENTS.md index ce2a13f..148160c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,10 +53,38 @@ If your credentials are not bound to a specific account, the CLI will prompt you `band auth status --plain` returns structured JSON describing what the active account can do. The two fields agents care about most: - **`build: true`** — this is a Bandwidth Build account. Voice-only, credit-based. Messaging, number ordering, sub-accounts, VCPs, 10DLC, and toll-free verification are not available; commands targeting those exit with code 4 and a clear message pointing at the upgrade path. -- **`capabilities`** — a derived map (`voice`, `messaging`, `numbers`, `vcp`, `campaign_management`, `tfv`, `app_management`) flipping `true`/`false` based on the credential's roles. Use this to gate work locally rather than discovering limits via 4xx errors. +- **`capabilities`** — a derived map (`voice`, `messaging`, `numbers`, `vcp`, `campaign_management`, `tfv`, `app_management`) flipping `true`/`false` based on the credential's roles. Use this to gate work locally rather than discovering limits via 4xx errors. This map is unchanged by SIP support — it stays boolean-only. Branch on these before attempting feature-gated work. The CLI also fails fast at the moment you try a restricted command, but checking capabilities up front avoids wasted setup. +#### SIP capability (tri-state, not boolean) + +SIP provisioning (`band sip realm ...`, `band sip credential ...`) needs **two** things: the `SIP Credentials` role on the credential, and account-level SIP configuration on the backend. Only the role is knowable from the JWT offline — the account-level setting can only be confirmed by calling the API. A plain boolean would conflate "don't know," "missing the role," and "has the role but the account isn't enabled," so `band auth status --plain` reports SIP as its own object instead of folding it into `capabilities`: + +```json +"sip": { "status": "unknown", "reason": "role_present_not_probed" } +``` + +`status` is one of `available`, `unavailable`, or `unknown`. `reason` is a stable enumerated identifier — branch on it, not on prose: + +| `reason` | `status` | Meaning | +|----------|----------|---------| +| `role_absent` | `unavailable` | Credential lacks the `SIP Credentials` role. | +| `role_present_not_probed` | `unknown` | Credential has the role, but `auth status` is offline and cannot confirm account-level configuration. | +| `account_not_enabled` | `unavailable` | Only returned by `band sip status` — the account has the role but SIP Credentials isn't enabled on the account. Contact Bandwidth support. | +| `probe_succeeded` | `available` | Only returned by `band sip status` — the account can use SIP provisioning. | +| `probe_failed` | `unknown` | Only returned by `band sip status` — the probe itself failed (e.g. rate limited or a server error); retry later. | + +`band auth status` never calls the network, so it can only ever report `role_absent` or `role_present_not_probed` for `sip`. To resolve an `unknown`, run the explicit probe: + +```bash +band sip status --plain +``` + +This issues one cheap `GET /realms` call. A `200` reports `available`/`probe_succeeded` (exit 0). Hitting error code `33004` ("account isn't setup for Sip Credentials") reports `unavailable`/`account_not_enabled` — and **exits 0**, because a successful probe that confirms a negative fact is not a command failure. Auth errors (401/403) exit 2 via the normal error path; rate limiting or server errors exit non-zero with `probe_failed`. + +Important: `band sip status` **does not persist** its result anywhere. Run it again any time you need a fresh answer, and don't expect `band auth status` to start reporting anything other than `unknown` for a role-holding credential — that command stays fully offline by design. + ### Account Hint When multiple accounts or profiles are active, commands write a hint to stderr so you know which account is being targeted: diff --git a/README.md b/README.md index 4dcf000..573a32d 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band sip credential list --realm ` | List a realm's credentials | | `band sip credential get --realm ` | Get one credential | | `band sip credential delete --realm ` | Delete a credential | +| `band sip status` | Probe whether this account can use SIP provisioning (resolves the `unknown` capability from `band auth status`) | ### Other diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 3275abc..1d51496 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -157,6 +157,19 @@ func TestParseJWTClaimsInvalidPayload(t *testing.T) { } } +func TestSIPCapability(t *testing.T) { + // capabilities stays map[string]bool — a tri-state cannot live inside it, + // so SIP is reported as a separate typed object. + got := sipCapability(true) + if got["status"] != "unknown" || got["reason"] != "role_present_not_probed" { + t.Errorf("sipCapability(true) = %v, want unknown/role_present_not_probed", got) + } + got = sipCapability(false) + if got["status"] != "unavailable" || got["reason"] != "role_absent" { + t.Errorf("sipCapability(false) = %v, want unavailable/role_absent", got) + } +} + // TestRunSwitch_PersistsTargetIntoActiveProfile guards against the bug where // switch only updated the legacy top-level cfg.AccountID, leaving the active // profile's AccountID stale — so subsequent commands continued targeting the diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 8828388..104a6aa 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -39,7 +39,11 @@ type statusJSON struct { Build bool `json:"build,omitempty"` Roles []string `json:"roles,omitempty"` Capabilities map[string]bool `json:"capabilities,omitempty"` - Error string `json:"error,omitempty"` + // SIP reports SIP provisioning availability as a tri-state object + // ({"status":..., "reason":...}) rather than a bool inside Capabilities — + // see sipCapability. + SIP map[string]string `json:"sip,omitempty"` + Error string `json:"error,omitempty"` } func runStatus(cmd *cobra.Command, args []string) error { @@ -88,6 +92,7 @@ func runStatus(cmd *cobra.Command, args []string) error { Build: p.Build, Roles: p.Roles, Capabilities: Capabilities(p.Roles), + SIP: sipCapability(hasRole(p.Roles, "sip credentials")), } if keychainErr != nil { out.Error = "credentials not found in keychain" @@ -122,6 +127,7 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Printf("Type: %s (voice-only, credit-based)\n", ui.Bold("Bandwidth Build")) fmt.Printf("Capable of: %s\n", capabilitySummary(Capabilities(p.Roles))) } + fmt.Printf("SIP: %s\n", sipSummary(sipCapability(hasRole(p.Roles, "sip credentials")))) if env != "prod" || cfg.HasMultipleEnvironments() { fmt.Printf("Environment: %s\n", env) } @@ -179,6 +185,43 @@ func Capabilities(roles []string) map[string]bool { return caps } +// hasRole reports whether any role string in roles contains substr, +// case-insensitively — the same matching style Capabilities uses. +func hasRole(roles []string, substr string) bool { + for _, r := range roles { + if strings.Contains(strings.ToLower(r), substr) { + return true + } + } + return false +} + +// sipCapability reports SIP provisioning availability as a tri-state. SIP needs +// both the "SIP Credentials" role and account-level SipCredentialSettings, and +// only the role is knowable offline — so a boolean would be misleading. +// Reasons are stable identifiers, not prose: role_absent, +// role_present_not_probed, account_not_enabled, probe_failed. +func sipCapability(hasRole bool) map[string]string { + if !hasRole { + return map[string]string{"status": "unavailable", "reason": "role_absent"} + } + return map[string]string{"status": "unknown", "reason": "role_present_not_probed"} +} + +// sipSummary renders the offline SIP tri-state for the human-readable auth +// status output. reason values are internal identifiers (see sipCapability); +// only this function turns them into prose. +func sipSummary(sip map[string]string) string { + switch sip["reason"] { + case "role_absent": + return ui.Muted("not available (missing SIP Credentials role)") + case "role_present_not_probed": + return ui.Muted("unknown — run 'band sip status' to check") + default: + return sip["status"] + } +} + // capabilitySummary renders a capability map as a "have / not" line // for the human-readable auth status output on Build accounts. func capabilitySummary(caps map[string]bool) string { diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index 6145bf0..9c628d5 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -36,7 +36,10 @@ var Cmd = &cobra.Command{ band sip credential rotate 870880 --realm vapi --generate-password --plain band sip credential list --realm vapi --plain band sip credential get 870880 --realm vapi --plain - band sip credential delete 870880 --realm vapi --plain`, + band sip credential delete 870880 --realm vapi --plain + + # Status + band sip status --plain`, } // emit is the single output path for every `band sip` command. Routing all diff --git a/cmd/sip/status.go b/cmd/sip/status.go new file mode 100644 index 0000000..fe4258e --- /dev/null +++ b/cmd/sip/status.go @@ -0,0 +1,44 @@ +package sip + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/cmdutil" + sipsvc "github.com/Bandwidth/cli/internal/sip" +) + +func init() { Cmd.AddCommand(statusCmd) } + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Check whether this account can use SIP provisioning", + Long: "Probes the SIP API to resolve the 'unknown' capability reported by 'band auth status'. " + + "SIP provisioning requires both the SIP Credentials role and account-level configuration; " + + "only the probe can confirm the latter. The result is not cached — 'band auth status' stays offline.", + Example: ` band sip status --plain`, + RunE: func(cmd *cobra.Command, args []string) error { + svc, err := service(cmd) + if err != nil { + return err + } + format, plain := cmdutil.OutputFlags(cmd) + + if _, err := svc.ListRealms(); err != nil { + var fault *sipsvc.APIFault + if errors.As(err, &fault) && fault.Code == "33004" { + // A successful probe reporting a negative fact: exit 0. + return emit(format, plain, map[string]string{ + "status": "unavailable", + "reason": "account_not_enabled", + }) + } + return faultExit(err) + } + return emit(format, plain, map[string]string{ + "status": "available", + "reason": "probe_succeeded", + }) + }, +} diff --git a/cmd/sip/status_test.go b/cmd/sip/status_test.go new file mode 100644 index 0000000..04dcf07 --- /dev/null +++ b/cmd/sip/status_test.go @@ -0,0 +1,99 @@ +package sip + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/testutil" +) + +// statusStubServer returns a stub that answers GET /realms (the cheap probe +// `band sip status` issues) either with a successful realms list or with the +// documented 33004 "account not enabled for SIP" fault, depending on +// wantAccountNotEnabled. +func statusStubServer(t *testing.T, wantAccountNotEnabled bool) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/realms") { + w.WriteHeader(404) + return + } + if wantAccountNotEnabled { + w.WriteHeader(400) + w.Write([]byte(`33004` + + `Your account isn't setup for Sip Credentials` + + ``)) + return + } + w.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + })) +} + +// TestSIPStatus_ProbeSucceeded covers the 200 path: a successful probe reports +// status=available with the stable reason "probe_succeeded", and — critically +// for scripts/agents — exits 0 (no error returned). +func TestSIPStatus_ProbeSucceeded(t *testing.T) { + srv := statusStubServer(t, false) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(statusCmd) + root.SetArgs([]string{"status", "--plain"}) + + var out string + var err error + out = testutil.CaptureStdout(t, func() { + err = root.Execute() + }) + if err != nil { + t.Fatalf("Execute() error = %v, want nil", err) + } + + var got map[string]string + if unmarshalErr := json.Unmarshal([]byte(out), &got); unmarshalErr != nil { + t.Fatalf("output is not JSON: %q (%v)", out, unmarshalErr) + } + if got["status"] != "available" { + t.Errorf("status = %q, want %q", got["status"], "available") + } + if got["reason"] != "probe_succeeded" { + t.Errorf("reason = %q, want the stable identifier %q", got["reason"], "probe_succeeded") + } +} + +// TestSIPStatus_AccountNotEnabled covers the 33004 path: this is a successful +// probe reporting a negative fact about account configuration, not a command +// failure, so it must exit 0 (err == nil) even though status is "unavailable". +func TestSIPStatus_AccountNotEnabled(t *testing.T) { + srv := statusStubServer(t, true) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(statusCmd) + root.SetArgs([]string{"status", "--plain"}) + + var out string + var err error + out = testutil.CaptureStdout(t, func() { + err = root.Execute() + }) + if err != nil { + t.Fatalf("Execute() error = %v, want nil (a 33004 probe result is not a command failure)", err) + } + + var got map[string]string + if unmarshalErr := json.Unmarshal([]byte(out), &got); unmarshalErr != nil { + t.Fatalf("output is not JSON: %q (%v)", out, unmarshalErr) + } + if got["status"] != "unavailable" { + t.Errorf("status = %q, want %q", got["status"], "unavailable") + } + if got["reason"] != "account_not_enabled" { + t.Errorf("reason = %q, want the stable identifier %q", got["reason"], "account_not_enabled") + } +} From c22549bbfdd319f21e253a0a99d1c798f41513a5 Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 10:05:11 -0400 Subject: [PATCH 18/25] fix(sip): emit probe_failed JSON on rate-limit/server-error, test hasRole band sip status is the probe result itself, not just a mutation that either succeeds or fails -- so 429/5xx must still surface {"status":"unknown","reason":"probe_failed"} on stdout even though the command exits non-zero, or the reason value documented in AGENTS.md is unreachable. 401/403 stay on the plain error path since those are auth failures, not a fact about SIP availability. Also add direct coverage for hasRole against the exact "SIP Credentials" JWT role string and a realistic mixed-role slice, mirroring TestCapabilities' fixture style. --- cmd/auth/auth_test.go | 41 +++++++++++++++++++++++++++ cmd/sip/status.go | 17 +++++++++++ cmd/sip/status_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 1d51496..962503e 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -170,6 +170,47 @@ func TestSIPCapability(t *testing.T) { } } +// TestHasRole guards the matcher that decides whether sipCapability reports +// role_absent vs. role_present_not_probed. It mirrors TestCapabilities' +// fixture style, using the exact "SIP Credentials" string as it appears in +// the JWT roles array — a near-miss in casing or spacing here would silently +// report role_absent for an account that genuinely holds the role. +func TestHasRole(t *testing.T) { + tests := []struct { + name string + roles []string + want bool + }{ + { + name: "exact role string as it appears in the JWT", + roles: []string{"SIP Credentials"}, + want: true, + }, + { + name: "realistic full role slice with SIP present", + roles: []string{"HTTP Application Management", "HttpVoice", "SIP Credentials", "brtcAccessRole"}, + want: true, + }, + { + name: "realistic full role slice without SIP", + roles: []string{"HTTP Application Management", "HttpVoice", "brtcAccessRole"}, + want: false, + }, + { + name: "empty roles", + roles: nil, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasRole(tt.roles, "sip credentials"); got != tt.want { + t.Errorf("hasRole(%v, %q) = %v, want %v", tt.roles, "sip credentials", got, tt.want) + } + }) + } +} + // TestRunSwitch_PersistsTargetIntoActiveProfile guards against the bug where // switch only updated the legacy top-level cfg.AccountID, leaving the active // profile's AccountID stale — so subsequent commands continued targeting the diff --git a/cmd/sip/status.go b/cmd/sip/status.go index fe4258e..1691c5a 100644 --- a/cmd/sip/status.go +++ b/cmd/sip/status.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" sipsvc "github.com/Bandwidth/cli/internal/sip" ) @@ -34,6 +35,22 @@ var statusCmd = &cobra.Command{ "reason": "account_not_enabled", }) } + // Rate limiting and server errors are probe results, not just + // failures to announce: the probe's job IS to report SIP + // availability, so a caller branching on stable JSON fields needs + // "unknown"/"probe_failed" on stdout even though this exits + // non-zero. 401/403 are deliberately excluded — those are auth + // failures, not a fact about SIP availability, so they fall + // straight through to the normal error path below. + var apiErr *api.APIError + if errors.As(err, &apiErr) && (apiErr.StatusCode == 429 || apiErr.StatusCode >= 500) { + if emitErr := emit(format, plain, map[string]string{ + "status": "unknown", + "reason": "probe_failed", + }); emitErr != nil { + return emitErr + } + } return faultExit(err) } return emit(format, plain, map[string]string{ diff --git a/cmd/sip/status_test.go b/cmd/sip/status_test.go index 04dcf07..293e90b 100644 --- a/cmd/sip/status_test.go +++ b/cmd/sip/status_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/testutil" ) @@ -97,3 +98,66 @@ func TestSIPStatus_AccountNotEnabled(t *testing.T) { t.Errorf("reason = %q, want the stable identifier %q", got["reason"], "account_not_enabled") } } + +// statusStubServerWithCode returns a stub that answers GET /realms with the +// given non-2xx status code and a plain (non-fault-envelope) body, simulating +// rate limiting (429) or a server error (5xx) rather than a documented +// Bandwidth fault code. +func statusStubServerWithCode(t *testing.T, code int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/realms") { + w.WriteHeader(404) + return + } + w.WriteHeader(code) + w.Write([]byte("boom")) + })) +} + +// TestSIPStatus_ProbeFailed_RateLimited and TestSIPStatus_ProbeFailed_ServerError +// cover the probe_failed path: the probe's job IS to report SIP availability, +// so even though these outcomes exit non-zero, an agent branching on stable +// JSON fields still needs "unknown"/"probe_failed" on stdout rather than only +// a raw error string. Both assert the JSON body AND the exit code together — +// that pairing (JSON on stdout *and* a non-zero exit) is the point. +func TestSIPStatus_ProbeFailed_RateLimited(t *testing.T) { + testSIPStatusProbeFailed(t, http.StatusTooManyRequests, cmdutil.ExitRateLimit) +} + +func TestSIPStatus_ProbeFailed_ServerError(t *testing.T) { + testSIPStatusProbeFailed(t, http.StatusInternalServerError, cmdutil.ExitGeneral) +} + +func testSIPStatusProbeFailed(t *testing.T, httpStatus, wantExitCode int) { + t.Helper() + srv := statusStubServerWithCode(t, httpStatus) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(statusCmd) + root.SetArgs([]string{"status", "--plain"}) + + var out string + var err error + out = testutil.CaptureStdout(t, func() { + err = root.Execute() + }) + if err == nil { + t.Fatalf("Execute() error = nil, want a non-nil error for HTTP %d", httpStatus) + } + if got := cmdutil.ExitCodeForError(err); got != wantExitCode { + t.Errorf("ExitCodeForError() = %d, want %d for HTTP %d: err = %v", got, wantExitCode, httpStatus, err) + } + + var gotJSON map[string]string + if unmarshalErr := json.Unmarshal([]byte(out), &gotJSON); unmarshalErr != nil { + t.Fatalf("output is not JSON: %q (%v)", out, unmarshalErr) + } + if gotJSON["status"] != "unknown" { + t.Errorf("status = %q, want %q", gotJSON["status"], "unknown") + } + if gotJSON["reason"] != "probe_failed" { + t.Errorf("reason = %q, want the stable identifier %q", gotJSON["reason"], "probe_failed") + } +} From 12485110f2e2ffd1e3d42aa0c9ead026e5211564 Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 10:12:28 -0400 Subject: [PATCH 19/25] test(sip): lock the --plain output contract for realm/credential commands Adds cmd/sip/golden_test.go with command-level golden tests driving the real cobra commands through the service seam: realm get field presence and real JSON types (default as bool, credentialCount as number, not XML-stringified), realm list rendering zero realms as [] not null, credential list stripping Hash1/Hash1b while carrying id/realmId/ username/hostname/appId, and realm delete's accepted-vs-completed shape without --wait. Coverage that already existed (status shapes, emitCredential password inclusion/omission, faultExit exit-code preservation) is intentionally not duplicated. The delete test caught a real defect: realm_delete.go set `"deleted": !realmDeleteWait`, so a plain `band sip realm delete` (no --wait) reported deleted:true immediately after the API's 202 Accepted, contradicting the command's own documented async-delete contract. Fixed to start deleted:false unconditionally; --wait still promotes it to true once polling confirms the realm is actually gone. --- cmd/sip/golden_test.go | 178 ++++++++++++++++++++++++++++++++++++++++ cmd/sip/realm_delete.go | 5 +- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 cmd/sip/golden_test.go diff --git a/cmd/sip/golden_test.go b/cmd/sip/golden_test.go new file mode 100644 index 0000000..b2f2c25 --- /dev/null +++ b/cmd/sip/golden_test.go @@ -0,0 +1,178 @@ +// golden_test.go locks the --plain output contract for `band sip` commands: +// the exact JSON shape agents parse. Every other test in this package covers +// logic; these assert what a command actually PRINTS — field presence, real +// JSON types surviving the XML layer (not stringified scalars), empty lists +// rendering as [] rather than null, and digest hashes never reaching output. +// +// Coverage already exists elsewhere in this package for: all four `band sip +// status` output shapes (status_test.go), the emitCredential password +// inclusion/omission shapes (credential_create_test.go's +// TestEmitCredential_IncludesGeneratedPasswordExactlyOnce and +// TestEmitCredential_OmitsCallerSuppliedPassword), and all faultExit / +// realmReuseAllowed / realmStateMatches paths (sip_test.go). This file does +// not duplicate those. +package sip + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/testutil" +) + +// runCmd drives a real cobra command through a minimal root and returns +// everything written to stdout. t.Fatal on a non-nil Execute error, since +// every golden test below expects success. +func runCmd(t *testing.T, c *cobra.Command, args ...string) string { + t.Helper() + root := testutil.NewTestRoot(c) + root.SetArgs(args) + return testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + }) +} + +const realmXML = `` + + `1103vapi-3efeaa.auth.bandwidth.com` + + `dfalse` + + `2ACTIVE` + + `` + +// TestRealmGetPlainShape guards field presence AND real JSON types: the SIP +// API is XML-only, and the older generic XML helper stringified everything. +// "default": "false" (a string) instead of false (a bool) is a live, +// realistic regression this must catch. +func TestRealmGetPlainShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(realmXML)) + })) + defer srv.Close() + withStubService(t, srv) + + out := runCmd(t, realmGetCmd, "get", "vapi", "--plain") + + var got map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil { + t.Fatalf("not JSON: %q (%v)", out, err) + } + // All three identifiers must be present: any one is valid input to the + // next command (band sip realm get/update/delete, credential list, ...). + for _, k := range []string{"id", "name", "hostname", "default", "status", "credentialCount", "description"} { + if _, ok := got[k]; !ok { + t.Errorf("missing field %q in %q", k, out) + } + } + if got["hostname"] != "vapi-3efeaa.auth.bandwidth.com" { + t.Errorf("hostname = %v, want the FQDN verbatim from the API", got["hostname"]) + } + if got["name"] != "vapi" { + t.Errorf("name = %v, want vapi (derived short label)", got["name"]) + } + // Booleans and counts must survive the XML layer as real types, not strings. + if _, ok := got["default"].(bool); !ok { + t.Errorf("default = %T (%v), want bool", got["default"], got["default"]) + } + if _, ok := got["credentialCount"].(float64); !ok { + t.Errorf("credentialCount = %T (%v), want number", got["credentialCount"], got["credentialCount"]) + } +} + +// TestRealmListEmptyIsArrayNotNull guards the classic XML-to-JSON regression: +// an account with zero realms must render as [], not null, or every script +// piping this into jq or a JSON array iterator breaks. +func TestRealmListEmptyIsArrayNotNull(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(``)) + })) + defer srv.Close() + withStubService(t, srv) + + out := strings.TrimSpace(runCmd(t, realmListCmd, "list", "--plain")) + + var got []map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("empty list is not a JSON array: %q (%v)", out, err) + } + if got == nil { + t.Errorf("empty list rendered as null, want []: %q", out) + } +} + +// TestCredentialListNoHashesAndFieldShape guards two things at once: that +// Hash1/Hash1b digest material never reaches --plain output (the stub +// deliberately echoes both, so a stub without them would make this +// vacuous), and that each item carries the field set the next command +// (credential get/delete/rotate) needs. +func TestCredentialListNoHashesAndFieldShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The realm lookup credential list issues first. + if strings.HasSuffix(r.URL.Path, "/realms/vapi") { + w.Write([]byte(realmXML)) + return + } + // The live API answers an unpaginated credential list with a 303. + if r.URL.Query().Get("page") == "" { + http.Redirect(w, r, r.URL.Path+"?page=1&size=500", http.StatusSeeOther) + return + } + w.Write([]byte(`` + + `8708741103agent` + + `1be6abcaa8e9956021d30f33a3925b99` + + `e028e6577a0bb1b90a33d30a110dbdfe` + + `vapi-3efeaa.auth.bandwidth.com` + + ``)) + })) + defer srv.Close() + withStubService(t, srv) + + out := runCmd(t, credentialListCmd, "list", "--realm", "vapi", "--plain") + + if strings.Contains(out, "1be6abcaa8e9956021d30f33a3925b99") || + strings.Contains(strings.ToLower(out), "hash1") { + t.Fatalf("digest hash reached --plain output: %q", out) + } + var got []map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil { + t.Fatalf("not a JSON array: %q (%v)", out, err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + for _, k := range []string{"id", "realmId", "username", "hostname", "appId"} { + if _, ok := got[0][k]; !ok { + t.Errorf("missing field %q in %q", k, out) + } + } +} + +// TestRealmDeleteAcceptedShape guards the accepted-vs-completed shape without +// --wait. The live API returns 202 with an empty body for delete, so the stub +// matches that exactly rather than a synthetic body. +func TestRealmDeleteAcceptedShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(202) + })) + defer srv.Close() + withStubService(t, srv) + + out := runCmd(t, realmDeleteCmd, "delete", "vapi", "--plain") + + var got map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil { + t.Fatalf("not JSON: %q (%v)", out, err) + } + if got["accepted"] != true { + t.Errorf("accepted = %v, want true", got["accepted"]) + } + if got["deleted"] != false { + t.Errorf("deleted = %v, want false without --wait", got["deleted"]) + } +} diff --git a/cmd/sip/realm_delete.go b/cmd/sip/realm_delete.go index d70db20..4296263 100644 --- a/cmd/sip/realm_delete.go +++ b/cmd/sip/realm_delete.go @@ -39,7 +39,10 @@ var realmDeleteCmd = &cobra.Command{ return faultExit(err) } format, plain := cmdutil.OutputFlags(cmd) - result := map[string]interface{}{"id": ref, "deleted": !realmDeleteWait, "accepted": true} + // A 202 only means the delete was accepted, not that it completed — + // deleted starts false regardless of --wait and is only promoted to + // true below once polling confirms the realm is actually gone. + result := map[string]interface{}{"id": ref, "deleted": false, "accepted": true} if realmDeleteWait { if _, err := cmdutil.Poll(cmdutil.PollConfig{ From f7ed827250aca811068b5643b35973cec3bfb16c Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 10:24:36 -0400 Subject: [PATCH 20/25] docs(sip): document provisioning chain, exit 8, and credential recovery paths AGENTS.md was missing the SIP prerequisite chain, write-once credential behavioral notes, and timeout-recovery rows for realm create/delete. Exit code 8 (ExitSecretUnavailable) was undocumented in both AGENTS.md and README.md, and README's SIP realm table rows omitted --wait, --if-not-exists, --timeout, and --description. --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ README.md | 7 ++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 148160c..0689bca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,30 @@ For full flag/argument reference, use `band --help`. This section cove - **`vcp delete` fails if numbers are assigned.** Move them first with `vcp assign `. - **`vcp assign` is an upsert.** Numbers already on another VCP are moved, not duplicated. +### SIP + +- **A generated SIP password is shown exactly once.** `sip credential create --generate-password` prints a + `password` field with `passwordShownOnce: true`. It cannot be retrieved later — `sip credential get` never + returns it. If it is lost, run `sip credential rotate --realm `; this **preserves the + credential ID**, so peers referencing it keep working. +- **Prefer `--password-stdin`.** When the caller owns the secret, retries are safe and the password is not + echoed back (`passwordShownOnce: false`, no `password` field in the response). With `--generate-password`, + `--if-not-exists` against an existing credential exits **8** (`ExitSecretUnavailable`) because the stored + password cannot be recovered — an agent must not treat that as success. +- **Exactly one of `--password-stdin`, `--password-file`, or `--generate-password` is required.** There is + deliberately no `--password` flag — passing a secret via argv leaks it through shell history, process + listings, CI logs, and agent transcripts. +- **A credential's username and application binding are immutable.** Changing either requires delete + + recreate; `sip credential rotate` only replaces the password. +- **`sip realm delete` fails while credentials exist** (error 12666) — delete the credentials first. The + account's **default realm cannot be deleted** (error 33006) — promote another realm first with + `sip realm update --default=true`. +- **Realm `ACTIVE` does not guarantee the FQDN resolves in public DNS yet.** If the far end reports an + unresolvable host immediately after creation, retry shortly. +- **`sip realm delete` without `--wait` reports `accepted: true, deleted: false`.** A 202 means the delete + was accepted, not that it completed. Only `--wait` promotes `deleted` to `true`, after confirming the realm + is actually gone. Don't treat a bare delete as teardown-complete. + ### Quickstart - **Agents should prefer the step-by-step provisioning workflows over `band quickstart`.** Quickstart creates real resources that cost money (it orders a phone number). The default (VCP) path is idempotent — re-running reuses existing resources via find-or-create and will not order a second number — and on failure it prints the resource IDs created so far (`status: partial`, see below). Re-running reuses the app/VCP/sub-account/location — but a number that was ordered and then failed to assign to the VCP is NOT auto-reassigned; finish it with `band vcp assign `. The `--legacy` path is NOT idempotent (re-running it may order an additional number). Because quickstart bundles several steps behind one command, prefer the step-by-step provisioning workflows in the [Agent Workflows](#agent-workflows) section when you need per-step structured output or fine-grained control. @@ -250,6 +274,8 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the | `number activate --wait` / `number deactivate --wait` | Service activation order may still be RECEIVED/PROCESSING | Check `band number get --plain` — the `inboundActivated` / `outbound*Activated` flags reflect the terminal state. Re-running the same activate is idempotent. | | `call create --wait` | Call may still be active | Check `band call get --plain` — look at the `state` field. | | `transcription create --wait` | Transcription may be processing | Check `band transcription get --plain`. | +| `sip realm create --wait` | Realm may still be CREATE_PENDING | Check `band sip realm get --plain` — `status` reflects the terminal state. Re-running create with `--if-not-exists` is safe. | +| `sip realm delete --wait` | Delete may still be in progress | Check `band sip realm get --plain`; a not-found result means the delete completed. | **General rule:** after a timeout, query the resource state before retrying. Don't blindly re-run a create that might have succeeded. @@ -323,6 +349,22 @@ account + auth **Key difference:** Voice on UP skips the sub-account/location hierarchy. Messaging always needs it, even on UP accounts. +**SIP interconnect (third-party voice-AI platform):** +``` +account + auth + └─→ sip realm create --wait (returns the realm FQDN + id) + └─→ sip credential create --realm --username --password-stdin + └─→ vcp create|update --route-endpoint --route-endpoint-type FQDN + └─→ vcp assign + └─→ number activate --voice-inbound +``` + +The realm's `hostname` is the outbound SIP address the far end needs — hand it to +the third-party platform so their SIP trunk can reach your account. Credentials +can only be created on an `ACTIVE` realm — without `--wait`, `sip realm create` +may still be `CREATE_PENDING` when the next step runs, and `sip credential create` +fails with "realm is not active" (API error 23022). + --- ### Diagnose: What state am I in? @@ -512,6 +554,7 @@ band number list --plain # → all numbers on account | 4 | Conflict / feature limit / payment required | 402, 409, or 403 due to a plan/role gate (e.g., Build account trying to message, missing VCP/Campaign Management/TFV role, out of credits, declined card). Non-retryable — stop and escalate to the user. | | 5 | Timeout | `--wait` exceeded `--timeout` | | 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. | +| 8 | Secret unavailable | A resource exists but its secret cannot be recovered — currently produced by `sip credential create --if-not-exists --generate-password` against an existing credential, and by a generated-password write whose response was lost. Not retryable as-is: rotate the credential (`sip credential rotate`) to get a usable password instead. | **Use exit codes for control flow, not string parsing.** diff --git a/README.md b/README.md index 573a32d..dc60b18 100644 --- a/README.md +++ b/README.md @@ -469,11 +469,11 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | Command | What it does | |---------|-------------| -| `band sip realm create --name --default=` | Create a SIP realm (async; add `--wait`) | +| `band sip realm create --name --default=` | Create a SIP realm (`--description`, `--if-not-exists`; async — add `--wait` and optionally `--timeout `) | | `band sip realm list` | List realms | | `band sip realm get ` | Get one realm, including its FQDN | | `band sip realm update --default=true` | Promote a realm to account default | -| `band sip realm delete ` | Delete a realm | +| `band sip realm delete ` | Delete a realm (async — add `--wait` and optionally `--timeout ` to confirm it's actually gone) | | `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`) | | `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved) | | `band sip credential list --realm ` | List a realm's credentials | @@ -551,6 +551,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | 4 | Conflict, feature limit, or payment required (duplicate resource, missing role, plan limit, out of credits) | | 5 | Timed out waiting | | 7 | Rate limited or quota exceeded (back off and retry) | +| 8 | A resource exists but its secret cannot be recovered (e.g. `sip credential create --if-not-exists --generate-password` against an existing credential) | --- @@ -585,7 +586,7 @@ This CLI is agent-native — not just "agent-compatible." The design principles: - **`--plain` everywhere.** Flat, stable JSON output. Auto-enabled when stdout is piped, so agents in pipelines don't need the flag. - **`--if-not-exists` for idempotency.** Create commands can be retried safely without duplicating resources. - **`--wait` for async operations.** Agents can't poll. `--wait` blocks until the number is active, the call completes, or the transcription is ready. -- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict/feature limit, 5 timeout, 7 rate limit. Use exit codes for control flow, not string parsing. +- **Structured exit codes.** 0 success, 2 auth, 3 not found, 4 conflict/feature limit, 5 timeout, 7 rate limit, 8 secret unavailable. Use exit codes for control flow, not string parsing. - **Env-var-driven auth.** `BW_CLIENT_ID` + `BW_CLIENT_SECRET` — no interactive prompts required. For the full agent reference — dependency chains, provisioning workflows, error patterns, and copy-pasteable scripts — see [AGENTS.md](AGENTS.md). From e0df739ec1a7c3cf8eb4506b4e8d5e1ef7d8b588 Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 10:34:58 -0400 Subject: [PATCH 21/25] docs(sip): fix inaccurate error citation and incomplete exit-8 recovery guidance Review caught that AGENTS.md attributed sip credential create's non-ACTIVE-realm failure to API error 23022, but that path is actually a client-side check that exits 1 with a different message; 23022 only fires in a narrow API race. Also extended the exit-8 recovery row to cover the case where the credential ID is not yet known (list before rotating), and added --app-id/--if-not-exists to README's sip credential create row and a realm create --if-not-exists mismatch caveat to AGENTS.md. --- AGENTS.md | 10 ++++++++-- README.md | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0689bca..5cd7faf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,6 +252,9 @@ For full flag/argument reference, use `band --help`. This section cove - **`sip realm delete` without `--wait` reports `accepted: true, deleted: false`.** A 202 means the delete was accepted, not that it completed. Only `--wait` promotes `deleted` to `true`, after confirming the realm is actually gone. Don't treat a bare delete as teardown-complete. +- **`sip realm create --if-not-exists` does not always silently reuse.** If a realm with that name exists but + its `default` or `description` differs from what was requested, the command errors instead of reusing it — + update the existing realm explicitly rather than retrying create. ### Quickstart @@ -363,7 +366,10 @@ The realm's `hostname` is the outbound SIP address the far end needs — hand it the third-party platform so their SIP trunk can reach your account. Credentials can only be created on an `ACTIVE` realm — without `--wait`, `sip realm create` may still be `CREATE_PENDING` when the next step runs, and `sip credential create` -fails with "realm is not active" (API error 23022). +fails its own client-side check with `"realm is — credentials can +only be created on ACTIVE realms; retry after 'band sip realm get ' reports +ACTIVE"` and exits **1**. (In the narrow case where the API itself rejects the +create in a race, error 23022 surfaces instead — see the errors table below.) --- @@ -554,7 +560,7 @@ band number list --plain # → all numbers on account | 4 | Conflict / feature limit / payment required | 402, 409, or 403 due to a plan/role gate (e.g., Build account trying to message, missing VCP/Campaign Management/TFV role, out of credits, declined card). Non-retryable — stop and escalate to the user. | | 5 | Timeout | `--wait` exceeded `--timeout` | | 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. | -| 8 | Secret unavailable | A resource exists but its secret cannot be recovered — currently produced by `sip credential create --if-not-exists --generate-password` against an existing credential, and by a generated-password write whose response was lost. Not retryable as-is: rotate the credential (`sip credential rotate`) to get a usable password instead. | +| 8 | Secret unavailable | A resource exists but its secret cannot be recovered — currently produced by `sip credential create --if-not-exists --generate-password` against an existing credential (the credential ID is known — the error names it directly), and by a generated-password write whose response was lost. Not retryable as-is: rotate the credential (`sip credential rotate --realm `) to get a usable password. In the lost-response case the ID isn't known yet — the command's own error message says so — so run `band sip credential list --realm --plain` first to find it, then rotate. | **Use exit codes for control flow, not string parsing.** diff --git a/README.md b/README.md index dc60b18..928481a 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band sip realm get ` | Get one realm, including its FQDN | | `band sip realm update --default=true` | Promote a realm to account default | | `band sip realm delete ` | Delete a realm (async — add `--wait` and optionally `--timeout ` to confirm it's actually gone) | -| `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`) | +| `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`; optional `--app-id` to bind it to a voice app; `--if-not-exists` for idempotent retries) | | `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved) | | `band sip credential list --realm ` | List a realm's credentials | | `band sip credential get --realm ` | Get one credential | From 61691e12e6772561e986047f28828e2d49ed1dac Mon Sep 17 00:00:00 2001 From: Kush Date: Wed, 29 Jul 2026 11:17:07 -0400 Subject: [PATCH 22/25] fix(sip,vcp): make the exit-code taxonomy match meaning, not HTTP status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final whole-branch review fix wave. The core finding: exit codes — the primary contract for the agents this CLI is built for — were derived from the HTTP status a Bandwidth endpoint happened to use, so every state conflict this feature produces exited 1 or 8 instead of the documented 4. Exit codes - Add cmdutil.ConflictError (exit 4) with Unwrap, mirroring SecretUnavailableError. Convert every client-side state conflict in `sip realm create`, `sip credential create`, `vcp create`, `vcp update`, and the service's duplicate-username check. Messages and remediation commands are preserved verbatim; only the type changed. - faultExit's 33002/33006/12666/23022/23026 branches now return ConflictError instead of leaning on APIFault.Unwrap's status-derived mapping. Live statuses are 409, 400, and even 201-with-an-Errors- envelope, so status could never produce a consistent 4. 33004 stays on FeatureLimitError. The old test built 23022 as a 409 — a status that endpoint never returns — so it proved nothing; fixtures are now live. - --generate-password no longer converts a definitive rejection into exit 8. An *APIFault means the server parsed and refused the request, so nothing was written; a 429 must report exit 7 (retry), not "a secret you can't recover may exist". Exit 8 is now reserved for genuine ambiguity (decode failure, mid-flight transport error), which 500-based tests still cover. Correctness - `realm create --if-not-exists --wait` polls a reused CREATE_PENDING realm to ACTIVE instead of returning exit 0 with an unusable realm. AGENTS.md tells agents this combination is safe after a --wait timeout. - `realm create --if-not-exists` matches realm names with EqualFold, per the spec's case-insensitive rule. The API normalizes the DNS label, so --name VAPI never matched an existing vapi. - Add `sip realm update --description`, the remediation the CLI's own --if-not-exists error names but no command could perform. Extend SetRealmDefault into UpdateRealm, still read-modify-write so the API's full-replace PUT cannot drop an unspecified field. Security - parseFault scrubs fault Descriptions: the live 23026 response echoes the submitted digest hashes back inside the error text, which is printed to stderr and captured in agent transcripts. Add a bare 32-hex redactor to ScrubHashes for hashes echoed as prose, scoped to raw bodies only — RedactSecrets keys off field names and must not eat legitimate hex IDs. - Discard malformed error bodies entirely rather than partially scrubbing them: a body truncated mid-hash has no closing tag, so the element regex provably missed it. A parseable body with no ErrorCode (the live 404 shape) is still kept — it is useful and provably hash-free. Output - emit normalizes payloads to generic JSON values, which fixes `--format table` printing Go struct dumps (&{1103 vapi ... ACTIVE 0}) and makes RedactSecrets actually run instead of being structurally inert on typed structs. --plain is unchanged modulo key order. A reflection test now enforces that the domain structs carry no hash fields. - `credential list` warns on stderr when it returns a full 500-item page. Auto-pagination stays deferred (the next-link shape is unverified), but a silent cap that reads as a complete list does not. Docs - AGENTS.md: 23026 and 33004 error rows, a note that the whole table exits 4, the realm update section, and three agent-contract bullets. - README: realm update and credential list rows. - Fix sipCapability's comment (five reasons, not four) and emit's comment, which overstated what redaction caught. cmdutil's exit-code test moves to the external cmdutil_test package: internal/sip now imports cmdutil for ConflictError, which makes an in-package test importing internal/sip a test-only import cycle. Every assertion is retained; the file only gained package qualifiers. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 26 +++- README.md | 4 +- cmd/auth/status.go | 6 +- cmd/sip/credential_create.go | 23 ++-- cmd/sip/credential_create_test.go | 103 +++++++++++++++ cmd/sip/credential_rotate.go | 20 ++- cmd/sip/credential_rotate_test.go | 72 ++++++++++ cmd/sip/realm_create.go | 24 +++- cmd/sip/realm_create_test.go | 155 ++++++++++++++++++++++ cmd/sip/realm_update.go | 42 ++++-- cmd/sip/realm_update_test.go | 172 ++++++++++++++++++++++++ cmd/sip/sip.go | 71 ++++++++-- cmd/sip/sip_test.go | 169 +++++++++++++++++++----- cmd/vcp/create.go | 12 +- cmd/vcp/route_test.go | 50 ++++++- cmd/vcp/update.go | 3 +- internal/cmdutil/exitcodes.go | 26 +++- internal/cmdutil/exitcodes_test.go | 102 +++++++++++---- internal/output/redact.go | 23 +++- internal/output/redact_test.go | 49 +++++++ internal/sip/service.go | 99 +++++++++++--- internal/sip/service_test.go | 203 +++++++++++++++++++++++++++++ 22 files changed, 1319 insertions(+), 135 deletions(-) create mode 100644 cmd/sip/realm_create_test.go create mode 100644 cmd/sip/realm_update_test.go diff --git a/AGENTS.md b/AGENTS.md index 5cd7faf..6945a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,8 +253,16 @@ For full flag/argument reference, use `band --help`. This section cove was accepted, not that it completed. Only `--wait` promotes `deleted` to `true`, after confirming the realm is actually gone. Don't treat a bare delete as teardown-complete. - **`sip realm create --if-not-exists` does not always silently reuse.** If a realm with that name exists but - its `default` or `description` differs from what was requested, the command errors instead of reusing it — - update the existing realm explicitly rather than retrying create. + its `default` or `description` differs from what was requested, the command exits **4** instead of reusing + it. Reconcile with `sip realm update --description ` (or promote it with `--default=true`) + rather than retrying create. Realm names match case-insensitively, so `--name VAPI` reuses an existing + `vapi`. +- **`sip realm create --if-not-exists --wait` is safe to combine.** A reused realm that is still + `CREATE_PENDING` is polled to `ACTIVE` before the command returns, so a re-run after a `--wait` timeout + cannot hand back exit 0 with a realm that is not yet usable. +- **`sip credential list` warns on stderr if it may be truncated.** Pagination is not implemented; a realm + with more than 500 credentials returns only the first page, and the CLI says so on stderr. `--plain` stdout + stays clean JSON. ### Quickstart @@ -806,7 +814,9 @@ band sip realm get vapi --plain `get` accepts a realm ID, name, or FQDN. -### Promote a different realm to default +### 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). The API refuses to delete the default realm, and a realm's `default` flag can only be set to `true` (never back to `false`). To retire a default realm, promote another one first: @@ -815,6 +825,12 @@ band sip realm update backup-realm --default=true band sip realm delete old-default-realm --wait ``` +`--description` is the remediation for a `--if-not-exists` description mismatch: + +```bash +band sip realm update vapi --description "Vapi production trunk" +``` + ### Delete a realm ```bash @@ -831,6 +847,10 @@ Deletion is asynchronous (the API returns 202). A realm cannot be deleted while | **12666** | Realm still has SIP credentials | Realm has credentials attached | Delete the credentials first | | **33002** | Realm already exists | Name collision | Use `--if-not-exists`, or pick a different `--name` | | **23022** | Realm is not active yet | Realm hasn't finished provisioning | Retry with `--wait` | +| **23026** | Credential already exists | A credential with that username is already on the realm | Use `--if-not-exists` to reuse it, or change its password with `band sip credential rotate --realm ` | +| **33004** | Account isn't set up for SIP credentials | Account lacks `SipCredentialSettings`. Not a role problem — the credential can hold the SIP Credentials role and still get this | Contact Bandwidth support to enable it. Check up front with `band sip status --plain` | + +Every error in this table exits **4**, regardless of the HTTP status the API used to report it (these arrive variously as 400, 409, and even 201 with an error envelope). Branch on the exit code, then read the message for the remediation. ## Limitations diff --git a/README.md b/README.md index 928481a..11e1950 100644 --- a/README.md +++ b/README.md @@ -472,11 +472,11 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band sip realm create --name --default=` | Create a SIP realm (`--description`, `--if-not-exists`; async — add `--wait` and optionally `--timeout `) | | `band sip realm list` | List realms | | `band sip realm get ` | Get one realm, including its FQDN | -| `band sip realm update --default=true` | Promote a realm to account default | +| `band sip realm update ` | Update a realm: `--default=true` promotes it to the account default, `--description ` replaces its description. Pass either or both; omitted fields are preserved | | `band sip realm delete ` | Delete a realm (async — add `--wait` and optionally `--timeout ` to confirm it's actually gone) | | `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`; optional `--app-id` to bind it to a voice app; `--if-not-exists` for idempotent retries) | | `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved) | -| `band sip credential list --realm ` | List a realm's credentials | +| `band sip credential list --realm ` | List a realm's credentials (pagination is not implemented — a full 500-credential page warns on stderr that the list may be truncated) | | `band sip credential get --realm ` | Get one credential | | `band sip credential delete --realm ` | Delete a credential | | `band sip status` | Probe whether this account can use SIP provisioning (resolves the `unknown` capability from `band auth status`) | diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 104a6aa..a1c5be0 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -199,8 +199,10 @@ func hasRole(roles []string, substr string) bool { // sipCapability reports SIP provisioning availability as a tri-state. SIP needs // both the "SIP Credentials" role and account-level SipCredentialSettings, and // only the role is knowable offline — so a boolean would be misleading. -// Reasons are stable identifiers, not prose: role_absent, -// role_present_not_probed, account_not_enabled, probe_failed. +// Reasons are stable identifiers, not prose. The full set, across this offline +// derivation and the `band sip status` probe: role_absent, +// role_present_not_probed, probe_succeeded, account_not_enabled, probe_failed. +// This function only ever emits the first two — it stays offline. func sipCapability(hasRole bool) map[string]string { if !hasRole { return map[string]string{"status": "unavailable", "reason": "role_absent"} diff --git a/cmd/sip/credential_create.go b/cmd/sip/credential_create.go index 8664aea..858f9e6 100644 --- a/cmd/sip/credential_create.go +++ b/cmd/sip/credential_create.go @@ -56,7 +56,7 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { return faultExit(err) } if realm.Status != "ACTIVE" { - return fmt.Errorf("realm %s is %s — credentials can only be created on ACTIVE realms; retry after 'band sip realm get %s' reports ACTIVE", realm.Name, realm.Status, realm.Name) + return conflict(nil, "realm %s is %s — credentials can only be created on ACTIVE realms; retry after 'band sip realm get %s' reports ACTIVE", realm.Name, realm.Status, realm.Name) } password, generated, err := readPassword(cmd, credCreate.stdin, credCreate.file, credCreate.generate) @@ -71,12 +71,15 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { if credCreateIfNotExists && errors.As(err, &fault) && fault.Code == "23026" { return reuseCredential(cmd, svc, realm, hash1, hash1b, password, generated) } - if generated { - // The write may have landed server-side (e.g. a decode failure after - // a successful POST) even though this call reports failure. Since the - // password was never printed, there is no way to know whether a live - // credential now exists with an unrecoverable password — this must - // not exit as a generic, retryable failure. + // Exit 8 is reserved for GENUINE ambiguity: a decode failure or a + // mid-flight transport error, where the POST may have committed and the + // generated password is unrecoverable. An *APIFault means the server + // parsed the request and rejected it, so nothing was written — reporting + // 8 there would tell an agent "a credential you can't use may exist" + // when the correct signal is the fault's own code (e.g. 7, back off and + // retry, for a 429). That is what --password-stdin already reports for + // the identical response. + if generated && !errors.As(err, &fault) { return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( "the write may have been applied but the generated password was not printed and cannot be recovered — check 'band sip credential list --realm %s --plain' and rotate the credential if it exists: %v", realm.Name, err)} @@ -102,9 +105,9 @@ func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Real } if existing.AppID != credCreateAppID { if existing.AppID == "" { - return fmt.Errorf("credential %q exists but is not bound to an application (wanted %q) — delete and recreate it", credCreateUsername, credCreateAppID) + return conflict(nil, "credential %q exists but is not bound to an application (wanted %q) — delete and recreate it", credCreateUsername, credCreateAppID) } - return fmt.Errorf("credential %q exists but is bound to a different application (%q) — delete and recreate it", credCreateUsername, existing.AppID) + return conflict(nil, "credential %q exists but is bound to a different application (%q) — delete and recreate it", credCreateUsername, existing.AppID) } if generated { return &cmdutil.SecretUnavailableError{ @@ -116,7 +119,7 @@ func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Real return faultExit(err) } if !match { - return fmt.Errorf("credential %q exists with a different password — rotate it: band sip credential rotate %s --realm %s --password-stdin", credCreateUsername, existing.ID, realm.Name) + return conflict(nil, "credential %q exists with a different password — rotate it: band sip credential rotate %s --realm %s --password-stdin", credCreateUsername, existing.ID, realm.Name) } return emitCredential(cmd, existing, password, false) } diff --git a/cmd/sip/credential_create_test.go b/cmd/sip/credential_create_test.go index 3101057..fb921f0 100644 --- a/cmd/sip/credential_create_test.go +++ b/cmd/sip/credential_create_test.go @@ -178,6 +178,109 @@ func TestCredentialCreate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThro } } +// credentialCreateFaultStubServer rejects the POST with a structured Bandwidth +// fault at the given status: the server parsed the request and refused it, so +// no credential was created. +func credentialCreateFaultStubServer(t *testing.T, status int, code string) *httptest.Server { + t.Helper() + 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.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/sipcredentials"): + w.WriteHeader(status) + w.Write([]byte(`` + + `` + code + `rejected` + + ``)) + default: + w.WriteHeader(404) + } + })) +} + +// TestCredentialCreate_DefinitiveFaultDoesNotExitSecretUnavailable is the +// discrimination the generated-password branch was missing. +// +// The concrete failure: `credential create --generate-password` hits a 429. +// Nothing was created. The agent got exit 8 — "not retryable as-is, rotate the +// credential" — listed credentials, found none, and dead-ended. Exit 8 is only +// honest when the write MIGHT have landed; an *APIFault proves it did not, +// because the server parsed the request before refusing it. +func TestCredentialCreate_DefinitiveFaultDoesNotExitSecretUnavailable(t *testing.T) { + cases := []struct { + name string + status int + code string + want int + }{ + {"rate limited", 429, "1001", cmdutil.ExitRateLimit}, + {"duplicate credential at live 400", 400, "23026", cmdutil.ExitConflict}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := credentialCreateFaultStubServer(t, c.status, c.code) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + // Flag values are package vars bound to a shared cobra command, so every + // flag this path reads is set explicitly — cobra only assigns on parse, + // and a --password-stdin left true by an earlier test in this process + // would trip the "exactly one password source" guard before the + // branch under test is ever reached. + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want the fault to surface") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want NOT *cmdutil.SecretUnavailableError: a parsed rejection means nothing was created", err) + } + if got := cmdutil.ExitCodeForError(err); got != c.want { + t.Errorf("ExitCodeForError() = %d, want %d; err = %v", got, c.want, err) + } + }) + } +} + +// TestCredentialCreate_NonActiveRealmExitsConflict pins the client-side guard's +// exit code. The spec (line 140) assigns 4 to "credential on a non-ACTIVE +// realm"; it returned a bare fmt.Errorf and so exited 1, which an agent reads +// as an unexpected failure rather than "wait for the realm, then retry." +func TestCredentialCreate_NonActiveRealmExitsConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/realms/vapi") { + w.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comCREATE_PENDING` + + ``)) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want a conflict for a non-ACTIVE realm") + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitConflict { + t.Errorf("ExitCodeForError() = %d, want ExitConflict (%d); err = %v", got, cmdutil.ExitConflict, err) + } + if !strings.Contains(err.Error(), "credentials can only be created on ACTIVE realms") { + t.Errorf("error = %q, want the existing message preserved verbatim", err.Error()) + } +} + // TestEmitCredential_OmitsCallerSuppliedPassword is the most security-load-bearing // assertion in this package: a caller-supplied password must never be echoed // back, and passwordShownOnce must be false whenever password is absent. diff --git a/cmd/sip/credential_rotate.go b/cmd/sip/credential_rotate.go index 8c0dffb..6f8be11 100644 --- a/cmd/sip/credential_rotate.go +++ b/cmd/sip/credential_rotate.go @@ -1,6 +1,7 @@ package sip import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -46,13 +47,18 @@ var credentialRotateCmd = &cobra.Command{ hash1, hash1b := sipsvc.ComputeHashes(existing.Username, realm.Hostname, password) cred, err := svc.RotateCredential(realm.ID, existing.ID, hash1, hash1b) if err != nil { - if generated { - // The PUT may have replaced the hashes server-side even though - // this call reports failure (e.g. a decode error after a - // successful write). The generated password was never printed, - // so a working peer's credential may now be silently dead with - // an unrecoverable password — that must not exit as a generic, - // retryable failure. + // The PUT may have replaced the hashes server-side even though this + // call reports failure (e.g. a decode error after a successful + // write). The generated password was never printed, so a working + // peer's credential may now be silently dead with an unrecoverable + // password — that must not exit as a generic, retryable failure. + // + // An *APIFault is excluded: the server parsed and rejected the + // request, so the hashes were NOT replaced. Reporting exit 8 there + // sends an agent down the unrecoverable-secret path for what is + // often just a 429 it should retry. + var fault *sipsvc.APIFault + if generated && !errors.As(err, &fault) { return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( "the write may have been applied but the generated password was not printed and cannot be recovered — rotate the credential again: %v", err)} } diff --git a/cmd/sip/credential_rotate_test.go b/cmd/sip/credential_rotate_test.go index b67ed3b..9c88335 100644 --- a/cmd/sip/credential_rotate_test.go +++ b/cmd/sip/credential_rotate_test.go @@ -65,6 +65,78 @@ func TestCredentialRotate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUna } } +// credentialRotateFaultStubServer is like the above but the PUT is rejected +// with a structured Bandwidth fault at the given status — the server parsed the +// request and refused it, so the hashes were definitively NOT replaced. +func credentialRotateFaultStubServer(t *testing.T, status int, code string) *httptest.Server { + t.Helper() + 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.Write([]byte(`1105` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/sipcredentials/"): + w.Write([]byte(`` + + `870880rotuser` + + ``)) + case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/sipcredentials/"): + w.WriteHeader(status) + w.Write([]byte(`` + + `` + code + `rejected` + + ``)) + default: + w.WriteHeader(404) + } + })) +} + +// TestCredentialRotate_DefinitiveFaultDoesNotExitSecretUnavailable is the +// discrimination this branch was missing. Exit 8 means "a credential you cannot +// use may now exist — rotate it." That is false for any *APIFault: the server +// parsed and rejected the request, so no write happened. A 429 must report exit +// 7 (back off and retry) exactly as --password-stdin would from the same +// response; blanket exit 8 sent the agent down an unrecoverable-secret path for +// a plainly retryable failure. +func TestCredentialRotate_DefinitiveFaultDoesNotExitSecretUnavailable(t *testing.T) { + cases := []struct { + name string + status int + code string + want int + }{ + {"rate limited", 429, "1001", cmdutil.ExitRateLimit}, + {"documented 400 conflict", 400, "23022", cmdutil.ExitConflict}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := credentialRotateFaultStubServer(t, c.status, c.code) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialRotateCmd) + // Every password flag is set explicitly: they are package vars on a + // shared command and cobra only assigns on parse, so a value left + // behind by an earlier test in this process would trip the + // "exactly one password source" guard first. + root.SetArgs([]string{"rotate", "870880", "--realm", "vapi", + "--generate-password", "--password-stdin=false", "--password-file="}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want the fault to surface") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want NOT *cmdutil.SecretUnavailableError: a parsed rejection means nothing was written", err) + } + if got := cmdutil.ExitCodeForError(err); got != c.want { + t.Errorf("ExitCodeForError() = %d, want %d; err = %v", got, c.want, err) + } + }) + } +} + // TestCredentialRotate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThroughToFaultExit // is the negative pairing: the identical failure with a caller-supplied // password (--password-stdin) must NOT produce SecretUnavailableError — the diff --git a/cmd/sip/realm_create.go b/cmd/sip/realm_create.go index 5c05006..cc7e789 100644 --- a/cmd/sip/realm_create.go +++ b/cmd/sip/realm_create.go @@ -2,6 +2,7 @@ package sip import ( "fmt" + "strings" "time" "github.com/spf13/cobra" @@ -62,14 +63,31 @@ func runRealmCreate(cmd *cobra.Command, args []string) error { } for i := range realms { r := realms[i] - if r.Name != realmCreateName { + // Case-insensitive per spec line 29: ValidateRealmName accepts + // uppercase, but the realm name is a DNS label the API may normalize + // to lowercase. An exact comparison would make --if-not-exists + // non-idempotent for `--name VAPI` against an existing `vapi`. + if !strings.EqualFold(r.Name, realmCreateName) { continue } if !realmReuseAllowed(r.Status) { - return fmt.Errorf("realm %q exists but is in state %s — delete it and retry", r.Name, r.Status) + return conflict(nil, "realm %q exists but is in state %s — delete it and retry", r.Name, r.Status) } if !realmStateMatches(&r, realmCreateDefault, realmCreateDescription, descSet) { - return fmt.Errorf("realm %q exists with different settings (default=%v, description=%q) — update it explicitly", r.Name, r.Default, r.Description) + return conflict(nil, "realm %q exists with different settings (default=%v, description=%q) — update it explicitly", r.Name, r.Default, r.Description) + } + // --wait must be honored on the reuse path too. realmReuseAllowed + // admits CREATE_PENDING, so returning here unconditionally would + // hand an agent exit 0 with status CREATE_PENDING — and AGENTS.md's + // Timeout Recovery table tells agents that re-running create with + // --if-not-exists after a --wait timeout is safe, which is exactly + // how that combination gets used. + if realmCreateWait && r.Status != "ACTIVE" { + final, err := waitForRealmActive(svc, r.ID, realmCreateTimeout) + if err != nil { + return err + } + return emit(format, plain, final) } return emit(format, plain, r) } diff --git a/cmd/sip/realm_create_test.go b/cmd/sip/realm_create_test.go new file mode 100644 index 0000000..6e0930d --- /dev/null +++ b/cmd/sip/realm_create_test.go @@ -0,0 +1,155 @@ +package sip + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/testutil" +) + +// realmListXML renders a one-realm list response. +func realmListXML(name, status string, isDefault bool) string { + def := "false" + if isDefault { + def = "true" + } + return `` + + `1103` + name + `-3efeaa.auth.bandwidth.com` + + `` + def + `` + + `0` + status + `` + + `` +} + +// realmGetXML renders a single-realm response. +func realmGetXML(name, status string) string { + return `` + + `1103` + name + `-3efeaa.auth.bandwidth.com` + + `false` + + `0` + status + `` + + `` +} + +// TestRealmCreate_IfNotExistsWithWaitPollsPendingRealmToActive covers the gap +// between AGENTS.md's Timeout Recovery advice and the code. That table tells an +// agent that after a `--wait` timeout, "re-running create with --if-not-exists +// is safe" — so the agent re-runs with BOTH flags. realmReuseAllowed admits +// CREATE_PENDING, so the reuse path used to return immediately, handing back +// exit 0 with status CREATE_PENDING. The agent reads success and the next step +// (credential create, which requires ACTIVE) fails. +func TestRealmCreate_IfNotExistsWithWaitPollsPendingRealmToActive(t *testing.T) { + var gets int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // The --if-not-exists lookup sees a realm still provisioning. + case strings.HasSuffix(r.URL.Path, "/realms"): + w.Write([]byte(realmListXML("vapi", "CREATE_PENDING", false))) + // The poll then observes it reach ACTIVE. + case strings.Contains(r.URL.Path, "/realms/"): + atomic.AddInt32(&gets, 1) + w.Write([]byte(realmGetXML("vapi", "ACTIVE"))) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(realmCreateCmd) + root.SetArgs([]string{"create", "--name", "vapi", "--default=false", "--if-not-exists", "--wait", "--timeout", "10", "--plain"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + if atomic.LoadInt32(&gets) == 0 { + t.Error("--wait was ignored on the --if-not-exists reuse path: no realm GET was issued") + } + var got map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil { + t.Fatalf("not JSON: %q (%v)", out, err) + } + if got["status"] != "ACTIVE" { + t.Errorf("status = %v, want ACTIVE — a reused CREATE_PENDING realm with --wait must be polled to ACTIVE before returning", got["status"]) + } +} + +// TestRealmCreate_IfNotExistsMatchesNameCaseInsensitively covers spec line 29: +// realm names compare case-insensitively. ValidateRealmName accepts uppercase, +// but the name is a DNS label the API may normalize to lowercase — so an exact +// comparison makes `--name VAPI --if-not-exists` create a second realm (or +// fail) every single run instead of reusing the existing `vapi`. +func TestRealmCreate_IfNotExistsMatchesNameCaseInsensitively(t *testing.T) { + var posts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + atomic.AddInt32(&posts, 1) + } + if strings.HasSuffix(r.URL.Path, "/realms") && r.Method == http.MethodGet { + w.Write([]byte(realmListXML("vapi", "ACTIVE", false))) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(realmCreateCmd) + root.SetArgs([]string{"create", "--name", "VAPI", "--default=false", "--if-not-exists", "--wait=false", "--plain"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + if n := atomic.LoadInt32(&posts); n != 0 { + t.Errorf("issued %d POSTs — a mixed-case --name must match the existing lowercase realm, not create a new one", n) + } + var got map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace([]byte(out)), &got); err != nil { + t.Fatalf("not JSON: %q (%v)", out, err) + } + if got["name"] != "vapi" { + t.Errorf("name = %v, want the existing realm vapi", got["name"]) + } +} + +// TestRealmCreate_IfNotExistsStateMismatchExitsConflict is the command-level +// half of the exit-code fix on the `sip` side: a client-side state conflict is +// a conflict (4), not a generic failure (1). An agent branching on 1 vs 4 +// cannot otherwise tell "the realm exists but differs, go update it" from "the +// CLI broke." +func TestRealmCreate_IfNotExistsStateMismatchExitsConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/realms") && r.Method == http.MethodGet { + w.Write([]byte(realmListXML("vapi", "ACTIVE", false))) + return + } + w.WriteHeader(404) + })) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(realmCreateCmd) + // The existing realm is default=false; ask for default=true. + root.SetArgs([]string{"create", "--name", "vapi", "--default=true", "--if-not-exists", "--wait=false"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want a state-mismatch conflict") + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitConflict { + t.Errorf("ExitCodeForError() = %d, want ExitConflict (%d); err = %v", got, cmdutil.ExitConflict, err) + } + if !strings.Contains(err.Error(), "update it explicitly") { + t.Errorf("error = %q, want the existing remediation text preserved verbatim", err.Error()) + } +} diff --git a/cmd/sip/realm_update.go b/cmd/sip/realm_update.go index e2354d1..fbdf1e0 100644 --- a/cmd/sip/realm_update.go +++ b/cmd/sip/realm_update.go @@ -8,31 +8,53 @@ import ( "github.com/Bandwidth/cli/internal/cmdutil" ) -var realmUpdateDefault bool +var ( + realmUpdateDefault bool + realmUpdateDescription string +) func init() { realmCmd.AddCommand(realmUpdateCmd) realmUpdateCmd.Flags().BoolVar(&realmUpdateDefault, "default", false, "Make this realm the account default (only true is supported by the API)") - realmUpdateCmd.MarkFlagRequired("default") + realmUpdateCmd.Flags().StringVar(&realmUpdateDescription, "description", "", "Set the realm's description") } var realmUpdateCmd = &cobra.Command{ Use: "update ", - Short: "Make a realm the account default", - Long: "Sets a realm as the account default. This exists so a default realm can be torn down: " + - "the API refuses to delete the default realm, and 'default' can only be set to true, so another " + - "realm must be promoted first.", - Args: cobra.ExactArgs(1), - Example: ` band sip realm update backup-realm --default=true`, + Short: "Update a SIP realm's default flag or description", + Long: "Updates a realm. Two fields are updatable: --default=true promotes the realm to the account " + + "default, and --description replaces its description. Promotion exists so a default realm can be " + + "torn down: the API refuses to delete the default realm, and 'default' can only be set to true, so " + + "another realm must be promoted first. --description is the remediation 'sip realm create " + + "--if-not-exists' names when an existing realm's description differs from what was requested. " + + "Omitted fields are preserved (the update is read-modify-write over the API's full-replace PUT).", + Args: cobra.ExactArgs(1), + Example: ` # Promote a realm to account default + band sip realm update backup-realm --default=true + + # Change only the description (default flag is preserved) + band sip realm update vapi --description "Vapi production trunk"`, RunE: func(cmd *cobra.Command, args []string) error { - if !realmUpdateDefault { + defaultSet := cmd.Flags().Changed("default") + descSet := cmd.Flags().Changed("description") + // --default=false is still rejected outright: the API cannot demote a + // realm, and silently ignoring the flag would misreport success. + if defaultSet && !realmUpdateDefault { return fmt.Errorf("--default=false is not supported by the API; promote a different realm instead") } + if !defaultSet && !descSet { + return fmt.Errorf("specify at least one of --default=true or --description") + } + svc, err := service(cmd) if err != nil { return err } - realm, err := svc.SetRealmDefault(args[0]) + var desc *string + if descSet { + desc = &realmUpdateDescription + } + realm, err := svc.UpdateRealm(args[0], defaultSet && realmUpdateDefault, desc) if err != nil { return faultExit(err) } diff --git a/cmd/sip/realm_update_test.go b/cmd/sip/realm_update_test.go new file mode 100644 index 0000000..24acefc --- /dev/null +++ b/cmd/sip/realm_update_test.go @@ -0,0 +1,172 @@ +package sip + +import ( + "encoding/xml" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/testutil" +) + +// resetRealmUpdateFlags restores realmUpdateCmd's flags to their unset state. +// Flag values and their Changed bits are package/command state that cobra only +// assigns on parse, so without this a value from an earlier test in the same +// process leaks into the next one — and `Changed` is exactly what this command +// branches on. +func resetRealmUpdateFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"default", "description"} { + fl := realmUpdateCmd.Flags().Lookup(name) + if err := fl.Value.Set(fl.DefValue); err != nil { + t.Fatalf("resetting --%s: %v", name, err) + } + fl.Changed = false + } +} + +// capturedRealmPUT records the body of the PUT `sip realm update` issues, so a +// test can assert what was actually SENT — the whole point of read-modify-write +// is that an omitted field is re-transmitted, which output alone cannot prove. +type capturedRealmPUT struct { + Realm string `xml:"Realm"` + Description string `xml:"Description"` + Default bool `xml:"Default"` +} + +// realmUpdateStub serves a realm with the given current state and captures the +// PUT body. The PUT response echoes the request, mimicking the live API. +func realmUpdateStub(t *testing.T, currentDesc string, currentDefault bool, got *capturedRealmPUT) *httptest.Server { + t.Helper() + def := "false" + if currentDefault { + def = "true" + } + current := `1103` + + `vapi-3efeaa.auth.bandwidth.com` + currentDesc + `` + + `` + def + `0` + + `ACTIVE` + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Write([]byte(current)) + case http.MethodPut: + var body struct { + XMLName xml.Name `xml:"Realm"` + Realm string `xml:"Realm"` + Description string `xml:"Description"` + Default bool `xml:"Default"` + } + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + if err := xml.Unmarshal(buf[:n], &body); err != nil { + t.Errorf("PUT body is not the expected Realm shape: %v (%q)", err, buf[:n]) + } + got.Realm, got.Description, got.Default = body.Realm, body.Description, body.Default + + respDefault := "false" + if body.Default { + respDefault = "true" + } + w.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.com` + body.Description + `` + + `` + respDefault + `0` + + `ACTIVE`)) + default: + w.WriteHeader(404) + } + })) +} + +// TestRealmUpdate_DescriptionOnlyPreservesDefault covers the half of the +// command that did not exist. `sip realm create --if-not-exists` tells a caller +// on a description mismatch to "update it explicitly", and AGENTS.md repeats it +// — but --description was never implemented, so no command could carry out the +// CLI's own remediation. Realm PUT is a full replace, so this also asserts the +// unspecified `default` is echoed back rather than cleared. +func TestRealmUpdate_DescriptionOnlyPreservesDefault(t *testing.T) { + var got capturedRealmPUT + srv := realmUpdateStub(t, "old", true, &got) // currently the DEFAULT realm + defer srv.Close() + withStubService(t, srv) + + resetRealmUpdateFlags(t) + root := testutil.NewTestRoot(realmUpdateCmd) + root.SetArgs([]string{"update", "vapi", "--description", "new text", "--plain"}) + + testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + if got.Description != "new text" { + t.Errorf("PUT Description = %q, want %q", got.Description, "new text") + } + if !got.Default { + t.Error("PUT Default = false, want true — a description-only update must not demote the default realm") + } +} + +// TestRealmUpdate_DefaultOnlyPreservesDescription is the mirror: promotion must +// not wipe the description out from under the caller. +func TestRealmUpdate_DefaultOnlyPreservesDescription(t *testing.T) { + var got capturedRealmPUT + srv := realmUpdateStub(t, "keep me", false, &got) + defer srv.Close() + withStubService(t, srv) + + resetRealmUpdateFlags(t) + root := testutil.NewTestRoot(realmUpdateCmd) + // --description is deliberately absent: that is the case under test. + root.SetArgs([]string{"update", "vapi", "--default=true", "--plain"}) + + testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + if !got.Default { + t.Error("PUT Default = false, want true") + } + if got.Description != "keep me" { + t.Errorf("PUT Description = %q, want the existing %q re-sent — realm PUT is a full replace", got.Description, "keep me") + } +} + +// TestRealmUpdate_RejectsDefaultFalse keeps the pre-existing guard: the API +// cannot demote a realm, so --default=false must fail loudly rather than +// silently no-op. Making --default optional must not weaken this. +func TestRealmUpdate_RejectsDefaultFalse(t *testing.T) { + resetRealmUpdateFlags(t) + root := testutil.NewTestRoot(realmUpdateCmd) + root.SetArgs([]string{"update", "vapi", "--default=false"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want --default=false to be rejected") + } + if !strings.Contains(err.Error(), "--default=false is not supported by the API") { + t.Errorf("error = %q, want the existing rejection message", err.Error()) + } +} + +// TestRealmUpdate_RequiresAtLeastOneField guards the flag contract now that +// --default is no longer strictly required: an argument-only invocation must +// not silently issue a PUT that changes nothing. +func TestRealmUpdate_RequiresAtLeastOneField(t *testing.T) { + resetRealmUpdateFlags(t) + root := testutil.NewTestRoot(realmUpdateCmd) + root.SetArgs([]string{"update", "vapi"}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want a flag error") + } + if !strings.Contains(err.Error(), "at least one of --default=true or --description") { + t.Errorf("error = %q, want guidance naming both flags", err.Error()) + } +} diff --git a/cmd/sip/sip.go b/cmd/sip/sip.go index 9c628d5..f346e70 100644 --- a/cmd/sip/sip.go +++ b/cmd/sip/sip.go @@ -3,6 +3,7 @@ package sip import ( + "encoding/json" "errors" "fmt" @@ -29,6 +30,7 @@ var Cmd = &cobra.Command{ band sip realm list --plain band sip realm get vapi --plain band sip realm update vapi --default=true --plain + band sip realm update vapi --description "Vapi production trunk" --plain band sip realm delete vapi --wait --plain # Credentials @@ -42,12 +44,46 @@ var Cmd = &cobra.Command{ band sip status --plain`, } -// emit is the single output path for every `band sip` command. Routing all -// output through one helper means the hash-redaction net cannot be bypassed by -// a future subcommand that prints a raw map — typed domain structs already omit -// hashes, and this catches everything else. +// emit is the single output path for every `band sip` command. +// +// Payloads are normalized to generic JSON values (maps/slices/scalars) before +// printing, which does three things: +// +// 1. `--format table` renders. output.printTable has no case for typed structs +// and falls through to `fmt.Fprintf("%v")`, printing a Go struct dump like +// `&{1103 vapi vapi-3efeaa.auth.bandwidth.com false ACTIVE 0}`. +// 2. output.RedactSecrets becomes effective. It only walks +// map[string]interface{} / []interface{}, so it is structurally inert on +// typed structs — the redaction net was documentation, not a runtime check. +// Hash safety still rests primarily on the domain structs carrying no hash +// fields (see TestDomainStructsCarryNoHashFields); this makes the net real. +// 3. `--plain` is unchanged modulo key order: the same json tags drive both +// paths. +// +// Precondition: output.FlattenResponse unwraps single-key maps, so a domain +// struct with exactly one field would be flattened down to its bare value. Both +// current domain structs have ≥2 fields; keep it that way. func emit(format string, plain bool, data interface{}) error { - return output.RedactAndPrint(format, plain, data) + normalized, err := normalizeForOutput(data) + if err != nil { + return err + } + return output.RedactAndPrint(format, plain, normalized) +} + +// normalizeForOutput JSON round-trips data into generic values. Every payload +// emit is given is JSON-marshalable by construction (domain structs and plain +// maps), so a failure here means a programming error, not bad user input. +func normalizeForOutput(data interface{}) (interface{}, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("encoding output: %w", err) + } + var generic interface{} + if err := json.Unmarshal(b, &generic); err != nil { + return nil, fmt.Errorf("normalizing output: %w", err) + } + return generic, nil } // service builds a SIP service for the active account. It is a package var, @@ -62,7 +98,13 @@ var service = func(cmd *cobra.Command) (*sipsvc.Service, error) { } // faultExit converts documented Bandwidth error codes into actionable messages. -// Exit codes follow from the error type via cmdutil.ExitCodeForError. +// Exit codes follow from the error TYPE, never from the HTTP status: the same +// logical conflict arrives as 409 (33002/33006/12666), 400 (23022/23026), or a +// 201 body carrying an Errors envelope (23026 on bulk create). Relying on +// APIFault.Unwrap's synthesized *api.APIError would therefore exit 4 for some +// documented conflicts and 1 for others. Every conflict branch below returns +// *cmdutil.ConflictError (exit 4) and keeps the original fault as its cause; +// 33004 stays on FeatureLimitError, which already maps to 4. func faultExit(err error) error { var fault *sipsvc.APIFault if !errors.As(err, &fault) { @@ -72,15 +114,22 @@ func faultExit(err error) error { case "33004": return cmdutil.NewFeatureLimit("this account isn't enabled for SIP credentials — contact Bandwidth support to enable SipCredentialSettings", err) case "33006": - return fmt.Errorf("cannot delete the default realm — make another realm default first: band sip realm update --default=true: %w", err) + return conflict(err, "cannot delete the default realm — make another realm default first: band sip realm update --default=true: %v", err) case "12666": - return fmt.Errorf("cannot delete this realm while it has SIP credentials — delete them first: band sip credential list --realm : %w", err) + return conflict(err, "cannot delete this realm while it has SIP credentials — delete them first: band sip credential list --realm : %v", err) case "23022": - return fmt.Errorf("realm is not active yet — retry with --wait: %s: %w", fault.Description, err) + return conflict(err, "realm is not active yet — retry with --wait: %s: %v", fault.Description, err) case "33002": - return fmt.Errorf("realm already exists: %s: %w", fault.Description, err) + return conflict(err, "realm already exists: %s: %v", fault.Description, err) case "23026": - return fmt.Errorf("credential already exists — use --if-not-exists to reuse it, or 'band sip credential rotate --realm ' to change its password: %w", err) + return conflict(err, "credential already exists — use --if-not-exists to reuse it, or 'band sip credential rotate --realm ' to change its password: %v", err) } return err } + +// conflict builds a *cmdutil.ConflictError (exit 4) with a formatted message, +// keeping cause reachable via errors.As so the original API fault's error code +// and status are still inspectable. +func conflict(cause error, format string, args ...interface{}) error { + return &cmdutil.ConflictError{Message: fmt.Sprintf(format, args...), Cause: cause} +} diff --git a/cmd/sip/sip_test.go b/cmd/sip/sip_test.go index bf17f6d..7d6a182 100644 --- a/cmd/sip/sip_test.go +++ b/cmd/sip/sip_test.go @@ -2,10 +2,16 @@ package sip import ( "errors" + "reflect" + "regexp" + "strings" "testing" + "github.com/spf13/cobra" + "github.com/Bandwidth/cli/internal/cmdutil" sipsvc "github.com/Bandwidth/cli/internal/sip" + "github.com/Bandwidth/cli/internal/testutil" ) func TestRealmReuseAllowed(t *testing.T) { @@ -45,38 +51,49 @@ func TestRealmStateMatches(t *testing.T) { } } -// TestFaultExit_PreservesExitCode guards against a regression where a -// documented error code's branch in faultExit wraps the original *APIFault in -// a plain fmt.Errorf (no %w), silently detaching it from errors.As. Because -// cmdutil.ExitCodeForError determines the process exit code purely by -// unwrapping to *FeatureLimitError / *api.APIError / ErrPollTimeout, a -// dropped wrap makes a documented conflict (should exit 4) fall through to -// exit 1 — indistinguishable from an unexpected failure to an agent branching -// on exit codes. +// TestFaultExit_PreservesExitCode asserts that every documented conflict maps +// to exit 4 regardless of the HTTP status the API used to report it. +// +// The StatusCode in each fixture is the LIVE-VERIFIED one, which is the whole +// point: an earlier version of this test built 23022 with StatusCode 409 — a +// status that endpoint never returns — so it passed while the real 400 fell +// through to exit 1. Exit codes must come from the error type, not the status. func TestFaultExit_PreservesExitCode(t *testing.T) { cases := []struct { - name string - code string + name string + code string + status int }{ - {"default realm delete conflict", "33006"}, - {"realm has credentials conflict", "12666"}, - {"realm not active yet conflict", "23022"}, - {"realm already exists conflict", "33002"}, + {"default realm delete conflict", "33006", 409}, + {"realm has credentials conflict", "12666", 409}, + {"realm already exists conflict", "33002", 409}, + // Live: 23022 is a 400, not a 409. + {"realm not active yet conflict", "23022", 400}, + // Live: 23026 arrives as a 400, and as a 201 carrying an Errors + // envelope on bulk create. Neither status maps to 4 on its own. + {"duplicate credential conflict (400)", "23026", 400}, + {"duplicate credential conflict (201 partial success)", "23026", 201}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - fault := &sipsvc.APIFault{Code: c.code, Description: "boom", StatusCode: 409} + fault := &sipsvc.APIFault{Code: c.code, Description: "boom", StatusCode: c.status} result := faultExit(fault) if got := cmdutil.ExitCodeForError(result); got != cmdutil.ExitConflict { - t.Errorf("faultExit(%s) -> ExitCodeForError = %d, want ExitConflict (%d): err = %v", - c.code, got, cmdutil.ExitConflict, result) + t.Errorf("faultExit(%s @ %d) -> ExitCodeForError = %d, want ExitConflict (%d): err = %v", + c.code, c.status, got, cmdutil.ExitConflict, result) + } + // The original fault must stay reachable so callers can still read + // the error code behind the conflict. + var unwrapped *sipsvc.APIFault + if !errors.As(result, &unwrapped) { + t.Errorf("faultExit(%s) = %v, want an error that unwraps to *sipsvc.APIFault", c.code, result) } }) } // 33004 must still map to ExitConflict via FeatureLimitError (regression // guard for the one branch that was already correct). - fault := &sipsvc.APIFault{Code: "33004", Description: "not enabled", StatusCode: 403} + fault := &sipsvc.APIFault{Code: "33004", Description: "not enabled", StatusCode: 400} result := faultExit(fault) if got := cmdutil.ExitCodeForError(result); got != cmdutil.ExitConflict { t.Errorf("faultExit(33004) -> ExitCodeForError = %d, want ExitConflict (%d): err = %v", @@ -84,21 +101,105 @@ func TestFaultExit_PreservesExitCode(t *testing.T) { } } -// TestFaultExit_DuplicateCredentialPreservesAPIFaultWrapping guards the same -// %w regression as TestFaultExit_PreservesExitCode for the 23026 branch. It -// does not assert a specific numeric exit code: CreateCredential's live -// duplicate-credential fault has been observed at both StatusCode 201 (2xx -// body carrying an Errors envelope) and, per code review, potentially 400 — -// neither of which ExitCodeForError's api.APIError switch maps to -// ExitConflict (it only maps 402/409). The 23026 branch here improves the -// remediation message; it does not change the resulting exit code, and this -// test only confirms the branch does not silently drop the *APIFault via a -// bare fmt.Errorf. -func TestFaultExit_DuplicateCredentialPreservesAPIFaultWrapping(t *testing.T) { - fault := &sipsvc.APIFault{Code: "23026", Description: "does already exist", StatusCode: 201} - result := faultExit(fault) - var got *sipsvc.APIFault - if !errors.As(result, &got) { - t.Fatalf("faultExit(23026) = %v, want an error that unwraps to *sipsvc.APIFault", result) +// TestFaultExit_UndocumentedFaultKeepsStatusMapping is the negative pairing: +// faultExit must NOT blanket-convert every fault into a conflict. An +// undocumented code passes through so its status still drives the exit code — +// a 429 must stay retryable (7), not become "stop, conflict" (4). +func TestFaultExit_UndocumentedFaultKeepsStatusMapping(t *testing.T) { + fault := &sipsvc.APIFault{Code: "1001", Description: "slow down", StatusCode: 429} + if got := cmdutil.ExitCodeForError(faultExit(fault)); got != cmdutil.ExitRateLimit { + t.Errorf("ExitCodeForError = %d, want ExitRateLimit (%d)", got, cmdutil.ExitRateLimit) + } +} + +// TestDomainStructsCarryNoHashFields converts emit's structural hash-safety +// assumption into an enforced one. output.RedactSecrets is a net, but the +// primary guarantee is that Realm and Credential simply have nowhere to put a +// digest hash. A future field named Hash1/Hash1b would silently publish +// password-equivalent material through every `band sip` output path. +func TestDomainStructsCarryNoHashFields(t *testing.T) { + hashFieldRe := regexp.MustCompile(`(?i)^hash1b?$`) + for _, v := range []interface{}{sipsvc.Realm{}, sipsvc.Credential{}} { + typ := reflect.TypeOf(v) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if !f.IsExported() { + continue + } + if hashFieldRe.MatchString(f.Name) { + t.Errorf("%s.%s is a digest-hash field on an output struct — remove it or keep hashes on the wire types only", + typ.Name(), f.Name) + } + if tag := strings.Split(f.Tag.Get("json"), ",")[0]; hashFieldRe.MatchString(tag) { + t.Errorf("%s.%s serializes as %q, a digest-hash key", typ.Name(), f.Name, tag) + } + } + } +} + +// TestEmit_TableRendersFieldsNotGoStructDump guards the --format table path. +// output.printTable has no case for typed structs and falls through to +// fmt.Fprintf("%v"), which printed `&{1103 vapi vapi-3efeaa… false ACTIVE 0}` +// — unlabeled, unparseable, and silently bypassing RedactSecrets. emit +// normalizes to generic JSON values first, so the table renders real columns. +func TestEmit_TableRendersFieldsNotGoStructDump(t *testing.T) { + realm := &sipsvc.Realm{ + ID: "1103", Name: "vapi", Hostname: "vapi-3efeaa.auth.bandwidth.com", + Status: "ACTIVE", CredentialCount: 0, + } + wrap := &cobra.Command{ + Use: "wrap", + RunE: func(cmd *cobra.Command, args []string) error { + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, realm) + }, + } + root := testutil.NewTestRoot(wrap) + root.SetArgs([]string{"wrap", "--format", "table"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + if strings.Contains(out, "&{") { + t.Errorf("table output is a Go struct dump: %q", out) + } + for _, want := range []string{"hostname", "vapi-3efeaa.auth.bandwidth.com", "status", "ACTIVE"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q: %q", want, out) + } + } +} + +// TestEmit_RedactsHashKeysAtRuntime proves the normalization made redaction +// live rather than structurally inert: a raw map carrying a hash key must lose +// it on the way out, in table format as well as --plain. +func TestEmit_RedactsHashKeysAtRuntime(t *testing.T) { + payload := map[string]interface{}{ + "id": "870874", + "Hash1": "1be6abcaa8e9956021d30f33a3925b99", + } + wrap := &cobra.Command{ + Use: "wrap", + RunE: func(cmd *cobra.Command, args []string) error { + format, plain := cmdutil.OutputFlags(cmd) + return emit(format, plain, payload) + }, + } + root := testutil.NewTestRoot(wrap) + root.SetArgs([]string{"wrap", "--format", "table"}) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + if strings.Contains(out, "1be6abcaa8e9956021d30f33a3925b99") || strings.Contains(strings.ToLower(out), "hash1") { + t.Errorf("hash reached table output: %q", out) + } + if !strings.Contains(out, "870874") { + t.Errorf("non-secret field was lost: %q", out) } } diff --git a/cmd/vcp/create.go b/cmd/vcp/create.go index 3135f42..3a58980 100644 --- a/cmd/vcp/create.go +++ b/cmd/vcp/create.go @@ -91,7 +91,8 @@ func runCreate(cmd *cobra.Command, args []string) error { if err := client.Get(fmt.Sprintf("/v2/accounts/%s/voiceConfigurationPackages", acctID), &listResult); err == nil { matches := findAllByName(listResult, "name", createName) if len(matches) > 1 { - return fmt.Errorf("found %d VCPs named %q; disambiguate by ID with band vcp update ", len(matches), createName) + return &cmdutil.ConflictError{Message: fmt.Sprintf( + "found %d VCPs named %q; disambiguate by ID with band vcp update ", len(matches), createName)} } if len(matches) == 1 { existing := matches[0] @@ -168,13 +169,16 @@ func normalizedField(v interface{}) string { func vcpConflict(existing map[string]interface{}, name string, checkDescription bool, description string, checkAppID bool, appID string, plan map[string]interface{}) error { id := existing["voiceConfigurationPackageId"] if checkDescription && normalizedField(existing["description"]) != description { - return fmt.Errorf("VCP %q exists with a different description — update it explicitly: band vcp update %v --description ", name, id) + return &cmdutil.ConflictError{Message: fmt.Sprintf( + "VCP %q exists with a different description — update it explicitly: band vcp update %v --description ", name, id)} } if checkAppID && normalizedField(existing["httpVoiceV2ApplicationId"]) != appID { - return fmt.Errorf("VCP %q exists but is linked to a different application — update it explicitly: band vcp update %v --app-id ", name, id) + return &cmdutil.ConflictError{Message: fmt.Sprintf( + "VCP %q exists but is linked to a different application — update it explicitly: band vcp update %v --app-id ", name, id)} } if plan != nil && !RoutePlansEqual(existing["originationRoutePlan"], plan) { - return fmt.Errorf("VCP %q exists with a different origination route plan — update it explicitly: band vcp update %v --route-endpoint ... --route-endpoint-type ... --replace-routes", name, id) + return &cmdutil.ConflictError{Message: fmt.Sprintf( + "VCP %q exists with a different origination route plan — update it explicitly: band vcp update %v --route-endpoint ... --route-endpoint-type ... --replace-routes", name, id)} } return nil } diff --git a/cmd/vcp/route_test.go b/cmd/vcp/route_test.go index 8e28b32..ebbc740 100644 --- a/cmd/vcp/route_test.go +++ b/cmd/vcp/route_test.go @@ -1,6 +1,11 @@ package vcp -import "testing" +import ( + "strings" + "testing" + + "github.com/Bandwidth/cli/internal/cmdutil" +) func TestBuildRoutePlan_SingleFQDNEndpoint(t *testing.T) { plan, err := BuildRoutePlan("vapi.example.sip.vapi.ai", "FQDN", "") @@ -305,3 +310,46 @@ func TestVCPConflict_RoutePlanMismatch(t *testing.T) { t.Error("expected conflict error when existing plan is empty but caller requested a route") } } + +// --- exit codes: every vcp state conflict must be exit 4, not exit 1 --- + +// TestVCPConflict_ExitsConflictNotGeneral covers the agent contract. These +// errors all say "the VCP exists but differs — update it explicitly," which is +// exactly the conflict signal (4). They were bare fmt.Errorf, so they exited 1, +// indistinguishable from an unexpected failure. An agent branching on 1 vs 4 +// cannot tell "go reconcile" from "something broke." +func TestVCPConflict_ExitsConflictNotGeneral(t *testing.T) { + plan, err := BuildRoutePlan("h.example.com", "FQDN", "") + if err != nil { + t.Fatalf("BuildRoutePlan() error = %v", err) + } + existing := map[string]interface{}{ + "voiceConfigurationPackageId": "vcp-1", + "description": "existing description", + "httpVoiceV2ApplicationId": "app-existing", + "originationRoutePlan": nil, + } + + cases := []struct { + name string + err error + }{ + {"description mismatch", vcpConflict(existing, "Prod VCP", true, "requested", false, "", nil)}, + {"app id mismatch", vcpConflict(existing, "Prod VCP", false, "", true, "app-requested", nil)}, + {"route plan mismatch", vcpConflict(existing, "Prod VCP", false, "", false, "", plan)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.err == nil { + t.Fatal("vcpConflict() = nil, want a conflict") + } + if got := cmdutil.ExitCodeForError(c.err); got != cmdutil.ExitConflict { + t.Errorf("ExitCodeForError() = %d, want ExitConflict (%d); err = %v", got, cmdutil.ExitConflict, c.err) + } + // The remediation text must be preserved verbatim: only the type changed. + if !strings.Contains(c.err.Error(), "update it explicitly") { + t.Errorf("error = %q, want the existing remediation text", c.err.Error()) + } + }) + } +} diff --git a/cmd/vcp/update.go b/cmd/vcp/update.go index cbe1f52..fb382cf 100644 --- a/cmd/vcp/update.go +++ b/cmd/vcp/update.go @@ -129,7 +129,8 @@ func runUpdate(cmd *cobra.Command, args []string) error { // This is a read-then-write check, so it is best-effort against // concurrent modification of the VCP between the GET and the PATCH. if requiresRouteReplaceConfirmation(existingPlan, plan, updateReplaceRoutes) { - return fmt.Errorf("VCP %s already has an origination route plan; writing a new one replaces it — re-run with --replace-routes to confirm", vcpID) + return &cmdutil.ConflictError{Message: fmt.Sprintf( + "VCP %s already has an origination route plan; writing a new one replaces it — re-run with --replace-routes to confirm", vcpID)} } body["originationRoutePlan"] = plan } diff --git a/internal/cmdutil/exitcodes.go b/internal/cmdutil/exitcodes.go index f263a0d..7efb370 100644 --- a/internal/cmdutil/exitcodes.go +++ b/internal/cmdutil/exitcodes.go @@ -27,11 +27,31 @@ type SecretUnavailableError struct{ Message string } func (e *SecretUnavailableError) Error() string { return e.Message } +// ConflictError reports that the target resource exists but is not in a state +// where the requested operation can succeed — a duplicate, a wrong lifecycle +// state, a mismatched existing setting. It maps to ExitConflict (4). +// +// It exists because the alternative — deriving 4 from the HTTP status — is +// unreliable: the same logical conflict is returned as 409, 400, or even 201 +// by different Bandwidth endpoints, and client-side conflicts have no status at +// all. Agents branch on exit 4, so the code must follow the *meaning*, not the +// transport. +type ConflictError struct { + Message string + // Cause, when non-nil, is preserved through Unwrap so callers that need the + // original fault (its error code or status) can still reach it. + Cause error +} + +func (e *ConflictError) Error() string { return e.Message } +func (e *ConflictError) Unwrap() error { return e.Cause } + // ExitCodeForError maps an error to the appropriate exit code. // FeatureLimitError takes precedence over the raw API status code so a // 403 caused by a plan/role limit maps to ExitConflict (4) rather than // ExitAuth (2) — agents can then distinguish "stop, escalate" from -// "re-auth or retry." +// "re-auth or retry." ConflictError takes precedence for the same reason: a +// state conflict must exit 4 even when the API reported it as a 400 or a 201. // All other errors fall back to status-code mapping, then ExitGeneral. func ExitCodeForError(err error) int { if err == nil { @@ -48,6 +68,10 @@ func ExitCodeForError(err error) int { if errors.As(err, &sue) { return ExitSecretUnavailable } + var ce *ConflictError + if errors.As(err, &ce) { + return ExitConflict + } var apiErr *api.APIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { diff --git a/internal/cmdutil/exitcodes_test.go b/internal/cmdutil/exitcodes_test.go index b87453a..d35db53 100644 --- a/internal/cmdutil/exitcodes_test.go +++ b/internal/cmdutil/exitcodes_test.go @@ -1,4 +1,10 @@ -package cmdutil +// This file is an EXTERNAL test package (cmdutil_test) rather than an +// in-package one. It asserts that *sip.APIFault and the SIP service's +// ConflictError map onto the exit-code taxonomy, which requires importing +// internal/sip — and internal/sip imports cmdutil for ConflictError. An +// in-package test would therefore form a test-only import cycle. Everything +// asserted here is exported API, so nothing is lost. +package cmdutil_test import ( "errors" @@ -6,6 +12,7 @@ import ( "testing" "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/sip" ) @@ -15,29 +22,29 @@ func TestExitCodeForError(t *testing.T) { err error want int }{ - {"nil error", nil, ExitOK}, - {"plain error", errors.New("boom"), ExitGeneral}, - {"401", &api.APIError{StatusCode: 401}, ExitAuth}, - {"403", &api.APIError{StatusCode: 403}, ExitAuth}, - {"402 payment required", &api.APIError{StatusCode: 402, Body: "insufficient credits"}, ExitConflict}, - {"404", &api.APIError{StatusCode: 404}, ExitNotFound}, - {"409", &api.APIError{StatusCode: 409}, ExitConflict}, - {"429 rate limited", &api.APIError{StatusCode: 429}, ExitRateLimit}, - {"500", &api.APIError{StatusCode: 500}, ExitGeneral}, - {"feature limit wraps 403", NewFeatureLimit("nope", &api.APIError{StatusCode: 403}), ExitConflict}, - {"feature limit precedence beats raw 401", NewFeatureLimit("nope", &api.APIError{StatusCode: 401}), ExitConflict}, - {"wrapped 429 keeps rate limit", fmt.Errorf("wrap: %w", &api.APIError{StatusCode: 429}), ExitRateLimit}, - {"wrapped ErrPollTimeout", fmt.Errorf("timed out: %w", ErrPollTimeout), ExitTimeout}, + {"nil error", nil, cmdutil.ExitOK}, + {"plain error", errors.New("boom"), cmdutil.ExitGeneral}, + {"401", &api.APIError{StatusCode: 401}, cmdutil.ExitAuth}, + {"403", &api.APIError{StatusCode: 403}, cmdutil.ExitAuth}, + {"402 payment required", &api.APIError{StatusCode: 402, Body: "insufficient credits"}, cmdutil.ExitConflict}, + {"404", &api.APIError{StatusCode: 404}, cmdutil.ExitNotFound}, + {"409", &api.APIError{StatusCode: 409}, cmdutil.ExitConflict}, + {"429 rate limited", &api.APIError{StatusCode: 429}, cmdutil.ExitRateLimit}, + {"500", &api.APIError{StatusCode: 500}, cmdutil.ExitGeneral}, + {"feature limit wraps 403", cmdutil.NewFeatureLimit("nope", &api.APIError{StatusCode: 403}), cmdutil.ExitConflict}, + {"feature limit precedence beats raw 401", cmdutil.NewFeatureLimit("nope", &api.APIError{StatusCode: 401}), cmdutil.ExitConflict}, + {"wrapped 429 keeps rate limit", fmt.Errorf("wrap: %w", &api.APIError{StatusCode: 429}), cmdutil.ExitRateLimit}, + {"wrapped ErrPollTimeout", fmt.Errorf("timed out: %w", cmdutil.ErrPollTimeout), cmdutil.ExitTimeout}, // *sip.APIFault must unwrap to *api.APIError so a documented SIP // failure (one that carries an ErrorCode) maps onto the CLI's // exit-code taxonomy instead of falling back to ExitGeneral. - {"sip APIFault 404", &sip.APIFault{Code: "33010", Description: "not found", StatusCode: 404}, ExitNotFound}, - {"sip APIFault 409", &sip.APIFault{Code: "23026", Description: "does already exist", StatusCode: 409}, ExitConflict}, + {"sip APIFault 404", &sip.APIFault{Code: "33010", Description: "not found", StatusCode: 404}, cmdutil.ExitNotFound}, + {"sip APIFault 409", &sip.APIFault{Code: "23026", Description: "does already exist", StatusCode: 409}, cmdutil.ExitConflict}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ExitCodeForError(tt.err) + got := cmdutil.ExitCodeForError(tt.err) if got != tt.want { t.Errorf("ExitCodeForError(%v) = %d, want %d", tt.err, got, tt.want) } @@ -46,11 +53,62 @@ func TestExitCodeForError(t *testing.T) { } func TestExitCodeForError_SecretUnavailable(t *testing.T) { - err := &SecretUnavailableError{Message: "credential exists but its password cannot be recovered"} - if got := ExitCodeForError(err); got != ExitSecretUnavailable { - t.Errorf("ExitCodeForError() = %d, want %d", got, ExitSecretUnavailable) + err := &cmdutil.SecretUnavailableError{Message: "credential exists but its password cannot be recovered"} + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d", got, cmdutil.ExitSecretUnavailable) } - if ExitSecretUnavailable != 8 { - t.Errorf("ExitSecretUnavailable = %d, want 8", ExitSecretUnavailable) + if cmdutil.ExitSecretUnavailable != 8 { + t.Errorf("ExitSecretUnavailable = %d, want 8", cmdutil.ExitSecretUnavailable) + } +} + +// TestExitCodeForError_Conflict is the load-bearing assertion for every +// client-side state conflict in `band sip` / `band vcp`: a ConflictError must +// exit 4 no matter what (if anything) it wraps. The wrapped cases matter most — +// a state conflict the API reported as a 400 (23022, 23026) or as a 201 body +// (bulk-create Errors envelope) previously fell through to ExitGeneral (1), +// which an agent cannot distinguish from an unexpected failure. +func TestExitCodeForError_Conflict(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"bare client-side conflict", &cmdutil.ConflictError{Message: "realm is CREATE_PENDING"}}, + {"wraps a 400 fault", &cmdutil.ConflictError{ + Message: "realm is not active yet", + Cause: &sip.APIFault{Code: "23022", Description: "not active", StatusCode: 400}, + }}, + {"wraps a 201 partial-success fault", &cmdutil.ConflictError{ + Message: "credential already exists", + Cause: &sip.APIFault{Code: "23026", Description: "does already exist", StatusCode: 201}, + }}, + {"wraps a 409 fault", &cmdutil.ConflictError{ + Message: "realm already exists", + Cause: &sip.APIFault{Code: "33002", Description: "exists", StatusCode: 409}, + }}, + {"wrapped by fmt.Errorf", fmt.Errorf("context: %w", &cmdutil.ConflictError{Message: "conflict"})}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cmdutil.ExitCodeForError(tt.err); got != cmdutil.ExitConflict { + t.Errorf("ExitCodeForError(%v) = %d, want ExitConflict (%d)", tt.err, got, cmdutil.ExitConflict) + } + }) + } +} + +// TestConflictError_PreservesCauseChain guards the Unwrap contract: sites that +// previously wrapped with %w must keep the original fault reachable, so a +// caller can still read the API error code behind the conflict. +func TestConflictError_PreservesCauseChain(t *testing.T) { + fault := &sip.APIFault{Code: "33006", Description: "cannot delete default", StatusCode: 409} + err := error(&cmdutil.ConflictError{Message: "cannot delete the default realm", Cause: fault}) + + var got *sip.APIFault + if !errors.As(err, &got) { + t.Fatalf("errors.As(%v, *sip.APIFault) = false, want the cause to stay reachable", err) + } + if got.Code != "33006" { + t.Errorf("unwrapped Code = %q, want 33006", got.Code) } } diff --git a/internal/output/redact.go b/internal/output/redact.go index 84fb10b..044b4dc 100644 --- a/internal/output/redact.go +++ b/internal/output/redact.go @@ -16,6 +16,19 @@ var redactedKeys = map[string]bool{ // a namespace prefix and optional attributes, case-insensitively. var hashElementRe = regexp.MustCompile(`(?is)<((?:\w+:)?hash1b?)(?:\s[^>]*)?>.*?`) +// bareHashRe matches a bare 32-lowercase-hex token: the rendered form of an MD5 +// digest (ComputeHashes emits lowercase hex). The element-anchored regex above +// cannot catch a hash echoed in prose — the live 23026 response echoes submitted +// hashes inside the error Description, e.g. "Invalid Hash1 value d41d8cd9…" — +// nor one truncated before its closing tag. +// +// This is applied ONLY by ScrubHashes, i.e. only to raw XML response/error +// bodies and fault descriptions. It is deliberately NOT used by RedactSecrets: +// a 32-hex value sitting in a structured output field is far more likely to be a +// legitimate identifier than a digest, and dropping the length/case constraint +// there would risk mangling real data. +var bareHashRe = regexp.MustCompile(`\b[0-9a-f]{32}\b`) + func isRedactedKey(k string) bool { if i := strings.LastIndex(k, ":"); i >= 0 { k = k[i+1:] @@ -62,8 +75,12 @@ func RedactAndPrint(format string, plain bool, data interface{}) error { return StdoutAuto(format, plain, RedactSecrets(data)) } -// ScrubHashes removes digest-hash values from a raw XML body while preserving -// the surrounding diagnostic content (error codes, usernames). +// ScrubHashes removes digest-hash values from a raw XML body or error +// description while preserving the surrounding diagnostic content (error codes, +// usernames). It runs two passes: the element-anchored one, which keeps the +// XML shape intact, then a bare 32-hex sweep for hashes echoed as prose or left +// unterminated by a truncated body. func ScrubHashes(s string) string { - return hashElementRe.ReplaceAllString(s, "<$1>[REDACTED]") + s = hashElementRe.ReplaceAllString(s, "<$1>[REDACTED]") + return bareHashRe.ReplaceAllString(s, "[REDACTED]") } diff --git a/internal/output/redact_test.go b/internal/output/redact_test.go index 4c2a810..3cbfcb9 100644 --- a/internal/output/redact_test.go +++ b/internal/output/redact_test.go @@ -121,3 +121,52 @@ func TestScrubHashes_AttributedHashElement(t *testing.T) { t.Errorf("username was lost: %s", got) } } + +// TestScrubHashes_BareHashInProse covers what the element-anchored regex +// structurally cannot: a digest echoed as prose rather than as an XML element. +// The live 23026 response echoes the submitted hashes inside the error +// Description, which is printed to stderr and captured in agent transcripts. +func TestScrubHashes_BareHashInProse(t *testing.T) { + got := ScrubHashes("Invalid Hash1 value d41d8cd98f00b204e9800998ecf8427e for user clitest") + if strings.Contains(got, "d41d8cd98f00b204e9800998ecf8427e") { + t.Errorf("bare hash survived scrubbing: %s", got) + } + if !strings.Contains(got, "[REDACTED]") { + t.Errorf("missing [REDACTED] marker: %s", got) + } + if !strings.Contains(got, "clitest") { + t.Errorf("surrounding diagnostic content was lost: %s", got) + } +} + +// TestScrubHashes_TruncatedHashElement is the truncated-body case: the closing +// tag never arrived, so hashElementRe cannot match, and the value used to pass +// through completely unredacted. +func TestScrubHashes_TruncatedHashElement(t *testing.T) { + got := ScrubHashes(`1be6abcaa8e9956021d30f33a3925b99`) + if strings.Contains(got, "1be6abcaa8e9956021d30f33a3925b99") { + t.Errorf("hash in a truncated element survived scrubbing: %s", got) + } +} + +// TestScrubHashes_LeavesShorterHexAlone bounds the bare-hex sweep: it must not +// eat ordinary identifiers. Only a full 32-hex run — the rendered length of an +// MD5 digest — is treated as secret material. +func TestScrubHashes_LeavesShorterHexAlone(t *testing.T) { + in := `vapi-3efeaa.auth.bandwidth.com1103deadbeefcafe` + if got := ScrubHashes(in); got != in { + t.Errorf("ScrubHashes mangled non-secret content:\n got %s\n want %s", got, in) + } +} + +// TestRedactSecrets_KeepsBareHexValues pins the deliberate scoping decision: +// the bare-hex sweep belongs to ScrubHashes (raw bodies) only. A 32-hex value in +// a structured output field is far more likely to be a legitimate identifier, +// and RedactSecrets keys off field NAMES instead. +func TestRedactSecrets_KeepsBareHexValues(t *testing.T) { + in := map[string]interface{}{"requestId": "d41d8cd98f00b204e9800998ecf8427e"} + out := RedactSecrets(in).(map[string]interface{}) + if out["requestId"] != "d41d8cd98f00b204e9800998ecf8427e" { + t.Errorf("requestId = %v, want it preserved — RedactSecrets redacts by key, not by value shape", out["requestId"]) + } +} diff --git a/internal/sip/service.go b/internal/sip/service.go index 5381ffb..eee3e36 100644 --- a/internal/sip/service.go +++ b/internal/sip/service.go @@ -3,11 +3,14 @@ package sip import ( "encoding/xml" "fmt" + "io" "net/url" + "os" "strings" "time" "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" "github.com/Bandwidth/cli/internal/output" ) @@ -71,44 +74,67 @@ func (s *Service) do(method, path string, reqBody interface{}) ([]byte, error) { } // A 2xx can still carry an error envelope (e.g. a partially successful // bulk credential create), so probe it the same as a non-2xx body. - if fault := parseFault(resp.Body, resp.StatusCode); fault != nil { + if fault, _ := parseFault(resp.Body, resp.StatusCode); fault != nil { return nil, fault } return resp.Body, nil } - if fault := parseFault(resp.Body, resp.StatusCode); fault != nil { + fault, parsed := parseFault(resp.Body, resp.StatusCode) + if fault != nil { return nil, fault } - return nil, &api.APIError{StatusCode: resp.StatusCode, Body: output.ScrubHashes(string(resp.Body))} + body := output.ScrubHashes(string(resp.Body)) + if !parsed && len(resp.Body) > 0 { + // Fail closed: an unparseable body cannot be proven hash-free, and the + // element-anchored scrubber demonstrably misses a hash truncated before + // its closing tag. An empty body is left empty so api.APIError can + // substitute the HTTP status text. + body = unparseableBodyPlaceholder + } + return nil, &api.APIError{StatusCode: resp.StatusCode, Body: body} } -// parseFault extracts ResponseStatus or the first Errors entry. Returns nil if -// the body carries neither, including when the body is not parseable XML — the -// caller falls through to the api.APIError path, which already scrubs hashes -// and substitutes a status-text placeholder when the body is empty. -func parseFault(body []byte, status int) *APIFault { +// unparseableBodyPlaceholder replaces an error body that is not well-formed XML. +const unparseableBodyPlaceholder = "(response body discarded: not well-formed XML, so it could not be proven free of digest hashes)" + +// parseFault extracts ResponseStatus or the first Errors entry, and reports +// whether the body was well-formed XML at all. +// +// The two fault==nil cases are deliberately distinguishable: +// +// - parsed=true — the body decoded but carries no ErrorCode. This is the live +// 404 shape (a ResponseStatus with a Description and no code), whose body is +// useful diagnostic content, so the caller keeps the scrubbed body. +// - parsed=false — the body is not well-formed XML (truncated mid-element, an +// HTML error page from a proxy, ...). The caller discards it entirely per +// the spec's redaction rules. +// +// Descriptions are run through output.ScrubHashes because APIFault.Error() +// prints Description and the live 23026 response echoes the submitted hashes +// back inside the error text. +func parseFault(body []byte, status int) (fault *APIFault, parsed bool) { var probe struct { ResponseStatus *responseStatus `xml:"ResponseStatus"` Errors []wireError `xml:"Errors>Error"` } if err := xml.Unmarshal(body, &probe); err != nil { - return nil + return nil, false } if probe.ResponseStatus != nil && probe.ResponseStatus.ErrorCode != "" { return &APIFault{ Code: probe.ResponseStatus.ErrorCode, - Description: probe.ResponseStatus.Description, + Description: output.ScrubHashes(probe.ResponseStatus.Description), StatusCode: status, - } + }, true } if len(probe.Errors) > 0 { return &APIFault{ Code: probe.Errors[0].ErrorCode, - Description: probe.Errors[0].Description, + Description: output.ScrubHashes(probe.Errors[0].Description), StatusCode: status, - } + }, true } - return nil + return nil, true } // shortName derives the realm's short name from its FQDN. The API returns the @@ -204,16 +230,25 @@ func (s *Service) DeleteRealm(ref string) error { return err } -// SetRealmDefault promotes a realm to the account default. The API only -// supports setting Default to true. -func (s *Service) SetRealmDefault(ref string) (*Realm, error) { +// UpdateRealm applies a partial update to a realm. Only two fields are +// updatable: promotion to the account default (the API rejects Default=false) +// and the description. Pass promoteDefault=false and description=nil for a no-op +// field and it is re-sent from current state, never dropped. +// +// Read-modify-write is mandatory, not an optimization: realm PUT is a full +// replace, so any field the caller did not name must be echoed back from the +// current realm or the API will clear it. +func (s *Service) UpdateRealm(ref string, promoteDefault bool, description *string) (*Realm, error) { current, err := s.GetRealm(ref) if err != nil { return nil, err } - // Read-modify-write: Description is resent so a full-replace PUT cannot drop it. + desc := current.Description + if description != nil { + desc = *description + } body, err := s.do("PUT", s.base()+"/realms/"+url.PathEscape(ref), realmRequest{ - Realm: current.Name, Description: current.Description, Default: true, + Realm: current.Name, Description: desc, Default: current.Default || promoteDefault, }) if err != nil { return nil, err @@ -247,7 +282,10 @@ func (s *Service) CreateCredential(realmID, username, hash1, hash1b, appID strin return nil, fmt.Errorf("decoding credential response: %w", err) } if len(resp.Errors) > 0 { - return nil, &APIFault{Code: resp.Errors[0].ErrorCode, Description: resp.Errors[0].Description, StatusCode: 201} + // Description is scrubbed for the same reason parseFault scrubs its own: + // APIFault.Error() prints it, and the live 23026 response echoes the + // submitted hashes inside the error text. + return nil, &APIFault{Code: resp.Errors[0].ErrorCode, Description: output.ScrubHashes(resp.Errors[0].Description), StatusCode: 201} } for i := range resp.Valid { if resp.Valid[i].UserName == username { @@ -285,9 +323,20 @@ func (s *Service) RotateCredential(realmID, credentialID, hash1, hash1b string) return toCredential(resp.Credential), nil } +// credentialPageSize is the page size the API's own 303 redirects to. A result +// of exactly this length means there is very likely a second page. +const credentialPageSize = 500 + +// warnOut is where truncation warnings go. A package var so tests can capture it. +var warnOut io.Writer = os.Stderr + // ListCredentials returns a realm's credentials, always as a non-nil slice. // The API answers an unpaginated request with a 303 to ?page=1&size=500, which // the client follows. +// +// Auto-pagination is NOT implemented (see the spec's deferred list). What is +// implemented is the refusal to be silent about it: a full page warns on stderr +// rather than reading as a complete list. func (s *Service) ListCredentials(realmID string) ([]Credential, error) { body, err := s.do("GET", s.credentialsPath(realmID), nil) if err != nil { @@ -301,6 +350,11 @@ func (s *Service) ListCredentials(realmID string) ([]Credential, error) { for i := range resp.Credentials { out = append(out, *toCredential(&resp.Credentials[i])) } + if len(out) == credentialPageSize { + // stderr, not stdout: --plain output must stay machine-parseable. + fmt.Fprintf(warnOut, "Warning: realm %s returned exactly %d credentials, the API's page size — this list may be truncated. "+ + "Pagination is not yet implemented, so any credential beyond the first page is not shown.\n", realmID, credentialPageSize) + } return out, nil } @@ -355,7 +409,10 @@ func (s *Service) FindCredentialByUsername(realmID, username string) (*Credentia case 0: lastErr = fmt.Errorf("the API reported a duplicate but no credential named %q is listed in realm %s — check for a case difference", username, realmID) default: - return nil, fmt.Errorf("found %d credentials named %q in realm %s; delete the duplicates", len(matches), username, realmID) + // Multiple matches is a conflict (spec line 30), not a generic + // failure: the caller must delete duplicates, not retry. + return nil, &cmdutil.ConflictError{Message: fmt.Sprintf( + "found %d credentials named %q in realm %s; delete the duplicates", len(matches), username, realmID)} } } return nil, lastErr diff --git a/internal/sip/service_test.go b/internal/sip/service_test.go index d6673c8..55b3630 100644 --- a/internal/sip/service_test.go +++ b/internal/sip/service_test.go @@ -471,3 +471,206 @@ func TestCredentialHashesMatch_WrongShapedBodyIsDecodeError(t *testing.T) { t.Fatal("CredentialHashesMatch() error = nil, want decode error for a wrong-shaped body") } } + +// TestParseFault_ScrubsHashEchoedInDescription covers the leak the spec records +// at line 294: the live 23026 response echoes the SUBMITTED hashes back inside +// the error text. APIFault.Error() prints Description and faultExit prints it +// again, and stderr is captured verbatim in agent transcripts and CI logs. The +// hash here is bare prose — no element — which is exactly what the +// element-anchored scrubber cannot see. +func TestParseFault_ScrubsHashEchoedInDescription(t *testing.T) { + const hash = "d41d8cd98f00b204e9800998ecf8427e" + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Write([]byte(`` + + `23026` + + `SipCredential with Hash1 value ` + hash + ` does already exist` + + ``)) + }) + defer done() + + _, err := svc.CreateCredential("1103", "agent", hash, hash, "") + if err == nil { + t.Fatal("CreateCredential() error = nil, want a fault") + } + if strings.Contains(err.Error(), hash) { + t.Errorf("error surfaced the digest hash: %s", err.Error()) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Errorf("error missing [REDACTED] marker: %s", err.Error()) + } + // The diagnostic content around the hash must survive. + if !strings.Contains(err.Error(), "23026") || !strings.Contains(err.Error(), "does already exist") { + t.Errorf("scrubbing destroyed the diagnostic content: %s", err.Error()) + } +} + +// TestDo_TruncatedBodyIsDiscardedNotPartiallyScrubbed covers the fail-open path +// the spec closes at line 293: "malformed or truncated XML error bodies are +// discarded entirely rather than partially scrubbed — if it can't be parsed, it +// can't be proven hash-free." +// +// A body truncated mid-hash has no closing tag, so hashElementRe cannot match +// it; before the fix the value passed through completely unredacted. +func TestDo_TruncatedBodyIsDiscardedNotPartiallyScrubbed(t *testing.T) { + const hash = "d41d8cd98f00b204e9800998ecf8427e" + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + // Connection died mid-element: no , no closing ancestors. + w.Write([]byte(`23026` + + `` + hash)) + }) + defer done() + + _, err := svc.GetRealm("1103") + if err == nil { + t.Fatal("GetRealm() error = nil, want an error") + } + if strings.Contains(err.Error(), hash) { + t.Errorf("truncated body leaked the digest hash: %s", err.Error()) + } + if !strings.Contains(err.Error(), "discarded") { + t.Errorf("error = %q, want the discarded-body placeholder", err.Error()) + } +} + +// TestDo_ParsedBodyWithoutErrorCodeKeepsItsBody is the negative pairing: the +// live 404 shape is a ResponseStatus carrying a Description and NO ErrorCode. +// It parses fine, so its body is provably hash-free and useful — discarding it +// would throw away the only diagnostic the caller gets. +func TestDo_ParsedBodyWithoutErrorCodeKeepsItsBody(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte(`` + + `The realm 9999 was not found` + + ``)) + }) + defer done() + + _, err := svc.GetRealm("9999") + if err == nil { + t.Fatal("GetRealm() error = nil, want a 404") + } + if !strings.Contains(err.Error(), "was not found") { + t.Errorf("error = %q, want the parseable 404 body preserved", err.Error()) + } + if strings.Contains(err.Error(), "discarded") { + t.Errorf("error = %q, want the body kept — it parsed, so it is provably hash-free", err.Error()) + } +} + +// TestListCredentials_FullPageWarnsAboutTruncation covers the silent cap. +// The spec promises auto-pagination; it is not implemented. A list that returns +// exactly the API's page size therefore reads as "complete" when it may not be. +// Pagination stays deferred, but the silence does not. +func TestListCredentials_FullPageWarnsAboutTruncation(t *testing.T) { + var b strings.Builder + for i := 0; i < credentialPageSize; i++ { + b.WriteString(`1u`) + } + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + b.String() + + ``)) + }) + defer done() + + var warnings strings.Builder + orig := warnOut + warnOut = &warnings + defer func() { warnOut = orig }() + + creds, err := svc.ListCredentials("1103") + if err != nil { + t.Fatalf("ListCredentials() error = %v", err) + } + if len(creds) != credentialPageSize { + t.Fatalf("len = %d, want %d", len(creds), credentialPageSize) + } + got := warnings.String() + if !strings.Contains(got, "may be truncated") { + t.Errorf("warning = %q, want it to state the list may be truncated", got) + } + if !strings.Contains(got, "Pagination is not yet implemented") { + t.Errorf("warning = %q, want it to name the missing capability", got) + } +} + +// TestListCredentials_PartialPageDoesNotWarn is the negative pairing: a list +// that is obviously complete must stay quiet, or the warning becomes noise an +// agent learns to ignore. +func TestListCredentials_PartialPageDoesNotWarn(t *testing.T) { + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`` + + `1u` + + ``)) + }) + defer done() + + var warnings strings.Builder + orig := warnOut + warnOut = &warnings + defer func() { warnOut = orig }() + + if _, err := svc.ListCredentials("1103"); err != nil { + t.Fatalf("ListCredentials() error = %v", err) + } + if warnings.Len() != 0 { + t.Errorf("unexpected warning for a partial page: %q", warnings.String()) + } +} + +// TestUpdateRealm_ReadModifyWritePreservesUnspecifiedFields pins the service +// contract the command depends on: realm PUT is a full replace, so any field +// the caller did not name must be echoed back from current state. +func TestUpdateRealm_ReadModifyWritePreservesUnspecifiedFields(t *testing.T) { + tests := []struct { + name string + currentDesc string + currentDefault bool + promote bool + description *string + wantDesc string + wantDefault bool + }{ + {"description only keeps default", "old", true, false, strPtr("new"), "new", true}, + {"promotion keeps description", "keep", false, true, nil, "keep", true}, + {"neither re-sends current state", "keep", true, false, nil, "keep", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sent string + svc, done := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + b, _ := io.ReadAll(r.Body) + sent = string(b) + } + def := "false" + if tt.currentDefault { + def = "true" + } + w.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.com` + + `` + tt.currentDesc + `` + + `` + def + `ACTIVE` + + ``)) + }) + defer done() + + if _, err := svc.UpdateRealm("vapi", tt.promote, tt.description); err != nil { + t.Fatalf("UpdateRealm() error = %v", err) + } + if !strings.Contains(sent, ""+tt.wantDesc+"") { + t.Errorf("PUT body = %q, want Description %q", sent, tt.wantDesc) + } + wantDefault := "false" + if tt.wantDefault { + wantDefault = "true" + } + if !strings.Contains(sent, wantDefault) { + t.Errorf("PUT body = %q, want %s", sent, wantDefault) + } + }) + } +} + +func strPtr(s string) *string { return &s } From 24908beabf843f34007347dd3b7afc5ab66e13a6 Mon Sep 17 00:00:00 2001 From: Kush Date: Thu, 30 Jul 2026 10:00:33 -0400 Subject: [PATCH 23/25] fix(sip,vcp): close the write-once-secret and route-plan-parsing gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes that share one theme: an operation that cannot be undone must not be reachable by accident, and a comparison that guards one must not be decided on coerced data. sip credential create/rotate — exit 8 on a stdout-write failure The API write commits before the password is printed. If stdout is full, closed, or short-writes at that moment, the credential exists and nobody will ever know its password: the same unrecoverable state a lost response leaves behind, so it now reports the same exit code (8) instead of the generic 1 a write error maps to. Rotate is the worse case of the two — the peer's hashes are already replaced — so its message names the exact rotate command to re-run. A caller-supplied password loses nothing, so its write errors are returned unchanged. sip credential create/rotate — the generated password is written first passwordFirstPayload.MarshalJSON emits "password" ahead of every other key so a truncated write still delivers the one copy of the secret. The JSON paths therefore bypass emit, which normalizes to a map and inherits Go's alphabetical key order. emitCredential performs emit's two meaningful steps itself in the order that matters: normalize, redact, THEN wrap. Wrapping last is required, not stylistic — output.RedactSecrets only walks unnamed map[string]interface{}, so a wrapped payload would slip past redaction entirely. The table path still uses emit. sip credential create — --app-id is validated as a UUID up front sipsvc.ValidateAppID runs before the service is built, before the realm lookup, and before any password is read or generated. An invalid value now costs zero HTTP requests and, more to the point, never generates a write-once secret the API was going to reject anyway. sip realm delete — returns the realm's canonical ID The ref is resolved with GetRealm before the delete, so --plain "id" is always the numeric realm ID rather than whichever name or FQDN the caller typed. Polling uses the canonical ID too: the name a deleted realm answered to is not guaranteed to keep resolving. Costs one extra GET. The 202 semantics are unchanged — deleted starts false and only --wait promotes it. sip credential password reading — trimOneLineEnding TrimSuffix(TrimSuffix(s, "\n"), "\r") also stripped a BARE trailing "\r". \r is a legal password byte and the hashes are built from whatever survives the trim, so dropping it silently produced a credential whose peer can never authenticate, with no error to explain why. Now exactly one line ending — "\r\n" or a lone "\n" — is removed. vcp --route-plan-json — validate nested structure instead of coercing it The parser checked only that the sole top-level key was "routes". Everything below it was unchecked, and canonicalPlanJSON then coerced wrong types and absent numerics into empty slices and zeros — so {"routes":"not-an-array"}, {"routes":[null]}, a route with "endponts", and an endpoint with "weigth" all parsed, then canonicalized into a plan the caller never wrote. That canonical form is what --if-not-exists compares and what the --replace-routes destructive-write guard is decided from, so a silent coercion there can let a real plan change read as a no-op. Input now decodes into typed structs via json.Decoder with DisallowUnknownFields, and dec.More() rejects trailing content (Decode stops at the first complete value, so a second concatenated plan was silently discarded). Structural checks reject an absent, null, or empty "routes", null route/endpoint entries, an absent/null/empty "endpoints", and an endpoint missing its "endpoint" or "type". Decode errors are rewritten to name the offending field rather than leak Go type names. Numerics are *float64 rather than json.Number for two reasons: float64 is the exact type encoding/json produces for a JSON number decoded into an interface{}, so the request map marshals byte-identically to the old raw map; and a json.Number field reports a wrong type without naming the field, which defeats the point. Every field is a pointer so a field the caller omitted stays omitted on the wire — --route-plan-json is a pass-through, not a defaults provider. Tests cover each malformed shape with an assertion that the message names the offending field, trailing content, and byte-identical wire output for valid input — the last compared against a reference implementation of the old marshaling, not only a literal, so the invariant cannot be quietly re-baselined. Every new rejection was mutation-verified against the pre-fix parser. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/sip/credential_create.go | 102 ++++++++++- cmd/sip/credential_create_test.go | 274 +++++++++++++++++++++++++++++- cmd/sip/credential_rotate.go | 17 +- cmd/sip/credential_rotate_test.go | 83 +++++++++ cmd/sip/golden_test.go | 28 ++- cmd/sip/password.go | 21 ++- cmd/sip/password_test.go | 36 ++++ cmd/sip/realm_delete.go | 20 ++- cmd/vcp/route.go | 150 +++++++++++++++- cmd/vcp/route_test.go | 165 ++++++++++++++++++ internal/sip/sip.go | 20 +++ 11 files changed, 893 insertions(+), 23 deletions(-) diff --git a/cmd/sip/credential_create.go b/cmd/sip/credential_create.go index 858f9e6..351f2d7 100644 --- a/cmd/sip/credential_create.go +++ b/cmd/sip/credential_create.go @@ -1,12 +1,16 @@ package sip import ( + "bytes" + "encoding/json" "errors" "fmt" + "sort" "github.com/spf13/cobra" "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" sipsvc "github.com/Bandwidth/cli/internal/sip" ) @@ -44,9 +48,16 @@ var credentialCreateCmd = &cobra.Command{ } func runCredentialCreate(cmd *cobra.Command, args []string) error { + // Both input validations run BEFORE the service is built, before the realm + // lookup, and before any password is read or generated: an invalid --app-id + // must cost zero HTTP requests and, more importantly, must never generate a + // write-once secret that the live API is going to reject anyway. if err := sipsvc.ValidateUsername(credCreateUsername); err != nil { return err } + if err := sipsvc.ValidateAppID(credCreateAppID); err != nil { + return err + } svc, err := service(cmd) if err != nil { return err @@ -86,7 +97,21 @@ func runCredentialCreate(cmd *cobra.Command, args []string) error { } return faultExit(err) } - return emitCredential(cmd, cred, password, generated) + // The POST has committed. If stdout is full, closed, or short-writes now, the + // credential exists and nobody will ever know its password — the same + // unrecoverable state a lost response leaves behind, so it must report the + // same exit code (8) rather than the generic 1 a write error maps to. With a + // caller-supplied password there is nothing to lose: return the write error + // as-is. + if err := emitCredential(cmd, cred, password, generated); err != nil { + if generated { + return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( + "the credential was created but the generated password could not be written to stdout and cannot be recovered — find it with 'band sip credential list --realm %s --plain', then rotate it: band sip credential rotate --realm %s --generate-password: %v", + realm.Name, realm.Name, err)} + } + return err + } + return nil } // reuseCredential implements --if-not-exists after a 23026 duplicate. Identity @@ -126,6 +151,21 @@ func reuseCredential(cmd *cobra.Command, svc *sipsvc.Service, realm *sipsvc.Real // emitCredential prints the credential. A generated password is included exactly // once; a caller-supplied one is omitted because the caller already has it. +// +// The JSON paths do NOT go through emit, for one reason: the spec requires the +// generated password to be the FIRST thing written on success, so a stdout write +// that is truncated part-way still delivers the only copy of the secret. emit +// normalizes payloads to a map and Go's encoder writes map keys alphabetically, +// which puts appId, hostname, and id ahead of password. This function performs +// emit's two meaningful steps itself — normalize, then redact — and only then +// wraps the result in passwordFirstPayload, whose MarshalJSON fixes the order. +// Wrapping has to come last: output.RedactSecrets only walks +// map[string]interface{} / []interface{}, so a wrapped payload would slip past +// the redaction net entirely. +// +// The table path keeps using emit: output.printTable has no case for a +// json.Marshaler and would print a Go struct dump, and a human reading a table +// has no ordering guarantee to lose. func emitCredential(cmd *cobra.Command, cred *sipsvc.Credential, password string, generated bool) error { format, plain := cmdutil.OutputFlags(cmd) out := map[string]interface{}{ @@ -139,5 +179,63 @@ func emitCredential(cmd *cobra.Command, cred *sipsvc.Credential, password string if generated { out["password"] = password } - return emit(format, plain, out) + if format == "table" && !plain { + return emit(format, plain, out) + } + normalized, err := normalizeForOutput(out) + if err != nil { + return err + } + redacted, ok := output.RedactSecrets(normalized).(map[string]interface{}) + if !ok { + // Unreachable: out is a map, and RedactSecrets maps a map to a map. + return emit(format, plain, out) + } + return output.StdoutAuto(format, plain, passwordFirstPayload(redacted)) +} + +// passwordFirstPayload is a JSON object that marshals "password" first and every +// other key in sorted order. It exists so the write-once generated password +// leads the output (see emitCredential), and it is a named map type rather than +// a struct so it carries whatever field set the payload has without a second +// place to keep in sync. +// +// It is deliberately opaque to output.FlattenResponse and output.RedactSecrets, +// which both type-assert on the unnamed map[string]interface{}: redaction has +// already run by the time a payload is wrapped, and this payload is flat, so +// there is nothing for flattening to unwrap. +type passwordFirstPayload map[string]interface{} + +func (p passwordFirstPayload) MarshalJSON() ([]byte, error) { + keys := make([]string, 0, len(p)) + for k := range p { + if k != "password" { + keys = append(keys, k) + } + } + sort.Strings(keys) + if _, ok := p["password"]; ok { + keys = append([]string{"password"}, keys...) + } + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + key, err := json.Marshal(k) + if err != nil { + return nil, err + } + val, err := json.Marshal(p[k]) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteByte(':') + buf.Write(val) + } + buf.WriteByte('}') + return buf.Bytes(), nil } diff --git a/cmd/sip/credential_create_test.go b/cmd/sip/credential_create_test.go index fb921f0..56e7c7b 100644 --- a/cmd/sip/credential_create_test.go +++ b/cmd/sip/credential_create_test.go @@ -5,13 +5,17 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" "github.com/spf13/cobra" "github.com/Bandwidth/cli/internal/api" "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" sipsvc "github.com/Bandwidth/cli/internal/sip" "github.com/Bandwidth/cli/internal/testutil" ) @@ -78,6 +82,12 @@ func TestCredentialCreate_IfNotExistsGeneratedPasswordOnExisting_ExitsSecretUnav } } +// testAppID is a syntactically valid voice application UUID. The fixture used +// to be "app-123", which the API would have rejected: --app-id is pinned to a +// UUID and is now validated client-side, so the old fixture could never have +// exercised the app-binding path against the live service. +const testAppID = "04e88489-df02-4e34-a0e2-4d0e0d3f7a1c" + // TestCredentialCreate_IfNotExistsAppMismatch_ReportsNotBound covers the // re-read-before-compare fix: the app-binding gate must not trust // ListCredentials' shape for HttpVoiceV2AppId, and an empty existing binding @@ -89,7 +99,7 @@ func TestCredentialCreate_IfNotExistsAppMismatch_ReportsNotBound(t *testing.T) { withStubService(t, srv) root := testutil.NewTestRoot(credentialCreateCmd) - root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--app-id", "app-123", "--generate-password", "--if-not-exists"}) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--app-id", testAppID, "--generate-password", "--if-not-exists"}) err := root.Execute() if err == nil { @@ -178,6 +188,264 @@ func TestCredentialCreate_CallerSuppliedPasswordLostToPostWriteFailure_FallsThro } } +// credentialCreateSuccessStubServer answers the realm lookup and returns a +// successful bulk-create response, so the command reaches its output write. +func credentialCreateSuccessStubServer(t *testing.T) *httptest.Server { + t.Helper() + 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.Write([]byte(`1103` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/sipcredentials"): + w.WriteHeader(201) + w.Write([]byte(`` + + `8708741103agent` + + `vapi-3efeaa.auth.bandwidth.com` + + ``)) + default: + w.WriteHeader(404) + } + })) +} + +// withFailingStdout points os.Stdout at a read-only descriptor for the duration +// of a test, so every write to it fails with EBADF before a single byte — the +// password included — reaches the caller. That is the spec's write-once hazard +// in its sharpest form: the POST/PUT has already committed and stdout is gone. +func withFailingStdout(t *testing.T) { + t.Helper() + f, err := os.OpenFile(filepath.Join(t.TempDir(), "unwritable"), os.O_RDONLY|os.O_CREATE, 0600) + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = f + t.Cleanup(func() { + os.Stdout = orig + f.Close() + }) + // Guard against a platform where writing to an O_RDONLY file succeeds: the + // whole test would silently prove nothing. + if _, err := f.Write([]byte("x")); err == nil { + t.Fatal("writes to the injected stdout succeeded; this test cannot detect an output-write failure") + } +} + +// TestCredentialCreate_GeneratedPasswordLostToStdoutFailure_ExitsSecretUnavailable +// covers the gap between "the API call succeeded" and "the caller has the +// secret". The POST committed, so the credential exists; the write of the only +// copy of its password failed. Before this fix emitCredential's error travelled +// out as a generic error and mapped to exit 1, which an agent reads as "nothing +// happened, retry" — while a credential it can never authenticate with sits on +// the realm. +func TestCredentialCreate_GeneratedPasswordLostToStdoutFailure_ExitsSecretUnavailable(t *testing.T) { + srv := credentialCreateSuccessStubServer(t) + defer srv.Close() + withStubService(t, srv) + withFailingStdout(t) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--plain", + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false", "--app-id="}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want *cmdutil.SecretUnavailableError for a failed password write") + } + var sue *cmdutil.SecretUnavailableError + if !errors.As(err, &sue) { + t.Fatalf("error = %v (%T), want *cmdutil.SecretUnavailableError", err, err) + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d (ExitSecretUnavailable)", got, cmdutil.ExitSecretUnavailable) + } + // The recovery path must be actionable: the ID was never printed, so the + // message has to point at list-then-rotate. + for _, want := range []string{"band sip credential list --realm vapi --plain", "band sip credential rotate"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to name %q", err.Error(), want) + } + } + // The error goes to stderr and into agent transcripts; it must not carry the + // generated password itself. + if strings.Contains(err.Error(), "passwordShownOnce") { + t.Errorf("error = %q, unexpectedly echoes the payload", err.Error()) + } +} + +// TestCredentialCreate_CallerSuppliedPasswordLostToStdoutFailure_ReturnsWriteError +// is the negative pairing: with --password-stdin the caller already holds the +// secret, so a stdout failure is an ordinary I/O error. Reporting exit 8 there +// would send an agent to rotate a credential that is perfectly usable. +func TestCredentialCreate_CallerSuppliedPasswordLostToStdoutFailure_ReturnsWriteError(t *testing.T) { + srv := credentialCreateSuccessStubServer(t) + defer srv.Close() + withStubService(t, srv) + withFailingStdout(t) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetIn(strings.NewReader("hunter2\n")) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--plain", + "--password-stdin", "--generate-password=false", "--password-file=", "--if-not-exists=false", "--app-id="}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want the stdout write error to surface") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want the plain write error, NOT *cmdutil.SecretUnavailableError: the caller supplied the password", err) + } + if got := cmdutil.ExitCodeForError(err); got == cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want anything but ExitSecretUnavailable", got) + } +} + +// TestCredentialCreate_InvalidAppIDMakesNoRequests pins the cost of a bad +// --app-id at zero: no realm lookup, and — the part that matters — no password +// generation, so a typo or a documentation placeholder can never put the caller +// on the write-once path. +func TestCredentialCreate_InvalidAppIDMakesNoRequests(t *testing.T) { + var requests int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + w.WriteHeader(500) + })) + defer srv.Close() + withStubService(t, srv) + + root := testutil.NewTestRoot(credentialCreateCmd) + root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", "--app-id", "app-123", + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false"}) + // --app-id is a package var on a shared cobra command, so the invalid value + // this test sets must not leak into whatever runs next. + t.Cleanup(func() { credCreateAppID = "" }) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want a validation error for a non-UUID --app-id") + } + if !strings.Contains(err.Error(), "must be a UUID") { + t.Errorf("error = %q, want it to say --app-id must be a UUID", err.Error()) + } + if got := atomic.LoadInt32(&requests); got != 0 { + t.Errorf("HTTP requests = %d, want 0 — an invalid --app-id must be rejected before any API call", got) + } +} + +// TestEmitCredential_WritesGeneratedPasswordFirst pins the spec's write-once +// mitigation: the generated password is the FIRST thing written on success, so a +// stdout write truncated part-way still delivers the only copy of the secret. +// The general emit path normalizes payloads to a map and Go's encoder writes map +// keys alphabetically, which silently put appId, hostname, and id ahead of +// password. +func TestEmitCredential_WritesGeneratedPasswordFirst(t *testing.T) { + cred := &sipsvc.Credential{ID: "870874", RealmID: "1103", Username: "agent", Hostname: "vapi.example.com", AppID: testAppID} + for _, plain := range []bool{true, false} { + name := "json" + if plain { + name = "plain" + } + t.Run(name, func(t *testing.T) { + wrap := &cobra.Command{ + Use: "wrap", + RunE: func(cmd *cobra.Command, args []string) error { + return emitCredential(cmd, cred, "generatedSecret123", true) + }, + } + root := testutil.NewTestRoot(wrap) + args := []string{"wrap"} + if plain { + args = append(args, "--plain") + } + root.SetArgs(args) + + out := testutil.CaptureStdout(t, func() { + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + }) + + // Still valid, complete JSON — ordering must not cost correctness. + var got map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not JSON: %q (%v)", out, err) + } + if got["password"] != "generatedSecret123" { + t.Errorf("password = %v, want generatedSecret123", got["password"]) + } + pwAt := strings.Index(out, `"password"`) + if pwAt < 0 { + t.Fatalf("output has no password field: %q", out) + } + for _, k := range []string{`"appId"`, `"hostname"`, `"id"`, `"passwordShownOnce"`, `"realmId"`, `"username"`} { + at := strings.Index(out, k) + if at < 0 { + t.Errorf("missing field %s in %q", k, out) + continue + } + if at < pwAt { + t.Errorf("%s is written before \"password\" — a truncated write would lose the only copy of the secret: %q", k, out) + } + } + }) + } +} + +// TestPasswordFirstPayload_MarshalJSON covers the ordering type directly, +// including the caller-supplied case (no password key at all) and the fact that +// the remaining keys stay in a deterministic, sorted order. +func TestPasswordFirstPayload_MarshalJSON(t *testing.T) { + withPassword, err := json.Marshal(passwordFirstPayload{ + "id": "1", "appId": "", "password": "s3cret", "passwordShownOnce": true, + }) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if want := `{"password":"s3cret","appId":"","id":"1","passwordShownOnce":true}`; string(withPassword) != want { + t.Errorf("MarshalJSON() = %s, want %s", withPassword, want) + } + withoutPassword, err := json.Marshal(passwordFirstPayload{"id": "1", "appId": ""}) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if want := `{"appId":"","id":"1"}`; string(withoutPassword) != want { + t.Errorf("MarshalJSON() = %s, want %s", withoutPassword, want) + } +} + +// TestEmitCredential_RedactionStillRunsOnTheOrderedPath is the guard for the +// order-vs-redaction tradeoff. Ordering is achieved by wrapping the payload in a +// type output.RedactSecrets does not walk, so redaction has to run BEFORE the +// wrap. If that order is ever swapped, hash material would flow straight to +// stdout: this asserts the composition, not just the current payload's fields. +func TestEmitCredential_RedactionStillRunsOnTheOrderedPath(t *testing.T) { + payload := map[string]interface{}{ + "id": "1", + "password": "s3cret", + "Hash1": "1be6abcaa8e9956021d30f33a3925b99", + "hash1b": "e028e6577a0bb1b90a33d30a110dbdfe", + } + redacted, ok := output.RedactSecrets(payload).(map[string]interface{}) + if !ok { + t.Fatal("RedactSecrets did not return a map") + } + b, err := json.Marshal(passwordFirstPayload(redacted)) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if want := `{"password":"s3cret","id":"1"}`; string(b) != want { + t.Errorf("redact-then-order = %s, want %s", b, want) + } + // The generated password is exempt from redaction by design — it is the + // deliverable — and must survive both steps. + if !strings.Contains(string(b), "s3cret") { + t.Errorf("redact-then-order dropped the generated password: %s", b) + } +} + // credentialCreateFaultStubServer rejects the POST with a structured Bandwidth // fault at the given status: the server parsed the request and refused it, so // no credential was created. @@ -231,7 +499,7 @@ func TestCredentialCreate_DefinitiveFaultDoesNotExitSecretUnavailable(t *testing // would trip the "exactly one password source" guard before the // branch under test is ever reached. root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", - "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false"}) + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false", "--app-id="}) err := root.Execute() if err == nil { @@ -267,7 +535,7 @@ func TestCredentialCreate_NonActiveRealmExitsConflict(t *testing.T) { root := testutil.NewTestRoot(credentialCreateCmd) root.SetArgs([]string{"create", "--realm", "vapi", "--username", "agent", - "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false"}) + "--generate-password", "--password-stdin=false", "--password-file=", "--if-not-exists=false", "--app-id="}) err := root.Execute() if err == nil { diff --git a/cmd/sip/credential_rotate.go b/cmd/sip/credential_rotate.go index 6f8be11..a4042a8 100644 --- a/cmd/sip/credential_rotate.go +++ b/cmd/sip/credential_rotate.go @@ -64,6 +64,21 @@ var credentialRotateCmd = &cobra.Command{ } return faultExit(err) } - return emitCredential(cmd, cred, password, generated) + // The PUT has committed: a working peer's hashes are already replaced. A + // stdout write failure here is the worst case in this command — the peer + // is broken AND the only copy of the password that would fix it is gone. + // That is exit 8 (rotate again), never the generic 1 a write error maps + // to. Unlike create, the credential ID is known, so the recovery command + // is named in full. With a caller-supplied password nothing is lost, so + // the write error is returned unchanged. + if err := emitCredential(cmd, cred, password, generated); err != nil { + if generated { + return &cmdutil.SecretUnavailableError{Message: fmt.Sprintf( + "credential %s was rotated but the generated password could not be written to stdout and cannot be recovered — the SIP peer using it cannot authenticate until you rotate it again: band sip credential rotate %s --realm %s --generate-password: %v", + existing.ID, existing.ID, realm.Name, err)} + } + return err + } + return nil }, } diff --git a/cmd/sip/credential_rotate_test.go b/cmd/sip/credential_rotate_test.go index 9c88335..88967aa 100644 --- a/cmd/sip/credential_rotate_test.go +++ b/cmd/sip/credential_rotate_test.go @@ -65,6 +65,89 @@ func TestCredentialRotate_GeneratedPasswordLostToPostWriteFailure_ExitsSecretUna } } +// credentialRotateSuccessStubServer answers the realm lookup, the credential +// re-read, and a successful PUT, so the command reaches its output write. +func credentialRotateSuccessStubServer(t *testing.T) *httptest.Server { + t.Helper() + 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.Write([]byte(`1105` + + `vapi-3efeaa.auth.bandwidth.comACTIVE` + + ``)) + case strings.Contains(r.URL.Path, "/sipcredentials/") && + (r.Method == http.MethodGet || r.Method == http.MethodPut): + w.Write([]byte(`` + + `8708801105rotuser` + + `vapi-3efeaa.auth.bandwidth.com` + + ``)) + default: + w.WriteHeader(404) + } + })) +} + +// TestCredentialRotate_GeneratedPasswordLostToStdoutFailure_ExitsSecretUnavailable +// is the worst case in this feature and the one no earlier review covered: the +// PUT already replaced a working peer's hashes, so the peer is broken, and the +// write of the only copy of the new password failed. Exit 1 would tell an agent +// "nothing happened"; the truth is "the peer is down and only a re-rotate can +// fix it", which is exit 8. +func TestCredentialRotate_GeneratedPasswordLostToStdoutFailure_ExitsSecretUnavailable(t *testing.T) { + srv := credentialRotateSuccessStubServer(t) + defer srv.Close() + withStubService(t, srv) + withFailingStdout(t) + + root := testutil.NewTestRoot(credentialRotateCmd) + root.SetArgs([]string{"rotate", "870880", "--realm", "vapi", "--plain", + "--generate-password", "--password-stdin=false", "--password-file="}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want *cmdutil.SecretUnavailableError for a failed password write") + } + var sue *cmdutil.SecretUnavailableError + if !errors.As(err, &sue) { + t.Fatalf("error = %v (%T), want *cmdutil.SecretUnavailableError", err, err) + } + if got := cmdutil.ExitCodeForError(err); got != cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want %d (ExitSecretUnavailable)", got, cmdutil.ExitSecretUnavailable) + } + // Unlike create, the credential ID is known here, so the recovery command must + // be spelled out rather than sending the agent to list credentials first. + if !strings.Contains(err.Error(), "band sip credential rotate 870880 --realm vapi") { + t.Errorf("error = %q, want the exact re-rotate command naming credential 870880", err.Error()) + } +} + +// TestCredentialRotate_CallerSuppliedPasswordLostToStdoutFailure_ReturnsWriteError +// is the negative pairing: with --password-stdin the caller can reconfigure the +// peer from the password it already holds, so this is an ordinary I/O error. +func TestCredentialRotate_CallerSuppliedPasswordLostToStdoutFailure_ReturnsWriteError(t *testing.T) { + srv := credentialRotateSuccessStubServer(t) + defer srv.Close() + withStubService(t, srv) + withFailingStdout(t) + + root := testutil.NewTestRoot(credentialRotateCmd) + root.SetIn(strings.NewReader("hunter2\n")) + root.SetArgs([]string{"rotate", "870880", "--realm", "vapi", "--plain", + "--password-stdin", "--generate-password=false", "--password-file="}) + + err := root.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want the stdout write error to surface") + } + var sue *cmdutil.SecretUnavailableError + if errors.As(err, &sue) { + t.Fatalf("error = %v, want the plain write error, NOT *cmdutil.SecretUnavailableError: the caller supplied the password", err) + } + if got := cmdutil.ExitCodeForError(err); got == cmdutil.ExitSecretUnavailable { + t.Errorf("ExitCodeForError() = %d, want anything but ExitSecretUnavailable", got) + } +} + // credentialRotateFaultStubServer is like the above but the PUT is rejected // with a structured Bandwidth fault at the given status — the server parsed the // request and refused it, so the hashes were definitively NOT replaced. diff --git a/cmd/sip/golden_test.go b/cmd/sip/golden_test.go index b2f2c25..390c1d8 100644 --- a/cmd/sip/golden_test.go +++ b/cmd/sip/golden_test.go @@ -154,11 +154,23 @@ func TestCredentialListNoHashesAndFieldShape(t *testing.T) { } // TestRealmDeleteAcceptedShape guards the accepted-vs-completed shape without -// --wait. The live API returns 202 with an empty body for delete, so the stub -// matches that exactly rather than a synthetic body. +// --wait, and that "id" is the realm's canonical ID rather than whatever +// identifier the caller happened to type. The command is invoked by NAME here +// (the form its own --help documents), so before the resolve-first fix this +// returned {"id":"vapi"} — a name in the field an agent feeds to the next +// command as an ID. +// +// The live API returns 202 with an empty body for delete; the stub matches that +// exactly and serves the realm lookup that now precedes it. func TestRealmDeleteAcceptedShape(t *testing.T) { + var deletePath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(202) + if r.Method == http.MethodDelete { + deletePath = r.URL.Path + w.WriteHeader(202) + return + } + w.Write([]byte(realmXML)) })) defer srv.Close() withStubService(t, srv) @@ -175,4 +187,14 @@ func TestRealmDeleteAcceptedShape(t *testing.T) { if got["deleted"] != false { t.Errorf("deleted = %v, want false without --wait", got["deleted"]) } + // realmXML's realm is ID 1103, name "vapi". Invoked by name, the output must + // still report the ID. + if got["id"] != "1103" { + t.Errorf("id = %v, want the resolved realm ID \"1103\" — not the %q ref the caller passed", got["id"], "vapi") + } + // The DELETE itself must go to the resolved ID too, so the polling loop and + // the reported ID describe the same resource. + if !strings.HasSuffix(deletePath, "/realms/1103") { + t.Errorf("DELETE path = %q, want it to end in /realms/1103", deletePath) + } } diff --git a/cmd/sip/password.go b/cmd/sip/password.go index 4778b91..c8b610d 100644 --- a/cmd/sip/password.go +++ b/cmd/sip/password.go @@ -80,7 +80,7 @@ func readPassword(cmd *cobra.Command, stdin bool, file string, generate bool) (s // byte count first would reject a legitimate maxPasswordBytes-length // password that happens to end in a newline (the common case for both a // piped secret and a file written by `echo`). - pw := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + pw := trimOneLineEnding(string(raw)) if len(pw) > maxPasswordBytes { return "", false, fmt.Errorf("password exceeds %d bytes", maxPasswordBytes) } @@ -89,3 +89,22 @@ func readPassword(cmd *cobra.Command, stdin bool, file string, generate bool) (s } return pw, false, nil } + +// trimOneLineEnding strips exactly one trailing line ending — the pair "\r\n" +// or a lone "\n" — and nothing else. The branches are explicit because the +// obvious nesting, TrimSuffix(TrimSuffix(s, "\n"), "\r"), also strips a BARE +// trailing "\r": "secret\r" became "secret". \r is a legal password byte, and +// the hashes are computed from whatever survives this trim, so silently +// dropping it produces a credential whose SIP peer can never authenticate — +// with no error to explain why. The spec sanctions stripping "\n" or "\r\n", +// not a standalone "\r". +func trimOneLineEnding(s string) string { + switch { + case strings.HasSuffix(s, "\r\n"): + return strings.TrimSuffix(s, "\r\n") + case strings.HasSuffix(s, "\n"): + return strings.TrimSuffix(s, "\n") + default: + return s + } +} diff --git a/cmd/sip/password_test.go b/cmd/sip/password_test.go index 76204fa..7919492 100644 --- a/cmd/sip/password_test.go +++ b/cmd/sip/password_test.go @@ -40,6 +40,42 @@ func TestReadPassword_FromFileStripsCRLF(t *testing.T) { } } +// TestReadPassword_TrimsExactlyOneLineEnding pins the trim to what the spec +// sanctions: one "\n" or the pair "\r\n". A password ending in a BARE "\r" must +// survive byte-for-byte — the hashes are derived from whatever survives this +// trim, so silently dropping the \r yields a credential that can never +// authenticate, with nothing in the output to explain it. +func TestReadPassword_TrimsExactlyOneLineEnding(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"bare CR survives", "secret\r", "secret\r"}, + {"CRLF stripped", "secret\r\n", "secret"}, + {"LF stripped", "secret\n", "secret"}, + {"no line ending untouched", "secret", "secret"}, + {"only the last LF is stripped", "secret\n\n", "secret\n"}, + {"CR inside is untouched", "sec\rret\n", "sec\rret"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "pw") + if err := os.WriteFile(p, []byte(c.input), 0600); err != nil { + t.Fatal(err) + } + pw, _, err := readPassword(&cobra.Command{}, false, p, false) + if err != nil { + t.Fatalf("readPassword(%q) error = %v", c.input, err) + } + if pw != c.want { + t.Errorf("readPassword(%q) = %q, want %q", c.input, pw, c.want) + } + }) + } +} + func TestReadPassword_RejectsEmpty(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "pw") diff --git a/cmd/sip/realm_delete.go b/cmd/sip/realm_delete.go index 4296263..11e3aee 100644 --- a/cmd/sip/realm_delete.go +++ b/cmd/sip/realm_delete.go @@ -34,22 +34,34 @@ var realmDeleteCmd = &cobra.Command{ if err != nil { return err } - ref := args[0] - if err := svc.DeleteRealm(ref); err != nil { + // Resolve the ref to the realm's canonical ID before deleting. The + // command accepts an ID, a short name, or an FQDN, and echoing the input + // back under "id" made `band sip realm delete vapi --plain` return + // {"id":"vapi"} — a name in a field an agent reads as an ID. This costs + // one extra GET on the delete path, which is the price of the output + // field meaning what it says. + realm, err := svc.GetRealm(args[0]) + if err != nil { + return faultExit(err) + } + if err := svc.DeleteRealm(realm.ID); err != nil { return faultExit(err) } format, plain := cmdutil.OutputFlags(cmd) // A 202 only means the delete was accepted, not that it completed — // deleted starts false regardless of --wait and is only promoted to // true below once polling confirms the realm is actually gone. - result := map[string]interface{}{"id": ref, "deleted": false, "accepted": true} + result := map[string]interface{}{"id": realm.ID, "deleted": false, "accepted": true} if realmDeleteWait { if _, err := cmdutil.Poll(cmdutil.PollConfig{ Interval: 2 * time.Second, Timeout: time.Duration(realmDeleteTimeout) * time.Second, Check: func() (bool, interface{}, error) { - _, err := svc.GetRealm(ref) + // Poll by canonical ID, not by the caller's ref: the name a + // deleted realm answered to is not guaranteed to keep + // resolving, and the ID is what the delete was issued against. + _, err := svc.GetRealm(realm.ID) if err == nil { return false, nil, nil // still present } diff --git a/cmd/vcp/route.go b/cmd/vcp/route.go index 43ba266..80cd01f 100644 --- a/cmd/vcp/route.go +++ b/cmd/vcp/route.go @@ -2,7 +2,9 @@ package vcp import ( "encoding/json" + "errors" "fmt" + "io" "net" "os" "regexp" @@ -73,22 +75,152 @@ func BuildRoutePlan(endpoint, endpointType, routeName string) (map[string]interf }, nil } +// routePlanInput, routeInput, and endpointInput are the typed shape of the +// originationRoutePlan object that --route-plan-json accepts. Decoding into +// these with DisallowUnknownFields — rather than into a bare +// map[string]interface{} — is what turns a misspelled or wrong-typed NESTED +// field into an error instead of a silent coercion. canonicalPlanJSON reads +// only the fields it knows about and normalizes anything missing or of the +// wrong type to an empty slice or 0, so a plan carrying "endponts" or "weigth" +// canonicalizes to a *different* plan than the caller wrote — and that +// canonical form is what --if-not-exists compares and what the +// --replace-routes destructive-write guard is decided from. Rejecting the input +// up front is the only way that comparison can be trusted. +// +// Every field is a pointer so presence is distinguishable from the zero value: +// --route-plan-json is a pass-through for callers who need fields the +// individual flags do not expose, so a field the caller omitted must stay +// omitted in the request body instead of being sent as 0 or "". Numerics are +// *float64 — the exact type encoding/json produces for a JSON number decoded +// into an interface{} — so the request map built below marshals to byte-identical +// output to what the previous raw-map implementation put on the wire. +type routePlanInput struct { + // A pointer to a slice so an absent "routes", a null "routes", and an + // empty one are all distinguishable from a populated one. + Routes *[]*routeInput `json:"routes"` +} + +type routeInput struct { + Priority *float64 `json:"priority"` + Name *string `json:"name"` + Type *string `json:"type"` + Endpoints *[]*endpointInput `json:"endpoints"` +} + +type endpointInput struct { + Endpoint *string `json:"endpoint"` + Type *string `json:"type"` + Weight *float64 `json:"weight"` +} + +// routePlanShapeHint is the one place the accepted shape is spelled out, so +// every rejection points the caller at the same example. +const routePlanShapeHint = `expected the originationRoutePlan object, e.g. ` + + `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED",` + + `"endpoints":[{"endpoint":"host.example.com","type":"FQDN","weight":100}]}]}` + // ParseRoutePlanJSON parses the value of --route-plan-json, which is the // originationRoutePlan object itself (i.e. {"routes":[...]}). +// +// Validation is strict on purpose. The parsed plan is not just a request body: +// it is one side of the RoutePlansEqual comparison behind --if-not-exists and +// behind the --replace-routes guard that stands between a caller and an +// unintended overwrite of a live route plan. A field this function lets through +// unvalidated is a field that participates in that comparison with a coerced +// value, which can make a real change look like a no-op. func ParseRoutePlanJSON(s string) (map[string]interface{}, error) { - var plan map[string]interface{} - if err := json.Unmarshal([]byte(s), &plan); err != nil { - return nil, fmt.Errorf("parsing --route-plan-json: %w", err) + dec := json.NewDecoder(strings.NewReader(s)) + dec.DisallowUnknownFields() + + var in routePlanInput + if err := dec.Decode(&in); err != nil { + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("--route-plan-json is empty; %s", routePlanShapeHint) + } + return nil, fmt.Errorf("parsing --route-plan-json: %s", describeRoutePlanDecodeError(err)) + } + // Decode stops at the end of the first JSON value, so `{...} {...}` or + // `{...} garbage` would otherwise be accepted with the trailing content + // silently discarded. + if dec.More() { + return nil, fmt.Errorf("parsing --route-plan-json: unexpected trailing content after the JSON object; %s", routePlanShapeHint) + } + + if in.Routes == nil { + return nil, fmt.Errorf("--route-plan-json must contain a \"routes\" array; %s", routePlanShapeHint) } - for k := range plan { - if k != "routes" { - return nil, fmt.Errorf("unknown field %q in --route-plan-json; expected the originationRoutePlan object, e.g. {\"routes\":[...]}", k) + if len(*in.Routes) == 0 { + return nil, fmt.Errorf("\"routes\" in --route-plan-json is empty; a route plan must contain at least one route") + } + + routes := make([]interface{}, 0, len(*in.Routes)) + for i, r := range *in.Routes { + if r == nil { + return nil, fmt.Errorf("routes[%d] in --route-plan-json is null; each route must be an object", i) + } + route := make(map[string]interface{}, 4) + if r.Priority != nil { + route["priority"] = *r.Priority + } + if r.Name != nil { + route["name"] = *r.Name + } + if r.Type != nil { + route["type"] = *r.Type + } + if r.Endpoints == nil { + return nil, fmt.Errorf("routes[%d] in --route-plan-json is missing its \"endpoints\" array", i) + } + // A route with no endpoints cannot route a call, and it canonicalizes + // to the same empty endpoint list as a route whose endpoints were + // typo'd away — exactly the coercion this parser exists to prevent. + if len(*r.Endpoints) == 0 { + return nil, fmt.Errorf("routes[%d].endpoints in --route-plan-json is empty; a route must contain at least one endpoint", i) + } + endpoints := make([]interface{}, 0, len(*r.Endpoints)) + for j, e := range *r.Endpoints { + if e == nil { + return nil, fmt.Errorf("routes[%d].endpoints[%d] in --route-plan-json is null; each endpoint must be an object", i, j) + } + if e.Endpoint == nil || *e.Endpoint == "" { + return nil, fmt.Errorf("routes[%d].endpoints[%d] in --route-plan-json is missing \"endpoint\"", i, j) + } + if e.Type == nil || *e.Type == "" { + return nil, fmt.Errorf("routes[%d].endpoints[%d] in --route-plan-json is missing \"type\" (TN, SIP, IP_V4, or FQDN)", i, j) + } + endpoint := map[string]interface{}{ + "endpoint": *e.Endpoint, + "type": *e.Type, + } + if e.Weight != nil { + endpoint["weight"] = *e.Weight + } + endpoints = append(endpoints, endpoint) + } + route["endpoints"] = endpoints + routes = append(routes, route) + } + return map[string]interface{}{"routes": routes}, nil +} + +// describeRoutePlanDecodeError turns encoding/json's decode errors into +// messages that name the offending field instead of leaking Go type names at +// the caller. A wrong type arrives as *json.UnmarshalTypeError, whose Field is +// the dotted JSON path (e.g. "routes.endpoints.weight"); an unknown field +// arrives as a plain error already carrying the field name. +func describeRoutePlanDecodeError(err error) string { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + if typeErr.Field != "" { + return fmt.Sprintf("field %q has the wrong type (got JSON %s); %s", typeErr.Field, typeErr.Value, routePlanShapeHint) } + return fmt.Sprintf("got JSON %s; %s", typeErr.Value, routePlanShapeHint) } - if _, ok := plan["routes"]; !ok { - return nil, fmt.Errorf("--route-plan-json must contain a \"routes\" array") + msg := strings.TrimPrefix(err.Error(), "json: ") + if strings.HasPrefix(msg, "unknown field") { + return fmt.Sprintf("%s; %s", msg, routePlanShapeHint) } - return plan, nil + return msg } // isEmptyPlan treats absent, null, and {"routes":[]} as the same state — the diff --git a/cmd/vcp/route_test.go b/cmd/vcp/route_test.go index ebbc740..d148206 100644 --- a/cmd/vcp/route_test.go +++ b/cmd/vcp/route_test.go @@ -1,6 +1,7 @@ package vcp import ( + "encoding/json" "strings" "testing" @@ -99,6 +100,170 @@ func TestParseRoutePlanJSON(t *testing.T) { } } +// --- --route-plan-json structural validation --- +// +// The parsed plan is not only a request body: it is one side of the +// RoutePlansEqual comparison behind --if-not-exists and behind the +// --replace-routes guard. canonicalPlanJSON coerces a wrong-typed or absent +// field to an empty slice or 0, so a plan that got past the parser with +// "endponts" or "weigth" in it canonicalizes to a plan the caller never wrote — +// which can make a real route change read as a no-op and slip past the guard. +// Validating only that the single top-level key is "routes" left every nested +// structure unchecked. + +func TestParseRoutePlanJSON_RejectsMalformedNestedStructures(t *testing.T) { + cases := []struct { + name string + in string + // wantInErr is the field (or indexed path) the message must name, so a + // caller can find the mistake without diffing their JSON by eye. + wantInErr string + }{ + {"routes is not an array", `{"routes":"not-an-array"}`, "routes"}, + {"routes is an object", `{"routes":{}}`, "routes"}, + {"routes is null", `{"routes":null}`, "routes"}, + {"routes is absent", `{}`, "routes"}, + {"whole plan is null", `null`, "routes"}, + {"routes is empty", `{"routes":[]}`, "routes"}, + {"null route entry", `{"routes":[null]}`, "routes[0]"}, + {"misspelled endpoints plus string priority", `{"routes":[{"endponts":[],"priority":"high"}]}`, "endponts"}, + {"misspelled endpoint weight", `{"routes":[{"endpoints":[{"endpoint":"x","weigth":100}]}]}`, "weigth"}, + {"string priority", `{"routes":[{"priority":"high","endpoints":[{"endpoint":"x","type":"FQDN"}]}]}`, "priority"}, + {"string weight", `{"routes":[{"endpoints":[{"endpoint":"x","type":"FQDN","weight":"heavy"}]}]}`, "weight"}, + {"boolean route name", `{"routes":[{"name":true,"endpoints":[{"endpoint":"x","type":"FQDN"}]}]}`, "name"}, + {"endpoints is not an array", `{"routes":[{"endpoints":"nope"}]}`, "endpoints"}, + {"endpoints is null", `{"routes":[{"endpoints":null}]}`, "endpoints"}, + {"endpoints is absent", `{"routes":[{"priority":1}]}`, "endpoints"}, + {"endpoints is empty", `{"routes":[{"priority":1,"endpoints":[]}]}`, "endpoints"}, + {"null endpoint entry", `{"routes":[{"endpoints":[null]}]}`, "endpoints[0]"}, + {"endpoint value missing", `{"routes":[{"endpoints":[{"type":"FQDN"}]}]}`, "endpoint"}, + {"endpoint value empty", `{"routes":[{"endpoints":[{"endpoint":"","type":"FQDN"}]}]}`, "endpoint"}, + {"endpoint type missing", `{"routes":[{"endpoints":[{"endpoint":"h.example.com"}]}]}`, "type"}, + {"endpoint type empty", `{"routes":[{"endpoints":[{"endpoint":"h.example.com","type":""}]}]}`, "type"}, + {"unknown top-level field", `{"routez":[]}`, "routez"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plan, err := ParseRoutePlanJSON(c.in) + if err == nil { + t.Fatalf("ParseRoutePlanJSON(%s) = %v, want an error", c.in, plan) + } + if !strings.Contains(err.Error(), c.wantInErr) { + t.Errorf("error = %q, want it to name %q", err.Error(), c.wantInErr) + } + }) + } +} + +// TestParseRoutePlanJSON_RejectsTrailingContent covers the decoder-specific +// hazard: json.Decoder.Decode stops at the end of the first JSON value, so +// anything after it is silently dropped unless dec.More() is checked. Two +// concatenated plans must not quietly resolve to the first one. +func TestParseRoutePlanJSON_RejectsTrailingContent(t *testing.T) { + valid := `{"routes":[{"priority":1,"endpoints":[{"endpoint":"h.example.com","type":"FQDN"}]}]}` + for _, in := range []string{ + valid + ` {"routes":[{"priority":2,"endpoints":[{"endpoint":"other.example.com","type":"FQDN"}]}]}`, + valid + ` garbage`, + valid + ` []`, + } { + if plan, err := ParseRoutePlanJSON(in); err == nil { + t.Errorf("ParseRoutePlanJSON(%s) = %v, want an error for trailing content", in, plan) + } + } + if _, err := ParseRoutePlanJSON(``); err == nil { + t.Error("empty --route-plan-json accepted, want an error") + } +} + +// TestParseRoutePlanJSON_WireShapeUnchanged pins the request body. Typed +// decoding is only safe if converting the typed value back into the request map +// reproduces exactly what the previous raw-map implementation sent: the plan +// goes straight onto the wire as body["originationRoutePlan"], and it is also +// one side of the --replace-routes comparison, so a changed shape is both a +// changed request and a changed guard verdict. referenceWireShape is the old +// implementation's marshaling — decode into a generic map, re-encode — so this +// asserts byte identity against the behavior being replaced, not against a +// literal someone could quietly re-baseline. +func TestParseRoutePlanJSON_WireShapeUnchanged(t *testing.T) { + referenceWireShape := func(t *testing.T, s string) string { + t.Helper() + var m map[string]interface{} + if err := json.Unmarshal([]byte(s), &m); err != nil { + t.Fatalf("reference unmarshal(%s) error = %v", s, err) + } + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("reference marshal(%s) error = %v", s, err) + } + return string(b) + } + + inputs := []string{ + // Minimal: omitted route name/type and endpoint weight must stay + // omitted, not be filled in with "" and 0. + `{"routes":[{"priority":1,"endpoints":[{"endpoint":"h.example.com","type":"FQDN"}]}]}`, + // Every field the shape allows. + `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`, + // Multiple routes and multiple endpoints, order preserved. + `{"routes":[{"priority":1,"name":"a","type":"WEIGHTED","endpoints":[{"endpoint":"one.example.com","type":"FQDN","weight":70},{"endpoint":"+19195551234","type":"TN","weight":30}]},{"priority":2,"name":"b","type":"ANI","endpoints":[{"endpoint":"sip:agent@example.com","type":"SIP","weight":100}]}]}`, + // Non-integer and exponent-notation numerics still marshal the way + // encoding/json rendered them when they were plain interface{} values. + `{"routes":[{"priority":1.5,"endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":1e2}]}]}`, + } + for _, in := range inputs { + plan, err := ParseRoutePlanJSON(in) + if err != nil { + t.Fatalf("ParseRoutePlanJSON(%s) error = %v", in, err) + } + got, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshaling parsed plan error = %v", err) + } + if want := referenceWireShape(t, in); string(got) != want { + t.Errorf("wire shape changed for %s:\n got %s\n want %s", in, got, want) + } + } + + // One literal, spelled out, so the pinned bytes are visible in the test + // and not only derivable from the reference implementation. + plan, err := ParseRoutePlanJSON(`{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + if err != nil { + t.Fatalf("ParseRoutePlanJSON() error = %v", err) + } + b, err := json.Marshal(plan) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + const want = `{"routes":[{"endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}],"name":"primary route","priority":1,"type":"WEIGHTED"}]}` + if string(b) != want { + t.Errorf("wire shape = %s, want %s", b, want) + } +} + +// TestParseRoutePlanJSON_StillIdempotentAgainstBuiltPlan guards the seam +// between the two plan sources: a --route-plan-json plan that describes exactly +// what BuildRoutePlan produces must still compare equal, or --if-not-exists +// would report a spurious conflict and --replace-routes would demand +// confirmation for a no-op write. +func TestParseRoutePlanJSON_StillIdempotentAgainstBuiltPlan(t *testing.T) { + built, err := BuildRoutePlan("h.example.com", "FQDN", "primary route") + if err != nil { + t.Fatalf("BuildRoutePlan() error = %v", err) + } + parsed := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"h.example.com","type":"FQDN","weight":100}]}]}`) + if !RoutePlansEqual(built, parsed) { + t.Error("a --route-plan-json plan identical to the flag-built plan compared unequal") + } + if requiresRouteReplaceConfirmation(built, parsed, false) { + t.Error("re-writing an identical plan via --route-plan-json demanded --replace-routes") + } + // And a genuine difference must still be caught after the rewrite. + different := mustParsePlan(t, `{"routes":[{"priority":1,"name":"primary route","type":"WEIGHTED","endpoints":[{"endpoint":"other.example.com","type":"FQDN","weight":100}]}]}`) + if !requiresRouteReplaceConfirmation(built, different, false) { + t.Error("a different plan via --route-plan-json did not require --replace-routes") + } +} + // --- Canonicalization gap: route type, priority, and endpoint weight are all // user-controllable via --route-plan-json (BuildRoutePlan hardcodes them, so // only the JSON path can vary them). RoutePlansEqual/canonicalPlanJSON must diff --git a/internal/sip/sip.go b/internal/sip/sip.go index 90e996f..d1caeb9 100644 --- a/internal/sip/sip.go +++ b/internal/sip/sip.go @@ -68,6 +68,26 @@ func ValidateRealmName(name string) error { return nil } +// appIDRe matches the canonical 8-4-4-4-12 hex UUID form. The check is +// deliberately strict rather than a "looks like an ID" heuristic: the API pins +// HttpVoiceV2AppId to a UUID, and the alternative — letting a typo or a +// documentation placeholder through — costs a realm lookup, a password +// generation, and (with --generate-password) a trip down the write-once path +// before the live API rejects it. +var appIDRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// ValidateAppID checks that a voice application ID is a UUID. An empty value is +// valid and means "unbound" — absent and "" are the same state to the API. +func ValidateAppID(appID string) error { + if appID == "" { + return nil + } + if !appIDRe.MatchString(appID) { + return fmt.Errorf("--app-id %q must be a UUID (e.g. 04e88489-df02-4e34-a0e2-4d0e0d3f7a1c); find yours with 'band app list --plain'", appID) + } + return nil +} + // ValidateUsername rejects usernames that would corrupt digest-hash construction. func ValidateUsername(username string) error { if username == "" { From 30cffa693a42c9018a6917068d2e536915ae011f Mon Sep 17 00:00:00 2001 From: Kush Date: Thu, 30 Jul 2026 10:00:49 -0400 Subject: [PATCH 24/25] docs(sip): document the UUID check, canonical delete ID, and exit-8 write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behavior changes shipped without documentation. cmd/doccontract_test.go validates that every documented command and flag resolves to a real one, but it cannot catch a description that is missing or wrong, so all three were silent gaps. --app-id must be a UUID. The check runs client-side, ahead of the realm lookup and ahead of reading or generating the password, so an invalid value fails deterministically with zero HTTP requests and no password generated. That last part is the agent-relevant guarantee: a typo cannot burn a write-once secret. sip realm delete returns the canonical realm ID. The argument still accepts an ID, a short name, or an FQDN, but the --plain "id" field is always the resolved numeric realm ID — `band sip realm delete vapi --plain` returns the ID, not "vapi" — which costs one extra GET. The existing accepted/deleted documentation is unchanged: 202 means accepted, and only --wait promotes deleted to true. Exit 8 now also covers stdout-write failures. The exit-code tables described exit 8 only for the existing-credential and lost-response cases. Both now include the third producer: the API write succeeded but the generated password could not be written to stdout (full pipe, closed pipe, or short write), leaving a credential whose password nobody holds. Recovery is unchanged — list to find the ID when it isn't known, then rotate — and the README gains a short recovery paragraph under the table. Both documents now state that the generated password is emitted FIRST in JSON output specifically so a truncated write still delivers it. AGENTS.md also records the consistency exception behind that ordering: every band sip command routes output through the shared emit helper except credential create/rotate's JSON paths, which use their own path to guarantee password-first ordering, with redaction still running there explicitly. It is written up as deliberate so a future reader doesn't "fix" it back to emit. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 28 +++++++++++++++++++++++++++- README.md | 10 ++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6945a63..428d2a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,6 +239,27 @@ For full flag/argument reference, use `band --help`. This section cove echoed back (`passwordShownOnce: false`, no `password` field in the response). With `--generate-password`, `--if-not-exists` against an existing credential exits **8** (`ExitSecretUnavailable`) because the stored password cannot be recovered — an agent must not treat that as success. +- **A stdout write failure after a successful write also exits 8.** If the API accepts the create/rotate but the + generated password cannot be written to stdout — a full pipe, a closed pipe, or a short write — the credential + exists and its password is unrecoverable, which is exactly the exit-8 state and not the generic 1 a write + error would otherwise produce. Recovery is the same as any exit 8: if you don't already have the credential + ID, `band sip credential list --realm --plain` to find it, then + `band sip credential rotate --realm --generate-password`. On rotate the ID is always + known, so the error names the exact command to re-run. With a caller-supplied password nothing is lost, so a + write failure there stays a plain write error. +- **The generated `password` is the FIRST key in JSON output.** `sip credential create` and + `sip credential rotate` emit `password` ahead of `id`, `hostname`, and `appId` specifically so a stdout write + that gets truncated part-way still delivers the only copy of the secret. This is the one place in `band sip` + that does not route its JSON through the shared `emit` helper — every other SIP command does, and gets Go's + alphabetical map-key order. The exception is deliberate: `emit` normalizes the payload to a map, and + alphabetical ordering would put `appId` ahead of `password`. Redaction still runs on this path, explicitly, + and the human-facing table output still goes through `emit`. Don't "fix" this back to `emit`, and don't rely + on key order for any other command. +- **`--app-id` is validated as a UUID before anything else happens.** `sip credential create --app-id` must be + a canonical 8-4-4-4-12 UUID. The check runs ahead of the realm lookup and ahead of reading or generating the + password, so an invalid value fails deterministically with exit **1**, **zero HTTP requests**, and — the part + that matters — **no password generated**. A typo in `--app-id` cannot burn a write-once secret. Get real IDs + from `band app list --plain`. Omitting `--app-id` (or passing an empty value) is valid and means "unbound". - **Exactly one of `--password-stdin`, `--password-file`, or `--generate-password` is required.** There is deliberately no `--password` flag — passing a secret via argv leaks it through shell history, process listings, CI logs, and agent transcripts. @@ -252,6 +273,11 @@ For full flag/argument reference, use `band --help`. This section cove - **`sip realm delete` without `--wait` reports `accepted: true, deleted: false`.** A 202 means the delete was accepted, not that it completed. Only `--wait` promotes `deleted` to `true`, after confirming the realm is actually gone. Don't treat a bare delete as teardown-complete. +- **`sip realm delete` returns the realm's canonical ID, not the ref you passed.** The argument accepts an ID, + a short name, or an FQDN, but the `--plain` `id` field is always the resolved numeric realm ID: + `band sip realm delete vapi --plain` returns that ID, not `"vapi"`. The command resolves the ref with a GET + before issuing the delete, so this costs one extra request on the delete path — the trade for an `id` field + that always means an ID. Don't match the returned `id` against the string you passed in. - **`sip realm create --if-not-exists` does not always silently reuse.** If a realm with that name exists but its `default` or `description` differs from what was requested, the command exits **4** instead of reusing it. Reconcile with `sip realm update --description ` (or promote it with `--default=true`) @@ -568,7 +594,7 @@ band number list --plain # → all numbers on account | 4 | Conflict / feature limit / payment required | 402, 409, or 403 due to a plan/role gate (e.g., Build account trying to message, missing VCP/Campaign Management/TFV role, out of credits, declined card). Non-retryable — stop and escalate to the user. | | 5 | Timeout | `--wait` exceeded `--timeout` | | 7 | Rate limited / quota exceeded | 429 or concurrent-resource ceiling. Back off and retry. | -| 8 | Secret unavailable | A resource exists but its secret cannot be recovered — currently produced by `sip credential create --if-not-exists --generate-password` against an existing credential (the credential ID is known — the error names it directly), and by a generated-password write whose response was lost. Not retryable as-is: rotate the credential (`sip credential rotate --realm `) to get a usable password. In the lost-response case the ID isn't known yet — the command's own error message says so — so run `band sip credential list --realm --plain` first to find it, then rotate. | +| 8 | Secret unavailable | A resource exists but its secret cannot be recovered. Three producers: (1) `sip credential create --if-not-exists --generate-password` against an existing credential (the credential ID is known — the error names it directly); (2) a generated-password write whose response was lost; (3) a generated-password write that the API accepted but whose password could not be written to stdout — full pipe, closed pipe, or short write — leaving a credential nobody holds the password for. Not retryable as-is: rotate the credential (`sip credential rotate --realm `) to get a usable password. When the ID isn't known yet — the lost-response and failed-write cases on create; the command's own error message says so — run `band sip credential list --realm --plain` first to find it, then rotate. On rotate the ID is always known, so the error names the exact rotate command. | **Use exit codes for control flow, not string parsing.** diff --git a/README.md b/README.md index 11e1950..8b8b3db 100644 --- a/README.md +++ b/README.md @@ -473,9 +473,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | `band sip realm list` | List realms | | `band sip realm get ` | Get one realm, including its FQDN | | `band sip realm update ` | Update a realm: `--default=true` promotes it to the account default, `--description ` replaces its description. Pass either or both; omitted fields are preserved | -| `band sip realm delete ` | Delete a realm (async — add `--wait` and optionally `--timeout ` to confirm it's actually gone) | -| `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`; optional `--app-id` to bind it to a voice app; `--if-not-exists` for idempotent retries) | -| `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved) | +| `band sip realm delete ` | Delete a realm (async — add `--wait` and optionally `--timeout ` to confirm it's actually gone). The argument may be an ID, short name, or FQDN, but the returned `id` is always the resolved numeric realm ID — resolving it costs one extra GET | +| `band sip credential create --realm --username ` | Create a credential (`--password-stdin`, `--password-file`, or `--generate-password`; optional `--app-id` to bind it to a voice app — it must be a UUID, checked before any HTTP request; `--if-not-exists` for idempotent retries). With `--generate-password`, `password` is the first key in the JSON output so a truncated write still delivers it | +| `band sip credential rotate --realm ` | Rotate a credential's password (ID is preserved). With `--generate-password`, `password` is the first key in the JSON output | | `band sip credential list --realm ` | List a realm's credentials (pagination is not implemented — a full 500-credential page warns on stderr that the list may be truncated) | | `band sip credential get --realm ` | Get one credential | | `band sip credential delete --realm ` | Delete a credential | @@ -551,7 +551,9 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f | 4 | Conflict, feature limit, or payment required (duplicate resource, missing role, plan limit, out of credits) | | 5 | Timed out waiting | | 7 | Rate limited or quota exceeded (back off and retry) | -| 8 | A resource exists but its secret cannot be recovered (e.g. `sip credential create --if-not-exists --generate-password` against an existing credential) | +| 8 | A resource exists but its secret cannot be recovered — `sip credential create --if-not-exists --generate-password` against an existing credential, a generated-password write whose response was lost, or a generated password that could not be written to stdout (full pipe, closed pipe, or short write) *after* the API write already succeeded. See below for recovery. | + +**Recovering from exit 8.** The resource exists; only the secret is gone. If you don't have the credential ID (the error message says so when it doesn't know it), find it with `band sip credential list --realm --plain`, then run `band sip credential rotate --realm --generate-password`. Rotating preserves the credential ID, so SIP peers referencing it keep working. Note that the generated `password` is written as the **first** key of the JSON object precisely so a write that gets cut short still delivers the one copy of the secret. --- From 875dc275675246fa1cfd9d9d0d0becb126954686 Mon Sep 17 00:00:00 2001 From: Kush Date: Thu, 30 Jul 2026 14:17:51 -0400 Subject: [PATCH 25/25] docs(sip): explain why MD5 is protocol-mandated for SIP digest hashes Snyk flags ComputeHashes as CWE-327. The finding is understood but not remediable: RFC 2617 digest auth defines HA1 as MD5(user:realm:pass), and the Bandwidth API accepts only MD5 for Hash1/Hash1b. A stronger hash would produce credentials no SIP peer could authenticate with. Records that the real risk here is the output being password-equivalent, which is already handled by hash-free domain types, output redaction, and error-body scrubbing. Co-Authored-By: Claude Opus 5 (1M context) --- internal/sip/sip.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/sip/sip.go b/internal/sip/sip.go index d1caeb9..7de1edd 100644 --- a/internal/sip/sip.go +++ b/internal/sip/sip.go @@ -28,6 +28,20 @@ var realmNameRe = regexp.MustCompile(`^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,28 // realmFQDN must be the realm's full hostname exactly as returned by the API // (e.g. "vapi-3efeaa.auth.bandwidth.com"), not the short realm name — the SIP // server presents the FQDN in its digest challenge. +// +// On the use of MD5: this is not a security choice available to us. SIP/HTTP +// digest authentication (RFC 2617) defines HA1 as MD5(user:realm:pass), and the +// Bandwidth API accepts only MD5 digests for Hash1/Hash1b — it stores what we +// send, and its SIP registrar validates against an MD5 challenge. Substituting a +// stronger hash would not harden anything; it would produce a credential that no +// SIP peer could ever authenticate with. Static analysers flag this line as +// CWE-327 ("Use of a Broken or Risky Cryptographic Algorithm"); the finding is +// understood and cannot be remediated without breaking protocol interoperability. +// +// The property that does matter is that the OUTPUT is password-equivalent: +// anyone holding Hash1 can authenticate as this credential. That is why the +// domain types carry no hash fields, why output is redacted, and why error +// bodies are scrubbed before being stored on an error. func ComputeHashes(username, realmFQDN, password string) (hash1 string, hash1b string) { sum := func(s string) string { h := md5.Sum([]byte(s))