package auto import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "os" "path/filepath" "strings" "testing" git "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/gitutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/nettest" ) // This takes the unusual step of testing an unexported func. The rationale is to be able to test // git code in isolation; testing the user of the unexported func (NewLocalWorkspace) drags in lots // of other factors. func TestGitClone(t *testing.T) { t.Parallel() // This makes a git repo to clone from, so to avoid relying on something at GitHub that could // change or be inaccessible. tmpDir := t.TempDir() originDir := filepath.Join(tmpDir, "origin") origin, err := git.PlainInit(originDir, false) assert.NoError(t, err) w, err := origin.Worktree() assert.NoError(t, err) nondefaultHead, err := w.Commit("nondefault branch", &git.CommitOptions{ Author: &object.Signature{ Name: "testo", Email: "testo@example.com", }, AllowEmptyCommits: true, }) assert.NoError(t, err) // The following sets up some tags and branches: with `default` becoming the "default" branch // when cloning, since it's left as the HEAD of the repo. assert.NoError(t, w.Checkout(&git.CheckoutOptions{ Branch: plumbing.NewBranchReferenceName("nondefault"), Create: true, })) // tag the nondefault head so we can test getting a tag too _, err = origin.CreateTag("v0.0.1", nondefaultHead, nil) assert.NoError(t, err) // make a branch with slashes in it, so that can be tested too assert.NoError(t, w.Checkout(&git.CheckoutOptions{ Branch: plumbing.NewBranchReferenceName("branch/with/slashes"), Create: true, })) assert.NoError(t, w.Checkout(&git.CheckoutOptions{ Branch: plumbing.NewBranchReferenceName("default"), Create: true, })) defaultHead, err := w.Commit("default branch", &git.CommitOptions{ Author: &object.Signature{ Name: "testo", Email: "testo@example.com", }, AllowEmptyCommits: true, }) assert.NoError(t, err) type testcase struct { branchName string commitHash string testName string // use when supplying a hash, for a stable name expectedHead plumbing.Hash expectedError string } for _, tc := range []testcase{ {branchName: "default", expectedHead: defaultHead}, {branchName: "nondefault", expectedHead: nondefaultHead}, {branchName: "branch/with/slashes", expectedHead: nondefaultHead}, // https://github.com/pulumi/pulumi-kubernetes-operator/issues/103#issuecomment-1107891475 // advises using `refs/heads/<default>` for the default, and `refs/remotes/origin/<branch>` // for a non-default branch -- so we can expect all these varieties to be in use. {branchName: "refs/heads/default", expectedHead: defaultHead}, {branchName: "refs/heads/nondefault", expectedHead: nondefaultHead}, {branchName: "refs/heads/branch/with/slashes", expectedHead: nondefaultHead}, {branchName: "refs/remotes/origin/default", expectedHead: defaultHead}, {branchName: "refs/remotes/origin/nondefault", expectedHead: nondefaultHead}, {branchName: "refs/remotes/origin/branch/with/slashes", expectedHead: nondefaultHead}, // try the special tag case {branchName: "refs/tags/v0.0.1", expectedHead: nondefaultHead}, // ask specifically for the commit hash {testName: "head of default as hash", commitHash: defaultHead.String(), expectedHead: defaultHead}, {testName: "head of nondefault as hash", commitHash: nondefaultHead.String(), expectedHead: nondefaultHead}, } { tc := tc if tc.testName == "" { tc.testName = tc.branchName } t.Run(tc.testName, func(t *testing.T) { t.Parallel() repo := &GitRepo{ URL: originDir, Branch: tc.branchName, CommitHash: tc.commitHash, } tmp, err := os.MkdirTemp(tmpDir, "testcase") // i.e., under the tmp dir from earlier assert.NoError(t, err) _, err = setupGitRepo(context.Background(), tmp, repo) assert.NoError(t, err) r, err := git.PlainOpen(tmp) assert.NoError(t, err) head, err := r.Head() assert.NoError(t, err) assert.Equal(t, tc.expectedHead, head.Hash()) }) } // test that these result in errors for _, tc := range []testcase{ { testName: "simple branch doesn't exist", branchName: "doesnotexist", expectedError: "unable to clone repo: reference not found", }, { testName: "full branch doesn't exist", branchName: "refs/heads/doesnotexist", expectedError: "unable to clone repo: reference not found", }, { testName: "malformed branch name", branchName: "refs/notathing/default", expectedError: "unable to clone repo: reference not found", }, { testName: "simple tag name won't work", branchName: "v1.0.0", expectedError: "unable to clone repo: reference not found", }, { testName: "wrong remote", branchName: "refs/remotes/upstream/default", expectedError: "a remote ref must begin with 'refs/remote/origin/', " + "but got \"refs/remotes/upstream/default\"", }, } { tc := tc if tc.testName == "" { tc.testName = tc.branchName } t.Run(tc.testName, func(t *testing.T) { t.Parallel() repo := &GitRepo{ URL: originDir, Branch: tc.branchName, CommitHash: tc.commitHash, } tmp, err := os.MkdirTemp(tmpDir, "testcase") // i.e., under the tmp dir from earlier assert.NoError(t, err) _, err = setupGitRepo(context.Background(), tmp, repo) assert.EqualError(t, err, tc.expectedError) }) } } //nolint:paralleltest // global environment variables func TestGitAuthParse(t *testing.T) { // Set up a fake SSH_AUTH_SOCK for testing l, err := nettest.NewLocalListener("unix") defer contract.IgnoreClose(l) require.NoError(t, err) type testcase struct { name string url string auth *GitAuth setSSHAgentSocket bool expectedURL string expectedError string expectedPublicKeysTransport bool // Expect a basic auth instance with the provided username and password expectedBasicAuth *http.BasicAuth // The same as the above, but expects a TransportAuth wrapper expectedWrappedBasicAuth *http.BasicAuth } for _, tc := range []testcase{ { name: "private key path", url: "git@example.com:repo.git", auth: &GitAuth{ SSHPrivateKeyPath: generateSSHKeyFile(t), }, expectedURL: "git@example.com:repo.git", expectedPublicKeysTransport: true, }, { name: "invalid private key path", url: "git@example.com:repo.git", auth: &GitAuth{ SSHPrivateKeyPath: "/invalid/path", }, expectedError: "unable to use SSH Private Key Path: open /invalid/path", }, { name: "private key value", url: "git@example.com:repo.git", auth: &GitAuth{ SSHPrivateKey: generateSSHKeyString(t), }, expectedURL: "git@example.com:repo.git", expectedPublicKeysTransport: true, }, { name: "invalid private key value", url: "git@example.com:repo.git", auth: &GitAuth{ SSHPrivateKey: "invalid-string", }, expectedError: "unable to use SSH Private Key: ssh: no key found", }, { name: "personal access token", url: "git@example.com:repo.git", auth: &GitAuth{ PersonalAccessToken: "dummy-token", }, expectedURL: "git@example.com:repo.git", expectedBasicAuth: &http.BasicAuth{Username: "git", Password: "dummy-token"}, }, { name: "username and password", url: "git@example.com:repo.git", auth: &GitAuth{ Username: "user", Password: "pass", }, expectedURL: "git@example.com:repo.git", expectedError: "", expectedBasicAuth: &http.BasicAuth{Username: "user", Password: "pass"}, }, { name: "incompatible auth options", url: "git@example.com:repo.git", auth: &GitAuth{ SSHPrivateKeyPath: "/path/to/private/key", Username: "user", }, expectedError: "please specify one authentication option", }, { name: "incompatible auth options", url: "git@example.com:repo.git", auth: &GitAuth{ PersonalAccessToken: "dummy-token", Username: "user", }, expectedError: "please specify one authentication option", }, { url: "git@example.com:repo.git", name: "default auth with SSH agent", setSSHAgentSocket: true, expectedURL: "git@example.com:repo.git", }, { name: "url with basic auth", url: "https://user:password@example.com/repo.git", expectedURL: "https://example.com/repo.git", expectedWrappedBasicAuth: &http.BasicAuth{ Username: "user", Password: "password", }, }, } { tc := tc t.Run(tc.name, func(t *testing.T) { if tc.setSSHAgentSocket { t.Setenv("SSH_AUTH_SOCK", l.Addr().String()) } else { os.Unsetenv("SSH_AUTH_SOCK") } url, authMethod, err := func() (string, transport.AuthMethod, error) { var _ context.Context = context.Background() return setupGitRepoAuth(tc.url, tc.auth) }() if tc.expectedError != "" { if err == nil { t.Fatalf("expected an error but got nil") } if !strings.Contains(err.Error(), tc.expectedError) { t.Fatalf("expected error %q to contain %q", err.Error(), tc.expectedError) } } else if err != nil { t.Fatalf("unexpected error: %v", err) } if url != tc.expectedURL { t.Errorf("expected url %q, got %q", tc.expectedURL, url) } if _, ok := authMethod.(*ssh.PublicKeys); ok != tc.expectedPublicKeysTransport { condition := "to be" if !tc.expectedPublicKeysTransport { condition = "not to be" } t.Errorf("expected transport %s of type *ssh.PublicKeys, got %T", condition, authMethod) } if tc.expectedBasicAuth != nil { basicAuth, ok := authMethod.(*http.BasicAuth) require.Truef(t, ok, "expected Auth to be of type *http.BasicAuth, got %T", authMethod) if basicAuth.Username != tc.expectedBasicAuth.Username || basicAuth.Password != tc.expectedBasicAuth.Password { t.Errorf("expected BasicAuth to have username %q and password %q, got username %q and password %q", tc.expectedBasicAuth.Username, tc.expectedBasicAuth.Password, basicAuth.Username, basicAuth.Password) } } if tc.expectedWrappedBasicAuth != nil { wrapper, ok := authMethod.(*gitutil.TransportAuth) require.Truef(t, ok, "expected transport to be of type *gitutil.TransportAuth, got %T", authMethod) basicAuth, ok := wrapper.AuthMethod.(*http.BasicAuth) require.Truef(t, ok, "expected Auth to be of type *http.BasicAuth, got %T", wrapper.AuthMethod) if basicAuth.Username != tc.expectedWrappedBasicAuth.Username || basicAuth.Password != tc.expectedWrappedBasicAuth.Password { t.Errorf("expected BasicAuth to have username %q and password %q, got username %q and password %q", tc.expectedWrappedBasicAuth.Username, tc.expectedWrappedBasicAuth.Password, basicAuth.Username, basicAuth.Password) } } }) } } func generateSSHKeyBlock(t *testing.T) *pem.Block { key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) return &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), } } func generateSSHKeyFile(t *testing.T) string { block := generateSSHKeyBlock(t) path := filepath.Join(t.TempDir(), "test-key") err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600) t.Cleanup(func() { os.Remove(path) }) require.NoError(t, err) return path } func generateSSHKeyString(t *testing.T) string { block := generateSSHKeyBlock(t) return string(pem.EncodeToMemory(block)) }