1fcc0b738a
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m30s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m8s
- SetHTTPClient(nil): preserve CheckRedirect auth-stripping policy instead of restoring a plain http.Client that loses cross-host protection. - Authorization header: add comment documenting why Bearer scheme is correct (OAuth2 standard, works for both classic PATs and fine-grained tokens). - Retry-After parsing: support HTTP-date format (RFC 7231) in addition to integer seconds. GitHub only sends integers today, but the implementation is now spec-compliant. - escapePath dot-segment removal: document the behavior in public API doc comments for ListContents and GetFileContentAtRef so callers are aware without reading the internal helper.
477 lines
14 KiB
Go
477 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewClient_DefaultBaseURL(t *testing.T) {
|
|
c := NewClient("test-token", "")
|
|
if c.baseURL != "https://api.github.com" {
|
|
t.Errorf("expected default base URL, got %q", c.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestNewClient_CustomBaseURL(t *testing.T) {
|
|
c := NewClient("test-token", "https://github.concur.com/api/v3")
|
|
if c.baseURL != "https://github.concur.com/api/v3" {
|
|
t.Errorf("expected custom base URL, got %q", c.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
|
|
c := NewClient("test-token", "https://github.concur.com/api/v3/")
|
|
if c.baseURL != "https://github.concur.com/api/v3" {
|
|
t.Errorf("expected trailing slash trimmed, got %q", c.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_SetsAuthHeader(t *testing.T) {
|
|
var gotAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(200)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("my-token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
|
|
|
if gotAuth != "Bearer my-token" {
|
|
t.Errorf("expected Bearer auth, got %q", gotAuth)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_SetsDefaultAcceptHeader(t *testing.T) {
|
|
var gotAccept string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAccept = r.Header.Get("Accept")
|
|
w.WriteHeader(200)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
|
|
|
if gotAccept != "application/vnd.github+json" {
|
|
t.Errorf("expected default Accept header, got %q", gotAccept)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_429Retry(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond})
|
|
|
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Errorf("unexpected body: %s", body)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_429ExhaustsRetries(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
|
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error after exhausting retries")
|
|
}
|
|
apiErr, ok := err.(*APIError)
|
|
if !ok {
|
|
t.Fatalf("expected *APIError, got %T", err)
|
|
}
|
|
if apiErr.StatusCode != 429 {
|
|
t.Errorf("expected 429, got %d", apiErr.StatusCode)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_404NoRetry(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(404)
|
|
w.Write([]byte(`{"message":"not found"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error for 404")
|
|
}
|
|
if attempts != 1 {
|
|
t.Errorf("expected 1 attempt (no retry on 404), got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_401NoRetry(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(401)
|
|
w.Write([]byte(`{"message":"bad credentials"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error for 401")
|
|
}
|
|
if attempts != 1 {
|
|
t.Errorf("expected 1 attempt (no retry on 401), got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestIsNotFound(t *testing.T) {
|
|
err := &APIError{StatusCode: 404, Body: "not found"}
|
|
if !IsNotFound(err) {
|
|
t.Error("expected IsNotFound to return true for 404")
|
|
}
|
|
err2 := &APIError{StatusCode: 500, Body: "server error"}
|
|
if IsNotFound(err2) {
|
|
t.Error("expected IsNotFound to return false for 500")
|
|
}
|
|
}
|
|
|
|
func TestIsUnauthorized(t *testing.T) {
|
|
err := &APIError{StatusCode: 401, Body: "bad credentials"}
|
|
if !IsUnauthorized(err) {
|
|
t.Error("expected IsUnauthorized to return true for 401")
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_429RetryAfterHeader(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping slow retry test in short mode")
|
|
}
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
// Use short backoff; Retry-After should override
|
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
|
|
|
start := time.Now()
|
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Errorf("unexpected body: %s", body)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
// Retry-After: 1 means at least 1 second delay
|
|
if elapsed < 900*time.Millisecond {
|
|
t.Errorf("expected ~1s delay from Retry-After, got %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping slow retry test in short mode")
|
|
}
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
|
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify the original retryBackoff slice was not mutated
|
|
if c.retryBackoff[0] != 1*time.Millisecond {
|
|
t.Errorf("retryBackoff[0] was mutated: got %v, want 1ms", c.retryBackoff[0])
|
|
}
|
|
if c.retryBackoff[1] != 1*time.Millisecond {
|
|
t.Errorf("retryBackoff[1] was mutated: got %v, want 1ms", c.retryBackoff[1])
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_429RetryAfterHTTPDate(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
// Use HTTP-date format (RFC 7231) — a time 2 seconds in the future.
|
|
future := time.Now().Add(2 * time.Second).UTC()
|
|
w.Header().Set("Retry-After", future.Format(http.TimeFormat))
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
|
|
|
|
start := time.Now()
|
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Errorf("unexpected body: %s", body)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
// HTTP-date was ~2s in the future; by the time client processes it,
|
|
// time.Until gives ~1-2s. Verify it's meaningfully delayed (not instant).
|
|
if elapsed < 500*time.Millisecond {
|
|
t.Errorf("expected meaningful delay from HTTP-date Retry-After, got %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_429RetryAfterHTTPDateInPast(t *testing.T) {
|
|
attempts := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts == 1 {
|
|
// Use a time in the past — should result in zero/immediate retry.
|
|
past := time.Now().Add(-10 * time.Second).UTC()
|
|
w.Header().Set("Retry-After", past.Format(http.TimeFormat))
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"message":"rate limit"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second})
|
|
|
|
start := time.Now()
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if attempts != 2 {
|
|
t.Errorf("expected 2 attempts, got %d", attempts)
|
|
}
|
|
// Past date should override the 5s backoff to ~0
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Errorf("expected near-instant retry for past HTTP-date, got %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_SetsUserAgentHeader(t *testing.T) {
|
|
var gotUA string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotUA = r.Header.Get("User-Agent")
|
|
w.WriteHeader(200)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
|
|
|
if gotUA != "review-bot/1.0" {
|
|
t.Errorf("expected User-Agent 'review-bot/1.0', got %q", gotUA)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_LimitsResponseBody(t *testing.T) {
|
|
// Verify that oversized responses return an error rather than silently truncating.
|
|
bigBody := strings.Repeat("x", maxResponseBytes+1024)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(bigBody))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized response body")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeded") {
|
|
t.Errorf("expected truncation error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_SkipsAuthWhenTokenEmpty(t *testing.T) {
|
|
var gotAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.WriteHeader(200)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("", srv.URL, AllowInsecureHTTP()) // empty token
|
|
c.SetHTTPClient(srv.Client())
|
|
_, _ = c.doGet(context.Background(), srv.URL+"/test")
|
|
|
|
if gotAuth != "" {
|
|
t.Errorf("expected no Authorization header with empty token, got %q", gotAuth)
|
|
}
|
|
}
|
|
|
|
func TestNewClient_CheckRedirectStripsAuthOnCrossHost(t *testing.T) {
|
|
// Verify the CheckRedirect function is configured
|
|
c := NewClient("secret-token", "https://api.github.com")
|
|
if c.httpClient.CheckRedirect == nil {
|
|
t.Fatal("expected CheckRedirect to be set")
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_RejectsHTTPWithToken(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Without AllowInsecureHTTP, should refuse to send token over HTTP
|
|
c := NewClient("secret-token", srv.URL)
|
|
c.SetHTTPClient(srv.Client())
|
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error when sending token over HTTP")
|
|
}
|
|
if !strings.Contains(err.Error(), "refusing to send credentials") {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_AllowsHTTPWithoutToken(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Without token, HTTP should be fine (no credentials to leak)
|
|
c := NewClient("", srv.URL)
|
|
c.SetHTTPClient(srv.Client())
|
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Errorf("unexpected body: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestDoRequest_AllowsHTTPWithInsecureOption(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient("secret-token", srv.URL, AllowInsecureHTTP())
|
|
c.SetHTTPClient(srv.Client())
|
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Errorf("unexpected body: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
|
c := NewClient("token", "https://api.github.com")
|
|
c.SetHTTPClient(nil)
|
|
if c.httpClient == nil {
|
|
t.Fatal("expected non-nil httpClient after SetHTTPClient(nil)")
|
|
}
|
|
if c.httpClient.Timeout != 30*time.Second {
|
|
t.Errorf("expected 30s timeout, got %v", c.httpClient.Timeout)
|
|
}
|
|
if c.httpClient.CheckRedirect == nil {
|
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
|
}
|
|
}
|