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.
Fold admin work into repository settings and a Users page
9579c1f976254fdeb2ec73d4016db5b25c7921c1 · Keenan Nemetz · 14d ago · parent 381d6aac
Fold admin work into repository settings and a Users page Remove the combined /admin tabs. Per-repository access rules now live on the repository's Settings page, visible to instance admins while repository details and the danger zone stay with repository admins. User management moves to /users, linked from the top navigation only for instance admins, and repository creation moves to /repos/new. Align the invite, danger-zone, and settings forms with the shared card-and-actions layout, and make settings section headings consistent.
server/admin_web_test.go +54−37
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | func TestAdminAccessEditor(t *testing.T) { | ||
| 11 | func TestRepositoryAccessSettings(t *testing.T) { | ||
| 12 | 12 | t.Parallel() | |
| 13 | 13 | f := browserFixture(t) | |
| 14 | 14 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | |
| @@ -17,27 +17,18 @@ func TestAdminAccessEditor(t *testing.T) { | |||
| 17 | 17 | f.addWriter(t) | |
| 18 | 18 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 19 | 19 | ||
| 20 | resp, body := f.browserGet(t, "/", cookie) | ||
| 21 | if resp.StatusCode != 200 || !strings.Contains(body, `href="/admin"`) { | ||
| 22 | t.Fatalf("instance admin does not see the Admin link: %d", resp.StatusCode) | ||
| 23 | } | ||
| 24 | resp, body = f.browserGet(t, "/admin", cookie) | ||
| 25 | if resp.StatusCode != 200 || !strings.Contains(body, f.repo) || !strings.Contains(body, "/access") { | ||
| 26 | t.Fatalf("admin page = %d:\n%s", resp.StatusCode, body) | ||
| 27 | } | ||
| 28 | |||
| 29 | // The repository is private and the admin has no ACL entry beyond the owner | ||
| 30 | // role; the admin route must still open it. | ||
| 31 | resp, body = f.browserGet(t, "/admin/repos/"+f.repo+"/access", cookie) | ||
| 20 | // Access rules are instance-admin settings, so they live on the repository's | ||
| 21 | // own Settings page rather than in a separate admin surface. | ||
| 22 | resp, body := f.browserGet(t, "/r/"+f.repo+"/settings", cookie) | ||
| 32 | 23 | if resp.StatusCode != 200 { | |
| 33 | t.Fatalf("access page = %d:\n%s", resp.StatusCode, body) | ||
| 24 | t.Fatalf("settings page = %d:\n%s", resp.StatusCode, body) | ||
| 34 | 25 | } | |
| 35 | if !strings.Contains(body, `class="admin-tabs"`) || !strings.Contains(body, `href="/admin" aria-current="page"`) { | ||
| 36 | t.Fatalf("access page is missing the active admin tab:\n%s", body) | ||
| 26 | if !strings.Contains(body, `id="access"`) { | ||
| 27 | t.Fatalf("settings page has no access section:\n%s", body) | ||
| 37 | 28 | } | |
| 38 | 29 | for _, want := range []string{"Default role", "Approvals required", "wilbur", "Runner"} { | |
| 39 | 30 | if !strings.Contains(body, want) { | |
| 40 | t.Fatalf("access page does not show %q:\n%s", want, body) | ||
| 31 | t.Fatalf("access section does not show %q:\n%s", want, body) | ||
| 41 | 32 | } | |
| 42 | 33 | } | |
| 43 | 34 | ||
| @@ -50,10 +41,13 @@ func TestAdminAccessEditor(t *testing.T) { | |||
| 50 | 41 | "role:" + f.writerID: {"maintain"}, | |
| 51 | 42 | "runner:" + f.writerID: {"on"}, | |
| 52 | 43 | } | |
| 53 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 54 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/admin/repos/"+f.repo+"/access?saved=1" { | ||
| 44 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form) | ||
| 45 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/r/"+f.repo+"/settings?saved=access#access" { | ||
| 55 | 46 | t.Fatalf("save = %d %q", resp.StatusCode, resp.Header.Get("Location")) | |
| 56 | 47 | } | |
| 48 | if resp, body := f.browserGet(t, "/r/"+f.repo+"/settings?saved=access", cookie); resp.StatusCode != 200 || !strings.Contains(body, "Access rules saved") { | ||
| 49 | t.Fatalf("saved access page = %d:\n%s", resp.StatusCode, body) | ||
| 50 | } | ||
| 57 | 51 | ||
| 58 | 52 | m, err := f.srv.Repos.Open(f.repo) | |
| 59 | 53 | if err != nil { | |
| @@ -104,7 +98,7 @@ func TestAdminAccessEditor(t *testing.T) { | |||
| 104 | 98 | form.Set("checks", "on") | |
| 105 | 99 | form.Set("approvals", "0") | |
| 106 | 100 | form.Set("role:"+f.writerID, "none") | |
| 107 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 101 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form) | ||
| 108 | 102 | if resp.StatusCode != 303 { | |
| 109 | 103 | t.Fatalf("second save = %d", resp.StatusCode) | |
| 110 | 104 | } | |
| @@ -153,51 +147,74 @@ func TestInstanceAdminCanBrowsePrivateRepoWithoutACLEntry(t *testing.T) { | |||
| 153 | 147 | } | |
| 154 | 148 | } | |
| 155 | 149 | ||
| 156 | func TestAdminAccessRequiresInstanceAdmin(t *testing.T) { | ||
| 150 | func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) { | ||
| 157 | 151 | t.Parallel() | |
| 158 | 152 | f := browserFixture(t) | |
| 159 | 153 | f.addWriter(t) | |
| 160 | 154 | ||
| 161 | 155 | // Anonymous is sent to login; an authenticated non-admin gets the same 404 | |
| 162 | 156 | // as a missing page. | |
| 163 | resp, _ := f.browserGet(t, "/admin", "") | ||
| 157 | resp, _ := f.browserGet(t, "/users", "") | ||
| 164 | 158 | if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "/login") { | |
| 165 | t.Fatalf("anonymous admin = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 159 | t.Fatalf("anonymous users page = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 166 | 160 | } | |
| 167 | 161 | writerSess, writerCookie := f.browserSession(t, f.writerID) | |
| 168 | if resp, _ := f.browserGet(t, "/admin", writerCookie); resp.StatusCode != 404 { | ||
| 169 | t.Fatalf("non-admin admin page = %d, want 404", resp.StatusCode) | ||
| 162 | if resp, _ := f.browserGet(t, "/users", writerCookie); resp.StatusCode != 404 { | ||
| 163 | t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode) | ||
| 170 | 164 | } | |
| 171 | if resp, _ := f.browserGet(t, "/admin/repos/"+f.repo+"/access", writerCookie); resp.StatusCode != 404 { | ||
| 172 | t.Fatalf("non-admin access page = %d, want 404", resp.StatusCode) | ||
| 165 | if resp, _ := f.browserGet(t, "/r/"+f.repo+"/settings", writerCookie); resp.StatusCode != 404 { | ||
| 166 | t.Fatalf("maintainer settings = %d, want 404", resp.StatusCode) | ||
| 173 | 167 | } | |
| 174 | if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/admin"`) { | ||
| 175 | t.Fatal("non-admin sees the Admin link") | ||
| 168 | if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/users"`) { | ||
| 169 | t.Fatal("non-admin sees the Users link") | ||
| 176 | 170 | } | |
| 177 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", writerCookie, url.Values{ | ||
| 171 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, url.Values{ | ||
| 178 | 172 | "csrf": {writerSess.CSRF}, "default": {"read"}, "approvals": {"0"}, | |
| 179 | 173 | }) | |
| 180 | 174 | if resp.StatusCode != 404 { | |
| 181 | t.Fatalf("non-admin save = %d, want 404", resp.StatusCode) | ||
| 175 | t.Fatalf("non-admin access save = %d, want 404", resp.StatusCode) | ||
| 182 | 176 | } | |
| 183 | 177 | ||
| 184 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 178 | // Promote the writer to instance admin. They still have no repository-admin | ||
| 179 | // role, so the page shows only the access section and never the danger zone. | ||
| 180 | if _, err := f.srv.Identity.SetAdmin(f.writerID, true, "test"); err != nil { | ||
| 185 | 181 | t.Fatal(err) | |
| 186 | 182 | } | |
| 187 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 183 | resp, body := f.browserGet(t, "/r/"+f.repo+"/settings", writerCookie) | ||
| 184 | if resp.StatusCode != 200 || !strings.Contains(body, `id="access"`) { | ||
| 185 | t.Fatalf("instance-admin settings = %d:\n%s", resp.StatusCode, body) | ||
| 186 | } | ||
| 187 | for _, unwanted := range []string{"Danger zone", "Default branch"} { | ||
| 188 | if strings.Contains(body, unwanted) { | ||
| 189 | t.Fatalf("instance admin without a repository role sees %q:\n%s", unwanted, body) | ||
| 190 | } | ||
| 191 | } | ||
| 192 | if _, home := f.browserGet(t, "/", writerCookie); !strings.Contains(home, `href="/users"`) { | ||
| 193 | t.Fatal("instance admin does not see the Users link") | ||
| 194 | } | ||
| 195 | |||
| 196 | // Repository details remain the repository admin's, even for an instance | ||
| 197 | // admin, and the handlers enforce that rather than trusting the template. | ||
| 198 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings", writerCookie, url.Values{ | ||
| 199 | "csrf": {writerSess.CSRF}, "description": {"hijack"}, "default_branch": {"main"}, "visibility": {"public"}, | ||
| 200 | }) | ||
| 201 | if resp.StatusCode != 404 { | ||
| 202 | t.Fatalf("instance-admin repository save = %d, want 404", resp.StatusCode) | ||
| 203 | } | ||
| 204 | |||
| 188 | 205 | form := url.Values{"default": {"read"}, "approvals": {"0"}} | |
| 189 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 206 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form) | ||
| 190 | 207 | if resp.StatusCode != 403 { | |
| 191 | 208 | t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode) | |
| 192 | 209 | } | |
| 193 | form.Set("csrf", sess.CSRF) | ||
| 210 | form.Set("csrf", writerSess.CSRF) | ||
| 194 | 211 | form.Set("approvals", "-1") | |
| 195 | if resp, body := f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") { | ||
| 212 | if resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") { | ||
| 196 | 213 | t.Fatalf("negative approvals = %d:\n%s", resp.StatusCode, body) | |
| 197 | 214 | } | |
| 198 | 215 | form.Set("approvals", "1") | |
| 199 | 216 | form.Set("default", "wizard") | |
| 200 | if resp, body := f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") { | ||
| 217 | if resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") { | ||
| 201 | 218 | t.Fatalf("unknown role = %d:\n%s", resp.StatusCode, body) | |
| 202 | 219 | } | |
| 203 | 220 | m, err := f.srv.Repos.Open(f.repo) | |