1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitremote_test
4
5import (
6 "context"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/adapters/gitremote"
18 "bnema.dev/ermite/internal/domain"
19)
20
21func gitBin(t *testing.T) string {
22 t.Helper()
23 bin, err := exec.LookPath("git")
24 if err != nil {
25 t.Skip("git is not installed")
26 }
27 return bin
28}
29
30func run(t *testing.T, dir string, args ...string) string {
31 t.Helper()
32 cmd := exec.Command("git", args...)
33 cmd.Dir = dir
34 cmd.Env = []string{
35 "PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
36 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x",
37 }
38 out, err := cmd.CombinedOutput()
39 require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out)
40 return strings.TrimSpace(string(out))
41}
42
43// fixture builds <base>/o/r with a branch, a lightweight tag and an annotated
44// tag, and returns the base as a file:// URL.
45func fixture(t *testing.T) (base, commit, tagObj string) {
46 t.Helper()
47 root := t.TempDir()
48 repo := filepath.Join(root, "o", "r")
49 require.NoError(t, os.MkdirAll(repo, 0o755))
50 run(t, repo, "init", "-q", "--initial-branch=main")
51 require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644))
52 run(t, repo, "add", "a")
53 run(t, repo, "commit", "-q", "-m", "a")
54 commit = run(t, repo, "rev-parse", "HEAD")
55 run(t, repo, "tag", "light")
56 run(t, repo, "tag", "-a", "-m", "annotated", "v1")
57 tagObj = run(t, repo, "rev-parse", "refs/tags/v1")
58 return "file://" + root, commit, tagObj
59}
60
61func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote {
62 t.Helper()
63 cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"}
64 if mut != nil {
65 mut(&cfg)
66 }
67 return gitremote.New(cfg)
68}
69
70func TestListRefsPeelsAnnotatedTags(t *testing.T) {
71 base, commit, tagObj := fixture(t)
72 refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r")
73 require.NoError(t, err)
74 byName := map[string][2]string{}
75 for _, r := range refs {
76 byName[r.Name] = [2]string{r.ID, r.Peeled}
77 }
78 assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"])
79 assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"])
80 assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled")
81 assert.Len(t, refs, 3)
82}
83
84// github.com answers a repository that does not exist, or that is private, with
85// a 404 that git reports as a missing repository or as a prompt it may not
86// show; both are "repository not found". Any other failure is unavailable.
87func TestListRefsMapsWhatGitSays(t *testing.T) {
88 fake := func(stderr string) string {
89 bin := filepath.Join(t.TempDir(), "git")
90 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755))
91 return bin
92 }
93 for _, tc := range []struct {
94 stderr string
95 want error
96 }{
97 {"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound},
98 {"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound},
99 {"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable},
100 {"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable},
101 } {
102 _, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r")
103 assert.ErrorIs(t, err, tc.want, tc.stderr)
104 }
105}
106
107func TestListRefsRefusesBadNames(t *testing.T) {
108 r := newRemote(t, "file:///nonexistent", nil)
109 for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} {
110 _, err := r.ListRefs(context.Background(), bad[0], bad[1])
111 assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1])
112 }
113}
114
115func TestListRefsBoundsTheOutput(t *testing.T) {
116 base, _, _ := fixture(t)
117 _, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r")
118 assert.ErrorIs(t, err, domain.ErrUnavailable)
119}
120
121func TestListRefsTimesOut(t *testing.T) {
122 bin := filepath.Join(t.TempDir(), "git")
123 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
124 start := time.Now()
125 _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 200*time.Millisecond }).ListRefs(context.Background(), "o", "r")
126 assert.ErrorIs(t, err, domain.ErrUnavailable)
127 assert.Less(t, time.Since(start), 10*time.Second)
128}
129
130// git asks for no credentials and runs with no configuration of the machine:
131// the environment it gets is only what the adapter builds.
132func TestListRefsGitEnvironment(t *testing.T) {
133 out := filepath.Join(t.TempDir(), "env")
134 bin := filepath.Join(t.TempDir(), "git")
135 script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n"
136 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
137 t.Setenv("GITHUB_TOKEN", "secret-from-the-process")
138 _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r")
139 require.NoError(t, err)
140 b, err := os.ReadFile(out)
141 require.NoError(t, err)
142 env := string(b)
143 assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0")
144 assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https")
145 assert.NotContains(t, env, "secret-from-the-process")
146 assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
147 assert.Contains(t, env, "credential.helper=")
148}
149
150// GO-008: the proxy variables of ermite reach git, and nothing else of its
151// environment does.
152func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
153 out := filepath.Join(t.TempDir(), "env")
154 bin := filepath.Join(t.TempDir(), "git")
155 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
156 t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
157 t.Setenv("no_proxy", "localhost,.internal")
158 t.Setenv("HTTP_PROXY", "")
159 t.Setenv("GITHUB_TOKEN", "not-for-git")
160
161 proxy := gitremote.ProxyEnv(os.Getenv)
162 assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
163
164 _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
165 require.NoError(t, err)
166 b, err := os.ReadFile(out)
167 require.NoError(t, err)
168 env := string(b)
169 assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
170 assert.Contains(t, env, "no_proxy=localhost,.internal")
171 assert.NotContains(t, env, "not-for-git")
172 assert.NotContains(t, env, "HTTP_PROXY=")
173
174 // Without Config.Proxy nothing is forwarded, whatever the process has.
175 _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
176 require.NoError(t, err)
177 b, _ = os.ReadFile(out)
178 assert.NotContains(t, string(b), "proxy.internal")
179}