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}