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}