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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pkg/store/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"slices"
"strings"

"github.com/brevdev/brev-cli/pkg/auth"
"github.com/brevdev/brev-cli/pkg/cmd/version"
"github.com/brevdev/brev-cli/pkg/entity"
breverrors "github.com/brevdev/brev-cli/pkg/errors"
Expand Down Expand Up @@ -317,7 +318,16 @@ func NewHTTPResponseError(response *resty.Response) *HTTPResponseError {
}
}

const expiredAPIKeyMessage = "Your API key is invalid or has expired. Generate a new API key and run 'brev login --api-key <new-api-key>' (or pass --api-key <new-api-key>)"

func (e HTTPResponseError) Error() string {
if e.Response.StatusCode() == http.StatusUnauthorized && e.Response.Request != nil && auth.IsBrevAPIKey(e.Response.Request.Token) {
return expiredAPIKeyMessage + "\n" + e.baseError()
}
return e.baseError()
}

func (e HTTPResponseError) baseError() string {
body := e.Response.Body()
if featureflag.Debug() {
return fmt.Sprintf("%s %s %s", e.Response.Request.URL, e.Response.Status(), body)
Expand Down
68 changes: 68 additions & 0 deletions pkg/store/http_response_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package store

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

resty "github.com/go-resty/resty/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const unauthorizedBody = `{"errors":[{"type":"UnauthorizedError"}]}`

func doRequestWithToken(t *testing.T, status int, body, token string) *HTTPResponseError {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer server.Close()

req := resty.New().R()
if token != "" {
req.SetAuthToken(token)
}
res, err := req.Get(server.URL)
require.NoError(t, err)
require.True(t, res.IsError())
return NewHTTPResponseError(res)
}

func TestHTTPResponseError_UnauthorizedWithAPIKeyShowsHintThenOriginalMessage(t *testing.T) {
err := doRequestWithToken(t, http.StatusUnauthorized, unauthorizedBody, "bak-expired")

msg := err.Error()
assert.True(t, strings.HasPrefix(msg, expiredAPIKeyMessage+"\n"), "hint should come first: %q", msg)
assert.Contains(t, msg, "brev login --api-key")
// the original error is preserved after the hint
assert.True(t, strings.HasSuffix(msg, err.baseError()))
assert.Contains(t, msg, "401 Unauthorized")
assert.Contains(t, msg, "UnauthorizedError")
}

func TestHTTPResponseError_UnauthorizedWithoutAPIKeyKeepsOriginalMessage(t *testing.T) {
// A non-API-key (e.g. browser login JWT) 401 must not claim the API key is bad.
err := doRequestWithToken(t, http.StatusUnauthorized, unauthorizedBody, "eyJhbGciOiJIUzI1NiJ9.e30.sig")

msg := err.Error()
assert.NotContains(t, msg, expiredAPIKeyMessage)
assert.Contains(t, msg, "401")
assert.Contains(t, msg, "UnauthorizedError")
}

func TestHTTPResponseError_UnauthorizedWithNoTokenKeepsOriginalMessage(t *testing.T) {
err := doRequestWithToken(t, http.StatusUnauthorized, unauthorizedBody, "")

assert.NotContains(t, err.Error(), expiredAPIKeyMessage)
}

func TestHTTPResponseError_NonUnauthorizedWithAPIKeyKeepsOriginalMessage(t *testing.T) {
// Only a 401 means the key is expired; other failures must surface as-is.
err := doRequestWithToken(t, http.StatusForbidden, `{"errors":[{"message":"forbidden"}]}`, "bak-valid")

assert.Equal(t, "forbidden\n", err.Error())
}
Loading