internal/config/config_test.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/internal/config/config_test.go history · blame · raw

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}