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 }