package scopes import ( "context" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type testAPIHostResolver struct { baseURL string } func (t testAPIHostResolver) BaseRESTURL(_ context.Context) (*url.URL, error) { return url.Parse(t.baseURL) } func (t testAPIHostResolver) GraphqlURL(_ context.Context) (*url.URL, error) { return nil, nil } func (t testAPIHostResolver) UploadURL(_ context.Context) (*url.URL, error) { return nil, nil } func (t testAPIHostResolver) RawURL(_ context.Context) (*url.URL, error) { return nil, nil } func (t testAPIHostResolver) AuthorizationServerURL(_ context.Context) (*url.URL, error) { return nil, nil } func TestParseScopeHeader(t *testing.T) { tests := []struct { name string header string expected []string }{ { name: "empty header", header: "", expected: []string{}, }, { name: "single scope", header: "repo", expected: []string{"repo"}, }, { name: "multiple scopes", header: "repo, user, gist", expected: []string{"repo", "user", "gist"}, }, { name: "scopes with extra whitespace", header: " repo , user , gist ", expected: []string{"repo", "user", "gist"}, }, { name: "scopes without spaces", header: "repo,user,gist", expected: []string{"repo", "user", "gist"}, }, { name: "scopes with colons", header: "read:org, write:org, admin:org", expected: []string{"read:org", "write:org", "admin:org"}, }, { name: "empty parts are filtered", header: "repo,,gist", expected: []string{"repo", "gist"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ParseScopeHeader(tt.header) assert.Equal(t, tt.expected, result) }) } } func TestFetcher_FetchTokenScopes(t *testing.T) { tests := []struct { name string handler http.HandlerFunc expectedScopes []string expectError bool errorContains string }{ { name: "successful fetch with multiple scopes", handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-OAuth-Scopes", "repo, user, gist") w.WriteHeader(http.StatusOK) }, expectedScopes: []string{"repo", "user", "gist"}, expectError: false, }, { name: "successful fetch with single scope", handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-OAuth-Scopes", "repo") w.WriteHeader(http.StatusOK) }, expectedScopes: []string{"repo"}, expectError: false, }, { name: "fine-grained PAT returns empty scopes", handler: func(w http.ResponseWriter, _ *http.Request) { // Fine-grained PATs don't return X-OAuth-Scopes w.WriteHeader(http.StatusOK) }, expectedScopes: []string{}, expectError: false, }, { name: "unauthorized token", handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, expectError: true, errorContains: "invalid or expired token", }, { name: "server error", handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, expectError: true, errorContains: "unexpected status code: 500", }, { name: "verifies authorization header is set", handler: func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader != "Bearer test-token" { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("X-OAuth-Scopes", "repo") w.WriteHeader(http.StatusOK) }, expectedScopes: []string{"repo"}, expectError: false, }, { name: "verifies request method is HEAD", handler: func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) return } w.Header().Set("X-OAuth-Scopes", "repo") w.WriteHeader(http.StatusOK) }, expectedScopes: []string{"repo"}, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(tt.handler) defer server.Close() apiHost := testAPIHostResolver{baseURL: server.URL} fetcher := NewFetcher(apiHost, FetcherOptions{}) scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") if tt.expectError { require.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { require.NoError(t, err) assert.Equal(t, tt.expectedScopes, scopes) } }) } } func TestFetcher_DefaultOptions(t *testing.T) { apiHost := testAPIHostResolver{baseURL: "https://api.github.com"} fetcher := NewFetcher(apiHost, FetcherOptions{}) // Verify default API host is set apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background()) require.NoError(t, err) assert.Equal(t, "https://api.github.com", apiURL.String()) // Verify default HTTP client is set with timeout assert.NotNil(t, fetcher.client) assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout) } func TestFetcher_CustomHTTPClient(t *testing.T) { customClient := &http.Client{Timeout: 5 * time.Second} apiHost := testAPIHostResolver{baseURL: "https://api.github.com"} fetcher := NewFetcher(apiHost, FetcherOptions{ HTTPClient: customClient, }) assert.Equal(t, customClient, fetcher.client) } func TestFetcher_CustomAPIHost(t *testing.T) { apiHost := testAPIHostResolver{baseURL: "https://api.github.enterprise.com"} fetcher := NewFetcher(apiHost, FetcherOptions{}) apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background()) require.NoError(t, err) assert.Equal(t, "https://api.github.enterprise.com", apiURL.String()) } func TestFetcher_ContextCancellation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusOK) })) defer server.Close() apiHost := testAPIHostResolver{baseURL: server.URL} fetcher := NewFetcher(apiHost, FetcherOptions{}) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately _, err := fetcher.FetchTokenScopes(ctx, "test-token") require.Error(t, err) }