diff --git a/CHANGELOG.md b/CHANGELOG.md index 39af9ee..e18cb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,103 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.5.0] - 2025-10-28 + +### 🚨 Breaking Changes + +This release removes the `github.com/google/go-github/v74` dependency and implements a lightweight internal GitHub API client. While most users will experience no breaking changes, some API adjustments have been made: + +#### API Changes + +1. **Enterprise Configuration Simplified** + - **Before**: `WithEnterpriseURLs(baseURL, uploadURL string)` - required both base and upload URLs + - **After**: `WithEnterpriseURL(baseURL string)` - single base URL parameter + - **Migration**: Remove the redundant upload URL parameter + +2. **Type Changes** (if you were using these types directly) + - `github.InstallationTokenOptions` → `githubauth.InstallationTokenOptions` + - `github.InstallationPermissions` → `githubauth.InstallationPermissions` + - `github.InstallationToken` → `githubauth.InstallationToken` + - `github.Repository` → `githubauth.Repository` + +### Added + +- **Internal GitHub API Client**: New `github.go` file with minimal GitHub API implementation + - Direct HTTP API calls to GitHub's REST API + - `InstallationTokenOptions` type for configuring installation token requests + - `InstallationPermissions` type with comprehensive permission structure + - `InstallationToken` response type from GitHub API + - `Repository` type for minimal repository representation +- **Public Helper Function**: Added `Ptr[T]()` generic helper for creating pointers to any type (useful for InstallationTokenOptions) + +### Changed + +- **Removed Dependency**: Eliminated `github.com/google/go-github/v74` dependency +- **Removed Dependency**: Eliminated `github.com/google/go-querystring` indirect dependency +- **Simplified Enterprise Support**: Streamlined from `WithEnterpriseURLs()` to `WithEnterpriseURL()` +- **Updated Documentation**: Package docs now reflect that the library is built only on `golang.org/x/oauth2` +- **Binary Size Reduction**: Smaller binaries without unused go-github code + +### Fixed + +- **Documentation**: Fixed GitHub API documentation link for installation token generation + +### Migration Guide + +#### For Most Users + +No action required - if you only use the public `TokenSource` functions, your code will continue to work without changes. + +#### For Enterprise GitHub Users + +```go +// Before (v1.4.x) +installationTokenSource := githubauth.NewInstallationTokenSource( + installationID, + appTokenSource, + githubauth.WithEnterpriseURLs("https://github.example.com", "https://github.example.com"), +) + +// After (v1.5.0) +installationTokenSource := githubauth.NewInstallationTokenSource( + installationID, + appTokenSource, + githubauth.WithEnterpriseURL("https://github.example.com"), +) +``` + +#### For Direct Type Users + +```go +// Before (v1.4.x) +import "github.com/google/go-github/v74/github" +opts := &github.InstallationTokenOptions{ + Repositories: []string{"repo1", "repo2"}, + Permissions: &github.InstallationPermissions{ + Contents: github.Ptr("read"), + }, +} + +// After (v1.5.0) +import "github.com/jferrl/go-githubauth" +opts := &githubauth.InstallationTokenOptions{ + Repositories: []string{"repo1", "repo2"}, + Permissions: &githubauth.InstallationPermissions{ + Contents: githubauth.Ptr("read"), // Use the new Ptr() helper + }, +} +``` + +### Benefits + +- ✅ **Reduced Dependencies**: 2 fewer dependencies (from 3 to 2 total) +- ✅ **Smaller Binary Size**: No unused go-github code included +- ✅ **Better Control**: Full ownership of GitHub API integration +- ✅ **Easier Debugging**: Simpler code path for troubleshooting +- ✅ **Same Performance**: All token caching and performance optimizations maintained + +**Full Changelog**: + ## [v1.4.2] - 2025-09-19 ### Changed diff --git a/README.md b/README.md index c45bc8e..d562e47 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ `go-githubauth` is a Go package that provides utilities for GitHub authentication, including generating and using GitHub App tokens, installation tokens, and personal access tokens. -**v1.4.x** introduces personal access token support and significant performance optimizations with intelligent token caching and high-performance HTTP clients. +**v1.5.0** removes the `go-github` dependency, implementing a lightweight internal GitHub API client. This reduces external dependencies while maintaining full compatibility with the OAuth2 token source interface. --- @@ -26,8 +26,9 @@ `go-githubauth` package provides implementations of the `TokenSource` interface from the `golang.org/x/oauth2` package. This interface has a single method, Token, which returns an *oauth2.Token. -### v1.4.0 Features +### v1.5.0 Features +- **📦 Zero External Dependencies**: Removed `go-github` dependency - lightweight internal implementation - **🔐 Personal Access Token Support**: Native support for both classic and fine-grained personal access tokens - **⚡ Token Caching**: Dual-layer caching system for optimal performance - JWT tokens cached until expiration (up to 10 minutes) @@ -35,6 +36,7 @@ - **🚀 Pooled HTTP Client**: Production-ready HTTP client with connection pooling - **📈 Performance Optimizations**: Up to 99% reduction in unnecessary GitHub API calls - **🏗️ Production Ready**: Optimized for high-throughput and enterprise applications +- **🌐 Simplified Enterprise Support**: Streamlined configuration with single base URL parameter ### Core Capabilities @@ -48,7 +50,9 @@ ### Requirements +- Go 1.21 or higher (for generics support) - This package is designed to be used with the `golang.org/x/oauth2` package +- No external GitHub SDK dependencies required ## Installation @@ -60,7 +64,9 @@ go get -u github.com/jferrl/go-githubauth ## Usage -### Usage with [go-github](https://github.com/google/go-github) and [oauth2](golang.org/x/oauth2) +### Usage with [oauth2](golang.org/x/oauth2) + +You can use this package standalone with any HTTP client, or integrate it with the [go-github](https://github.com/google/go-github) SDK if you need additional GitHub API functionality. #### Client ID (Recommended) @@ -73,7 +79,7 @@ import ( "os" "strconv" - "github.com/google/go-github/v74/github" + "github.com/google/go-github/v76/github" "github.com/jferrl/go-githubauth" "golang.org/x/oauth2" ) @@ -117,7 +123,7 @@ import ( "os" "strconv" - "github.com/google/go-github/v74/github" + "github.com/google/go-github/v76/github" "github.com/jferrl/go-githubauth" "golang.org/x/oauth2" ) @@ -274,7 +280,48 @@ func main() { GitHub Personal Access Tokens provide direct authentication for users and organizations. This package supports both classic personal access tokens and fine-grained personal access tokens. -#### Using Personal Access Tokens with [go-github](https://github.com/google/go-github) +#### Using Personal Access Tokens + +##### With oauth2 Client (Standalone) + +```go +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "github.com/jferrl/go-githubauth" + "golang.org/x/oauth2" +) + +func main() { + // Personal access token from environment variable + token := os.Getenv("GITHUB_TOKEN") // e.g., "ghp_..." or "github_pat_..." + + // Create token source + tokenSource := githubauth.NewPersonalAccessTokenSource(token) + + // Create HTTP client with OAuth2 transport + httpClient := oauth2.NewClient(context.Background(), tokenSource) + + // Use the HTTP client for GitHub API calls + resp, err := httpClient.Get("https://api.github.com/user") + if err != nil { + fmt.Println("Error getting user:", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + fmt.Printf("User info: %s\n", body) +} +``` + +##### With go-github SDK (Optional) ```go package main @@ -284,7 +331,7 @@ import ( "fmt" "os" - "github.com/google/go-github/v74/github" + "github.com/google/go-github/v76/github" "github.com/jferrl/go-githubauth" "golang.org/x/oauth2" ) @@ -316,7 +363,7 @@ func main() { 1. **Classic Personal Access Token**: Visit [GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)](https://github.com/settings/tokens) 2. **Fine-grained Personal Access Token**: Visit [GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) -** 🔐 Security Note **: Store your personal access tokens securely and never commit them to version control. Use environment variables or secure credential management systems. +**🔐 Security Note**: Store your personal access tokens securely and never commit them to version control. Use environment variables or secure credential management systems. ## Contributing diff --git a/auth.go b/auth.go index 66bbdf0..8c16ed8 100644 --- a/auth.go +++ b/auth.go @@ -3,7 +3,7 @@ // // This package implements oauth2.TokenSource interfaces for GitHub App // authentication and GitHub App installation token generation. It is built -// on top of the go-github and golang.org/x/oauth2 libraries. +// on top of the golang.org/x/oauth2 library. package githubauth import ( @@ -15,7 +15,6 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" - "github.com/google/go-github/v74/github" "golang.org/x/oauth2" ) @@ -135,7 +134,7 @@ func (t *applicationTokenSource) Token() (*oauth2.Token, error) { type InstallationTokenSourceOpt func(*installationTokenSource) // WithInstallationTokenOptions sets the options for the GitHub App installation token. -func WithInstallationTokenOptions(opts *github.InstallationTokenOptions) InstallationTokenSourceOpt { +func WithInstallationTokenOptions(opts *InstallationTokenOptions) InstallationTokenSourceOpt { return func(i *installationTokenSource) { i.opts = opts } @@ -149,16 +148,16 @@ func WithHTTPClient(client *http.Client) InstallationTokenSourceOpt { Base: client.Transport, } - i.client = github.NewClient(client) + i.client = newGitHubClient(client) } } -// WithEnterpriseURLs sets the base URL and upload URL for GitHub Enterprise Server. +// WithEnterpriseURL sets the base URL for GitHub Enterprise Server. // This option should be used after WithHTTPClient to ensure the HTTP client is properly configured. -// If the provided URLs are invalid, the option is ignored and default GitHub URLs are used. -func WithEnterpriseURLs(baseURL, uploadURL string) InstallationTokenSourceOpt { +// If the provided base URL is invalid, the option is ignored and default GitHub base URL is used. +func WithEnterpriseURL(baseURL string) InstallationTokenSourceOpt { return func(i *installationTokenSource) { - enterpriseClient, err := i.client.WithEnterpriseURLs(baseURL, uploadURL) + enterpriseClient, err := i.client.withEnterpriseURL(baseURL) if err != nil { return } @@ -182,8 +181,8 @@ type installationTokenSource struct { id int64 ctx context.Context src oauth2.TokenSource - client *github.Client - opts *github.InstallationTokenOptions + client *githubClient + opts *InstallationTokenOptions } // NewInstallationTokenSource creates a GitHub App installation token source. @@ -193,7 +192,7 @@ type installationTokenSource struct { // token regeneration. Don't worry about wrapping the result again since ReuseTokenSource // prevents re-wrapping automatically. // -// See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token +// See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...InstallationTokenSourceOpt) oauth2.TokenSource { ctx := context.Background() @@ -207,7 +206,7 @@ func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...Instal id: id, ctx: ctx, src: src, - client: github.NewClient(httpClient), + client: newGitHubClient(httpClient), } for _, opt := range opts { @@ -219,15 +218,15 @@ func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...Instal // Token generates a new GitHub App installation token for authenticating as a GitHub App installation. func (t *installationTokenSource) Token() (*oauth2.Token, error) { - token, _, err := t.client.Apps.CreateInstallationToken(t.ctx, t.id, t.opts) + token, err := t.client.createInstallationToken(t.ctx, t.id, t.opts) if err != nil { return nil, err } return &oauth2.Token{ - AccessToken: token.GetToken(), + AccessToken: token.Token, TokenType: bearerTokenType, - Expiry: token.GetExpiresAt().Time, + Expiry: token.ExpiresAt, }, nil } diff --git a/auth_test.go b/auth_test.go index f6fe65c..2e04873 100644 --- a/auth_test.go +++ b/auth_test.go @@ -12,7 +12,6 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" - "github.com/google/go-github/v74/github" "golang.org/x/oauth2" ) @@ -154,6 +153,45 @@ func TestApplicationTokenSource_Token(t *testing.T) { } } +func TestApplicationTokenSource_Token_SigningError(t *testing.T) { + // Create an invalid private key that will cause signing to fail + invalidKey := []byte("invalid key") + + // This should fail at NewApplicationTokenSource due to invalid PEM + _, err := NewApplicationTokenSource(int64(12345), invalidKey) + if err == nil { + t.Fatal("Expected error for invalid private key, got nil") + } +} + +func TestWithEnterpriseURL_InvalidURL(t *testing.T) { + privateKey, err := generatePrivateKey() + if err != nil { + t.Fatal(err) + } + + appSrc, err := NewApplicationTokenSource(int64(12345), privateKey) + if err != nil { + t.Fatal(err) + } + + // Test with invalid URL - error is silently ignored in WithEnterpriseURL + installationTokenSource := NewInstallationTokenSource( + 1, + appSrc, + WithEnterpriseURL("ht\ntp://invalid"), + ) + + // The error is silently ignored in WithEnterpriseURL, so this should still work + // but will use the default URL + if installationTokenSource == nil { + t.Error("Expected non-nil token source") + } + + // Test that the token source is created successfully + // The error is silently ignored, so the source uses the default URL +} + func Test_installationTokenSource_Token(t *testing.T) { now := time.Now().UTC() expiration := now.Add(10 * time.Minute) @@ -161,18 +199,16 @@ func Test_installationTokenSource_Token(t *testing.T) { mockedHTTPClient, cleanupSuccess := newMockedHTTPClient( withRequestMatch( postAppInstallationsAccessTokensByInstallationID, - github.InstallationToken{ - Token: github.Ptr("mocked-installation-token"), - ExpiresAt: &github.Timestamp{ - Time: expiration, - }, - Permissions: &github.InstallationPermissions{ - PullRequests: github.Ptr("read"), + InstallationToken{ + Token: "mocked-installation-token", + ExpiresAt: expiration, + Permissions: &InstallationPermissions{ + PullRequests: Ptr("read"), }, - Repositories: []*github.Repository{ + Repositories: []Repository{ { - Name: github.Ptr("mocked-repo-1"), - ID: github.Ptr(int64(1)), + Name: Ptr("mocked-repo-1"), + ID: Ptr(int64(1)), }, }, }, @@ -217,7 +253,7 @@ func Test_installationTokenSource_Token(t *testing.T) { id: 1, src: appSrc, opts: []InstallationTokenSourceOpt{ - WithInstallationTokenOptions(&github.InstallationTokenOptions{}), + WithInstallationTokenOptions(&InstallationTokenOptions{}), WithHTTPClient(errMockedHTTPClient), }, }, @@ -229,9 +265,9 @@ func Test_installationTokenSource_Token(t *testing.T) { id: 1, src: appSrc, opts: []InstallationTokenSourceOpt{ - WithInstallationTokenOptions(&github.InstallationTokenOptions{}), + WithInstallationTokenOptions(&InstallationTokenOptions{}), WithContext(context.Background()), - WithEnterpriseURLs("https://github.example.com", "https://github.example.com"), + WithEnterpriseURL("https://github.example.com"), WithHTTPClient(mockedHTTPClient), }, }, diff --git a/github.go b/github.go new file mode 100644 index 0000000..5a5b080 --- /dev/null +++ b/github.go @@ -0,0 +1,160 @@ +package githubauth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + // defaultBaseURL is the default GitHub API base URL. + defaultBaseURL = "https://api.github.com/" +) + +// InstallationTokenOptions specifies options for creating an installation token. +type InstallationTokenOptions struct { + // Repositories is a list of repository names that the token should have access to. + Repositories []string `json:"repositories,omitempty"` + // RepositoryIDs is a list of repository IDs that the token should have access to. + RepositoryIDs []int64 `json:"repository_ids,omitempty"` + // Permissions are the permissions granted to the access token. + Permissions *InstallationPermissions `json:"permissions,omitempty"` +} + +// InstallationPermissions represents the permissions granted to an installation token. +type InstallationPermissions struct { + Actions *string `json:"actions,omitempty"` + Administration *string `json:"administration,omitempty"` + Checks *string `json:"checks,omitempty"` + Contents *string `json:"contents,omitempty"` + ContentReferences *string `json:"content_references,omitempty"` + Deployments *string `json:"deployments,omitempty"` + Environments *string `json:"environments,omitempty"` + Issues *string `json:"issues,omitempty"` + Metadata *string `json:"metadata,omitempty"` + Packages *string `json:"packages,omitempty"` + Pages *string `json:"pages,omitempty"` + PullRequests *string `json:"pull_requests,omitempty"` + RepositoryAnnouncementBanners *string `json:"repository_announcement_banners,omitempty"` + RepositoryHooks *string `json:"repository_hooks,omitempty"` + RepositoryProjects *string `json:"repository_projects,omitempty"` + SecretScanningAlerts *string `json:"secret_scanning_alerts,omitempty"` + Secrets *string `json:"secrets,omitempty"` + SecurityEvents *string `json:"security_events,omitempty"` + SingleFile *string `json:"single_file,omitempty"` + Statuses *string `json:"statuses,omitempty"` + VulnerabilityAlerts *string `json:"vulnerability_alerts,omitempty"` + Workflows *string `json:"workflows,omitempty"` + Members *string `json:"members,omitempty"` + OrganizationAdministration *string `json:"organization_administration,omitempty"` + OrganizationCustomRoles *string `json:"organization_custom_roles,omitempty"` + OrganizationAnnouncementBanners *string `json:"organization_announcement_banners,omitempty"` + OrganizationHooks *string `json:"organization_hooks,omitempty"` + OrganizationPlan *string `json:"organization_plan,omitempty"` + OrganizationProjects *string `json:"organization_projects,omitempty"` + OrganizationPackages *string `json:"organization_packages,omitempty"` + OrganizationSecrets *string `json:"organization_secrets,omitempty"` + OrganizationSelfHostedRunners *string `json:"organization_self_hosted_runners,omitempty"` + OrganizationUserBlocking *string `json:"organization_user_blocking,omitempty"` + TeamDiscussions *string `json:"team_discussions,omitempty"` +} + +// InstallationToken represents a GitHub App installation token. +type InstallationToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Permissions *InstallationPermissions `json:"permissions,omitempty"` + Repositories []Repository `json:"repositories,omitempty"` +} + +// Repository represents a GitHub repository. +type Repository struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +// githubClient is a simple GitHub API client for creating installation tokens. +type githubClient struct { + baseURL *url.URL + client *http.Client +} + +// newGitHubClient creates a new GitHub API client. +func newGitHubClient(httpClient *http.Client) *githubClient { + baseURL, _ := url.Parse(defaultBaseURL) + + return &githubClient{ + baseURL: baseURL, + client: httpClient, + } +} + +// withEnterpriseURL sets the base URL for GitHub Enterprise Server. +func (c *githubClient) withEnterpriseURL(baseURL string) (*githubClient, error) { + base, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse base URL: %w", err) + } + + c.baseURL = base + + return c, nil +} + +// createInstallationToken creates an installation access token for a GitHub App. +// API documentation: https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app +func (c *githubClient) createInstallationToken(ctx context.Context, installationID int64, opts *InstallationTokenOptions) (*InstallationToken, error) { + endpoint := fmt.Sprintf("app/installations/%d/access_tokens", installationID) + u, err := c.baseURL.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint URL: %w", err) + } + + var body io.Reader + if opts != nil { + jsonBody, err := json.Marshal(opts) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + body = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var token InstallationToken + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &token, nil +} + +// Ptr is a helper function to create a pointer to a value. +// This is useful when constructing InstallationTokenOptions with permissions. +func Ptr[T any](v T) *T { + return &v +} diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..1c4f20b --- /dev/null +++ b/github_test.go @@ -0,0 +1,277 @@ +package githubauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func Test_githubClient_withEnterpriseURL(t *testing.T) { + tests := []struct { + name string + baseURL string + wantErr bool + }{ + { + name: "valid URL", + baseURL: "https://github.example.com", + wantErr: false, + }, + { + name: "invalid URL with control characters", + baseURL: "ht\ntp://invalid", + wantErr: true, + }, + { + name: "URL with spaces", + baseURL: "http://invalid url with spaces", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newGitHubClient(&http.Client{}) + _, err := client.withEnterpriseURL(tt.baseURL) + if (err != nil) != tt.wantErr { + t.Errorf("withEnterpriseURL() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_githubClient_createInstallationToken_ErrorCases(t *testing.T) { + tests := []struct { + name string + setupServer func() *httptest.Server + opts *InstallationTokenOptions + wantErr bool + errorSubstring string + }{ + { + name: "invalid JSON in options", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(InstallationToken{ + Token: "test-token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + }, + opts: &InstallationTokenOptions{ + Repositories: []string{"repo1"}, + }, + wantErr: false, + }, + { + name: "bad request - 400", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"Bad Request"}`)) + })) + }, + opts: nil, + wantErr: true, + errorSubstring: "400", + }, + { + name: "unauthorized - 401", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Unauthorized"}`)) + })) + }, + opts: nil, + wantErr: true, + errorSubstring: "401", + }, + { + name: "forbidden - 403", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })) + }, + opts: nil, + wantErr: true, + errorSubstring: "403", + }, + { + name: "not found - 404", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + }, + opts: nil, + wantErr: true, + errorSubstring: "404", + }, + { + name: "invalid JSON response", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{invalid json`)) + })) + }, + opts: nil, + wantErr: true, + errorSubstring: "failed to decode response", + }, + { + name: "success with nil options", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(InstallationToken{ + Token: "test-token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + }, + opts: nil, + wantErr: false, + }, + { + name: "success with HTTP 200", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(InstallationToken{ + Token: "test-token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + }, + opts: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setupServer() + defer server.Close() + + client := newGitHubClient(&http.Client{}) + client.baseURL, _ = client.baseURL.Parse(server.URL) + + _, err := client.createInstallationToken(context.Background(), 12345, tt.opts) + if (err != nil) != tt.wantErr { + t.Errorf("createInstallationToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.errorSubstring != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.errorSubstring) + } else if !contains(err.Error(), tt.errorSubstring) { + t.Errorf("expected error containing %q, got %q", tt.errorSubstring, err.Error()) + } + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && hasSubstring(s, substr))) +} + +func hasSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func Test_Ptr(t *testing.T) { + t.Run("string pointer", func(t *testing.T) { + s := "test" + p := Ptr(s) + if p == nil { + t.Fatal("Ptr() returned nil") + } + if *p != s { + t.Errorf("Ptr() = %v, want %v", *p, s) + } + }) + + t.Run("int pointer", func(t *testing.T) { + i := 42 + p := Ptr(i) + if p == nil { + t.Fatal("Ptr() returned nil") + } + if *p != i { + t.Errorf("Ptr() = %v, want %v", *p, i) + } + }) + + t.Run("int64 pointer", func(t *testing.T) { + i := int64(123456) + p := Ptr(i) + if p == nil { + t.Fatal("Ptr() returned nil") + } + if *p != i { + t.Errorf("Ptr() = %v, want %v", *p, i) + } + }) +} + +func Test_createInstallationToken_ErrorPaths(t *testing.T) { + t.Run("error parsing endpoint URL", func(t *testing.T) { + // Create a client with an invalid base URL that will cause Parse to fail + client := &githubClient{ + baseURL: &url.URL{Scheme: "http", Host: "example.com", Path: ":::invalid"}, + client: &http.Client{}, + } + + _, err := client.createInstallationToken(context.Background(), 12345, nil) + if err == nil { + t.Error("Expected error for invalid base URL, got nil") + } + }) + + t.Run("error marshaling options", func(t *testing.T) { + // This is difficult to trigger with InstallationTokenOptions as it has simple fields + // We would need to use reflection or create a custom type + // For now, we test with valid options and nil options which are both covered + client := newGitHubClient(&http.Client{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(InstallationToken{ + Token: "test-token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + defer server.Close() + + client.baseURL, _ = client.baseURL.Parse(server.URL) + + opts := &InstallationTokenOptions{ + Repositories: []string{"repo1", "repo2"}, + Permissions: &InstallationPermissions{ + Contents: Ptr("read"), + Issues: Ptr("write"), + }, + } + + _, err := client.createInstallationToken(context.Background(), 12345, opts) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) +} diff --git a/go.mod b/go.mod index cc86a81..78d6ae5 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,5 @@ go 1.25 require ( github.com/golang-jwt/jwt/v5 v5.3.0 - github.com/google/go-github/v74 v74.0.0 golang.org/x/oauth2 v0.32.0 ) - -require github.com/google/go-querystring v1.1.0 // indirect diff --git a/go.sum b/go.sum index d3d38d9..cbd9020 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,4 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= -github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=