e2e/adminusers_test.go

v1.21.0
gitbay/e2e/adminusers_test.go history · blame · raw

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 != 1 || !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 != 1 {
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 != 1 || !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}