internal/config/config_test.go

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

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