internal/config/config_test.go
391 lines · 11470 bytes
1package config
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/x509"
8 "encoding/pem"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "filippo.io/age"
15)
16
17func writeConfig(t *testing.T, body string) string {
18 t.Helper()
19 p := filepath.Join(t.TempDir(), "config.toml")
20 if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
21 t.Fatal(err)
22 }
23 return p
24}
25
26const minimal = `
27[server]
28root = "/var/lib/gitbay"
29site_url = "https://gitbay.example"
30`
31
32func TestLoadMinimal(t *testing.T) {
33 cfg, err := Load(writeConfig(t, minimal))
34 if err != nil {
35 t.Fatal(err)
36 }
37 // Defaults applied.
38 if cfg.SSH.Mode != "embedded" || cfg.SSH.Port != 22 {
39 t.Errorf("ssh defaults wrong: %+v", cfg.SSH)
40 }
41 if cfg.Web.Mode != "view_only" {
42 t.Errorf("web default wrong: %+v", cfg.Web)
43 }
44 if cfg.Registration.Mode != "closed" {
45 t.Errorf("registration default wrong: %+v", cfg.Registration)
46 }
47}
48
49func TestContradictions(t *testing.T) {
50 cases := []struct {
51 name string
52 body string
53 wantErr string
54 }{
55 {
56 "registration open without smtp",
57 minimal + "\n[registration]\nmode = \"open\"\n",
58 "requires [mail] smtp_host",
59 },
60 {
61 "notify_admin without smtp",
62 minimal + "\n[registration]\nnotify_admin = true\n",
63 "notify_admin = true requires [mail] smtp_host",
64 },
65 {
66 "system ssh with open registration",
67 minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n",
68 "requires registration.mode = \"closed\"",
69 },
70 {
71 "unknown mail.tls",
72 minimal + "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\ntls = \"ssl\"\n",
73 "mail.tls must be starttls or implicit",
74 },
75 {
76 "password auth in view_only",
77 minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
78 "password_auth",
79 },
80 {
81 "password auth not implemented",
82 minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
83 "not implemented",
84 },
85 {
86 "bad ssh mode",
87 minimal + "\n[ssh]\nmode = \"tcp\"\n",
88 "ssh.mode",
89 },
90 {
91 "unknown key",
92 "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.example\"\nbogus = 1\n",
93 "unknown config key",
94 },
95 {
96 "missing site_url",
97 "[server]\nroot = \"/var/lib/gitbay\"\n",
98 "site_url",
99 },
100 {
101 "negative repo limit",
102 minimal + "\n[limits]\nmax_repos_per_user = -1\n",
103 "must not be negative",
104 },
105 {
106 "negative snippet limit",
107 minimal + "\n[limits]\nmax_snippets_per_user = -1\n",
108 "max_snippets_per_user",
109 },
110 }
111 for _, tc := range cases {
112 t.Run(tc.name, func(t *testing.T) {
113 _, err := Load(writeConfig(t, tc.body))
114 if err == nil {
115 t.Fatalf("expected error containing %q, got nil", tc.wantErr)
116 }
117 if !strings.Contains(err.Error(), tc.wantErr) {
118 t.Fatalf("error %q does not contain %q", err, tc.wantErr)
119 }
120 })
121 }
122}
123
124func TestValidCombinations(t *testing.T) {
125 cases := []struct {
126 name string
127 body string
128 }{
129 {
130 "invite with smtp",
131 minimal + "\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n",
132 },
133 {
134 "system ssh closed registration",
135 minimal + "\n[ssh]\nmode = \"system\"\n",
136 },
137 {
138 "accounts web without password auth",
139 minimal + "\n[web]\nmode = \"accounts\"\n",
140 },
141 {
142 "closed registration, no smtp at all",
143 minimal,
144 },
145 {
146 "acme with public https host",
147 "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.org\"\n[http]\ntls = \"acme\"\nacme_email = \"noreply@gitbay.org\"\n",
148 },
149 }
150 for _, tc := range cases {
151 t.Run(tc.name, func(t *testing.T) {
152 if _, err := Load(writeConfig(t, tc.body)); err != nil {
153 t.Fatal(err)
154 }
155 })
156 }
157}
158
159// writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's
160// .p8 provider key, and returns its path.
161func writeP8(t *testing.T) string {
162 t.Helper()
163 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
164 if err != nil {
165 t.Fatal(err)
166 }
167 der, err := x509.MarshalPKCS8PrivateKey(key)
168 if err != nil {
169 t.Fatal(err)
170 }
171 p := filepath.Join(t.TempDir(), "apns.p8")
172 f, err := os.Create(p)
173 if err != nil {
174 t.Fatal(err)
175 }
176 defer f.Close()
177 if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil {
178 t.Fatal(err)
179 }
180 return p
181}
182
183func TestPushConfigValidation(t *testing.T) {
184 keyPath := writeP8(t)
185 full := `
186[push]
187enabled = true
188key_file = "` + keyPath + `"
189key_id = "KEYID"
190team_id = "TEAMID"
191topic = "org.gitbay.gitbay"
192environment = "production"
193`
194 cases := []struct {
195 name string
196 body string
197 want string // substring of the expected error; "" means valid
198 }{
199 {"disabled needs nothing", "\n[push]\nenabled = false\n", ""},
200 {"complete is valid", full, ""},
201 {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"},
202 {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"},
203 {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"},
204 {"environment must be a known name",
205 strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1),
206 "push.environment"},
207 }
208 for _, tc := range cases {
209 t.Run(tc.name, func(t *testing.T) {
210 _, err := Load(writeConfig(t, minimal+tc.body))
211 if tc.want == "" {
212 if err != nil {
213 t.Fatalf("want valid, got %v", err)
214 }
215 return
216 }
217 if err == nil || !strings.Contains(err.Error(), tc.want) {
218 t.Fatalf("want an error mentioning %q, got %v", tc.want, err)
219 }
220 })
221 }
222}
223
224// A key_file that exists but is not a PKCS#8 EC key is refused at load,
225// not at the first notice: the failure mode otherwise is a queue that
226// fills and dead-letters with nobody watching.
227func TestPushConfigRejectsAnUnparseableKey(t *testing.T) {
228 p := filepath.Join(t.TempDir(), "junk.p8")
229 if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil {
230 t.Fatal(err)
231 }
232 body := `
233[push]
234enabled = true
235key_file = "` + p + `"
236key_id = "K"
237team_id = "T"
238topic = "org.gitbay.gitbay"
239environment = "production"
240`
241 _, err := Load(writeConfig(t, minimal+body))
242 if err == nil || !strings.Contains(err.Error(), "push.key_file") {
243 t.Fatalf("want a push.key_file error, got %v", err)
244 }
245}
246
247func TestPushHost(t *testing.T) {
248 if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" {
249 t.Fatalf("production host = %q", got)
250 }
251 if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" {
252 t.Fatalf("sandbox host = %q", got)
253 }
254 t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234")
255 if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" {
256 t.Fatalf("GITBAY_APNS_HOST ignored: %q", got)
257 }
258}
259
260func TestMailTLSRequired(t *testing.T) {
261 off, on := false, true
262 for _, tc := range []struct {
263 m Mail
264 want bool
265 }{
266 {Mail{SMTPHost: "smtp.example.com:587"}, true},
267 {Mail{SMTPHost: "smtp.example.com"}, true},
268 {Mail{SMTPHost: "localhost:25"}, false},
269 {Mail{SMTPHost: "localhost"}, false},
270 {Mail{SMTPHost: "127.0.0.1:25"}, false},
271 {Mail{SMTPHost: "[::1]:25"}, false},
272 {Mail{SMTPHost: "smtp.example.com:587", RequireTLS: &off}, false},
273 {Mail{SMTPHost: "127.0.0.1:25", RequireTLS: &on}, true},
274 } {
275 if got := tc.m.TLSRequired(); got != tc.want {
276 t.Errorf("%+v: TLSRequired = %v, want %v", tc.m, got, tc.want)
277 }
278 }
279}
280
281func TestSecretKeyFile(t *testing.T) {
282 cfg, err := Load(writeConfig(t, minimal))
283 if err != nil {
284 t.Fatal(err)
285 }
286 if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" {
287 t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile)
288 }
289 for body, want := range map[string]string{
290 minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root",
291 minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root",
292 minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required",
293 } {
294 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
295 t.Errorf("%q: got %v, want an error containing %q", body, err, want)
296 }
297 }
298 if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil {
299 t.Errorf("a sibling directory of the root is outside it: %v", err)
300 }
301}
302
303// TestSecretKeyFileSymlinks exercises resolvePath's symlink resolution: a
304// key path or root reached through a symlink is still compared on its
305// resolved location, not its literal spelling.
306func TestSecretKeyFileSymlinks(t *testing.T) {
307 valid := func(root, keyFile string) Config {
308 cfg := Default()
309 cfg.Server.SiteURL = "https://gitbay.example"
310 cfg.Server.Root = root
311 cfg.Server.SecretKeyFile = keyFile
312 return cfg
313 }
314
315 t.Run("key path reaches into root through a symlink", func(t *testing.T) {
316 tmp := t.TempDir()
317 root := filepath.Join(tmp, "root")
318 if err := os.Mkdir(root, 0o700); err != nil {
319 t.Fatal(err)
320 }
321 link := filepath.Join(tmp, "link-into-root")
322 if err := os.Symlink(root, link); err != nil {
323 t.Fatal(err)
324 }
325 // The key file itself need not exist yet; only the symlinked
326 // directory component does.
327 keyFile := filepath.Join(link, "secret.key")
328 if err := valid(root, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
329 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
330 }
331 })
332
333 t.Run("root itself is reached through a symlinked parent", func(t *testing.T) {
334 tmp := t.TempDir()
335 actualRoot := filepath.Join(tmp, "actual", "root")
336 if err := os.MkdirAll(actualRoot, 0o700); err != nil {
337 t.Fatal(err)
338 }
339 rootLink := filepath.Join(tmp, "root-link")
340 if err := os.Symlink(actualRoot, rootLink); err != nil {
341 t.Fatal(err)
342 }
343 // server.root is configured as the symlink; the key file is given
344 // by its real, unsymlinked path under the same directory.
345 keyFile := filepath.Join(actualRoot, "secret.key")
346 if err := valid(rootLink, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
347 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
348 }
349 })
350
351 t.Run("symlink points outside root", func(t *testing.T) {
352 tmp := t.TempDir()
353 root := filepath.Join(tmp, "root")
354 outside := filepath.Join(tmp, "outside")
355 if err := os.Mkdir(root, 0o700); err != nil {
356 t.Fatal(err)
357 }
358 if err := os.Mkdir(outside, 0o700); err != nil {
359 t.Fatal(err)
360 }
361 escape := filepath.Join(root, "escape")
362 if err := os.Symlink(outside, escape); err != nil {
363 t.Fatal(err)
364 }
365 keyFile := filepath.Join(escape, "secret.key")
366 if err := valid(root, keyFile).Validate(); err != nil {
367 t.Errorf("a symlink leading outside server.root should be accepted: %v", err)
368 }
369 })
370}
371
372func TestBackupRecipients(t *testing.T) {
373 id, err := age.GenerateX25519Identity()
374 if err != nil {
375 t.Fatal(err)
376 }
377 cfg, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\""+id.Recipient().String()+"\"]\n"))
378 if err != nil {
379 t.Fatal(err)
380 }
381 rs, err := cfg.Backup.Recipients()
382 if err != nil || len(rs) != 1 {
383 t.Fatalf("Recipients = %v, %v", rs, err)
384 }
385 if _, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\"age1notakey\"]\n")); err == nil || !strings.Contains(err.Error(), "backup.age_recipients") {
386 t.Fatalf("a malformed recipient: %v", err)
387 }
388 if cfg, err := Load(writeConfig(t, minimal)); err != nil || len(cfg.Backup.AgeRecipients) != 0 {
389 t.Fatalf("default: %v, %v", cfg.Backup, err)
390 }
391}