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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 17m ago 4b8c9e1d
history blame raw 14.1 KiB
1package server_test
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "rodent.software/vole/forge/access"
11)
12
13func TestRepositoryAccessSettings(t *testing.T) {
14 t.Parallel()
15 f := browserFixture(t)
16 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
17 t.Fatal(err)
18 }
19 f.addWriter(t)
20 sess, cookie := f.browserSession(t, f.ownerID)
21
22 // Access rules are instance-admin settings, so they are a tab on the
23 // repository's own Settings page rather than a separate admin surface.
24 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
25 if resp.StatusCode != 200 {
26 t.Fatalf("settings page = %d:\n%s", resp.StatusCode, body)
27 }
28 if !strings.Contains(body, `id="access"`) {
29 t.Fatalf("settings page has no access section:\n%s", body)
30 }
31 for _, want := range []string{"Default role", "Approvals required", "Require a linked issue", "wilbur", "Runner"} {
32 if !strings.Contains(body, want) {
33 t.Fatalf("access section does not show %q:\n%s", want, body)
34 }
35 }
36 // The section is rendered from its own view, so the form must still name the
37 // repository it posts to rather than an empty path.
38 if action := `action="/` + f.repo + `/settings/access"`; !strings.Contains(body, action) {
39 t.Fatalf("access section does not post to %s:\n%s", action, body)
40 }
41
42 form := url.Values{
43 "csrf": {sess.CSRF},
44 "default": {"read"},
45 "approvals": {"2"},
46 "require_linked_issue": {"on"},
47 "protected": {"refs/heads/main\nrefs/heads/release/*"},
48 "role:" + f.ownerID: {"admin"},
49 "role:" + f.writerID: {"maintain"},
50 "runner:" + f.writerID: {"on"},
51 }
52 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
53 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/"+f.repo+"/settings?tab=access&saved=access#access" {
54 t.Fatalf("save = %d %q", resp.StatusCode, resp.Header.Get("Location"))
55 }
56 if resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access&saved=access", cookie); resp.StatusCode != 200 || !strings.Contains(body, "Access rules saved") {
57 t.Fatalf("saved access page = %d:\n%s", resp.StatusCode, body)
58 }
59
60 m, err := f.srv.Repos.Open(f.repo)
61 if err != nil {
62 t.Fatal(err)
63 }
64 acl, err := m.ACL()
65 if err != nil {
66 t.Fatal(err)
67 }
68 if acl.Default != "read" {
69 t.Fatalf("default role = %q", acl.Default)
70 }
71 if got := acl.RequiredApprovals(); got != 2 {
72 t.Fatalf("approvals = %d, want 2", got)
73 }
74 if acl.RequiresChecks() {
75 t.Fatal("checks stayed required after an unchecked save")
76 }
77 if !acl.RequiresLinkedIssue() {
78 t.Fatal("linked-issue rule was not saved")
79 }
80 if got := strings.Join(acl.Protected, ","); got != "refs/heads/main,refs/heads/release/*" {
81 t.Fatalf("protected = %q", got)
82 }
83 if acl.Entries[f.ownerID] != "admin" || acl.Entries[f.writerID] != "maintain" {
84 t.Fatalf("entries = %v", acl.Entries)
85 }
86 if got := strings.Join(acl.Runners, ","); got != f.writerID {
87 t.Fatalf("runners = %q, want %s", got, f.writerID)
88 }
89 entry, err := f.srv.Index.Get(f.repo)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if entry.ACLDefault != "read" || entry.ACLPrincipals != 2 {
94 t.Fatalf("index entry = %+v", entry)
95 }
96 audit, err := os.ReadFile(f.cfg.AuditLog())
97 if err != nil {
98 t.Fatal(err)
99 }
100 if !strings.Contains(string(audit), "web.admin.acl") {
101 t.Fatalf("ACL change was not audited:\n%s", audit)
102 }
103 if code, _ := f.get("/" + f.repo); code != 200 {
104 t.Fatalf("default read did not publish the repository: %d", code)
105 }
106
107 // A second save exercises checks on, zero approvals, and removing an entry
108 // by setting the role back to none.
109 form.Set("checks", "on")
110 form.Set("approvals", "0")
111 form.Set("role:"+f.writerID, "none")
112 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
113 if resp.StatusCode != 303 {
114 t.Fatalf("second save = %d", resp.StatusCode)
115 }
116 acl, err = m.ACL()
117 if err != nil {
118 t.Fatal(err)
119 }
120 if !acl.RequiresChecks() || acl.RequiredApprovals() != 0 {
121 t.Fatalf("policy = checks %t approvals %d", acl.RequiresChecks(), acl.RequiredApprovals())
122 }
123 if _, ok := acl.Entries[f.writerID]; ok {
124 t.Fatalf("role none did not remove the entry: %v", acl.Entries)
125 }
126}
127
128// A pattern the hook can never match must be refused by the form, not stored and
129// echoed back by the settings page as if the branch were protected.
130func TestRepositoryAccessRefusesAPatternThatCannotMatch(t *testing.T) {
131 t.Parallel()
132 f := browserFixture(t)
133 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
134 t.Fatal(err)
135 }
136 sess, cookie := f.browserSession(t, f.ownerID)
137
138 form := url.Values{
139 "csrf": {sess.CSRF},
140 "default": {"contribute"},
141 "approvals": {"1"},
142 "protected": {"refs/head/main"},
143 }
144 resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
145 if resp.StatusCode != 400 {
146 t.Fatalf("a pattern that cannot match was saved: %d", resp.StatusCode)
147 }
148 if !strings.Contains(body, "refs/heads/main") {
149 t.Fatalf("the refusal does not name the fix:\n%s", body)
150 }
151 m, err := f.srv.Repos.Open(f.repo)
152 if err != nil {
153 t.Fatal(err)
154 }
155 if acl, err := m.ACL(); err != nil {
156 t.Fatal(err)
157 } else if len(acl.Protected) != 0 {
158 t.Fatalf("the refusal stored %v", acl.Protected)
159 }
160
161 // A valid pattern still saves and round-trips through the page.
162 form.Set("protected", "refs/heads/main\nrefs/heads/release/*")
163 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
164 if resp.StatusCode != 303 {
165 t.Fatalf("valid save = %d", resp.StatusCode)
166 }
167 if acl, err := m.ACL(); err != nil {
168 t.Fatal(err)
169 } else if got := strings.Join(acl.Protected, ","); got != "refs/heads/main,refs/heads/release/*" {
170 t.Fatalf("protected = %q", got)
171 }
172}
173
174func TestInstanceAdminCanBrowsePrivateRepoWithoutACLEntry(t *testing.T) {
175 t.Parallel()
176 f := browserFixture(t)
177
178 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_operator"))
179 op, err := f.srv.Identity.Create("operator", pub, "test")
180 if err != nil {
181 t.Fatal(err)
182 }
183
184 // The operator is not in the repository ACL, so before the instance-admin
185 // grant the repository is absent, exactly as it is for any other stranger.
186 _, cookie := f.browserSession(t, op.Name)
187 if resp, _ := f.browserGet(t, "/"+f.repo, cookie); resp.StatusCode != 404 {
188 t.Fatalf("non-admin without a role = %d, want 404", resp.StatusCode)
189 }
190
191 if _, err := f.srv.Identity.SetAdmin(op.Name, true, "test"); err != nil {
192 t.Fatal(err)
193 }
194 sess, cookie := f.browserSession(t, op.Name)
195 if sess == nil {
196 t.Fatal("no session")
197 }
198 resp, body := f.browserGet(t, "/"+f.repo, cookie)
199 if resp.StatusCode != 200 {
200 t.Fatalf("instance admin without a role = %d, want 200:\n%s", resp.StatusCode, body)
201 }
202 if !strings.Contains(body, f.repo) {
203 t.Fatalf("repository page does not name %q:\n%s", f.repo, body)
204 }
205}
206
207func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
208 t.Parallel()
209 f := browserFixture(t)
210 f.addWriter(t)
211
212 // Anonymous is sent to login; an authenticated non-admin gets the same 404
213 // as a missing page.
214 resp, _ := f.browserGet(t, "/-/users", "")
215 if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "/-/login") {
216 t.Fatalf("anonymous users page = %d %q", resp.StatusCode, resp.Header.Get("Location"))
217 }
218 writerSess, writerCookie := f.browserSession(t, f.writerID)
219 if resp, _ := f.browserGet(t, "/-/users", writerCookie); resp.StatusCode != 404 {
220 t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode)
221 }
222 // A maintainer may open the settings page for the repository's secrets, but the
223 // instance-admin half of it must not be there.
224 resp, maintainerBody := f.browserGet(t, "/"+f.repo+"/settings", writerCookie)
225 if resp.StatusCode != 200 || !strings.Contains(maintainerBody, `id="secrets"`) {
226 t.Fatalf("maintainer settings = %d", resp.StatusCode)
227 }
228 if strings.Contains(maintainerBody, `id="access"`) {
229 t.Fatal("a maintainer saw the instance-admin access section")
230 }
231 if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/-/admin"`) {
232 t.Fatal("non-admin sees the Users link")
233 }
234 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, url.Values{
235 "csrf": {writerSess.CSRF}, "default": {"read"}, "approvals": {"0"},
236 })
237 if resp.StatusCode != 404 {
238 t.Fatalf("non-admin access save = %d, want 404", resp.StatusCode)
239 }
240
241 // Promote the writer to instance admin. The instance Admin role governs
242 // every repository, private ones included, so the settings page now opens
243 // completely rather than showing only the access section.
244 if _, err := f.srv.Identity.SetAdmin(f.writerID, true, "test"); err != nil {
245 t.Fatal(err)
246 }
247 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", writerCookie)
248 if resp.StatusCode != 200 || !strings.Contains(body, `id="access"`) {
249 t.Fatalf("instance-admin settings = %d:\n%s", resp.StatusCode, body)
250 }
251 // The repository's own fields and the destructive operations are separate
252 // tabs, so they are read from those URLs too.
253 resp, general := f.browserGet(t, "/"+f.repo+"/settings", writerCookie)
254 if resp.StatusCode != 200 || !strings.Contains(general, "Default branch") {
255 t.Fatalf("instance-admin general tab = %d:\n%s", resp.StatusCode, general)
256 }
257 resp, danger := f.browserGet(t, "/"+f.repo+"/settings?tab=danger", writerCookie)
258 if resp.StatusCode != 200 {
259 t.Fatalf("instance-admin danger tab = %d:\n%s", resp.StatusCode, danger)
260 }
261 for _, want := range []string{"Rename repository", "Delete repository"} {
262 if !strings.Contains(danger, want) {
263 t.Fatalf("instance admin does not see %q:\n%s", want, danger)
264 }
265 }
266 if _, home := f.browserGet(t, "/", writerCookie); !strings.Contains(home, `href="/-/admin"`) {
267 t.Fatal("instance admin does not see the Users link")
268 }
269
270 // Instance admin is Admin on every repository, so repository details save
271 // through the same handler as they do for a repository admin.
272 resp, _ = f.browserPost(t, "/"+f.repo+"/settings", writerCookie, url.Values{
273 "csrf": {writerSess.CSRF}, "description": {"hijack"}, "default_branch": {"main"}, "visibility": {"private"},
274 })
275 if resp.StatusCode != 303 {
276 t.Fatalf("instance-admin repository save = %d, want 303", resp.StatusCode)
277 }
278
279 form := url.Values{"default": {"read"}, "approvals": {"0"}}
280 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form)
281 if resp.StatusCode != 403 {
282 t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode)
283 }
284 form.Set("csrf", writerSess.CSRF)
285 form.Set("approvals", "-1")
286 if resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") {
287 t.Fatalf("negative approvals = %d:\n%s", resp.StatusCode, body)
288 }
289 form.Set("approvals", "1")
290 form.Set("default", "wizard")
291 if resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") {
292 t.Fatalf("unknown role = %d:\n%s", resp.StatusCode, body)
293 }
294 m, err := f.srv.Repos.Open(f.repo)
295 if err != nil {
296 t.Fatal(err)
297 }
298 acl, err := m.ACL()
299 if err != nil {
300 t.Fatal(err)
301 }
302 if acl.Default == "read" {
303 t.Fatal("rejected save changed the default role")
304 }
305}
306
307func TestInstanceRoleIsNotFrozenIntoRepositoryACL(t *testing.T) {
308 t.Parallel()
309 f := browserFixture(t)
310 f.addWriter(t)
311 if _, err := f.srv.Identity.SetInstanceRole(f.writerID, access.Maintain, "test"); err != nil {
312 t.Fatal(err)
313 }
314 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
315 t.Fatal(err)
316 }
317 m, err := f.srv.Repos.Open(f.repo)
318 if err != nil {
319 t.Fatal(err)
320 }
321 acl, err := m.ACL()
322 if err != nil {
323 t.Fatal(err)
324 }
325 acl.Default = access.Read.String()
326 if err := m.SetACL(acl, "test"); err != nil {
327 t.Fatal(err)
328 }
329 sess, cookie := f.browserSession(t, f.ownerID)
330 form := url.Values{
331 "csrf": {sess.CSRF}, "default": {"read"}, "approvals": {"1"},
332 "checks": {"on"},
333 "role:" + f.writerID: {"none"},
334 }
335 resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
336 if resp.StatusCode != 303 {
337 t.Fatalf("save access = %d:\n%s", resp.StatusCode, body)
338 }
339 acl, err = m.ACL()
340 if err != nil {
341 t.Fatal(err)
342 }
343 if _, ok := acl.Entries[f.writerID]; ok {
344 t.Fatalf("inherited instance role was written into the repo ACL: %v", acl.Entries)
345 }
346 if got := acl.RoleFor(f.writerID); got != access.Maintain {
347 t.Fatalf("public instance role = %v, want maintain", got)
348 }
349}
350
351func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
352 t.Parallel()
353 f := browserFixture(t)
354 f.addWriter(t)
355 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_carol"))
356 if _, err := f.srv.Identity.Create("carol", pub, "test"); err != nil {
357 t.Fatal(err)
358 }
359 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
360 t.Fatal(err)
361 }
362 sess, cookie := f.browserSession(t, f.ownerID)
363
364 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
365 if resp.StatusCode != 200 {
366 t.Fatalf("settings = %d:\n%s", resp.StatusCode, body)
367 }
368 if strings.Contains(body, `name="role:carol"`) {
369 t.Fatalf("carol has no repository entry but is in the access table:\n%s", body)
370 }
371 if !strings.Contains(body, `<option value="carol">carol</option>`) {
372 t.Fatalf("carol is not offered in Add account:\n%s", body)
373 }
374 if action := `action="/` + f.repo + `/settings/access/add"`; !strings.Contains(body, action) {
375 t.Fatalf("add-account form does not post to %s:\n%s", action, body)
376 }
377
378 form := url.Values{
379 "csrf": {sess.CSRF}, "default": {"none"}, "approvals": {"1"},
380 "new_account": {"carol"}, "new_role": {"contribute"},
381 }
382 resp, body = f.browserPost(t, "/"+f.repo+"/settings/access/add", cookie, form)
383 if resp.StatusCode != 303 {
384 t.Fatalf("add account = %d:\n%s", resp.StatusCode, body)
385 }
386 m, err := f.srv.Repos.Open(f.repo)
387 if err != nil {
388 t.Fatal(err)
389 }
390 acl, err := m.ACL()
391 if err != nil {
392 t.Fatal(err)
393 }
394 if acl.Entries["carol"] != access.Contribute.String() {
395 t.Fatalf("carol entry = %q, want contribute", acl.Entries["carol"])
396 }
397 _, body = f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
398 if !strings.Contains(body, `name="role:carol"`) {
399 t.Fatalf("carol is not in the access table after being added:\n%s", body)
400 }
401 if strings.Contains(body, `<option value="carol">carol</option>`) {
402 t.Fatalf("carol is still offered in Add account after being added:\n%s", body)
403 }
404}
405