Ctrl K
ComunidadGuías…

internal/pluginstore/github_rate_limit_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pluginstore

import (
	"context"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func githubTestHeaders(values map[string]string) http.Header {
	headers := make(http.Header)
	for name, value := range values {
		headers.Set(name, value)
	}
	return headers
}

func TestGitHubRateLimitHeaders(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	reset := strconv.FormatInt(now.Add(time.Hour).Unix(), 10)
	tests := []struct {
		name    string
		status  int
		headers map[string]string
		body    string
		wait    time.Duration
	}{
		{name: "primary forbidden", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: time.Hour},
		{name: "retry seconds", status: 429, headers: map[string]string{"Retry-After": "120"}, wait: 2 * time.Minute},
		{name: "retry date", status: 429, headers: map[string]string{"Retry-After": now.Add(3 * time.Minute).Format(http.TimeFormat)}, wait: 3 * time.Minute},
		{name: "reset wins", status: 403, headers: map[string]string{"Retry-After": "120", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: time.Hour},
		{name: "retry wins", status: 429, headers: map[string]string{"Retry-After": "7200", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": reset}, wait: 2 * time.Hour},
		{name: "secondary message", status: 403, body: `{"message":"You have exceeded a secondary rate limit."}`, wait: time.Minute},
		{name: "abuse message", status: 403, body: `{"message":"You have triggered an abuse detection mechanism."}`, wait: time.Minute},
		{name: "retry forbidden", status: 403, headers: map[string]string{"Retry-After": "60"}, wait: time.Minute},
		{name: "headerless too many requests", status: 429, wait: time.Minute},
		{name: "invalid retry", status: 429, headers: map[string]string{"Retry-After": "invalid"}, wait: time.Minute},
		{name: "overflow retry", status: 429, headers: map[string]string{"Retry-After": "9223372036854775807"}, wait: time.Minute},
		{name: "negative retry", status: 429, headers: map[string]string{"Retry-After": "-10"}, wait: time.Minute},
		{name: "past reset", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "1"}, wait: time.Minute},
		{name: "invalid reset", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "invalid"}, wait: time.Minute},
		{name: "permission denied", status: 403, body: `{"message":"Resource not accessible by integration"}`},
		{name: "invalid body", status: 403, body: "forbidden"},
		{name: "not found", status: 404},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
			errLimit := limiter.observe("quota", tt.status, githubTestHeaders(tt.headers), []byte(tt.body))
			if tt.wait == 0 {
				if errLimit != nil || limiter.check("quota") != nil {
					t.Fatalf("non-rate-limit response started cooldown: %v", errLimit)
				}
				return
			}
			var rateLimit *RateLimitError
			if !errors.As(errLimit, &rateLimit) || rateLimit.StatusCode != tt.status || !rateLimit.RetryAt.Equal(now.Add(tt.wait)) {
				t.Fatalf("error=%v, want status=%d retry=%s", errLimit, tt.status, now.Add(tt.wait))
			}
			if errCheck := limiter.check("quota"); !errors.As(errCheck, &rateLimit) {
				t.Fatalf("check=%v, want cooldown", errCheck)
			}
		})
	}
}

func TestGitHubRateLimitKeyCanonicalHost(t *testing.T) {
	t.Parallel()
	want := githubRateLimitKey("https://api.github.com/repos/owner/repo", "", nil, false)
	for _, requestURL := range []string{"https://api.github.com:443/repos/other/repo", "https://API.GITHUB.COM/releases"} {
		if got := githubRateLimitKey(requestURL, "", nil, false); got != want || got == "" {
			t.Fatalf("key for %s = %q, want %q", requestURL, got, want)
		}
	}
	for _, requestURL := range []string{"https://api.github.com:444/repos/owner/repo", "http://api.github.com/", "https://api.github.com.example/", "https://raw.githubusercontent.com/"} {
		if got := githubRateLimitKey(requestURL, "", nil, false); got != "" {
			t.Fatalf("unrelated URL %s has GitHub quota key %q", requestURL, got)
		}
	}
}

func TestGitHubRateLimitPrunesInactiveIdentities(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
	_ = limiter.observe("old-token", 429, nil, nil)
	_ = limiter.observe("active-token", 429, githubTestHeaders(map[string]string{"Retry-After": "7200"}), nil)
	now = now.Add(time.Hour + time.Minute)
	_ = limiter.check("new-token")
	if _, exists := limiter.entries["old-token"]; exists {
		t.Fatal("inactive credential cooldown was not pruned")
	}
	if errCheck := limiter.check("active-token"); errCheck == nil {
		t.Fatal("active cooldown was pruned")
	}
}

func TestGitHubRateLimitBackoffAndRecovery(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
	for _, delay := range []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute, 32 * time.Minute, time.Hour, time.Hour} {
		errLimit := limiter.observe("quota", 429, nil, nil)
		var rateLimit *RateLimitError
		if !errors.As(errLimit, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(delay)) {
			t.Fatalf("backoff=%v, want %s", errLimit, delay)
		}
		for range 5 {
			if limiter.check("quota") == nil {
				t.Fatal("local check lost cooldown")
			}
		}
		now = now.Add(delay)
		if errCheck := limiter.check("quota"); errCheck != nil {
			t.Fatalf("expired cooldown: %v", errCheck)
		}
	}
	_ = limiter.observe("quota", 200, nil, nil)
	var rateLimit *RateLimitError
	if errLimit := limiter.observe("quota", 429, nil, nil); !errors.As(errLimit, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(time.Minute)) {
		t.Fatalf("success did not reset backoff: %v", errLimit)
	}
}

func TestGitHubRateLimitSuccessfulExhaustionAndLateResponses(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
	headers := githubTestHeaders(map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(now.Add(time.Hour).Unix(), 10)})
	if errObserve := limiter.observe("quota", 200, headers, nil); errObserve != nil {
		t.Fatalf("final successful request failed: %v", errObserve)
	}
	_ = limiter.observe("quota", 200, nil, nil)
	_ = limiter.observe("quota", 429, githubTestHeaders(map[string]string{"Retry-After": "60"}), nil)
	var rateLimit *RateLimitError
	if errCheck := limiter.check("quota"); !errors.As(errCheck, &rateLimit) || !rateLimit.RetryAt.Equal(now.Add(time.Hour)) {
		t.Fatalf("late response shortened cooldown: %v", errCheck)
	}
}

func TestGitHubCooldownSharedAcrossClientsRepositoriesAndAssets(t *testing.T) {
	t.Parallel()
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	limiter := &GitHubRateLimiter{nowFunc: func() time.Time { return now }}
	calls := 0
	doer := pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
		calls++
		if calls == 1 {
			return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "60"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
		}
		return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`))}, nil
	})
	first := Client{HTTPClient: doer, RateLimiter: limiter}
	second := Client{HTTPClient: doer, RateLimiter: limiter}
	ctx := context.Background()
	_, errFirst := first.FetchLatestRelease(ctx, Plugin{Repository: "https://github.com/owner/first"})
	_, errSecond := second.FetchReleaseByTag(ctx, Plugin{Repository: "https://github.com/other/second"}, "v1.0.0")
	_, errAsset := second.DownloadAsset(ctx, ReleaseAsset{APIURL: "https://api.github.com:443/repos/other/second/releases/assets/1"})
	for _, errRequest := range []error{errFirst, errSecond, errAsset} {
		var rateLimit *RateLimitError
		if !errors.As(errRequest, &rateLimit) {
			t.Fatalf("request error=%v, want rate limit", errRequest)
		}
	}
	if calls != 1 {
		t.Fatalf("calls=%d, want 1", calls)
	}
	if _, errRaw := second.get(ctx, DefaultRegistryURL, "application/json", RequestKindRegistry, 0); errRaw != nil {
		t.Fatalf("API cooldown blocked raw registry: %v", errRaw)
	}
	now = now.Add(time.Minute)
	if _, errRecovered := second.FetchLatestRelease(ctx, Plugin{Repository: "https://github.com/other/second"}); errRecovered != nil || calls != 3 {
		t.Fatalf("recovery error=%v calls=%d", errRecovered, calls)
	}
}

func TestGitHubCooldownSeparatesCredentialsAndNetworkScope(t *testing.T) {
	t.Parallel()
	limiter := &GitHubRateLimiter{}
	calls := 0
	client := Client{RateLimiter: limiter, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
		calls++
		return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
	})}
	plugin := Plugin{Repository: "https://github.com/owner/repo"}
	for _, token := range []string{"", "token-a", "token-b", "token-a", ""} {
		client.ResolvedAuth = nil
		if token != "" {
			client.ResolvedAuth = []ResolvedAuthConfig{{Match: "https://api.github.com/", Type: AuthTypeGitHubToken, Token: Secret(token)}}
		}
		_, _ = client.FetchLatestRelease(context.Background(), plugin)
	}
	if calls != 3 {
		t.Fatalf("credential scopes made %d calls, want 3", calls)
	}
	client.NetworkScope = "other-egress"
	_, _ = client.FetchLatestRelease(context.Background(), plugin)
	if calls != 4 {
		t.Fatalf("egress scopes made %d calls, want 4", calls)
	}
}

func TestGitHubRateLimitAuthenticatedBodyIsNotExposed(t *testing.T) {
	t.Parallel()
	client := Client{
		RateLimiter:  &GitHubRateLimiter{},
		ResolvedAuth: []ResolvedAuthConfig{{Match: "https://api.github.com/", Type: AuthTypeGitHubToken, Token: Secret("secret-token")}},
		HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
			return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"message":"secondary rate limit: secret-token"}`))}, nil
		}),
	}
	_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
	var rateLimit *RateLimitError
	if !errors.As(errRelease, &rateLimit) || strings.Contains(errRelease.Error(), "secret-token") {
		t.Fatalf("unsafe or unclassified error: %v", errRelease)
	}
}

func TestGitHubDefaultCooldownBlocksConcurrentNewClients(t *testing.T) {
	t.Parallel()
	var calls atomic.Int32
	client := Client{NetworkScope: t.Name(), HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
		calls.Add(1)
		return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
	})}
	key := githubRateLimitKey("https://api.github.com/", client.NetworkScope, nil, false)
	t.Cleanup(func() {
		defaultGitHubRateLimiter.mu.Lock()
		delete(defaultGitHubRateLimiter.entries, key)
		defaultGitHubRateLimiter.mu.Unlock()
	})
	_, _ = client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/first"})
	var wg sync.WaitGroup
	for range 20 {
		wg.Go(func() {
			copyClient := Client{NetworkScope: client.NetworkScope, HTTPClient: client.HTTPClient}
			_, errRelease := copyClient.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/other/second"})
			var rateLimit *RateLimitError
			if !errors.As(errRelease, &rateLimit) {
				t.Errorf("request error = %v", errRelease)
			}
		})
	}
	wg.Wait()
	if calls.Load() != 1 {
		t.Fatalf("calls=%d, want 1", calls.Load())
	}
}

type githubHeaderOnlyBody struct {
	reads  int
	closed bool
}

func (body *githubHeaderOnlyBody) Read([]byte) (int, error) {
	body.reads++
	return 0, errors.New("body must not be read")
}

func (body *githubHeaderOnlyBody) Close() error {
	body.closed = true
	return nil
}

func TestGitHubHeaderCooldownClosesBodyWithoutReading(t *testing.T) {
	t.Parallel()
	body := &githubHeaderOnlyBody{}
	client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
		return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "60"}), Body: body}, nil
	})}
	_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
	var rateLimit *RateLimitError
	if !errors.As(errRelease, &rateLimit) || body.reads != 0 || !body.closed {
		t.Fatalf("error=%v reads=%d closed=%v", errRelease, body.reads, body.closed)
	}
}

func TestGitHubSuccessfulFinalRequestReturnsRelease(t *testing.T) {
	t.Parallel()
	calls := 0
	client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(*http.Request) (*http.Response, error) {
		calls++
		return &http.Response{
			StatusCode: 200,
			Header:     githubTestHeaders(map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)}),
			Body:       io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`)),
		}, nil
	})}
	release, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
	if errRelease != nil || release.TagName != "v1.0.0" {
		t.Fatalf("release=%#v error=%v", release, errRelease)
	}
	_, errNext := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/other"})
	var rateLimit *RateLimitError
	if !errors.As(errNext, &rateLimit) || calls != 1 {
		t.Fatalf("next error=%v calls=%d", errNext, calls)
	}
}

func TestNonGitHubRateLimitDoesNotCoolGitHub(t *testing.T) {
	t.Parallel()
	calls := 0
	client := Client{RateLimiter: &GitHubRateLimiter{}, HTTPClient: pluginIdentityDoerFunc(func(req *http.Request) (*http.Response, error) {
		calls++
		if req.URL.Host != "api.github.com" {
			return &http.Response{StatusCode: 429, Header: githubTestHeaders(map[string]string{"Retry-After": "3600"}), Body: io.NopCloser(strings.NewReader("limited"))}, nil
		}
		return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`))}, nil
	})}
	_, errRegistry := client.FetchRegistry(context.Background())
	var rateLimit *RateLimitError
	if errRegistry == nil || errors.As(errRegistry, &rateLimit) {
		t.Fatalf("non-API error=%v, want ordinary HTTP error", errRegistry)
	}
	_, errRelease := client.FetchLatestRelease(context.Background(), Plugin{Repository: "https://github.com/owner/repo"})
	if errRelease != nil || calls != 2 {
		t.Fatalf("release error=%v calls=%d", errRelease, calls)
	}
}

func TestRateLimitRetryAfterRoundsUp(t *testing.T) {
	t.Parallel()
	now := time.Unix(100, 0)
	for _, tt := range []struct {
		delay time.Duration
		want  int64
	}{{-time.Second, 0}, {0, 0}, {time.Nanosecond, 1}, {time.Second, 1}, {time.Second + time.Nanosecond, 2}} {
		errLimit := &RateLimitError{RetryAt: now.Add(tt.delay)}
		if got := errLimit.RetryAfterSeconds(now); got != tt.want {
			t.Fatalf("RetryAfterSeconds(%s)=%d, want %d", tt.delay, got, tt.want)
		}
	}
}