Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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&amp;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, "&lt;script&gt;alert(1)&lt;/script&gt;") {
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 }