mirror of
https://github.com/cooperspencer/gickup.git
synced 2026-10-19 16:16:46 +00:00
fix(github): use App installation token for destination pushes (#433)
Pass the resolved destination credential through GetOrCreate to the push helper. Cover App and configured tokens on github.com and Enterprise Server, API failures, and the backup call chain.
This commit is contained in:
+4
-2
@@ -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
|
||||
|
||||
+11
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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().
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user