vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add one Admin page with status, accounts, and the audit log
43e681d8a5d94567aa9530d32b2c89223c1a17f9 · Keenan Nemetz · 5d ago · parent af018ce8
Add one Admin page with status, accounts, and the audit log Instance administration was split between the Status and Accounts pages, and the audit log had no browser view at all. The new /admin page gathers the status tabs, the account roster, and a read-only audit viewer into one place with a tab for each, and /status, /runners, and /users redirect to the tab they named so old links keep working. The audit reader walks the file backwards a chunk at a time, returns the newest records first, and shows one extra record to know whether an older page exists. It skips a line it cannot parse rather than failing the page and refuses a symlink or a non-regular file, as startup already does. Refs #198 (01M6MK0NNZ2XR1D5TQKNWYT4HN).
server/admin_page_test.go +205
| @@ -0,0 +1,205 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/rodent-software/vole/internal/audit" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // seedAudit replaces the instance's audit log with n records whose detail names | ||
| 15 | // the position, so a page test can say which end of the log it is looking at. | ||
| 16 | func seedAudit(t *testing.T, path string, n int, detail string) { | ||
| 17 | t.Helper() | ||
| 18 | var b strings.Builder | ||
| 19 | at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | ||
| 20 | for i := 0; i < n; i++ { | ||
| 21 | b.WriteString(audit.Format(audit.Record{ | ||
| 22 | Action: "test.action", | ||
| 23 | Actor: "keenan", | ||
| 24 | Subject: "demo", | ||
| 25 | Detail: fmt.Sprintf("%s-%d", detail, i), | ||
| 26 | }, at.Add(time.Duration(i)*time.Second))) | ||
| 27 | } | ||
| 28 | if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | func TestAdminPageIsOnePageWithEveryTab(t *testing.T) { | ||
| 34 | t.Parallel() | ||
| 35 | f := newFixture(t) | ||
| 36 | code, body := f.adminGet(t, "/admin") | ||
| 37 | if code != http.StatusOK { | ||
| 38 | t.Fatalf("GET /admin = %d:\n%s", code, body) | ||
| 39 | } | ||
| 40 | for _, want := range []string{ | ||
| 41 | "Overview", "Storage", "Runners", "Configuration", "Accounts", "Audit log", | ||
| 42 | "/admin?tab=storage", "/admin?tab=runners", "/admin?tab=configuration", | ||
| 43 | "/admin?tab=accounts", "/admin?tab=audit", | ||
| 44 | "Storage used", "Page views", | ||
| 45 | } { | ||
| 46 | if !strings.Contains(body, want) { | ||
| 47 | t.Fatalf("admin overview does not show %q:\n%s", want, body) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | // Accounts is a tab now, not a separate page. | ||
| 52 | code, accounts := f.adminGet(t, "/admin?tab=accounts") | ||
| 53 | if code != http.StatusOK || !strings.Contains(accounts, "Invite a person") || | ||
| 54 | !strings.Contains(accounts, `href="/admin?tab=accounts" aria-current="page"`) { | ||
| 55 | t.Fatalf("accounts tab = %d:\n%s", code, accounts) | ||
| 56 | } | ||
| 57 | if strings.Contains(accounts, "Storage used") { | ||
| 58 | t.Fatalf("accounts tab shows overview content:\n%s", accounts) | ||
| 59 | } | ||
| 60 | |||
| 61 | code, log := f.adminGet(t, "/admin?tab=audit") | ||
| 62 | if code != http.StatusOK || !strings.Contains(log, "Audit log") || | ||
| 63 | !strings.Contains(log, `href="/admin?tab=audit" aria-current="page"`) { | ||
| 64 | t.Fatalf("audit tab = %d:\n%s", code, log) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestAdminPageRequiresInstanceAdmin(t *testing.T) { | ||
| 69 | t.Parallel() | ||
| 70 | f := newFixture(t) | ||
| 71 | f.addWriter(t) | ||
| 72 | _, writerCookie := f.browserSession(t, f.writerID) | ||
| 73 | |||
| 74 | if resp, _ := f.browserGet(t, "/admin", ""); resp.StatusCode != http.StatusSeeOther || | ||
| 75 | !strings.Contains(resp.Header.Get("Location"), "/login") { | ||
| 76 | t.Fatalf("anonymous GET /admin = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 77 | } | ||
| 78 | for _, tab := range []string{"", "?tab=accounts", "?tab=audit"} { | ||
| 79 | if resp, _ := f.browserGet(t, "/admin"+tab, writerCookie); resp.StatusCode != http.StatusNotFound { | ||
| 80 | t.Fatalf("non-admin GET /admin%s = %d, want 404", tab, resp.StatusCode) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | // Old links still land on the tab they named; the admin gate is on the | ||
| 86 | // destination, so the redirect itself does not reveal whether anything is there. | ||
| 87 | func TestAdminRedirectsKeepOldLinksWorking(t *testing.T) { | ||
| 88 | t.Parallel() | ||
| 89 | f := newFixture(t) | ||
| 90 | if resp, _ := f.browserGet(t, "/status?tab=storage", ""); resp.StatusCode != http.StatusMovedPermanently || | ||
| 91 | resp.Header.Get("Location") != "/admin?tab=storage" { | ||
| 92 | t.Fatalf("GET /status?tab=storage = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 93 | } | ||
| 94 | if resp, _ := f.browserGet(t, "/status", ""); resp.StatusCode != http.StatusMovedPermanently || | ||
| 95 | resp.Header.Get("Location") != "/admin" { | ||
| 96 | t.Fatalf("GET /status = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 97 | } | ||
| 98 | |||
| 99 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 100 | t.Fatal(err) | ||
| 101 | } | ||
| 102 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 103 | if resp, _ := f.browserGet(t, "/users", cookie); resp.StatusCode != http.StatusMovedPermanently || | ||
| 104 | resp.Header.Get("Location") != "/admin?tab=accounts" { | ||
| 105 | t.Fatalf("GET /users = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | func TestAdminAuditLogPagesNewestFirst(t *testing.T) { | ||
| 110 | t.Parallel() | ||
| 111 | f := newFixture(t) | ||
| 112 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 113 | t.Fatal(err) | ||
| 114 | } | ||
| 115 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 116 | // Seed after the session: signing in and changing a role are themselves | ||
| 117 | // audited, and this test is about which end of the log a page shows. | ||
| 118 | seedAudit(t, f.cfg.AuditLog(), 150, "paging") | ||
| 119 | |||
| 120 | resp, first := f.browserGet(t, "/admin?tab=audit", cookie) | ||
| 121 | code := resp.StatusCode | ||
| 122 | if code != http.StatusOK { | ||
| 123 | t.Fatalf("audit tab = %d:\n%s", code, first) | ||
| 124 | } | ||
| 125 | if !strings.Contains(first, "paging-149") || !strings.Contains(first, "paging-50") { | ||
| 126 | t.Fatalf("the first page does not start at the newest record:\n%s", first) | ||
| 127 | } | ||
| 128 | if strings.Contains(first, "paging-49") { | ||
| 129 | t.Fatalf("the first page reached past its page size:\n%s", first) | ||
| 130 | } | ||
| 131 | if strings.Index(first, "paging-149") > strings.Index(first, "paging-50") { | ||
| 132 | t.Fatal("the audit tab is not newest first") | ||
| 133 | } | ||
| 134 | if !strings.Contains(first, "/admin?tab=audit&page=2") { | ||
| 135 | t.Fatalf("the first page has no link to older records:\n%s", first) | ||
| 136 | } | ||
| 137 | if strings.Contains(first, ">Newer</a>") { | ||
| 138 | t.Fatalf("the first page offers a newer page:\n%s", first) | ||
| 139 | } | ||
| 140 | |||
| 141 | resp, second := f.browserGet(t, "/admin?tab=audit&page=2", cookie) | ||
| 142 | code = resp.StatusCode | ||
| 143 | if code != http.StatusOK || !strings.Contains(second, "paging-49") || | ||
| 144 | !strings.Contains(second, "paging-0") { | ||
| 145 | t.Fatalf("second page = %d:\n%s", code, second) | ||
| 146 | } | ||
| 147 | if strings.Contains(second, "paging-50") { | ||
| 148 | t.Fatalf("the second page repeated the first:\n%s", second) | ||
| 149 | } | ||
| 150 | if !strings.Contains(second, "/admin?tab=audit\"") || !strings.Contains(second, ">Newer</a>") { | ||
| 151 | t.Fatalf("the second page has no link back to newer records:\n%s", second) | ||
| 152 | } | ||
| 153 | if strings.Contains(second, ">Older</a>") { | ||
| 154 | t.Fatalf("the second page offers an older page past the end:\n%s", second) | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | func TestAdminAuditLogEmptyAndBadPage(t *testing.T) { | ||
| 159 | t.Parallel() | ||
| 160 | f := newFixture(t) | ||
| 161 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 162 | t.Fatal(err) | ||
| 163 | } | ||
| 164 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 165 | seedAudit(t, f.cfg.AuditLog(), 0, "unused") | ||
| 166 | |||
| 167 | resp, body := f.browserGet(t, "/admin?tab=audit", cookie) | ||
| 168 | code := resp.StatusCode | ||
| 169 | if code != http.StatusOK || !strings.Contains(body, "No privileged actions have been recorded yet.") { | ||
| 170 | t.Fatalf("empty audit log = %d:\n%s", code, body) | ||
| 171 | } | ||
| 172 | |||
| 173 | resp, body = f.browserGet(t, "/admin?tab=audit&page=3", cookie) | ||
| 174 | code = resp.StatusCode | ||
| 175 | if code != http.StatusOK || !strings.Contains(body, "No entries on this page.") { | ||
| 176 | t.Fatalf("past-the-end page = %d:\n%s", code, body) | ||
| 177 | } | ||
| 178 | if strings.Contains(body, ">Older</a>") { | ||
| 179 | t.Fatalf("a past-the-end page offers an older page:\n%s", body) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | func TestAdminAuditLogEscapesDetailAndIsNotCached(t *testing.T) { | ||
| 184 | t.Parallel() | ||
| 185 | f := newFixture(t) | ||
| 186 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 187 | t.Fatal(err) | ||
| 188 | } | ||
| 189 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 190 | seedAudit(t, f.cfg.AuditLog(), 1, "<script>alert(1)</script>") | ||
| 191 | |||
| 192 | resp, body := f.browserGet(t, "/admin?tab=audit", cookie) | ||
| 193 | if resp.StatusCode != http.StatusOK { | ||
| 194 | t.Fatalf("audit tab = %d:\n%s", resp.StatusCode, body) | ||
| 195 | } | ||
| 196 | if strings.Contains(body, "<script>alert(1)</script>") { | ||
| 197 | t.Fatalf("the audit detail was not escaped:\n%s", body) | ||
| 198 | } | ||
| 199 | if !strings.Contains(body, "<script>alert(1)</script>") { | ||
| 200 | t.Fatalf("the audit detail is missing from the page:\n%s", body) | ||
| 201 | } | ||
| 202 | if got := resp.Header.Get("Cache-Control"); got != "no-store" { | ||
| 203 | t.Fatalf("audit log Cache-Control = %q, want no-store", got) | ||
| 204 | } | ||
| 205 | } | ||