v0.35.1 2aca1df

config_test.go RawHistoryFolder

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

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