internal/config/config_test.go
466 lines · 14918 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}
414
415func TestMailInbound(t *testing.T) {
416 const smtp = "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n"
417 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"
418 cfg, err := Load(writeConfig(t, minimal+smtp+inbound))
419 if err != nil {
420 t.Fatal(err)
421 }
422 in := cfg.Mail.Inbound
423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
425 }
426 in.TLS = "starttls"
427 if in.Addr() != "imap.example:143" {
428 t.Fatalf("starttls default port: %q", in.Addr())
429 }
430 for body, want := range map[string]string{
431 minimal + inbound: "requires [mail] smtp_host",
432 minimal + smtp + inbound + "tls = \"none\"\n": "IMAP in clear is not supported",
433 minimal + smtp + inbound + "poll_interval = \"1s\"\n": "poll_interval",
434 minimal + smtp + "[mail.inbound]\nenabled = true\n": "mail.inbound.password_file is required",
435 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "reply+x@gitbay.example", 1): "no + in it",
436 minimal + smtp + strings.Replace(inbound, "reply@gitbay.example", "gitbay.example", 1): "bare address",
437 minimal + smtp + inbound + "trusted_authserv_id = \"mx; x\"\n": "trusted_authserv_id",
438 minimal + smtp + inbound + "password = \"x\"\n": "unknown config key",
439 } {
440 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
441 t.Errorf("want %q, got %v\n%s", want, err, body)
442 }
443 }
444 // Off, nothing is required.
445 if _, err := Load(writeConfig(t, minimal+"\n[mail.inbound]\nenabled = false\n")); err != nil {
446 t.Fatal(err)
447 }
448}
449
450func TestMailInboundPassword(t *testing.T) {
451 dir := t.TempDir()
452 in := MailInbound{PasswordFile: dir + "/pass"}
453 os.WriteFile(in.PasswordFile, []byte("hunter2\n"), 0o600)
454 if p, err := in.Password(); err != nil || p != "hunter2" {
455 t.Fatalf("Password = %q, %v", p, err)
456 }
457 os.Chmod(in.PasswordFile, 0o644)
458 if _, err := in.Password(); err == nil || strings.Contains(err.Error(), "hunter2") {
459 t.Fatalf("group-readable file: %v", err)
460 }
461 os.WriteFile(in.PasswordFile, []byte("a\nb\n"), 0o600)
462 os.Chmod(in.PasswordFile, 0o600)
463 if _, err := in.Password(); err == nil {
464 t.Fatal("two lines accepted")
465 }
466}