internal/config/config_test.go
521 lines · 17260 bytes
1package config
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/x509"
8 "encoding/pem"
9 "math"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "filippo.io/age"
16 "time"
17)
18
19func writeConfig(t *testing.T, body string) string {
20 t.Helper()
21 p := filepath.Join(t.TempDir(), "config.toml")
22 if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
23 t.Fatal(err)
24 }
25 return p
26}
27
28const minimal = `
29[server]
30root = "/var/lib/gitbay"
31site_url = "https://gitbay.example"
32`
33
34func TestLoadMinimal(t *testing.T) {
35 cfg, err := Load(writeConfig(t, minimal))
36 if err != nil {
37 t.Fatal(err)
38 }
39 // Defaults applied.
40 if cfg.SSH.Mode != "embedded" || cfg.SSH.Port != 22 {
41 t.Errorf("ssh defaults wrong: %+v", cfg.SSH)
42 }
43 if cfg.Web.Mode != "view_only" {
44 t.Errorf("web default wrong: %+v", cfg.Web)
45 }
46 if cfg.Registration.Mode != "closed" {
47 t.Errorf("registration default wrong: %+v", cfg.Registration)
48 }
49}
50
51func TestPackLimits(t *testing.T) {
52 max, per, queue, wait := Limits{}.PackLimits()
53 if max != DefaultPackConcurrency || per != DefaultPackPerPrincipal || queue != DefaultPackQueue || wait != DefaultPackQueueWait {
54 t.Fatalf("defaults: %d %d %d %s", max, per, queue, wait)
55 }
56 max, per, queue, wait = Limits{PackConcurrency: -1, PackPerPrincipal: -1, PackQueue: -1, PackQueueWait: "5s"}.PackLimits()
57 if max != 0 || per != 0 || queue != math.MaxInt || wait != 5*time.Second {
58 t.Fatalf("off: %d %d %d %s", max, per, queue, wait)
59 }
60 max, per, queue, _ = Limits{PackConcurrency: 8, PackPerPrincipal: 3, PackQueue: 64}.PackLimits()
61 if max != 8 || per != 3 || queue != 64 {
62 t.Fatalf("set: %d %d %d", max, per, queue)
63 }
64}
65
66// push_* resolve like pack_*, from their own defaults.
67func TestPushLimits(t *testing.T) {
68 max, per, queue, wait := Limits{}.PushLimits()
69 if max != DefaultPushConcurrency || per != DefaultPushPerPrincipal || queue != DefaultPushQueue || wait != DefaultPushQueueWait {
70 t.Fatalf("defaults: %d %d %d %s", max, per, queue, wait)
71 }
72 if max != 2 || per != 1 || queue != 16 || wait != time.Minute {
73 t.Fatalf("defaults moved: %d %d %d %s", max, per, queue, wait)
74 }
75 max, per, queue, wait = Limits{PushConcurrency: -1, PushPerPrincipal: -1, PushQueue: -1, PushQueueWait: "5s"}.PushLimits()
76 if max != 0 || per != 0 || queue != math.MaxInt || wait != 5*time.Second {
77 t.Fatalf("off: %d %d %d %s", max, per, queue, wait)
78 }
79 cfg, err := Load(writeConfig(t, minimal+"\n[limits]\npush_concurrency = 4\npush_per_principal = 2\npush_queue = 8\npush_queue_wait = \"30s\"\npush_idle = \"20s\"\npush_receive_timeout = \"5m\"\n"))
80 if err != nil {
81 t.Fatal(err)
82 }
83 max, per, queue, wait = cfg.Limits.PushLimits()
84 if max != 4 || per != 2 || queue != 8 || wait != 30*time.Second {
85 t.Fatalf("loaded: %d %d %d %s", max, per, queue, wait)
86 }
87 if idle, receive := cfg.Limits.PushTimeouts(); idle != 20*time.Second || receive != 5*time.Minute {
88 t.Fatalf("timeouts: %s %s", idle, receive)
89 }
90 if idle, receive := (Limits{}).PushTimeouts(); idle != time.Minute || receive != 15*time.Minute {
91 t.Fatalf("default timeouts: %s %s", idle, receive)
92 }
93 // The pack budget is not read from the push settings.
94 if pm, _, _, _ := cfg.Limits.PackLimits(); pm != DefaultPackConcurrency {
95 t.Fatalf("pack_concurrency %d, want the default", pm)
96 }
97}
98
99func TestContradictions(t *testing.T) {
100 cases := []struct {
101 name string
102 body string
103 wantErr string
104 }{
105 {
106 "bad pack_queue_wait",
107 minimal + "\n[limits]\npack_queue_wait = \"soon\"\n",
108 "limits.pack_queue_wait",
109 },
110 {
111 "bad push_queue_wait",
112 minimal + "\n[limits]\npush_queue_wait = \"-5s\"\n",
113 "limits.push_queue_wait",
114 },
115 {
116 "bad push_idle",
117 minimal + "\n[limits]\npush_idle = \"0s\"\n",
118 "limits.push_idle",
119 },
120 {
121 "bad push_receive_timeout",
122 minimal + "\n[limits]\npush_receive_timeout = \"later\"\n",
123 "limits.push_receive_timeout",
124 },
125 {
126 "registration open without smtp",
127 minimal + "\n[registration]\nmode = \"open\"\n",
128 "requires [mail] smtp_host",
129 },
130 {
131 "notify_admin without smtp",
132 minimal + "\n[registration]\nnotify_admin = true\n",
133 "notify_admin = true requires [mail] smtp_host",
134 },
135 {
136 "system ssh with open registration",
137 minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n",
138 "requires registration.mode = \"closed\"",
139 },
140 {
141 "unknown mail.tls",
142 minimal + "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\ntls = \"ssl\"\n",
143 "mail.tls must be starttls or implicit",
144 },
145 {
146 "password auth in view_only",
147 minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
148 "password_auth",
149 },
150 {
151 "password auth not implemented",
152 minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
153 "not implemented",
154 },
155 {
156 "bad ssh mode",
157 minimal + "\n[ssh]\nmode = \"tcp\"\n",
158 "ssh.mode",
159 },
160 {
161 "unknown key",
162 "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.example\"\nbogus = 1\n",
163 "unknown config key",
164 },
165 {
166 "missing site_url",
167 "[server]\nroot = \"/var/lib/gitbay\"\n",
168 "site_url",
169 },
170 {
171 "negative repo limit",
172 minimal + "\n[limits]\nmax_repos_per_user = -1\n",
173 "must not be negative",
174 },
175 {
176 "negative snippet limit",
177 minimal + "\n[limits]\nmax_snippets_per_user = -1\n",
178 "max_snippets_per_user",
179 },
180 }
181 for _, tc := range cases {
182 t.Run(tc.name, func(t *testing.T) {
183 _, err := Load(writeConfig(t, tc.body))
184 if err == nil {
185 t.Fatalf("expected error containing %q, got nil", tc.wantErr)
186 }
187 if !strings.Contains(err.Error(), tc.wantErr) {
188 t.Fatalf("error %q does not contain %q", err, tc.wantErr)
189 }
190 })
191 }
192}
193
194func TestValidCombinations(t *testing.T) {
195 cases := []struct {
196 name string
197 body string
198 }{
199 {
200 "invite with smtp",
201 minimal + "\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n",
202 },
203 {
204 "system ssh closed registration",
205 minimal + "\n[ssh]\nmode = \"system\"\n",
206 },
207 {
208 "accounts web without password auth",
209 minimal + "\n[web]\nmode = \"accounts\"\n",
210 },
211 {
212 "closed registration, no smtp at all",
213 minimal,
214 },
215 {
216 "acme with public https host",
217 "[server]\nroot = \"/var/lib/gitbay\"\nsite_url = \"https://gitbay.org\"\n[http]\ntls = \"acme\"\nacme_email = \"noreply@gitbay.org\"\n",
218 },
219 }
220 for _, tc := range cases {
221 t.Run(tc.name, func(t *testing.T) {
222 if _, err := Load(writeConfig(t, tc.body)); err != nil {
223 t.Fatal(err)
224 }
225 })
226 }
227}
228
229// writeP8 writes a PEM-wrapped PKCS#8 P-256 key, the shape of Apple's
230// .p8 provider key, and returns its path.
231func writeP8(t *testing.T) string {
232 t.Helper()
233 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
234 if err != nil {
235 t.Fatal(err)
236 }
237 der, err := x509.MarshalPKCS8PrivateKey(key)
238 if err != nil {
239 t.Fatal(err)
240 }
241 p := filepath.Join(t.TempDir(), "apns.p8")
242 f, err := os.Create(p)
243 if err != nil {
244 t.Fatal(err)
245 }
246 defer f.Close()
247 if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil {
248 t.Fatal(err)
249 }
250 return p
251}
252
253func TestPushConfigValidation(t *testing.T) {
254 keyPath := writeP8(t)
255 full := `
256[push]
257enabled = true
258key_file = "` + keyPath + `"
259key_id = "KEYID"
260team_id = "TEAMID"
261topic = "org.gitbay.gitbay"
262environment = "production"
263`
264 cases := []struct {
265 name string
266 body string
267 want string // substring of the expected error; "" means valid
268 }{
269 {"disabled needs nothing", "\n[push]\nenabled = false\n", ""},
270 {"complete is valid", full, ""},
271 {"key_id required", strings.Replace(full, `key_id = "KEYID"`, "", 1), "push.key_id"},
272 {"team_id required", strings.Replace(full, `team_id = "TEAMID"`, "", 1), "push.team_id"},
273 {"topic required", strings.Replace(full, `topic = "org.gitbay.gitbay"`, "", 1), "push.topic"},
274 {"environment must be a known name",
275 strings.Replace(full, `environment = "production"`, `environment = "staging"`, 1),
276 "push.environment"},
277 }
278 for _, tc := range cases {
279 t.Run(tc.name, func(t *testing.T) {
280 _, err := Load(writeConfig(t, minimal+tc.body))
281 if tc.want == "" {
282 if err != nil {
283 t.Fatalf("want valid, got %v", err)
284 }
285 return
286 }
287 if err == nil || !strings.Contains(err.Error(), tc.want) {
288 t.Fatalf("want an error mentioning %q, got %v", tc.want, err)
289 }
290 })
291 }
292}
293
294// A key_file that exists but is not a PKCS#8 EC key is refused at load,
295// not at the first notice: the failure mode otherwise is a queue that
296// fills and dead-letters with nobody watching.
297func TestPushConfigRejectsAnUnparseableKey(t *testing.T) {
298 p := filepath.Join(t.TempDir(), "junk.p8")
299 if err := os.WriteFile(p, []byte("not a key\n"), 0o600); err != nil {
300 t.Fatal(err)
301 }
302 body := `
303[push]
304enabled = true
305key_file = "` + p + `"
306key_id = "K"
307team_id = "T"
308topic = "org.gitbay.gitbay"
309environment = "production"
310`
311 _, err := Load(writeConfig(t, minimal+body))
312 if err == nil || !strings.Contains(err.Error(), "push.key_file") {
313 t.Fatalf("want a push.key_file error, got %v", err)
314 }
315}
316
317func TestPushHost(t *testing.T) {
318 if got := (Push{Environment: "production"}).Host(); got != "api.push.apple.com" {
319 t.Fatalf("production host = %q", got)
320 }
321 if got := (Push{Environment: "sandbox"}).Host(); got != "api.sandbox.push.apple.com" {
322 t.Fatalf("sandbox host = %q", got)
323 }
324 t.Setenv("GITBAY_APNS_HOST", "127.0.0.1:1234")
325 if got := (Push{Environment: "production"}).Host(); got != "127.0.0.1:1234" {
326 t.Fatalf("GITBAY_APNS_HOST ignored: %q", got)
327 }
328}
329
330func TestMailTLSRequired(t *testing.T) {
331 off, on := false, true
332 for _, tc := range []struct {
333 m Mail
334 want bool
335 }{
336 {Mail{SMTPHost: "smtp.example.com:587"}, true},
337 {Mail{SMTPHost: "smtp.example.com"}, true},
338 {Mail{SMTPHost: "localhost:25"}, false},
339 {Mail{SMTPHost: "localhost"}, false},
340 {Mail{SMTPHost: "127.0.0.1:25"}, false},
341 {Mail{SMTPHost: "[::1]:25"}, false},
342 {Mail{SMTPHost: "smtp.example.com:587", RequireTLS: &off}, false},
343 {Mail{SMTPHost: "127.0.0.1:25", RequireTLS: &on}, true},
344 } {
345 if got := tc.m.TLSRequired(); got != tc.want {
346 t.Errorf("%+v: TLSRequired = %v, want %v", tc.m, got, tc.want)
347 }
348 }
349}
350
351func TestSecretKeyFile(t *testing.T) {
352 cfg, err := Load(writeConfig(t, minimal))
353 if err != nil {
354 t.Fatal(err)
355 }
356 if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" {
357 t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile)
358 }
359 for body, want := range map[string]string{
360 minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root",
361 minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root",
362 minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required",
363 } {
364 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
365 t.Errorf("%q: got %v, want an error containing %q", body, err, want)
366 }
367 }
368 if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil {
369 t.Errorf("a sibling directory of the root is outside it: %v", err)
370 }
371}
372
373// TestSecretKeyFileSymlinks exercises resolvePath's symlink resolution: a
374// key path or root reached through a symlink is still compared on its
375// resolved location, not its literal spelling.
376func TestSecretKeyFileSymlinks(t *testing.T) {
377 valid := func(root, keyFile string) Config {
378 cfg := Default()
379 cfg.Server.SiteURL = "https://gitbay.example"
380 cfg.Server.Root = root
381 cfg.Server.SecretKeyFile = keyFile
382 return cfg
383 }
384
385 t.Run("key path reaches into root through a symlink", func(t *testing.T) {
386 tmp := t.TempDir()
387 root := filepath.Join(tmp, "root")
388 if err := os.Mkdir(root, 0o700); err != nil {
389 t.Fatal(err)
390 }
391 link := filepath.Join(tmp, "link-into-root")
392 if err := os.Symlink(root, link); err != nil {
393 t.Fatal(err)
394 }
395 // The key file itself need not exist yet; only the symlinked
396 // directory component does.
397 keyFile := filepath.Join(link, "secret.key")
398 if err := valid(root, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
399 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
400 }
401 })
402
403 t.Run("root itself is reached through a symlinked parent", func(t *testing.T) {
404 tmp := t.TempDir()
405 actualRoot := filepath.Join(tmp, "actual", "root")
406 if err := os.MkdirAll(actualRoot, 0o700); err != nil {
407 t.Fatal(err)
408 }
409 rootLink := filepath.Join(tmp, "root-link")
410 if err := os.Symlink(actualRoot, rootLink); err != nil {
411 t.Fatal(err)
412 }
413 // server.root is configured as the symlink; the key file is given
414 // by its real, unsymlinked path under the same directory.
415 keyFile := filepath.Join(actualRoot, "secret.key")
416 if err := valid(rootLink, keyFile).Validate(); err == nil || !strings.Contains(err.Error(), "inside server.root") {
417 t.Errorf("got %v, want an error containing %q", err, "inside server.root")
418 }
419 })
420
421 t.Run("symlink points outside root", func(t *testing.T) {
422 tmp := t.TempDir()
423 root := filepath.Join(tmp, "root")
424 outside := filepath.Join(tmp, "outside")
425 if err := os.Mkdir(root, 0o700); err != nil {
426 t.Fatal(err)
427 }
428 if err := os.Mkdir(outside, 0o700); err != nil {
429 t.Fatal(err)
430 }
431 escape := filepath.Join(root, "escape")
432 if err := os.Symlink(outside, escape); err != nil {
433 t.Fatal(err)
434 }
435 keyFile := filepath.Join(escape, "secret.key")
436 if err := valid(root, keyFile).Validate(); err != nil {
437 t.Errorf("a symlink leading outside server.root should be accepted: %v", err)
438 }
439 })
440}
441
442func TestBackupRecipients(t *testing.T) {
443 id, err := age.GenerateX25519Identity()
444 if err != nil {
445 t.Fatal(err)
446 }
447 cfg, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\""+id.Recipient().String()+"\"]\n"))
448 if err != nil {
449 t.Fatal(err)
450 }
451 rs, err := cfg.Backup.Recipients()
452 if err != nil || len(rs) != 1 {
453 t.Fatalf("Recipients = %v, %v", rs, err)
454 }
455 if _, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\"age1notakey\"]\n")); err == nil || !strings.Contains(err.Error(), "backup.age_recipients") {
456 t.Fatalf("a malformed recipient: %v", err)
457 }
458 if cfg, err := Load(writeConfig(t, minimal)); err != nil || len(cfg.Backup.AgeRecipients) != 0 {
459 t.Fatalf("default: %v, %v", cfg.Backup, err)
460 }
461}
462
463func TestMailInbound(t *testing.T) {
464 const smtp = "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n"
465 const inbound = "[mail.inbound]\nenabled = true\nimap_host = \"imap.example\"\nuser = \"reply@example\"\npassword_file = \"/etc/gitbay/imap.pass\"\nreply_address = \"reply@gitbay.example\"\n"
466 cfg, err := Load(writeConfig(t, minimal+smtp+inbound))
467 if err != nil {
468 t.Fatal(err)
469 }
470 in := cfg.Mail.Inbound
471 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
472 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
473 }
474 if in.RequireDKIM || in.Authenticated() {
475 t.Fatalf("require_dkim defaults on or From counts as authenticated: %+v", in)
476 }
477 cfg, err = Load(writeConfig(t, minimal+smtp+inbound+"require_dkim = true\n"))
478 if err != nil || !cfg.Mail.Inbound.RequireDKIM || !cfg.Mail.Inbound.Authenticated() {
479 t.Fatalf("require_dkim: %+v, %v", cfg.Mail.Inbound, err)
480 }
481 in.TLS = "starttls"
482 if in.Addr() != "imap.example:143" {
483 t.Fatalf("starttls default port: %q", in.Addr())
484 }
485 for body, want := range map[string]string{
486 minimal + inbound: "requires [mail] smtp_host",
487 minimal + smtp + inbound + "tls = \"none\"\n": "IMAP in clear is not supported",
488 minimal + smtp + inbound + "poll_interval = \"1s\"\n": "poll_interval",
489 minimal + smtp + "[mail.inbound]\nenabled = true\n": "mail.inbound.password_file is required",
490 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "reply+x@gitbay.example", 1): "no + in it",
491 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "gitbay.example", 1): "bare address",
492 minimal + smtp + inbound + "trusted_authserv_id = \"mx; x\"\n": "trusted_authserv_id",
493 minimal + smtp + inbound + "password = \"x\"\n": "unknown config key",
494 } {
495 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
496 t.Errorf("want %q, got %v\n%s", want, err, body)
497 }
498 }
499 // Off, nothing is required.
500 if _, err := Load(writeConfig(t, minimal+"\n[mail.inbound]\nenabled = false\n")); err != nil {
501 t.Fatal(err)
502 }
503}
504
505func TestMailInboundPassword(t *testing.T) {
506 dir := t.TempDir()
507 in := MailInbound{PasswordFile: dir + "/pass"}
508 os.WriteFile(in.PasswordFile, []byte("hunter2\n"), 0o600)
509 if p, err := in.Password(); err != nil || p != "hunter2" {
510 t.Fatalf("Password = %q, %v", p, err)
511 }
512 os.Chmod(in.PasswordFile, 0o644)
513 if _, err := in.Password(); err == nil || strings.Contains(err.Error(), "hunter2") {
514 t.Fatalf("group-readable file: %v", err)
515 }
516 os.WriteFile(in.PasswordFile, []byte("a\nb\n"), 0o600)
517 os.Chmod(in.PasswordFile, 0o600)
518 if _, err := in.Password(); err == nil {
519 t.Fatal("two lines accepted")
520 }
521}