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