e2e/adminusers_test.go

3bcdce33fb9a2309312854331359d376171c7368
gitbay/e2e/adminusers_test.go history · blame · raw

588 lines · 23751 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	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}