diff --git a/conf.example.yml b/conf.example.yml index 0866cd4..e63e773 100644 --- a/conf.example.yml +++ b/conf.example.yml @@ -276,10 +276,12 @@ destination: repositories: private # private, public, default: private force: false # force push to destination # alternatively, authenticate with a GitHub App: + # requires Contents: write, plus Administration: write to create repositories # - app_id: 123456 # GitHub App ID (numeric) - # app_installation_id: 78901234 # Installation ID of the App + # app_installation_id: 78901234 # installation on the destination organization, including for enterprise-owned Apps # app_private_key_file: /path/to/private.pem # path to the App's RSA private key PEM file - # organization: whatever # org to back up into (leave empty for personal account) + # organization: whatever # destination organization; required for App authentication + # url: https://github.example.com # GitHub Enterprise Server base URL; omit for github.com # visibility: # repositories: private # force: false diff --git a/github/github.go b/github/github.go index c2e6093..cc412ad 100644 --- a/github/github.go +++ b/github/github.go @@ -497,12 +497,13 @@ func Get(conf *types.Conf) ([]types.Repo, bool) { return repos, ran } -// GetOrCreate Get or create a repository -func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) { +// GetOrCreate returns the destination's clone URL and resolved push token, +// creating the repository if needed. +func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, string, error) { sub = logger.CreateSubLogger("stage", "github", "url", githubInstanceURL(destination.URL)) - client, _, err := newGithubClient(context.TODO(), destination) + client, token, err := newGithubClient(context.TODO(), destination) if err != nil { - return "", err + return "", "", err } dest := types.GithubDestination{} @@ -510,14 +511,14 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) { if destination.Organization == "" { user, _, err := client.Users.Get(context.TODO(), "") if err != nil { - return "", err + return "", "", err } dest.User = user login = *user.Login } else { organization, _, err := client.Organizations.Get(context.TODO(), destination.Organization) if err != nil { - return "", err + return "", "", err } dest.Organization = organization login = *organization.Login @@ -526,12 +527,12 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) { r, _, err := client.Repositories.Get(context.TODO(), login, repo.Name) if err != nil { if !strings.Contains(err.Error(), "404 Not Found") { - return "", err + return "", "", err } if destination.Organization == "" { r, _, err = client.Repositories.Create(context.TODO(), "", &github.Repository{Name: github.String(repo.Name), Private: github.Bool(destination.Visibility.Repositories == "private"), Visibility: github.String(destination.Visibility.Repositories), Owner: dest.User}) if err != nil { - return "", err + return "", "", err } } else { if destination.Visibility.Repositories == "" { @@ -539,12 +540,12 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) { } r, _, err = client.Repositories.Create(context.TODO(), *dest.Organization.Login, &github.Repository{Name: github.String(repo.Name), Private: github.Bool(destination.Visibility.Repositories == "private"), Visibility: github.String(destination.Visibility.Repositories), Organization: dest.Organization}) if err != nil { - return "", err + return "", "", err } } } - return *r.CloneURL, nil + return *r.CloneURL, token, nil } // GetIssues get issues diff --git a/github/github_test.go b/github/github_test.go index 46ebc83..d08cfc1 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -2,9 +2,18 @@ package github import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" "github.com/cooperspencer/gickup/types" ) @@ -82,3 +91,140 @@ func TestNewGithubClientAppAuthMissingKeyFile(t *testing.T) { t.Fatal("expected error when App private key file does not exist") } } + +//nolint:paralleltest // Replaces http.DefaultTransport and calls code with a package-level logger. +func TestGetOrCreateReturnsResolvedToken(t *testing.T) { + const wantToken = "installation-token-for-test" + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey() error = %v", err) + } + keypath := filepath.Join(t.TempDir(), "app.pem") + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + if err := os.WriteFile(keypath, pemBytes, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(wantToken+"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("GITHUB_DESTINATION_TEST_TOKEN", wantToken) + + for _, instance := range []struct { + name, url, apiURL, cloneURL string + }{ + {"github.com", "", "https://api.github.com", "https://github.com/example/repo.git"}, + {"enterprise-server", "https://github.example.com/", "https://github.example.com/api/v3", "https://github.example.com/example/repo.git"}, + } { + for _, tc := range []struct { + name, auth, failAt string + create bool + }{ + {name: "app-existing", auth: "app"}, + {name: "app-create", auth: "app", create: true}, + {name: "pat-existing", auth: "pat"}, + {name: "pat-create", auth: "pat", create: true}, + {name: "environment-token", auth: "environment"}, + {name: "token-file", auth: "file"}, + {name: "token-error", auth: "app", failAt: "/app/installations/4242/access_tokens"}, + {name: "organization-error", auth: "app", failAt: "/orgs/example"}, + {name: "repository-error", auth: "app", failAt: "/repos/example/repo"}, + {name: "creation-error", auth: "app", create: true, failAt: "/orgs/example/repos"}, + } { + t.Run(instance.name+"/"+tc.name, func(t *testing.T) { + destination := types.GenRepo{URL: instance.url, Organization: "example"} + switch tc.auth { + case "app": + destination.AppID = 99 + destination.AppInstallationID = 4242 + destination.AppPrivateKeyFile = keypath + destination.Token = "unused-configured-token" + case "pat": + destination.Token = wantToken + case "environment": + destination.Token = "GITHUB_DESTINATION_TEST_TOKEN" + case "file": + destination.TokenFile = tokenFile + } + + created, failed := false, false + // These fixtures rely on clients using http.DefaultTransport to intercept github.com requests. + oldTransport := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = oldTransport }) + http.DefaultTransport = roundTripperFunc(func(r *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + w.Header().Set("Content-Type", "application/json") + if !strings.HasPrefix(r.URL.String(), instance.apiURL+"/") { + t.Errorf("unexpected API URL: %s", r.URL) + w.WriteHeader(http.StatusNotFound) + return w.Result(), nil + } + endpoint := strings.TrimPrefix(r.URL.String(), instance.apiURL) + if endpoint == "/app/installations/4242/access_tokens" { + if r.Method != http.MethodPost || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Error("installation token request must use POST and App JWT authentication") + } + } else if !strings.HasSuffix(r.Header.Get("Authorization"), " "+wantToken) { + t.Error("API request did not use the resolved token") + } + if endpoint == tc.failAt { + failed = true + w.WriteHeader(http.StatusForbidden) + return w.Result(), nil + } + switch r.Method + " " + endpoint { + case "POST /app/installations/4242/access_tokens": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{ + "token": wantToken, "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + case "GET /orgs/example": + _, _ = w.WriteString(`{"login":"example"}`) + case "GET /repos/example/repo": + if tc.create { + w.WriteHeader(http.StatusNotFound) + _, _ = w.WriteString(`{"message":"Not Found"}`) + } else { + _ = json.NewEncoder(w).Encode(map[string]string{"clone_url": instance.cloneURL}) + } + case "POST /orgs/example/repos": + created = true + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"clone_url": instance.cloneURL}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + } + return w.Result(), nil + }) + + cloneURL, token, err := GetOrCreate(destination, types.Repo{Name: "repo"}) + if tc.failAt != "" { + if !failed || err == nil || cloneURL != "" || token != "" { + t.Fatalf("API error returned (%q, %q, %v), want empty URL and token with an error", cloneURL, token, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if cloneURL != instance.cloneURL || token != wantToken { + t.Fatalf("GetOrCreate() = (%q, %q), want (%q, %q)", cloneURL, token, instance.cloneURL, wantToken) + } + if created != tc.create { + t.Fatalf("repository created = %v, want %v", created, tc.create) + } + }) + } + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} diff --git a/local/local.go b/local/local.go index fb42f86..e4fc5c3 100644 --- a/local/local.go +++ b/local/local.go @@ -659,9 +659,9 @@ func tempCloneBase(repo types.Repo, tempdir string, isBare bool) (*git.Repositor return r, err } -func CreateRemotePush(repo *git.Repository, destination types.GenRepo, url string, lfs bool) error { +// CreateRemotePush pushes to url using the caller's resolved token or SSH configuration. +func CreateRemotePush(repo *git.Repository, destination types.GenRepo, url, token string, lfs bool) error { sub = logger.CreateSubLogger("stage", "tempclone", "url", url) - token := destination.GetToken() var auth transport.AuthMethod if destination.SSH { if destination.SSHKey == "" { diff --git a/local/local_push_test.go b/local/local_push_test.go new file mode 100644 index 0000000..7c4fa5f --- /dev/null +++ b/local/local_push_test.go @@ -0,0 +1,90 @@ +package local + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/cooperspencer/gickup/types" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" +) + +//nolint:paralleltest // CreateRemotePush assigns the package-level logger. +func TestCreateRemotePushUsesProvidedToken(t *testing.T) { + const want = "resolved-token-for-test" + + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("git.PlainInit() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("x\n"), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("Worktree() error = %v", err) + } + if _, err := wt.Add("README.md"); err != nil { + t.Fatalf("Add() error = %v", err) + } + if _, err := wt.Commit("seed", &git.CommitOptions{ + Author: &object.Signature{Name: "t", Email: "t@example.com", When: time.Now()}, + }); err != nil { + t.Fatalf("Commit() error = %v", err) + } + + var passwordsMu sync.Mutex + var passwords []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, password, _ := r.BasicAuth() + passwordsMu.Lock() + passwords = append(passwords, password) + passwordsMu.Unlock() + w.Header().Set("WWW-Authenticate", `Basic realm="git"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + for _, configuredToken := range []string{"", "unused-configured-token"} { + destination := types.GenRepo{Token: configuredToken} + err := CreateRemotePush(repo, destination, server.URL+"/org/repo.git", want, false) + if !errors.Is(err, transport.ErrAuthenticationRequired) { + t.Fatalf("push error = %v, want authentication required", err) + } + passwordsMu.Lock() + gotPasswords := passwords + passwords = nil + passwordsMu.Unlock() + if len(gotPasswords) == 0 { + t.Fatal("push did not reach the server") + } + for _, got := range gotPasswords { + if got != want { + t.Fatalf("push password = %q, want %q", got, want) + } + } + } + + reopened, err := git.PlainOpen(dir) + if err != nil { + t.Fatal(err) + } + remotes, err := reopened.Remotes() + if err != nil { + t.Fatal(err) + } + for _, remote := range remotes { + urls := remote.Config().URLs + if len(urls) != 1 || urls[0] != server.URL+"/org/repo.git" { + t.Fatalf("persisted remote URLs = %v, want original credential-free URL", urls) + } + } +} diff --git a/main.go b/main.go index f70168b..ba46a42 100644 --- a/main.go +++ b/main.go @@ -568,7 +568,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). @@ -650,7 +650,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). @@ -736,7 +736,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). @@ -808,7 +808,7 @@ func backup(repos []types.Repo, conf *types.Conf) { } } - cloneurl, err := github.GetOrCreate(d, r) + cloneurl, destinationtoken, err := github.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "github"). @@ -818,7 +818,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, destinationtoken, r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). @@ -896,7 +896,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). @@ -975,7 +975,7 @@ func backup(repos []types.Repo, conf *types.Conf) { continue } - err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS) + err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). diff --git a/main_github_test.go b/main_github_test.go new file mode 100644 index 0000000..3616f44 --- /dev/null +++ b/main_github_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/cooperspencer/gickup/types" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +//nolint:paralleltest // backup calls providers that assign package-level loggers. +func TestBackupGitHubAppPushCredential(t *testing.T) { + const wantToken = "installation-token-for-test" + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(t.TempDir(), "app.pem") + if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), + }), 0o600); err != nil { + t.Fatal(err) + } + + sourceDir := t.TempDir() + source, err := git.PlainInit(sourceDir, false) + if err != nil { + t.Fatal(err) + } + worktree, err := source.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := worktree.Commit("seed", &git.CommitOptions{ + AllowEmptyCommits: true, + Author: &object.Signature{Name: "test", Email: "test@example.com", When: time.Now()}, + }); err != nil { + t.Fatal(err) + } + + var passwordsMu sync.Mutex + var passwords []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "POST /api/v3/app/installations/42/access_tokens": + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{ + "token": wantToken, "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + case "GET /api/v3/orgs/example": + _, _ = w.Write([]byte(`{"login":"example"}`)) + case "GET /api/v3/repos/example/repo": + _ = json.NewEncoder(w).Encode(map[string]string{"clone_url": "http://" + r.Host + "/example/repo.git"}) + case "GET /example/repo.git/info/refs": + _, password, _ := r.BasicAuth() + passwordsMu.Lock() + passwords = append(passwords, password) + passwordsMu.Unlock() + w.WriteHeader(http.StatusUnauthorized) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + backup([]types.Repo{{Name: "repo", URL: sourceDir}}, &types.Conf{ + Destination: types.Destination{Github: []types.GenRepo{{ + URL: server.URL, Organization: "example", + AppID: 1, AppInstallationID: 42, AppPrivateKeyFile: keyFile, + }}}, + }) + passwordsMu.Lock() + gotPasswords := passwords + passwords = nil + passwordsMu.Unlock() + if len(gotPasswords) == 0 { + t.Fatal("backup did not attempt a push") + } + for _, got := range gotPasswords { + if got != wantToken { + t.Fatalf("backup push password = %q, want resolved installation token", got) + } + } +}