e2e/adminusers_test.go
588 lines · 23751 bytes
8 symbols in this file
1package e2e
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10)
11
12type adminUserRow struct {
13 Username string `json:"username"`
14 State string `json:"state"`
15 Admin bool `json:"admin"`
16 LastSeen string `json:"last_seen"`
17}
18
19func TestAdminUserListAndShow(t *testing.T) {
20 t.Parallel()
21 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
22 adminKey := inst.newKey(t, "root")
23 aliceKey := inst.newKey(t, "alice")
24 bobKey := inst.newKey(t, "bob")
25 inst.admin(t, "admin", "user", "create", "root", "--key", adminKey+".pub", "--admin")
26 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
27 "--email", "alice@example.org", "--verified")
28 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
29 inst.admin(t, "admin", "user", "disable", "bob")
30
31 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "list"); code != 4 {
32 t.Fatalf("non-admin listed users: exit %d", code)
33 }
34 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "show", "bob"); code != 4 {
35 t.Fatalf("non-admin showed a user: exit %d", code)
36 }
37
38 list := func(args ...string) []adminUserRow {
39 t.Helper()
40 out, errOut, code := inst.ssh(t, adminKey, "", append([]string{"admin", "user", "list", "--json"}, args...)...)
41 if code != 0 {
42 t.Fatalf("admin user list %v: exit %d\n%s", args, code, errOut)
43 }
44 var env struct {
45 Data json.RawMessage `json:"data"`
46 }
47 if err := json.Unmarshal([]byte(out), &env); err != nil {
48 t.Fatalf("list envelope: %v\n%s", err, out)
49 }
50 var rows []adminUserRow
51 if err := json.Unmarshal(env.Data, &rows); err != nil {
52 // paged shape
53 var paged struct {
54 Items []adminUserRow `json:"items"`
55 Next string `json:"next"`
56 }
57 if err := json.Unmarshal(env.Data, &paged); err != nil {
58 t.Fatalf("list shape: %v\n%s", err, out)
59 }
60 return paged.Items
61 }
62 return rows
63 }
64
65 // gitbay-bot is seeded by the schema: it authors dependency issues.
66 rows := list()
67 if len(rows) != 4 || rows[0].Username != "alice" || rows[1].Username != "bob" ||
68 rows[2].Username != "gitbay-bot" || rows[3].Username != "root" {
69 t.Fatalf("list: %+v", rows)
70 }
71 if rows[1].State != "disabled" || rows[0].State != "active" || !rows[3].Admin || rows[0].Admin {
72 t.Fatalf("states: %+v", rows)
73 }
74 if rows[0].LastSeen == "" {
75 t.Fatal("alice authenticated above but has no last_seen")
76 }
77 if rows[1].LastSeen != "" {
78 t.Fatalf("bob never authenticated but has last_seen %q", rows[1].LastSeen)
79 }
80 if rows := list("--state", "disabled"); len(rows) != 1 || rows[0].Username != "bob" {
81 t.Fatalf("--state disabled: %+v", rows)
82 }
83 if rows := list("--state", "admin"); len(rows) != 1 || rows[0].Username != "root" {
84 t.Fatalf("--state admin: %+v", rows)
85 }
86 if rows := list("--state", "active"); len(rows) != 3 {
87 t.Fatalf("--state active: %+v", rows)
88 }
89 if _, _, code := inst.ssh(t, adminKey, "", "admin", "user", "list", "--state", "bogus"); code != 2 {
90 t.Fatalf("bad --state accepted: exit %d", code)
91 }
92
93 // Pagination: two pages of usernames, keyset by username.
94 out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "list", "--json", "--limit", "2")
95 var env struct {
96 Data struct {
97 Items []adminUserRow `json:"items"`
98 Next string `json:"next"`
99 } `json:"data"`
100 }
101 if err := json.Unmarshal([]byte(out), &env); err != nil || len(env.Data.Items) != 2 || env.Data.Next == "" {
102 t.Fatalf("first page: %v\n%s", err, out)
103 }
104 if rows := list("--limit", "2", "--cursor", env.Data.Next); len(rows) != 2 ||
105 rows[0].Username != "gitbay-bot" || rows[1].Username != "root" {
106 t.Fatalf("second page: %+v", rows)
107 }
108
109 // Show: alice owns a repo, admins an org, has a verified email.
110 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
111 t.Fatal("repo create failed")
112 }
113 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
114 t.Fatal("org create failed")
115 }
116 out, errOut, code := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice", "--json")
117 if code != 0 {
118 t.Fatalf("show: exit %d\n%s", code, errOut)
119 }
120 var show struct {
121 Data struct {
122 adminUserRow
123 Keys []struct {
124 Fingerprint string `json:"fingerprint"`
125 Scope string `json:"scope"`
126 LastUsedAt string `json:"last_used_at"`
127 } `json:"keys"`
128 Emails []struct {
129 Address string `json:"address"`
130 Verified bool `json:"verified"`
131 VerifiedBy string `json:"verified_by"`
132 } `json:"emails"`
133 Orgs []struct {
134 Org string `json:"org"`
135 Role string `json:"role"`
136 } `json:"orgs"`
137 Repos int64 `json:"repos"`
138 WebSessions int64 `json:"web_sessions"`
139 } `json:"data"`
140 }
141 if err := json.Unmarshal([]byte(out), &show); err != nil {
142 t.Fatalf("show envelope: %v\n%s", err, out)
143 }
144 d := show.Data
145 if d.Username != "alice" || d.State != "active" || d.Repos != 1 || d.WebSessions != 0 {
146 t.Fatalf("show summary: %+v", d)
147 }
148 if len(d.Keys) != 1 || !strings.HasPrefix(d.Keys[0].Fingerprint, "SHA256:") || d.Keys[0].Scope != "full" || d.Keys[0].LastUsedAt == "" {
149 t.Fatalf("show keys: %+v", d.Keys)
150 }
151 if len(d.Emails) != 1 || d.Emails[0].Address != "alice@example.org" || !d.Emails[0].Verified || d.Emails[0].VerifiedBy != "admin" {
152 t.Fatalf("show emails: %+v", d.Emails)
153 }
154 if len(d.Orgs) != 1 || d.Orgs[0].Org != "acme" || d.Orgs[0].Role != "admin" {
155 t.Fatalf("show orgs: %+v", d.Orgs)
156 }
157 // A browser session counts once minted.
158 inst.login(t, aliceKey)
159 out, _, _ = inst.ssh(t, adminKey, "", "admin", "user", "show", "alice", "--json")
160 if !strings.Contains(out, `"web_sessions":1`) {
161 t.Fatalf("session not counted:\n%s", out)
162 }
163
164 if _, _, code := inst.ssh(t, adminKey, "", "admin", "user", "show", "nobody"); code != 3 {
165 t.Fatalf("unknown user: exit %d", code)
166 }
167 // Plain output carries the same facts.
168 if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.HasPrefix(out, "alice active\n") ||
169 !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") {
170 t.Fatalf("plain show:\n%s", out)
171 }
172}
173
174func TestAdminPromoteDemote(t *testing.T) {
175 t.Parallel()
176 inst := startInstance(t)
177 rootKey := inst.newKey(t, "root")
178 aliceKey := inst.newKey(t, "alice")
179 bobKey := inst.newKey(t, "bob")
180 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
181 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
182 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
183 inst.admin(t, "admin", "user", "disable", "bob")
184
185 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "promote", "alice"); code != 4 {
186 t.Fatalf("non-admin promoted: exit %d", code)
187 }
188 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "nobody"); code != 3 {
189 t.Fatalf("unknown user: exit %d", code)
190 }
191 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "bob"); code != 2 || !strings.Contains(errOut, "disabled") {
192 t.Fatalf("disabled account promoted: exit %d %s", code, errOut)
193 }
194 // The only admin cannot step down.
195 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "demote", "root"); code != 1 || !strings.Contains(errOut, "only instance admin") {
196 t.Fatalf("last admin demoted: exit %d %s", code, errOut)
197 }
198 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "alice"); code != 0 {
199 t.Fatal("promote failed")
200 }
201 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "promote", "alice"); code != 2 {
202 t.Fatal("promoting an admin should be a usage error")
203 }
204 if out, _, code := inst.ssh(t, aliceKey, "", "audit"); code != 0 || !strings.Contains(out, "cmd admin user promote") {
205 t.Fatalf("promoted account cannot read the audit log, or the promotion is not in it: exit %d\n%s", code, out)
206 }
207 // With two admins, either may demote the other; then the survivor is stuck.
208 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "demote", "root"); code != 0 {
209 t.Fatal("demote failed")
210 }
211 if _, _, code := inst.ssh(t, rootKey, "", "audit"); code != 4 {
212 t.Fatal("demoted account still admin")
213 }
214 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "user", "demote", "alice"); code != 1 {
215 t.Fatal("last admin demoted")
216 }
217 // Host-local recovery: the operator restores root without an admin key.
218 if out := inst.forgedAdminErr(t, "admin", "user", "demote", "alice"); !strings.Contains(out, "only instance admin") {
219 t.Fatalf("host demote of last admin: %s", out)
220 }
221 inst.admin(t, "admin", "user", "promote", "root")
222 if _, _, code := inst.ssh(t, rootKey, "", "audit"); code != 0 {
223 t.Fatal("host promote did not take")
224 }
225 if out := inst.admin(t, "admin", "audit"); !strings.Contains(out, "admin user.promoted") {
226 t.Fatalf("host promote not audited:\n%s", out)
227 }
228}
229
230func TestAdminRepoModeration(t *testing.T) {
231 t.Parallel()
232 inst := startInstance(t)
233 rootKey := inst.newKey(t, "root")
234 aliceKey := inst.newKey(t, "alice")
235 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
236 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
237 for _, args := range [][]string{{"repo", "create", "alice/app"}, {"repo", "create", "alice/secret", "--private"}} {
238 if _, _, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
239 t.Fatalf("%v failed", args)
240 }
241 }
242 // A push, so last_push has something to report.
243 work := t.TempDir()
244 env := inst.gitEnv(aliceKey)
245 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
246 dir := filepath.Join(work, "w")
247 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
248 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
249 mustGit(t, dir, env, "add", ".")
250 mustGit(t, dir, env, "commit", "-q", "-m", "a")
251 mustGit(t, dir, env, "push", "-q", "origin", "main")
252
253 // Instance admin carries no read right: the private repo still 404s.
254 if _, _, code := inst.ssh(t, rootKey, "", "repo", "show", "alice/secret"); code != 3 {
255 t.Fatalf("admin read a private repo: exit %d", code)
256 }
257 if _, _, code := inst.ssh(t, aliceKey, "", "admin", "repo", "list"); code != 4 {
258 t.Fatal("non-admin listed repos")
259 }
260
261 type row struct {
262 Path string `json:"path"`
263 Visibility string `json:"visibility"`
264 Archived bool `json:"archived"`
265 LastPush string `json:"last_push"`
266 Bytes int64 `json:"bytes"`
267 }
268 list := func(args ...string) []row {
269 t.Helper()
270 out, errOut, code := inst.ssh(t, rootKey, "", append([]string{"admin", "repo", "list", "--json"}, args...)...)
271 if code != 0 {
272 t.Fatalf("admin repo list %v: exit %d %s", args, code, errOut)
273 }
274 var env struct {
275 Data []row `json:"data"`
276 }
277 if err := json.Unmarshal([]byte(out), &env); err != nil {
278 t.Fatalf("list: %v\n%s", err, out)
279 }
280 return env.Data
281 }
282 rows := list()
283 if len(rows) != 2 || rows[0].Path != "alice/app" || rows[1].Path != "alice/secret" || rows[1].Visibility != "private" {
284 t.Fatalf("list: %+v", rows)
285 }
286 if rows[0].LastPush == "" || rows[1].LastPush != "" || rows[0].Bytes == 0 {
287 t.Fatalf("push and size facts: %+v", rows)
288 }
289 if rows := list("--visibility", "private"); len(rows) != 1 || rows[0].Path != "alice/secret" {
290 t.Fatalf("--visibility: %+v", rows)
291 }
292 if rows := list("--owner", "root"); len(rows) != 0 {
293 t.Fatalf("--owner root: %+v", rows)
294 }
295
296 // Archive, then visibility: the private repo becomes readable to
297 // everyone once public, admin included.
298 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "archive", "alice/app"); code != 0 {
299 t.Fatal("admin archive failed")
300 }
301 if rows := list(); !rows[0].Archived {
302 t.Fatalf("not archived: %+v", rows)
303 }
304 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "unarchive", "alice/app"); code != 0 {
305 t.Fatal("admin unarchive failed")
306 }
307 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "visibility", "alice/secret", "public"); code != 0 {
308 t.Fatal("admin visibility failed")
309 }
310 if _, _, code := inst.ssh(t, rootKey, "", "repo", "show", "alice/secret"); code != 0 {
311 t.Fatal("repo still hidden after going public")
312 }
313
314 // Delete wants the typed confirmation and then removes it from the
315 // owner's view too.
316 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "alice/app"); code != 2 {
317 t.Fatal("delete without --yes accepted")
318 }
319 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "alice/app", "--yes"); code != 0 {
320 t.Fatal("admin delete failed")
321 }
322 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "list"); strings.Contains(out, "alice/app") {
323 t.Fatalf("deleted repo still listed:\n%s", out)
324 }
325 if _, _, code := inst.ssh(t, rootKey, "", "admin", "repo", "delete", "nobody/none", "--yes"); code != 3 {
326 t.Fatal("unknown repo should be not found")
327 }
328
329 // Every override is in the audit log under its own action, on top of
330 // the generic cmd row.
331 out, _, _ := inst.ssh(t, rootKey, "", "audit", "--json")
332 for _, want := range []string{"admin repo.archive", "admin repo.unarchive", "admin repo.visibility", "admin repo.delete"} {
333 if !strings.Contains(out, want) {
334 t.Fatalf("audit lacks %q:\n%s", want, out)
335 }
336 }
337}
338
339// The host-local admin commands dispatch into the registry, so the same
340// commands work in an admin's SSH session and audit rows say which path
341// ran them.
342func TestAdminHostAndSSHAreOneSurface(t *testing.T) {
343 t.Parallel()
344 inst := startInstance(t)
345 rootKey := inst.newKey(t, "root")
346 aliceKey := inst.newKey(t, "alice")
347 carolKey := inst.newKey(t, "carol")
348 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
349 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
350
351 pub, err := os.ReadFile(carolKey + ".pub")
352 if err != nil {
353 t.Fatal(err)
354 }
355 out, errOut, code := inst.ssh(t, rootKey, string(pub), "admin", "user", "create", "carol",
356 "--email", "carol@example.test", "--verified", "--key", "-")
357 if code != 0 || !strings.Contains(out, "created user carol") || !strings.Contains(out, "key SHA256:") {
358 t.Fatalf("ssh user create: exit %d\n%s%s", code, out, errOut)
359 }
360 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 {
361 t.Fatal("created account cannot authenticate")
362 }
363 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "user", "create", "alice"); code != 1 || !strings.Contains(errOut, "taken") {
364 t.Fatalf("duplicate create: exit %d %s", code, errOut)
365 }
366 for _, args := range [][]string{{"admin", "stats"}, {"admin", "user", "disable", "carol"}, {"admin", "invite", "--email", "x@example.test"}} {
367 if _, _, code := inst.ssh(t, aliceKey, "", args...); code != 4 {
368 t.Fatalf("non-admin ran %v: exit %d", args, code)
369 }
370 }
371 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "disable", "carol"); code != 0 {
372 t.Fatal("ssh disable failed")
373 }
374 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 4 {
375 t.Fatal("disabled account still authenticates")
376 }
377 inst.admin(t, "admin", "user", "enable", "carol")
378 if _, _, code := inst.ssh(t, carolKey, "", "whoami"); code != 0 {
379 t.Fatal("host enable did not take")
380 }
381 if out, _, code := inst.ssh(t, rootKey, "", "admin", "stats", "--json"); code != 0 || !strings.Contains(out, `"users":`) {
382 t.Fatalf("ssh stats: exit %d\n%s", code, out)
383 }
384 // No SMTP: the invite code comes back on stdout instead of by mail.
385 if out, _, code := inst.ssh(t, rootKey, "", "admin", "invite", "--email", "dave@example.test", "--json"); code != 0 || !strings.Contains(out, `"code":"`) {
386 t.Fatalf("ssh invite: exit %d\n%s", code, out)
387 }
388 if _, errOut, code := inst.ssh(t, rootKey, "", "admin", "email", "verify", "carol", "nope@example.test"); code != 3 || !strings.Contains(errOut, "no address") {
389 t.Fatalf("verify unknown address: exit %d %s", code, errOut)
390 }
391 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "carol", "--yes"); code != 0 {
392 t.Fatal("ssh delete failed")
393 }
394 if _, _, code := inst.ssh(t, rootKey, "", "admin", "user", "delete", "root", "--yes"); code != 2 {
395 t.Fatal("deleted own account")
396 }
397
398 // Both paths audit under the same action names; the row says which
399 // credential ran it.
400 audit := inst.admin(t, "admin", "audit")
401 for _, want := range []string{`"source":"host"`, `"source":"SHA256:`, "admin user.created", "admin user.disabled", "admin user.enabled", "admin user.deleted"} {
402 if !strings.Contains(audit, want) {
403 t.Fatalf("audit lacks %q:\n%s", want, audit)
404 }
405 }
406}
407
408func TestAuditFilters(t *testing.T) {
409 t.Parallel()
410 inst := startInstance(t)
411 rootKey := inst.newKey(t, "root")
412 aliceKey := inst.newKey(t, "alice")
413 bobKey := inst.newKey(t, "bob")
414 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
415 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
416 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
417 for _, c := range [][]string{{aliceKey, "alice/app"}, {bobKey, "bob/app"}} {
418 if _, _, code := inst.ssh(t, c[0], "", "repo", "create", c[1]); code != 0 {
419 t.Fatalf("repo create %s failed", c[1])
420 }
421 }
422 audit := func(args ...string) string {
423 t.Helper()
424 out, errOut, code := inst.ssh(t, rootKey, "", append([]string{"audit"}, args...)...)
425 if code != 0 {
426 t.Fatalf("audit %v: exit %d %s", args, code, errOut)
427 }
428 return out
429 }
430 if out := audit("--actor", "alice"); !strings.Contains(out, "alice/app") || strings.Contains(out, "bob/app") || strings.Contains(out, "user.created") {
431 t.Fatalf("--actor alice:\n%s", out)
432 }
433 if out := audit("--actor", "-"); !strings.Contains(out, "admin user.created") || strings.Contains(out, "repo create") {
434 t.Fatalf("--actor -:\n%s", out)
435 }
436 if out := audit("--action", "'cmd repo'"); strings.Count(out, "\n") != 2 || strings.Contains(out, "user.created") {
437 t.Fatalf("--action prefix:\n%s", out)
438 }
439 if out := audit("--action", "'cmd repo'", "--limit", "1"); strings.Count(out, "\n") != 1 {
440 t.Fatalf("--limit with filter:\n%s", out)
441 }
442 if out := audit("--since", "1h"); !strings.Contains(out, "alice/app") {
443 t.Fatalf("--since 1h:\n%s", out)
444 }
445 if out := audit("--since", "2099-01-01"); strings.TrimSpace(out) != "" {
446 t.Fatalf("--since in the future returned rows:\n%s", out)
447 }
448 if _, _, code := inst.ssh(t, rootKey, "", "audit", "--since", "yesterday"); code != 2 {
449 t.Fatal("bad --since accepted")
450 }
451 if _, _, code := inst.ssh(t, rootKey, "", "audit", "--actor"); code != 2 {
452 t.Fatal("dangling flag accepted")
453 }
454 // The host-local command takes the same flags and --json.
455 if out := inst.admin(t, "admin", "audit", "--actor", "bob", "--json"); !strings.Contains(out, `"protocol_version"`) ||
456 !strings.Contains(out, "bob/app") || strings.Contains(out, "alice/app") {
457 t.Fatalf("host audit --json --actor:\n%s", out)
458 }
459}
460
461func TestAdminQueuesDashboard(t *testing.T) {
462 t.Parallel()
463 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[webhooks]\nallow_local = true\n")
464 rootKey := inst.newKey(t, "root")
465 aliceKey := inst.newKey(t, "alice")
466 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
467 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
468 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
469 t.Fatal("repo create failed")
470 }
471 // A webhook whose receiver keeps failing, and a CI job with no runner:
472 // one delivery retrying, one build pending.
473 hook := startHookReceiver(t)
474 hook.failNext = 100
475 if _, errOut, code := inst.ssh(t, aliceKey, "", "webhook", "add", "alice/app", "http://"+hook.addr+"/hook"); code != 0 {
476 t.Fatalf("webhook add: %s", errOut)
477 }
478 work := t.TempDir()
479 env := inst.gitEnv(aliceKey)
480 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
481 dir := filepath.Join(work, "w")
482 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
483 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n ok:\n steps:\n - echo fine\n"), 0o644)
484 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
485 mustGit(t, dir, env, "add", ".")
486 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
487 mustGit(t, dir, env, "push", "-q", "origin", "main")
488
489 type queues struct {
490 Webhooks struct {
491 Pending int64 `json:"pending"`
492 Retrying int64 `json:"retrying"`
493 Items []struct {
494 Repo string `json:"repo"`
495 Attempts int64 `json:"attempts"`
496 LastError string `json:"last_error"`
497 } `json:"items"`
498 } `json:"webhooks"`
499 Builds struct {
500 Pending int64 `json:"pending"`
501 OldestPending string `json:"oldest_pending"`
502 Items []struct {
503 Repo string `json:"repo"`
504 Job string `json:"job"`
505 Status string `json:"status"`
506 CreatedAt string `json:"created_at"`
507 } `json:"items"`
508 } `json:"builds"`
509 Mail struct {
510 Pending int64 `json:"pending"`
511 } `json:"mail"`
512 }
513 dashboard := func(key string) (*queues, string) {
514 t.Helper()
515 out, errOut, code := inst.ssh(t, key, "", "dashboard", "--json")
516 if code != 0 {
517 t.Fatalf("dashboard: exit %d %s", code, errOut)
518 }
519 var env struct {
520 Data struct {
521 Queues *queues `json:"queues"`
522 } `json:"data"`
523 }
524 if err := json.Unmarshal([]byte(out), &env); err != nil {
525 t.Fatalf("dashboard json: %v\n%s", err, out)
526 }
527 return env.Data.Queues, out
528 }
529 if q, out := dashboard(aliceKey); q != nil {
530 t.Fatalf("non-admin dashboard carries queues:\n%s", out)
531 }
532 var q *queues
533 deadline := time.Now().Add(20 * time.Second)
534 for {
535 q, _ = dashboard(rootKey)
536 if q != nil && q.Webhooks.Retrying >= 1 && q.Builds.Pending >= 1 {
537 break
538 }
539 if time.Now().After(deadline) {
540 t.Fatalf("queues never showed the retrying delivery and pending build: %+v", q)
541 }
542 time.Sleep(200 * time.Millisecond)
543 }
544 if q.Builds.OldestPending == "" || q.Mail.Pending != 0 {
545 t.Fatalf("queue facts: %+v", q)
546 }
547 if len(q.Webhooks.Items) == 0 || q.Webhooks.Items[0].Repo != "alice/app" || q.Webhooks.Items[0].Attempts == 0 || q.Webhooks.Items[0].LastError == "" {
548 t.Fatalf("retrying item: %+v", q.Webhooks.Items)
549 }
550 // A build no runner has claimed is listed, not just counted.
551 if len(q.Builds.Items) == 0 || q.Builds.Items[0].Repo != "alice/app" || q.Builds.Items[0].Job != "ok" ||
552 q.Builds.Items[0].Status != "pending" || q.Builds.Items[0].CreatedAt == "" {
553 t.Fatalf("pending build item: %+v", q.Builds.Items)
554 }
555
556 // The web page dispatches the same read; non-admins get a 404 and no
557 // rail link.
558 alice := inst.login(t, aliceKey)
559 if status, body := browserGet(t, alice, inst.base()+"/admin"); status != 404 || strings.Contains(body, "Webhook deliveries") {
560 t.Fatalf("non-admin /admin: %d", status)
561 }
562 if _, body := browserGet(t, alice, inst.base()+"/"); strings.Contains(body, `href="/admin"`) {
563 t.Fatal("non-admin rail links to /admin")
564 }
565 root := inst.login(t, rootKey)
566 status, body := browserGet(t, root, inst.base()+"/admin")
567 if status != 200 || !strings.Contains(body, "Webhook deliveries") || !strings.Contains(body, "alice/app") ||
568 !strings.Contains(body, "retrying") || !strings.Contains(body, "1 pending") || !strings.Contains(body, "<td>pending</td>") {
569 t.Fatalf("/admin: %d\n%s", status, body)
570 }
571 if _, body := browserGet(t, root, inst.base()+"/"); !strings.Contains(body, `href="/admin"`) {
572 t.Fatal("admin rail lacks /admin")
573 }
574}
575
576func TestAdminConfigShow(t *testing.T) {
577 t.Parallel()
578 inst := startInstanceWith(t, "[mail]\nsmtp_host = \"127.0.0.1:1\"\nfrom = \"forge@example.test\"\nsmtp_pass = \"hunter2\"\n")
579 out := inst.admin(t, "admin", "config", "show")
580 for _, want := range []string{"[server]", "site_url", "ssh_auth_rate = 10", "pull_interval_minutes = 15", "[mail]", `smtp_pass = "<redacted>"`} {
581 if !strings.Contains(out, want) {
582 t.Fatalf("config show lacks %q:\n%s", want, out)
583 }
584 }
585 if strings.Contains(out, "hunter2") {
586 t.Fatalf("config show printed the SMTP password:\n%s", out)
587 }
588}