v0.35.2 80d5067

gitpush_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitpush
4
5import (
6	"context"
7	"crypto/ed25519"
8	"crypto/rand"
9	"errors"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"strings"
14	"testing"
15	"time"
16	"unicode/utf8"
17
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24func git(t *testing.T, args ...string) string {
25	t.Helper()
26	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27	defer cancel()
28	cmd := exec.CommandContext(ctx, "git", args...)
29	cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"}
30	out, err := cmd.CombinedOutput()
31	require.NoError(t, err, string(out))
32	return strings.TrimSpace(string(out))
33}
34func setup(t *testing.T) (Config, ports.MirrorPush) {
35	t.Helper()
36	root := t.TempDir()
37	source := filepath.Join(root, "source")
38	git(t, "init", "-b", "main", source)
39	git(t, "-C", source, "commit", "--allow-empty", "-m", "first")
40	bin, err := exec.LookPath("git")
41	require.NoError(t, err)
42	_, key, err := ed25519.GenerateKey(rand.Reader)
43	require.NoError(t, err)
44	return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}}
45}
46func clean(t *testing.T, cfg Config) {
47	t.Helper()
48	entries, err := os.ReadDir(cfg.TempDir)
49	require.NoError(t, err)
50	require.Empty(t, entries)
51}
52func TestPushLocal(t *testing.T) {
53	cfg, req := setup(t)
54	remote := filepath.Join(t.TempDir(), "remote.git")
55	git(t, "init", "--bare", remote)
56	req.URL = "file://" + remote
57	p := New(cfg)
58	result, err := p.Push(context.Background(), req)
59	require.NoError(t, err)
60	require.Len(t, result.Refs, 1)
61	require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status)
62	clean(t, cfg)
63	result, err = p.Push(context.Background(), req)
64	require.NoError(t, err)
65	require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status)
66	clean(t, cfg)
67	other := filepath.Join(t.TempDir(), "other")
68	git(t, "init", "-b", "main", other)
69	git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated")
70	git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other")
71	// Move remote main locally, without a force push, to establish divergence.
72	otherID := git(t, "-C", other, "rev-parse", "HEAD")
73	git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID)
74	_, err = p.Push(context.Background(), req)
75	var me *domain.MirrorError
76	require.ErrorAs(t, err, &me)
77	require.Equal(t, domain.MirrorClassDiverged, me.Class)
78	require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main"))
79	clean(t, cfg)
80	req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git")
81	_, err = p.Push(context.Background(), req)
82	require.ErrorAs(t, err, &me)
83	require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class)
84	clean(t, cfg)
85}
86func TestBoundsAndKeyEnvironment(t *testing.T) {
87	for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} {
88		t.Run(mode, func(t *testing.T) {
89			cfg, req := setup(t)
90			req.URL = "file:///unused"
91			script := filepath.Join(t.TempDir(), "git")
92			report := filepath.Join(t.TempDir(), "env")
93			body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n"
94			switch mode {
95			case "error":
96				body += "echo 'Permission denied (publickey)' >&2; exit 1\n"
97			case "timeout", "cancel":
98				body += "sleep 10\n"
99			case "stdout":
100				body += "head -c 4096 /dev/zero\n"
101			case "stderr":
102				body += "head -c 4096 /dev/zero >&2\n"
103			}
104			require.NoError(t, os.WriteFile(script, []byte(body), 0700))
105			cfg.Bin = script
106			cfg.MaxOutput = 1024
107			cfg.Timeout = 5 * time.Second
108			if mode == "timeout" {
109				cfg.Timeout = 200 * time.Millisecond // the only case that must wait for the deadline
110			}
111			t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
112			ctx := context.Background()
113			if mode == "cancel" {
114				var cancel context.CancelFunc
115				ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
116				defer cancel()
117			}
118			_, err := New(cfg).Push(ctx, req)
119			switch mode {
120			case "success":
121				require.NoError(t, err)
122			case "cancel":
123				require.ErrorIs(t, err, context.DeadlineExceeded)
124			default:
125				var me *domain.MirrorError
126				require.ErrorAs(t, err, &me)
127				want := domain.MirrorClassTransient
128				if mode == "error" {
129					want = domain.MirrorClassAuthDenied
130				}
131				require.Equal(t, want, me.Class)
132			}
133			clean(t, cfg)
134			env, e := os.ReadFile(report)
135			require.NoError(t, e)
136			require.NotContains(t, string(env), "ERMITE_")
137			require.Contains(t, string(env), "IdentitiesOnly=yes")
138			require.Contains(t, string(env), "BatchMode=yes")
139			require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
140			require.Contains(t, string(env), "UserKnownHostsFile=")
141			require.Contains(t, string(env), "ConnectTimeout=30")
142			require.Contains(t, string(env), "ServerAliveInterval=15")
143			require.Contains(t, string(env), "ServerAliveCountMax=3")
144		})
145	}
146}
147func TestClassifierSamples(t *testing.T) {
148	for _, tc := range []struct {
149		s string
150		c domain.MirrorFailureClass
151	}{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} {
152		require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false))
153	}
154}
155func TestRejectUnsafeRefspecs(t *testing.T) {
156	for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} {
157		require.False(t, validRefspec(ref))
158	}
159	require.True(t, validRefspec("refs/tags/v1:refs/tags/v1"))
160}
161func TestSafeDetail(t *testing.T) {
162	s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo")
163	require.LessOrEqual(t, len(s), 4096)
164	require.NotContains(t, s, "password")
165	require.NotContains(t, s, "user@")
166}
167
168func TestSafeDetailKeepsValidUTF8(t *testing.T) {
169	// The cut falls inside a multibyte character, and the remote sent a stray byte.
170	in := "\xff" + strings.Repeat("x", 3) + strings.Repeat("é", 4097) + "\xff end"
171	var s string
172	require.NotPanics(t, func() { s = safeDetail(in, "ssh://host/repo") })
173	require.True(t, utf8.ValidString(s))
174	require.LessOrEqual(t, len(s), maxDetail)
175	require.True(t, strings.HasSuffix(s, "? end"))
176}
177
178func TestClassifyIgnoresRemoteLines(t *testing.T) {
179	stderr := "remote: Permission denied (publickey) is what your CI says\nfatal: unable to access: Could not resolve hostname\n"
180	require.Equal(t, domain.MirrorClassTransient, domain.ClassifyMirrorOutput(withoutRemoteLines(stderr), errors.New("exit 128"), false))
181	require.Contains(t, withoutRemoteLines("remote: a\n  remote: b\nkept"), "kept")
182	require.NotContains(t, withoutRemoteLines("remote: a\n  remote: b\nkept"), "remote:")
183}
184
185func TestRemoteLinesStayInDetailButNotInClass(t *testing.T) {
186	cfg, req := setup(t)
187	req.URL = "file:///unused"
188	script := filepath.Join(t.TempDir(), "git")
189	body := "#!/bin/sh\necho 'remote: error: non-fast-forward hook says no' >&2\necho 'fatal: Could not read from remote repository.' >&2\nexit 1\n"
190	require.NoError(t, os.WriteFile(script, []byte(body), 0700))
191	cfg.Bin = script
192	_, err := New(cfg).Push(context.Background(), req)
193	var me *domain.MirrorError
194	require.ErrorAs(t, err, &me)
195	require.Equal(t, domain.MirrorClassTransient, me.Class, "a message from the remote does not pick the class")
196	require.Contains(t, me.Detail, "remote: error")
197}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close