v0.10.1 e4c85c7

config_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package config_test
4
5import (
6	"crypto/sha256"
7	"errors"
8	"net/netip"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/config"
17)
18
19const token = "0123456789abcdef0123456789abcdef" // 32 chars
20
21func lookup(env map[string]string) func(string) string {
22	return func(k string) string { return env[k] }
23}
24
25func valid(overrides map[string]string) map[string]string {
26	env := map[string]string{
27		config.EnvDataDir:    "/var/lib/ermite",
28		config.EnvPublicURL:  "https://git.example.com",
29		config.EnvAdminToken: token,
30	}
31	for k, v := range overrides {
32		if v == "\x00unset" {
33			delete(env, k)
34			continue
35		}
36		env[k] = v
37	}
38	return env
39}
40
41func TestLoadDefaults(t *testing.T) {
42	c, err := config.Load(lookup(valid(nil)))
43	require.NoError(t, err)
44	assert.Equal(t, ":7341", c.Addr)
45	assert.Equal(t, "/var/lib/ermite", c.DataDir)
46	assert.Equal(t, "https://git.example.com", c.PublicURL)
47	assert.Equal(t, "git", c.Git)
48	assert.Equal(t, "ermite-git", c.GitReader, "ermite-git reads by default")
49	assert.Equal(t, "https://git.example.com/ermite", c.SourceURL)
50	assert.Equal(t, "info", c.LogLevel)
51	assert.Equal(t, "json", c.LogFormat)
52	assert.Empty(t, c.TrustedProxies)
53	assert.Equal(t, sha256.Sum256([]byte(token)), c.AdminTokenHash)
54}
55
56func TestLoadAllValues(t *testing.T) {
57	c, err := config.Load(lookup(valid(map[string]string{
58		config.EnvAddr:           "127.0.0.1:8080",
59		config.EnvDataDir:        "/srv/../srv/data/",
60		config.EnvPublicURL:      "http://localhost:7341/",
61		config.EnvTrustedProxies: "10.1.2.3/8, fd00::1/8",
62		config.EnvGit:            "/usr/local/bin/git",
63		config.EnvGitReader:      " CLI ",
64		config.EnvSourceURL:      "https://forge.example.org/src/",
65		config.EnvLogLevel:       "DEBUG",
66		config.EnvLogFormat:      "Console",
67	})))
68	require.NoError(t, err)
69	assert.Equal(t, "127.0.0.1:8080", c.Addr)
70	assert.Equal(t, "/srv/data", c.DataDir)
71	assert.Equal(t, "http://localhost:7341", c.PublicURL)
72	assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}, c.TrustedProxies)
73	assert.Equal(t, "/usr/local/bin/git", c.Git)
74	assert.Equal(t, "cli", c.GitReader)
75	assert.Equal(t, "https://forge.example.org/src", c.SourceURL)
76	assert.Equal(t, "debug", c.LogLevel)
77	assert.Equal(t, "console", c.LogFormat)
78}
79
80func TestLoadSourceURLFollowsPublicURL(t *testing.T) {
81	c, err := config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://127.0.0.1:7341/"})))
82	require.NoError(t, err)
83	assert.Equal(t, "http://127.0.0.1:7341/ermite", c.SourceURL)
84}
85
86func TestLoadProblems(t *testing.T) {
87	tests := []struct {
88		name string
89		env  map[string]string
90		want string // variable named in the error
91		msg  string // fragment of its message
92	}{
93		{"missing data dir", map[string]string{config.EnvDataDir: "\x00unset"}, config.EnvDataDir, "required"},
94		{"empty data dir", map[string]string{config.EnvDataDir: ""}, config.EnvDataDir, "required"},
95		{"relative data dir", map[string]string{config.EnvDataDir: "data"}, config.EnvDataDir, "absolute"},
96		{"missing public url", map[string]string{config.EnvPublicURL: "\x00unset"}, config.EnvPublicURL, "required"},
97		{"public url scheme", map[string]string{config.EnvPublicURL: "ftp://x.org"}, config.EnvPublicURL, "scheme"},
98		{"public url no scheme", map[string]string{config.EnvPublicURL: "git.example.com"}, config.EnvPublicURL, "scheme"},
99		{"public url no host", map[string]string{config.EnvPublicURL: "https://"}, config.EnvPublicURL, "host"},
100		{"public url path", map[string]string{config.EnvPublicURL: "https://x.org/forge"}, config.EnvPublicURL, "path"},
101		{"public url query", map[string]string{config.EnvPublicURL: "https://x.org?a=b"}, config.EnvPublicURL, "query"},
102		{"public url fragment", map[string]string{config.EnvPublicURL: "https://x.org#top"}, config.EnvPublicURL, "fragment"},
103		{"public url credentials", map[string]string{config.EnvPublicURL: "https://u:p@x.org"}, config.EnvPublicURL, "credentials"},
104		{"missing token", map[string]string{config.EnvAdminToken: "\x00unset"}, config.EnvAdminToken, "required"},
105		{"short token", map[string]string{config.EnvAdminToken: strings.Repeat("a", 31)}, config.EnvAdminToken, "at least 32"},
106		{"bad addr", map[string]string{config.EnvAddr: "7341"}, config.EnvAddr, "host:port"},
107		{"bad port", map[string]string{config.EnvAddr: ":http"}, config.EnvAddr, "port"},
108		{"port zero", map[string]string{config.EnvAddr: ":0"}, config.EnvAddr, "port"},
109		{"port too big", map[string]string{config.EnvAddr: ":70000"}, config.EnvAddr, "port"},
110		{"bad cidr", map[string]string{config.EnvTrustedProxies: "10.0.0.1"}, config.EnvTrustedProxies, "CIDR"},
111		{"empty cidr entry", map[string]string{config.EnvTrustedProxies: "10.0.0.0/8,,"}, config.EnvTrustedProxies, "empty"},
112		{"bad git reader", map[string]string{config.EnvGitReader: "libgit2"}, config.EnvGitReader, "cli or ermite-git"},
113		{"git reader typo", map[string]string{config.EnvGitReader: "ermite_git"}, config.EnvGitReader, `"ermite_git"`},
114		{"bad source url", map[string]string{config.EnvSourceURL: "nope"}, config.EnvSourceURL, "scheme"},
115		{"bad level", map[string]string{config.EnvLogLevel: "loud"}, config.EnvLogLevel, "trace, debug"},
116		{"bad format", map[string]string{config.EnvLogFormat: "xml"}, config.EnvLogFormat, "json or console"},
117	}
118	for _, tt := range tests {
119		t.Run(tt.name, func(t *testing.T) {
120			_, err := config.Load(lookup(valid(tt.env)))
121			require.Error(t, err)
122			var ce *config.Error
123			require.True(t, errors.As(err, &ce))
124			require.Len(t, ce.Problems, 1, err.Error())
125			assert.True(t, strings.HasPrefix(ce.Problems[0], tt.want+":"), ce.Problems[0])
126			assert.Contains(t, ce.Problems[0], tt.msg)
127		})
128	}
129}
130
131func TestLoadReportsEveryProblemTogether(t *testing.T) {
132	_, err := config.Load(lookup(map[string]string{
133		config.EnvAddr:      "bad",
134		config.EnvLogLevel:  "loud",
135		config.EnvLogFormat: "xml",
136	}))
137	require.Error(t, err)
138	msg := err.Error()
139	for _, name := range []string{
140		config.EnvAddr, config.EnvDataDir, config.EnvPublicURL, config.EnvAdminToken, config.EnvLogLevel, config.EnvLogFormat,
141	} {
142		assert.Contains(t, msg, name)
143	}
144	var ce *config.Error
145	require.True(t, errors.As(err, &ce))
146	assert.Len(t, ce.Problems, 6)
147}
148
149func TestErrorNeverContainsToken(t *testing.T) {
150	short := "secret-but-too-short"
151	_, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: short, config.EnvAddr: "bad"})))
152	require.Error(t, err)
153	assert.NotContains(t, err.Error(), short)
154}
155
156func TestTokenLengthCountsCharacters(t *testing.T) {
157	_, err := config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 32)})))
158	assert.NoError(t, err)
159	_, err = config.Load(lookup(valid(map[string]string{config.EnvAdminToken: strings.Repeat("é", 31)})))
160	assert.Error(t, err)
161}
162
163func TestLoadCommitterDefaults(t *testing.T) {
164	c, err := config.Load(lookup(valid(nil)))
165	require.NoError(t, err)
166	assert.Equal(t, "ermite", c.CommitterName)
167	assert.Equal(t, "ermite@git.example.com", c.CommitterEmail, "the host of the public URL, without port or path")
168
169	c, err = config.Load(lookup(valid(map[string]string{config.EnvPublicURL: "http://localhost:7341/"})))
170	require.NoError(t, err)
171	assert.Equal(t, "ermite@localhost", c.CommitterEmail)
172}
173
174func TestLoadCommitterFromTheEnvironment(t *testing.T) {
175	c, err := config.Load(lookup(valid(map[string]string{
176		config.EnvCommitterName:  " Forge Bot ",
177		config.EnvCommitterEmail: "bot@example.org",
178	})))
179	require.NoError(t, err)
180	assert.Equal(t, "Forge Bot", c.CommitterName)
181	assert.Equal(t, "bot@example.org", c.CommitterEmail)
182}
183
184func TestLoadRejectsBadCommitter(t *testing.T) {
185	for name, env := range map[string]map[string]string{
186		"name with an angle bracket":  {config.EnvCommitterName: "Bot <x>"},
187		"name with a line break":      {config.EnvCommitterName: "Bot\nCo-authored-by: x"},
188		"name not utf-8":              {config.EnvCommitterName: "\xff"},
189		"email without an @":          {config.EnvCommitterEmail: "bot"},
190		"email with a space":          {config.EnvCommitterEmail: "a b@example.org"},
191		"email with an angle bracket": {config.EnvCommitterEmail: "<bot@example.org>"},
192	} {
193		t.Run(name, func(t *testing.T) {
194			_, err := config.Load(lookup(valid(env)))
195			var ce *config.Error
196			require.ErrorAs(t, err, &ce)
197			assert.Contains(t, ce.Problems[0], "ERMITE_COMMITTER_")
198		})
199	}
200}
201
202func TestLoadGitReader(t *testing.T) {
203	for in, want := range map[string]string{
204		"": config.GitReaderErmiteGit, "ermite-git": config.GitReaderErmiteGit, "ERMITE-GIT": config.GitReaderErmiteGit,
205		"cli": config.GitReaderCLI, "Cli": config.GitReaderCLI,
206	} {
207		c, err := config.Load(lookup(valid(map[string]string{config.EnvGitReader: in})))
208		require.NoError(t, err, in)
209		assert.Equal(t, want, c.GitReader, in)
210	}
211}
212
213func TestOwnerAndCIRetention(t *testing.T) {
214	base := map[string]string{
215		config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org",
216		config.EnvAdminToken: "0123456789abcdef0123456789abcdef",
217	}
218	load := func(extra map[string]string) (config.Config, error) {
219		env := map[string]string{}
220		for k, v := range base {
221			env[k] = v
222		}
223		for k, v := range extra {
224			env[k] = v
225		}
226		return config.Load(func(k string) string { return env[k] })
227	}
228
229	c, err := load(nil)
230	if err != nil {
231		t.Fatal(err)
232	}
233	if c.Owner != "owner" || c.CIRetention != 0 {
234		t.Fatalf("defaults (retention is left to the use case): owner %q, retention %v", c.Owner, c.CIRetention)
235	}
236
237	c, err = load(map[string]string{config.EnvOwner: "bnema", config.EnvCILogRetention: "30d"})
238	if err != nil {
239		t.Fatal(err)
240	}
241	if c.Owner != "bnema" || c.CIRetention != 30*24*time.Hour {
242		t.Fatalf("set: owner %q, retention %v", c.Owner, c.CIRetention)
243	}
244	if c, err = load(map[string]string{config.EnvCILogRetention: "48h"}); err != nil || c.CIRetention != 48*time.Hour {
245		t.Fatalf("go duration: %v, %v", c.CIRetention, err)
246	}
247
248	for _, extra := range []map[string]string{
249		{config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"},
250	} {
251		if _, err := load(extra); err == nil {
252			t.Errorf("%v: want an error", extra)
253		}
254	}
255}
256
257func TestCIPollTimeout(t *testing.T) {
258	load := func(extra map[string]string) (config.Config, error) {
259		env := map[string]string{
260			config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org",
261			config.EnvAdminToken: "0123456789abcdef0123456789abcdef",
262		}
263		for k, v := range extra {
264			env[k] = v
265		}
266		return config.Load(func(k string) string { return env[k] })
267	}
268
269	c, err := load(nil)
270	require.NoError(t, err)
271	assert.Zero(t, c.CIPollTimeout, "unset: the default is left to the CI use case")
272
273	for in, want := range map[string]time.Duration{
274		"25s": 25 * time.Second, " 30s ": 30 * time.Second, "1s": time.Second, "55s": 55 * time.Second, "1500ms": 1500 * time.Millisecond,
275	} {
276		c, err = load(map[string]string{config.EnvCIPollTimeout: in})
277		require.NoError(t, err, in)
278		assert.Equal(t, want, c.CIPollTimeout, in)
279	}
280
281	for _, in := range []string{"soon", "25", "0s", "-5s", "999ms", "56s", "2m"} {
282		_, err := load(map[string]string{config.EnvCIPollTimeout: in})
283		var ce *config.Error
284		require.True(t, errors.As(err, &ce), in)
285		assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in)
286	}
287}

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 3

Tags 43

↑↓ moveenter openesc close