github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/error_test.go (about)

     1  package auth
     2  
     3  import (
     4  	"errors"
     5  	"strings"
     6  	"testing"
     7  
     8  	"github.com/anthropics/anthropic-sdk-go/config"
     9  )
    10  
    11  func TestNoCredentialsError_IsSentinel(t *testing.T) {
    12  	err := &NoCredentialsError{}
    13  	if !errors.Is(err, ErrNoCredentials) {
    14  		t.Fatal("errors.Is(NoCredentialsError, ErrNoCredentials) should be true")
    15  	}
    16  	wrapped := errors.Join(errors.New("leading"), err)
    17  	if !errors.Is(wrapped, ErrNoCredentials) {
    18  		t.Fatal("errors.Is should see through wrappers")
    19  	}
    20  	other := errors.New("unrelated")
    21  	if errors.Is(err, other) {
    22  		t.Fatal("errors.Is should not match unrelated errors")
    23  	}
    24  }
    25  
    26  func TestOAuthTokenError_RedactsSensitiveJSONKeys(t *testing.T) {
    27  	err := &OAuthTokenError{
    28  		StatusCode: 400,
    29  		Body:       `{"error":"invalid_grant","access_token":"sk-ant-oat01-secret","refresh_token":"rt-secret","assertion":"eyJhbGciOi..."}`,
    30  	}
    31  	msg := err.Error()
    32  	for _, secret := range []string{"sk-ant-oat01-secret", "rt-secret", "eyJhbGciOi"} {
    33  		if strings.Contains(msg, secret) {
    34  			t.Errorf("error message contains unredacted secret %q: %s", secret, msg)
    35  		}
    36  	}
    37  	if !strings.Contains(msg, "invalid_grant") {
    38  		t.Errorf("expected non-sensitive fields preserved: %s", msg)
    39  	}
    40  }
    41  
    42  // TestOAuthTokenError_RedactsJSONWithoutRFCCode verifies that JSON bodies
    43  // without an RFC 6749 `error` field are redacted to an empty allowed-keys
    44  // object. The previous denylist behavior would have leaked any non-listed
    45  // fields; the allowlist drops them by default.
    46  func TestOAuthTokenError_RedactsJSONWithoutRFCCode(t *testing.T) {
    47  	err := &OAuthTokenError{
    48  		StatusCode: 500,
    49  		Body:       `{"message":"server error","access_token":"sk-ant-oat01-secret","refresh_token":"rt-secret"}`,
    50  	}
    51  	msg := err.Error()
    52  	for _, secret := range []string{"sk-ant-oat01-secret", "rt-secret", "server error"} {
    53  		if strings.Contains(msg, secret) {
    54  			t.Errorf("error message contains unredacted secret %q: %s", secret, msg)
    55  		}
    56  	}
    57  }
    58  
    59  func TestOAuthTokenError_TruncatesLongBody(t *testing.T) {
    60  	// Bodies WITH an RFC `error` key bypass redaction (formatted directly),
    61  	// so to exercise the truncation path we use a body the RFC parser
    62  	// rejects (no `error` key) but that the allowlist preserves a long
    63  	// allowed value from. This is the realistic shape for a server that
    64  	// responds with `{"error_uri":"..."}` alone.
    65  	uri := strings.Repeat("x", config.OAuthErrorBodyMaxLen+200)
    66  	body := `{"error_uri":"` + uri + `"}`
    67  	err := &OAuthTokenError{StatusCode: 500, Body: body}
    68  	msg := err.Error()
    69  	if !strings.Contains(msg, "[truncated]") {
    70  		t.Errorf("expected truncation marker: %s", msg[len(msg)-40:])
    71  	}
    72  	// Allow ~256 bytes of OAuthTokenError formatting overhead.
    73  	if len(msg) > config.OAuthErrorBodyMaxLen+256 {
    74  		t.Errorf("message too long after truncation: %d bytes", len(msg))
    75  	}
    76  }
    77  
    78  func TestOAuthTokenError_NonJSONBodyRedacted(t *testing.T) {
    79  	// A non-JSON body (HTML 5xx page, plain text from a misbehaving proxy)
    80  	// must not pass through to the error message. Replaces the previous
    81  	// denylist behavior of "if not JSON, just include the raw body."
    82  	err := &OAuthTokenError{StatusCode: 503, Body: "upstream secret leaked here"}
    83  	msg := err.Error()
    84  	if strings.Contains(msg, "secret") {
    85  		t.Errorf("non-JSON body must not pass through: %s", msg)
    86  	}
    87  	if !strings.Contains(msg, "[redacted; not a JSON error response]") {
    88  		t.Errorf("expected non-JSON redaction marker: %s", msg)
    89  	}
    90  }
    91  
    92  func TestOAuthTokenError_JSONNullBodyRedactsToEmpty(t *testing.T) {
    93  	// A `null` body is valid JSON: redaction must not classify it as
    94  	// non-JSON. The filter loop on a nil map serializes to "{}".
    95  	err := &OAuthTokenError{StatusCode: 400, Body: "null"}
    96  	msg := err.Error()
    97  	if strings.Contains(msg, "[redacted; not a JSON error response]") {
    98  		t.Errorf("`null` is valid JSON; must not get non-JSON marker: %s", msg)
    99  	}
   100  	if !strings.Contains(msg, "{}") {
   101  		t.Errorf("expected empty-object redaction for null body: %s", msg)
   102  	}
   103  }
   104  
   105  // TestOAuthTokenError_InvalidGrantSuggestsRelogin checks that a refresh-token
   106  // rejection (400 with error=invalid_grant) surfaces the RFC 6749 error_description
   107  // and a remediation hint telling the user to re-authenticate.
   108  func TestOAuthTokenError_InvalidGrantSuggestsRelogin(t *testing.T) {
   109  	err := &OAuthTokenError{
   110  		StatusCode: 400,
   111  		Body:       `{"error":"invalid_grant","error_description":"refresh token expired"}`,
   112  	}
   113  	msg := err.Error()
   114  	if !strings.Contains(msg, "refresh token expired") {
   115  		t.Errorf("expected error_description in message, got: %s", msg)
   116  	}
   117  	if !strings.Contains(msg, "ant auth login") {
   118  		t.Errorf("expected relogin hint in message, got: %s", msg)
   119  	}
   120  	if !strings.Contains(msg, "invalid_grant") {
   121  		t.Errorf("expected error code in message, got: %s", msg)
   122  	}
   123  }
   124  
   125  // TestOAuthTokenError_401UnauthorizedSuggestsRelogin checks that a 401 with any
   126  // body still triggers the re-login hint because a token-endpoint 401 almost
   127  // always means the refresh credential has been revoked.
   128  func TestOAuthTokenError_401UnauthorizedSuggestsRelogin(t *testing.T) {
   129  	err := &OAuthTokenError{StatusCode: 401, Body: `{"error":"unauthorized_client"}`}
   130  	msg := err.Error()
   131  	if !strings.Contains(msg, "ant auth login") {
   132  		t.Errorf("expected relogin hint on 401, got: %s", msg)
   133  	}
   134  }
   135  
   136  // TestOAuthTokenError_500DoesNotSuggestRelogin verifies that transient server
   137  // errors do NOT blame the user's credentials.
   138  func TestOAuthTokenError_500DoesNotSuggestRelogin(t *testing.T) {
   139  	err := &OAuthTokenError{StatusCode: 500, Body: `{"error":"server_error"}`}
   140  	msg := err.Error()
   141  	if strings.Contains(msg, "ant auth login") {
   142  		t.Errorf("5xx should not suggest re-login: %s", msg)
   143  	}
   144  }
   145  
   146  // TestOAuthTokenError_RequestIDInMessage verifies the HTTP Request-Id response
   147  // header is captured into the error and printed so support can correlate.
   148  func TestOAuthTokenError_RequestIDInMessage(t *testing.T) {
   149  	err := &OAuthTokenError{
   150  		StatusCode: 400,
   151  		Body:       `{"error":"invalid_grant"}`,
   152  		RequestID:  "req_abc123",
   153  	}
   154  	msg := err.Error()
   155  	if !strings.Contains(msg, "req_abc123") {
   156  		t.Errorf("expected Request-Id in message, got: %s", msg)
   157  	}
   158  }
   159  
   160  // TestOAuthTokenError_InvalidGrantStillRedactsSecrets makes sure the new
   161  // RFC 6749 parsing path does not accidentally bypass body redaction.
   162  func TestOAuthTokenError_InvalidGrantStillRedactsSecrets(t *testing.T) {
   163  	err := &OAuthTokenError{
   164  		StatusCode: 400,
   165  		Body:       `{"error":"invalid_grant","refresh_token":"rt-secret","assertion":"eyJhbGciOi.."}`,
   166  	}
   167  	msg := err.Error()
   168  	if strings.Contains(msg, "rt-secret") {
   169  		t.Errorf("refresh_token leaked: %s", msg)
   170  	}
   171  	if strings.Contains(msg, "eyJhbGciOi..") {
   172  		t.Errorf("assertion leaked: %s", msg)
   173  	}
   174  }