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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/admin_page_test.go

Line attribution on main · 321 line(s)

43e681d8
Keenan Nemetz
4d ago
1 package server_test
2
3 import (
4 "fmt"
5 "net/http"
6 "os"
0db980ee
Keenan Nemetz
4d ago
7 "path/filepath"
43e681d8
Keenan Nemetz
4d ago
8 "strings"
9 "testing"
10 "time"
11
d37d301f
Keenan Nemetz
15h ago
12 "rodent.software/vole/forge/access"
13 "rodent.software/vole/forge/config"
14 "rodent.software/vole/internal/audit"
43e681d8
Keenan Nemetz
4d ago
15 )
16
17 // seedAudit replaces the instance's audit log with n records whose detail names
18 // the position, so a page test can say which end of the log it is looking at.
19 func seedAudit(t *testing.T, path string, n int, detail string) {
20 t.Helper()
21 var b strings.Builder
22 at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
23 for i := 0; i < n; i++ {
24 b.WriteString(audit.Format(audit.Record{
25 Action: "test.action",
26 Actor: "keenan",
27 Subject: "demo",
28 Detail: fmt.Sprintf("%s-%d", detail, i),
29 }, at.Add(time.Duration(i)*time.Second)))
30 }
31 if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
32 t.Fatal(err)
33 }
34 }
35
36 func TestAdminPageIsOnePageWithEveryTab(t *testing.T) {
37 t.Parallel()
38 f := newFixture(t)
ed03b3d1
Keenan Nemetz
2h ago
39 code, body := f.adminGet(t, "/-/admin")
43e681d8
Keenan Nemetz
4d ago
40 if code != http.StatusOK {
41 t.Fatalf("GET /admin = %d:\n%s", code, body)
42 }
43 for _, want := range []string{
44 "Overview", "Storage", "Runners", "Configuration", "Accounts", "Audit log",
ed03b3d1
Keenan Nemetz
2h ago
45 "/-/admin?tab=storage", "/-/admin?tab=runners", "/-/admin?tab=configuration",
46 "/-/admin?tab=accounts", "/-/admin?tab=audit",
43e681d8
Keenan Nemetz
4d ago
47 "Storage used", "Page views",
48 } {
49 if !strings.Contains(body, want) {
50 t.Fatalf("admin overview does not show %q:\n%s", want, body)
51 }
52 }
53
54 // Accounts is a tab now, not a separate page.
ed03b3d1
Keenan Nemetz
2h ago
55 code, accounts := f.adminGet(t, "/-/admin?tab=accounts")
43e681d8
Keenan Nemetz
4d ago
56 if code != http.StatusOK || !strings.Contains(accounts, "Invite a person") ||
ed03b3d1
Keenan Nemetz
2h ago
57 !strings.Contains(accounts, `href="/-/admin?tab=accounts" aria-current="page"`) {
43e681d8
Keenan Nemetz
4d ago
58 t.Fatalf("accounts tab = %d:\n%s", code, accounts)
59 }
60 if strings.Contains(accounts, "Storage used") {
61 t.Fatalf("accounts tab shows overview content:\n%s", accounts)
62 }
63
ed03b3d1
Keenan Nemetz
2h ago
64 code, log := f.adminGet(t, "/-/admin?tab=audit")
43e681d8
Keenan Nemetz
4d ago
65 if code != http.StatusOK || !strings.Contains(log, "Audit log") ||
ed03b3d1
Keenan Nemetz
2h ago
66 !strings.Contains(log, `href="/-/admin?tab=audit" aria-current="page"`) {
43e681d8
Keenan Nemetz
4d ago
67 t.Fatalf("audit tab = %d:\n%s", code, log)
68 }
69 }
70
71 func TestAdminPageRequiresInstanceAdmin(t *testing.T) {
72 t.Parallel()
73 f := newFixture(t)
74 f.addWriter(t)
75 _, writerCookie := f.browserSession(t, f.writerID)
76
ed03b3d1
Keenan Nemetz
2h ago
77 if resp, _ := f.browserGet(t, "/-/admin", ""); resp.StatusCode != http.StatusSeeOther ||
78 !strings.Contains(resp.Header.Get("Location"), "/-/login") {
43e681d8
Keenan Nemetz
4d ago
79 t.Fatalf("anonymous GET /admin = %d %q", resp.StatusCode, resp.Header.Get("Location"))
80 }
81 for _, tab := range []string{"", "?tab=accounts", "?tab=audit"} {
ed03b3d1
Keenan Nemetz
2h ago
82 if resp, _ := f.browserGet(t, "/-/admin"+tab, writerCookie); resp.StatusCode != http.StatusNotFound {
43e681d8
Keenan Nemetz
4d ago
83 t.Fatalf("non-admin GET /admin%s = %d, want 404", tab, resp.StatusCode)
84 }
85 }
86 }
87
88 // Old links still land on the tab they named; the admin gate is on the
89 // destination, so the redirect itself does not reveal whether anything is there.
90 func TestAdminRedirectsKeepOldLinksWorking(t *testing.T) {
91 t.Parallel()
92 f := newFixture(t)
ed03b3d1
Keenan Nemetz
2h ago
93 if resp, _ := f.browserGet(t, "/-/status?tab=storage", ""); resp.StatusCode != http.StatusMovedPermanently ||
94 resp.Header.Get("Location") != "/-/admin?tab=storage" {
43e681d8
Keenan Nemetz
4d ago
95 t.Fatalf("GET /status?tab=storage = %d %q", resp.StatusCode, resp.Header.Get("Location"))
96 }
ed03b3d1
Keenan Nemetz
2h ago
97 if resp, _ := f.browserGet(t, "/-/status", ""); resp.StatusCode != http.StatusMovedPermanently ||
98 resp.Header.Get("Location") != "/-/admin" {
43e681d8
Keenan Nemetz
4d ago
99 t.Fatalf("GET /status = %d %q", resp.StatusCode, resp.Header.Get("Location"))
100 }
101
102 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
103 t.Fatal(err)
104 }
105 _, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
2h ago
106 if resp, _ := f.browserGet(t, "/-/users", cookie); resp.StatusCode != http.StatusMovedPermanently ||
107 resp.Header.Get("Location") != "/-/admin?tab=accounts" {
43e681d8
Keenan Nemetz
4d ago
108 t.Fatalf("GET /users = %d %q", resp.StatusCode, resp.Header.Get("Location"))
109 }
110 }
111
112 func TestAdminAuditLogPagesNewestFirst(t *testing.T) {
113 t.Parallel()
114 f := newFixture(t)
115 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
116 t.Fatal(err)
117 }
118 _, cookie := f.browserSession(t, f.ownerID)
119 // Seed after the session: signing in and changing a role are themselves
120 // audited, and this test is about which end of the log a page shows.
121 seedAudit(t, f.cfg.AuditLog(), 150, "paging")
122
ed03b3d1
Keenan Nemetz
2h ago
123 resp, first := f.browserGet(t, "/-/admin?tab=audit", cookie)
43e681d8
Keenan Nemetz
4d ago
124 code := resp.StatusCode
125 if code != http.StatusOK {
126 t.Fatalf("audit tab = %d:\n%s", code, first)
127 }
128 if !strings.Contains(first, "paging-149") || !strings.Contains(first, "paging-50") {
129 t.Fatalf("the first page does not start at the newest record:\n%s", first)
130 }
131 if strings.Contains(first, "paging-49") {
132 t.Fatalf("the first page reached past its page size:\n%s", first)
133 }
134 if strings.Index(first, "paging-149") > strings.Index(first, "paging-50") {
135 t.Fatal("the audit tab is not newest first")
136 }
ed03b3d1
Keenan Nemetz
2h ago
137 if !strings.Contains(first, "/-/admin?tab=audit&amp;page=2") {
43e681d8
Keenan Nemetz
4d ago
138 t.Fatalf("the first page has no link to older records:\n%s", first)
139 }
140 if strings.Contains(first, ">Newer</a>") {
141 t.Fatalf("the first page offers a newer page:\n%s", first)
142 }
143
ed03b3d1
Keenan Nemetz
2h ago
144 resp, second := f.browserGet(t, "/-/admin?tab=audit&page=2", cookie)
43e681d8
Keenan Nemetz
4d ago
145 code = resp.StatusCode
146 if code != http.StatusOK || !strings.Contains(second, "paging-49") ||
147 !strings.Contains(second, "paging-0") {
148 t.Fatalf("second page = %d:\n%s", code, second)
149 }
150 if strings.Contains(second, "paging-50") {
151 t.Fatalf("the second page repeated the first:\n%s", second)
152 }
ed03b3d1
Keenan Nemetz
2h ago
153 if !strings.Contains(second, "/-/admin?tab=audit\"") || !strings.Contains(second, ">Newer</a>") {
43e681d8
Keenan Nemetz
4d ago
154 t.Fatalf("the second page has no link back to newer records:\n%s", second)
155 }
156 if strings.Contains(second, ">Older</a>") {
157 t.Fatalf("the second page offers an older page past the end:\n%s", second)
158 }
159 }
160
161 func TestAdminAuditLogEmptyAndBadPage(t *testing.T) {
162 t.Parallel()
163 f := newFixture(t)
164 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
165 t.Fatal(err)
166 }
167 _, cookie := f.browserSession(t, f.ownerID)
168 seedAudit(t, f.cfg.AuditLog(), 0, "unused")
169
ed03b3d1
Keenan Nemetz
2h ago
170 resp, body := f.browserGet(t, "/-/admin?tab=audit", cookie)
43e681d8
Keenan Nemetz
4d ago
171 code := resp.StatusCode
172 if code != http.StatusOK || !strings.Contains(body, "No privileged actions have been recorded yet.") {
173 t.Fatalf("empty audit log = %d:\n%s", code, body)
174 }
175
ed03b3d1
Keenan Nemetz
2h ago
176 resp, body = f.browserGet(t, "/-/admin?tab=audit&page=3", cookie)
43e681d8
Keenan Nemetz
4d ago
177 code = resp.StatusCode
178 if code != http.StatusOK || !strings.Contains(body, "No entries on this page.") {
179 t.Fatalf("past-the-end page = %d:\n%s", code, body)
180 }
181 if strings.Contains(body, ">Older</a>") {
182 t.Fatalf("a past-the-end page offers an older page:\n%s", body)
183 }
184 }
185
186 func TestAdminAuditLogEscapesDetailAndIsNotCached(t *testing.T) {
187 t.Parallel()
188 f := newFixture(t)
189 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
190 t.Fatal(err)
191 }
192 _, cookie := f.browserSession(t, f.ownerID)
193 seedAudit(t, f.cfg.AuditLog(), 1, "<script>alert(1)</script>")
194
ed03b3d1
Keenan Nemetz
2h ago
195 resp, body := f.browserGet(t, "/-/admin?tab=audit", cookie)
43e681d8
Keenan Nemetz
4d ago
196 if resp.StatusCode != http.StatusOK {
197 t.Fatalf("audit tab = %d:\n%s", resp.StatusCode, body)
198 }
199 if strings.Contains(body, "<script>alert(1)</script>") {
200 t.Fatalf("the audit detail was not escaped:\n%s", body)
201 }
202 if !strings.Contains(body, "&lt;script&gt;alert(1)&lt;/script&gt;") {
203 t.Fatalf("the audit detail is missing from the page:\n%s", body)
204 }
205 if got := resp.Header.Get("Cache-Control"); got != "no-store" {
206 t.Fatalf("audit log Cache-Control = %q, want no-store", got)
207 }
208 }
0db980ee
Keenan Nemetz
4d ago
209
210 // cardValue reads the <strong> value that follows a card's muted label. The
211 // overview repeats counts as cards, so a test has to look past the label to say
212 // which number belongs to it.
213 func cardValue(body, label string) string {
9a2a270b
Keenan Nemetz
4d ago
214 marker := `<span class="muted">` + label + `</span>`
215 idx := strings.Index(body, marker)
0db980ee
Keenan Nemetz
4d ago
216 if idx < 0 {
217 return ""
218 }
219 rest := body[idx:]
220 start := strings.Index(rest, "<strong>")
221 if start < 0 {
222 return ""
223 }
224 rest = rest[start+len("<strong>"):]
225 end := strings.Index(rest, "</strong>")
226 if end < 0 {
227 return ""
228 }
229 return strings.TrimSpace(rest[:end])
230 }
231
232 func TestAdminOverviewSummarizesAccountsAndRecentActivity(t *testing.T) {
233 t.Parallel()
234 f := newFixture(t)
235 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
236 t.Fatal(err)
237 }
238 f.addWriter(t)
239 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_bot"))
240 if _, err := f.srv.Identity.CreateBot("ci", access.Contribute, pub, "test"); err != nil {
241 t.Fatal(err)
242 }
243 if _, err := f.srv.Identity.Retire(f.writerID, "test"); err != nil {
244 t.Fatal(err)
245 }
246 _, cookie := f.browserSession(t, f.ownerID)
247 seedAudit(t, f.cfg.AuditLog(), 8, "overview")
248
ed03b3d1
Keenan Nemetz
2h ago
249 resp, body := f.browserGet(t, "/-/admin", cookie)
0db980ee
Keenan Nemetz
4d ago
250 if resp.StatusCode != http.StatusOK {
251 t.Fatalf("GET /admin = %d:\n%s", resp.StatusCode, body)
252 }
9a2a270b
Keenan Nemetz
4d ago
253 // The row is one card per metric now: active accounts, repositories, and the
254 // account mix as a note rather than four separate cards.
255 if got := cardValue(body, "Users"); got != "2" {
256 t.Errorf("Users = %q, want the two active accounts:\n%s", got, body)
257 }
258 if got := cardValue(body, "Repositories"); got != "1" {
259 t.Errorf("Repositories = %q, want 1:\n%s", got, body)
260 }
261 for _, want := range []string{"1 person", "1 bot", "1 admin", "1 retired"} {
262 if !strings.Contains(body, want) {
263 t.Fatalf("the Users card does not describe the roster with %q:\n%s", want, body)
0db980ee
Keenan Nemetz
4d ago
264 }
265 }
266 // The overview shows the newest few records, not the whole log.
267 for _, want := range []string{"overview-7", "overview-2", "Read the full audit log"} {
268 if !strings.Contains(body, want) {
269 t.Fatalf("the overview does not show %q:\n%s", want, body)
270 }
271 }
272 for _, gone := range []string{"overview-1", "overview-0"} {
273 if strings.Contains(body, gone) {
274 t.Fatalf("the overview reached past its recent-activity limit: %q:\n%s", gone, body)
275 }
276 }
277 }
278
279 func TestAdminOverviewShowsConfigurationWarnings(t *testing.T) {
280 t.Parallel()
281 f := newFixtureWith(t, func(c *config.Config) {
282 c.DevOnly = true
283 c.DevLogin = "keenan"
284 })
285 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
286 t.Fatal(err)
287 }
288 _, cookie := f.browserSession(t, f.ownerID)
289
ed03b3d1
Keenan Nemetz
2h ago
290 resp, body := f.browserGet(t, "/-/admin", cookie)
0db980ee
Keenan Nemetz
4d ago
291 if resp.StatusCode != http.StatusOK {
292 t.Fatalf("GET /admin = %d:\n%s", resp.StatusCode, body)
293 }
294 for _, want := range []string{
295 "Configuration attention", "dev_only", "dev_login", "browser sign-in",
296 "See every effective setting",
297 } {
298 if !strings.Contains(body, want) {
299 t.Fatalf("the overview does not show the configuration warning %q:\n%s", want, body)
300 }
301 }
302 }
303
304 func TestAdminOverviewOmitsConfigurationAttentionWhenClean(t *testing.T) {
305 t.Parallel()
306 f := newFixtureWith(t, func(c *config.Config) {
307 c.PublicURL = "https://forge.example.com"
308 })
309 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
310 t.Fatal(err)
311 }
312 _, cookie := f.browserSession(t, f.ownerID)
313
ed03b3d1
Keenan Nemetz
2h ago
314 resp, body := f.browserGet(t, "/-/admin", cookie)
0db980ee
Keenan Nemetz
4d ago
315 if resp.StatusCode != http.StatusOK {
316 t.Fatalf("GET /admin = %d:\n%s", resp.StatusCode, body)
317 }
318 if strings.Contains(body, "Configuration attention") {
319 t.Fatalf("a clean configuration still shows a warnings section:\n%s", body)
320 }
321 }