internal/config/config_test.go

v1.41.0
gitbay/internal/config/config_test.go history · blame · raw

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}