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.
Serve repositories from the root of the URL space
ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 4h ago · parent d37d301f
Serve repositories from the root of the URL space A repository is now /<name> and its Git transport /<name>.git, so the URL a person opens, the URL a clone uses, and the Go module path are the same name. The forge's own pages moved under /-/ (admin, accounts, the directory, the feed, sign-in, assets), a prefix no repository name can start with: names are [a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint spaces and removes the /r/ and /git/ prefixes, which existed only to keep repositories away from the forge's own paths. Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets move to /-/static/ because a subtree pattern like /static/ cannot coexist with one-segment-wildcard repo routes — Go's mux reports them as conflicting neither-more-specific patterns. The catch-all still renders the styled 404. Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix, and forgepaths.go hands such requests to the proxy before the mux, because a wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the same pre-router so a package path under a public repository is answered even when its first segments name a page tab. Names: repo.ValidateName now refuses the four root paths the forge must keep — static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an ordinary repository name, including admin, u, and activity. Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and the instance activity feed point at the new paths. No aliases: the old /r/ and /git/ URLs are gone. Verified with ./vole workflow check and ./vole workflow run verify.
server/auth_test.go +59−59
| @@ -109,11 +109,11 @@ func TestBrowserLoginAndInvitePages(t *testing.T) { | |||
| 109 | 109 | t.Parallel() | |
| 110 | 110 | f := browserFixture(t) | |
| 111 | 111 | ||
| 112 | resp, body := f.browserGet(t, "/login?next=/r/demo", "") | ||
| 112 | resp, body := f.browserGet(t, "/-/login?next=/demo", "") | ||
| 113 | 113 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Use a passkey") { | |
| 114 | 114 | t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body) | |
| 115 | 115 | } | |
| 116 | if !strings.Contains(body, `data-next="/r/demo"`) { | ||
| 116 | if !strings.Contains(body, `data-next="/demo"`) { | ||
| 117 | 117 | t.Fatalf("login page lost the next path: %s", body) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| @@ -124,7 +124,7 @@ func TestBrowserLoginAndInvitePages(t *testing.T) { | |||
| 124 | 124 | // The token is in the fragment, so the server sees only /invite: the page is | |
| 125 | 125 | // identical for a valid link and a stale one, and nothing about the token | |
| 126 | 126 | // reaches a log. InviteBegin is what answers whether it is still good. | |
| 127 | resp, body = f.browserGet(t, "/invite", "") | ||
| 127 | resp, body = f.browserGet(t, "/-/invite", "") | ||
| 128 | 128 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create a passkey") { | |
| 129 | 129 | t.Fatalf("invite page: status=%d body=%s", resp.StatusCode, body) | |
| 130 | 130 | } | |
| @@ -135,8 +135,8 @@ func TestBrowserLoginAndInvitePages(t *testing.T) { | |||
| 135 | 135 | t.Fatalf("invite page referrer policy = %q", got) | |
| 136 | 136 | } | |
| 137 | 137 | // A link in the old shape still works: it is redirected into the fragment. | |
| 138 | resp, _ = f.browserGet(t, "/invite/"+inv.Token, "") | ||
| 139 | if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/invite#"+inv.Token { | ||
| 138 | resp, _ = f.browserGet(t, "/-/invite/"+inv.Token, "") | ||
| 139 | if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/-/invite#"+inv.Token { | ||
| 140 | 140 | t.Fatalf("legacy invite link = %d %q", resp.StatusCode, resp.Header.Get("Location")) | |
| 141 | 141 | } | |
| 142 | 142 | } | |
| @@ -144,11 +144,11 @@ func TestBrowserLoginAndInvitePages(t *testing.T) { | |||
| 144 | 144 | func TestLoginIsDisabledWithoutHTTPS(t *testing.T) { | |
| 145 | 145 | t.Parallel() | |
| 146 | 146 | f := newFixture(t) | |
| 147 | resp, body := f.browserGet(t, "/login", "") | ||
| 147 | resp, body := f.browserGet(t, "/-/login", "") | ||
| 148 | 148 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") { | |
| 149 | 149 | t.Fatalf("disabled login page: status=%d body=%s", resp.StatusCode, body) | |
| 150 | 150 | } | |
| 151 | resp, body = f.browserGet(t, "/invite", "") | ||
| 151 | resp, body = f.browserGet(t, "/-/invite", "") | ||
| 152 | 152 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") { | |
| 153 | 153 | t.Fatalf("disabled invite page: status=%d body=%s", resp.StatusCode, body) | |
| 154 | 154 | } | |
| @@ -160,21 +160,21 @@ func TestSessionVisibilityAndLogoutCSRF(t *testing.T) { | |||
| 160 | 160 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 161 | 161 | ||
| 162 | 162 | // Anonymous still gets a private repository's 404. | |
| 163 | resp, _ := f.browserGet(t, "/r/"+f.repo, "") | ||
| 163 | resp, _ := f.browserGet(t, "/"+f.repo, "") | ||
| 164 | 164 | if resp.StatusCode != http.StatusNotFound { | |
| 165 | 165 | t.Fatalf("anonymous private repo status = %d, want 404", resp.StatusCode) | |
| 166 | 166 | } | |
| 167 | resp, body := f.browserGet(t, "/r/"+f.repo, cookie) | ||
| 167 | resp, body := f.browserGet(t, "/"+f.repo, cookie) | ||
| 168 | 168 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "keenan") || !strings.Contains(body, "account") { | |
| 169 | 169 | t.Fatalf("logged-in repo page: status=%d body=%s", resp.StatusCode, body) | |
| 170 | 170 | } | |
| 171 | 171 | ||
| 172 | 172 | // Logout is a state-changing POST and needs the session's CSRF token. | |
| 173 | resp, _ = f.browserPost(t, "/logout", cookie, url.Values{}) | ||
| 173 | resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{}) | ||
| 174 | 174 | if resp.StatusCode != http.StatusForbidden { | |
| 175 | 175 | t.Fatalf("logout without CSRF status = %d, want 403", resp.StatusCode) | |
| 176 | 176 | } | |
| 177 | resp, _ = f.browserPost(t, "/logout", cookie, url.Values{"csrf": {sess.CSRF}}) | ||
| 177 | resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{"csrf": {sess.CSRF}}) | ||
| 178 | 178 | if resp.StatusCode != http.StatusSeeOther { | |
| 179 | 179 | t.Fatalf("logout status = %d, want 303", resp.StatusCode) | |
| 180 | 180 | } | |
| @@ -188,17 +188,17 @@ func TestWebIssueAndCommentWritesUseSessionIdentity(t *testing.T) { | |||
| 188 | 188 | f := browserFixture(t) | |
| 189 | 189 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 190 | 190 | ||
| 191 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 191 | resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ | ||
| 192 | 192 | "csrf": {sess.CSRF}, "title": {"Browser filed issue"}, "body": {"Written from the web."}, | |
| 193 | 193 | }) | |
| 194 | 194 | if resp.StatusCode != http.StatusSeeOther { | |
| 195 | 195 | t.Fatalf("create issue: status=%d body=%s", resp.StatusCode, body) | |
| 196 | 196 | } | |
| 197 | 197 | loc := resp.Header.Get("Location") | |
| 198 | if !strings.HasPrefix(loc, "/r/"+f.repo+"/issues/") { | ||
| 198 | if !strings.HasPrefix(loc, "/"+f.repo+"/issues/") { | ||
| 199 | 199 | t.Fatalf("create issue redirect = %q", loc) | |
| 200 | 200 | } | |
| 201 | id := strings.TrimPrefix(loc, "/r/"+f.repo+"/issues/") | ||
| 201 | id := strings.TrimPrefix(loc, "/"+f.repo+"/issues/") | ||
| 202 | 202 | ||
| 203 | 203 | resp, body = f.browserGet(t, loc, cookie) | |
| 204 | 204 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Browser filed issue") || !strings.Contains(body, "Written from the web.") { | |
| @@ -225,7 +225,7 @@ func TestWebIssueAndCommentWritesUseSessionIdentity(t *testing.T) { | |||
| 225 | 225 | } | |
| 226 | 226 | ||
| 227 | 227 | // A write without the token is refused before anything is committed. | |
| 228 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 228 | resp, _ = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ | ||
| 229 | 229 | "title": {"forged"}, | |
| 230 | 230 | }) | |
| 231 | 231 | if resp.StatusCode != http.StatusForbidden { | |
| @@ -248,7 +248,7 @@ func TestWebApprovalNamesTheSessionAccount(t *testing.T) { | |||
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | 250 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 251 | resp, body := f.browserPost(t, "/r/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 251 | resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 252 | 252 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) | |
| 253 | 253 | if resp.StatusCode != http.StatusSeeOther { | |
| 254 | 254 | t.Fatalf("approve: status=%d body=%s", resp.StatusCode, body) | |
| @@ -282,7 +282,7 @@ func TestWebApprovalRefusesAStaleTarget(t *testing.T) { | |||
| 282 | 282 | } | |
| 283 | 283 | ||
| 284 | 284 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 285 | resp, body := f.browserPost(t, "/r/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 285 | resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 286 | 286 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {stale}}) | |
| 287 | 287 | if resp.StatusCode != http.StatusBadRequest { | |
| 288 | 288 | t.Fatalf("stale approval: status=%d body=%s", resp.StatusCode, body) | |
| @@ -296,7 +296,7 @@ func TestWebApprovalRefusesAStaleTarget(t *testing.T) { | |||
| 296 | 296 | if _, err := metadata.SetRequestBase(m.Git(), req.ID, "other", f.ownerID); err != nil { | |
| 297 | 297 | t.Fatal(err) | |
| 298 | 298 | } | |
| 299 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 299 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, | ||
| 300 | 300 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) | |
| 301 | 301 | if resp.StatusCode != http.StatusBadRequest { | |
| 302 | 302 | t.Fatalf("stale base approval: status=%d body=%s", resp.StatusCode, body) | |
| @@ -328,7 +328,7 @@ func TestWebAuthnBeginEndpoints(t *testing.T) { | |||
| 328 | 328 | if err := f.srv.Identity.AddCredential(f.ownerID, identity.Credential{ID: id, PublicKey: pub, Label: "test"}, "test"); err != nil { | |
| 329 | 329 | t.Fatal(err) | |
| 330 | 330 | } | |
| 331 | resp, out := f.browserJSON(t, "/auth/login/begin", "", "", map[string]string{"username": "keenan"}) | ||
| 331 | resp, out := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "keenan"}) | ||
| 332 | 332 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | |
| 333 | 333 | t.Fatalf("login begin: status=%d body=%v", resp.StatusCode, out) | |
| 334 | 334 | } | |
| @@ -337,18 +337,18 @@ func TestWebAuthnBeginEndpoints(t *testing.T) { | |||
| 337 | 337 | if err != nil { | |
| 338 | 338 | t.Fatal(err) | |
| 339 | 339 | } | |
| 340 | resp, out = f.browserJSON(t, "/auth/invite/begin", "", "", map[string]string{"token": inv.Token}) | ||
| 340 | resp, out = f.browserJSON(t, "/-/auth/invite/begin", "", "", map[string]string{"token": inv.Token}) | ||
| 341 | 341 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | |
| 342 | 342 | t.Fatalf("invite begin: status=%d body=%v", resp.StatusCode, out) | |
| 343 | 343 | } | |
| 344 | 344 | ||
| 345 | 345 | // Account registration is state-changing and must demand the CSRF token. | |
| 346 | 346 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 347 | resp, _ = f.browserJSON(t, "/account/passkeys/begin", cookie, "", map[string]string{"label": "phone"}) | ||
| 347 | resp, _ = f.browserJSON(t, "/-/account/passkeys/begin", cookie, "", map[string]string{"label": "phone"}) | ||
| 348 | 348 | if resp.StatusCode != http.StatusForbidden { | |
| 349 | 349 | t.Fatalf("passkey begin without CSRF status = %d, want 403", resp.StatusCode) | |
| 350 | 350 | } | |
| 351 | resp, out = f.browserJSON(t, "/account/passkeys/begin", cookie, sess.CSRF, map[string]string{"label": "phone"}) | ||
| 351 | resp, out = f.browserJSON(t, "/-/account/passkeys/begin", cookie, sess.CSRF, map[string]string{"label": "phone"}) | ||
| 352 | 352 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | |
| 353 | 353 | t.Fatalf("passkey begin: status=%d body=%v", resp.StatusCode, out) | |
| 354 | 354 | } | |
| @@ -394,19 +394,19 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 394 | 394 | ||
| 395 | 395 | sess, cookie := f.browserSession(t, contrib.Name) | |
| 396 | 396 | // Contributors can discuss without access to shared branches. | |
| 397 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 397 | resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ | ||
| 398 | 398 | "csrf": {sess.CSRF}, "title": {"Contributor issue"}, "body": {"from outside"}, | |
| 399 | 399 | }) | |
| 400 | 400 | if resp.StatusCode != http.StatusSeeOther { | |
| 401 | 401 | t.Fatalf("contributor issue: status=%d body=%s", resp.StatusCode, body) | |
| 402 | 402 | } | |
| 403 | 403 | ||
| 404 | resp, body = f.browserGet(t, "/r/"+f.repo+"/changes/new", cookie) | ||
| 404 | resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie) | ||
| 405 | 405 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, headRef) || !strings.Contains(body, "Open change") { | |
| 406 | 406 | t.Fatalf("request form: status=%d body=%s", resp.StatusCode, body) | |
| 407 | 407 | } | |
| 408 | 408 | ||
| 409 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{ | ||
| 409 | resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ | ||
| 410 | 410 | "csrf": {sess.CSRF}, "title": {"Contributor patch"}, "body": {"please review"}, | |
| 411 | 411 | "base": {"main"}, "head": {headRef}, | |
| 412 | 412 | }) | |
| @@ -427,7 +427,7 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 427 | 427 | ||
| 428 | 428 | // The handler refuses a head outside the contributor namespace even if the | |
| 429 | 429 | // form is edited by hand. | |
| 430 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{ | ||
| 430 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ | ||
| 431 | 431 | "csrf": {sess.CSRF}, "title": {"Wrong branch"}, "base": {"main"}, "head": {"main"}, | |
| 432 | 432 | }) | |
| 433 | 433 | if resp.StatusCode != http.StatusForbidden { | |
| @@ -471,7 +471,7 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 471 | 471 | } | |
| 472 | 472 | ||
| 473 | 473 | sess, cookie := f.browserSession(t, contrib.Name) | |
| 474 | resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{ | ||
| 474 | resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ | ||
| 475 | 475 | "csrf": {sess.CSRF}, "title": {"Contributor patch"}, "base": {"main"}, "head": {headRef}, | |
| 476 | 476 | }) | |
| 477 | 477 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -508,11 +508,11 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 508 | 508 | } | |
| 509 | 509 | ||
| 510 | 510 | // The control is offered, with the contributor's own branch as a choice. | |
| 511 | resp, body = f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie) | ||
| 511 | resp, body = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie) | ||
| 512 | 512 | if resp.StatusCode != http.StatusOK { | |
| 513 | 513 | t.Fatalf("request page: status=%d body=%s", resp.StatusCode, body) | |
| 514 | 514 | } | |
| 515 | if !strings.Contains(body, `action="/r/`+f.repo+`/changes/`+id+`/target"`) || !strings.Contains(body, headRef) { | ||
| 515 | if !strings.Contains(body, `action="/`+f.repo+`/changes/`+id+`/target"`) || !strings.Contains(body, headRef) { | ||
| 516 | 516 | t.Fatalf("the request page does not offer the target control:\n%s", body) | |
| 517 | 517 | } | |
| 518 | 518 | // The Review header carries the head commit under review. | |
| @@ -520,7 +520,7 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 520 | 520 | t.Fatalf("the Review header does not show the head commit:\n%s", body) | |
| 521 | 521 | } | |
| 522 | 522 | ||
| 523 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 523 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 524 | 524 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef}, | |
| 525 | 525 | }) | |
| 526 | 526 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -536,7 +536,7 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 536 | 536 | ||
| 537 | 537 | // The namespace rule is the one the create form applies: a contributor cannot | |
| 538 | 538 | // point their request at a branch they could not have pushed. | |
| 539 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 539 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 540 | 540 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {"main"}, | |
| 541 | 541 | }) | |
| 542 | 542 | if resp.StatusCode != http.StatusForbidden { | |
| @@ -549,7 +549,7 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 549 | 549 | // A maintainer may move any open request, including a contributor's. | |
| 550 | 550 | ownerSess, ownerCookie := f.browserSession(t, f.ownerID) | |
| 551 | 551 | mainTip, _ := m.Git().RevParse("refs/heads/main") | |
| 552 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/target", ownerCookie, url.Values{ | ||
| 552 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", ownerCookie, url.Values{ | ||
| 553 | 553 | "csrf": {ownerSess.CSRF}, "base": {"main"}, "head": {"main"}, | |
| 554 | 554 | }) | |
| 555 | 555 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -565,11 +565,11 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 565 | 565 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/parent"); err != nil { | |
| 566 | 566 | t.Fatalf("parent push: %v\n%s", err, errs) | |
| 567 | 567 | } | |
| 568 | resp, body = f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie) | ||
| 569 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/changes/`+id+`/target"`) { | ||
| 568 | resp, body = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie) | ||
| 569 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/changes/`+id+`/target"`) { | ||
| 570 | 570 | t.Fatalf("the request page does not offer the target control:\n%s", body) | |
| 571 | 571 | } | |
| 572 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 572 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 573 | 573 | "csrf": {sess.CSRF}, "base": {"parent"}, "head": {"main"}, | |
| 574 | 574 | }) | |
| 575 | 575 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -580,13 +580,13 @@ func TestContributorRepointsRequestTarget(t *testing.T) { | |||
| 580 | 580 | } | |
| 581 | 581 | ||
| 582 | 582 | // Closed requests are settled: the head and base are history, not settings. | |
| 583 | resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{ | ||
| 583 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{ | ||
| 584 | 584 | "csrf": {sess.CSRF}, "state": {"closed"}, | |
| 585 | 585 | }) | |
| 586 | 586 | if resp.StatusCode != http.StatusSeeOther { | |
| 587 | 587 | t.Fatalf("close: status=%d body=%s", resp.StatusCode, body) | |
| 588 | 588 | } | |
| 589 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 589 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ | ||
| 590 | 590 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef}, | |
| 591 | 591 | }) | |
| 592 | 592 | if resp.StatusCode != http.StatusForbidden { | |
| @@ -652,7 +652,7 @@ jobs: | |||
| 652 | 652 | } | |
| 653 | 653 | ||
| 654 | 654 | contribSess, contribCookie := f.browserSession(t, contrib.Name) | |
| 655 | resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", contribCookie, url.Values{ | ||
| 655 | resp, body := f.browserPost(t, "/"+f.repo+"/changes", contribCookie, url.Values{ | ||
| 656 | 656 | "csrf": {contribSess.CSRF}, "title": {"CI gate"}, "body": {"please review"}, | |
| 657 | 657 | "base": {"main"}, "head": {ref}, | |
| 658 | 658 | }) | |
| @@ -664,10 +664,10 @@ jobs: | |||
| 664 | 664 | t.Fatalf("requests = %v, err = %v", list, err) | |
| 665 | 665 | } | |
| 666 | 666 | requestID := list[0].ID | |
| 667 | requestURL := "/r/" + f.repo + "/changes/" + requestID | ||
| 667 | requestURL := "/" + f.repo + "/changes/" + requestID | ||
| 668 | 668 | ||
| 669 | 669 | // The Runs tab is history only; the approval belongs with the code under review. | |
| 670 | resp, body = f.browserGet(t, "/r/"+f.repo+"/runs", contribCookie) | ||
| 670 | resp, body = f.browserGet(t, "/"+f.repo+"/runs", contribCookie) | ||
| 671 | 671 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "waiting for a maintainer") || strings.Contains(body, "Allow CI</button>") { | |
| 672 | 672 | t.Fatalf("runs tab still owns CI approval: status=%d body=%s", resp.StatusCode, body) | |
| 673 | 673 | } | |
| @@ -678,7 +678,7 @@ jobs: | |||
| 678 | 678 | if strings.Contains(body, "Allow CI</button>") { | |
| 679 | 679 | t.Fatalf("contributor saw the Allow CI control: body=%s", body) | |
| 680 | 680 | } | |
| 681 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/ci/allow", contribCookie, url.Values{ | ||
| 681 | resp, _ = f.browserPost(t, "/"+f.repo+"/ci/allow", contribCookie, url.Values{ | ||
| 682 | 682 | "csrf": {contribSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID}, | |
| 683 | 683 | }) | |
| 684 | 684 | if resp.StatusCode != http.StatusForbidden { | |
| @@ -692,15 +692,15 @@ jobs: | |||
| 692 | 692 | !strings.Contains(body, `class="success"`) || !strings.Contains(body, "View runs") { | |
| 693 | 693 | t.Fatalf("maintainer actions missing from request: status=%d body=%s", resp.StatusCode, body) | |
| 694 | 694 | } | |
| 695 | resp, runsPage := f.browserGet(t, "/r/"+f.repo+"/runs", ownerCookie) | ||
| 695 | resp, runsPage := f.browserGet(t, "/"+f.repo+"/runs", ownerCookie) | ||
| 696 | 696 | if resp.StatusCode != http.StatusOK || strings.Contains(runsPage, "Allow CI</button>") || strings.Contains(runsPage, "Awaiting CI approval") { | |
| 697 | 697 | t.Fatalf("runs tab should not show contributor CI approval: status=%d body=%s", resp.StatusCode, runsPage) | |
| 698 | 698 | } | |
| 699 | resp, codePage := f.browserGet(t, "/r/"+f.repo+"/code", ownerCookie) | ||
| 699 | resp, codePage := f.browserGet(t, "/"+f.repo+"/code", ownerCookie) | ||
| 700 | 700 | if resp.StatusCode != http.StatusOK || strings.Contains(codePage, "Allow CI</button>") { | |
| 701 | 701 | t.Fatalf("contributor CI control should not be in the code browser: status=%d body=%s", resp.StatusCode, codePage) | |
| 702 | 702 | } | |
| 703 | resp, body = f.browserPost(t, "/r/"+f.repo+"/ci/allow", ownerCookie, url.Values{ | ||
| 703 | resp, body = f.browserPost(t, "/"+f.repo+"/ci/allow", ownerCookie, url.Values{ | ||
| 704 | 704 | "csrf": {ownerSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID}, | |
| 705 | 705 | }) | |
| 706 | 706 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -729,23 +729,23 @@ func TestOpenRegistrationPagesAndBegin(t *testing.T) { | |||
| 729 | 729 | c.OpenRegistration = true | |
| 730 | 730 | }) | |
| 731 | 731 | ||
| 732 | resp, body := f.browserGet(t, "/login", "") | ||
| 732 | resp, body := f.browserGet(t, "/-/login", "") | ||
| 733 | 733 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create one") { | |
| 734 | 734 | t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body) | |
| 735 | 735 | } | |
| 736 | resp, body = f.browserGet(t, "/register", "") | ||
| 736 | resp, body = f.browserGet(t, "/-/register", "") | ||
| 737 | 737 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create account and passkey") { | |
| 738 | 738 | t.Fatalf("register page: status=%d body=%s", resp.StatusCode, body) | |
| 739 | 739 | } | |
| 740 | resp, out := f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie"}) | ||
| 740 | resp, out := f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"}) | ||
| 741 | 741 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { | |
| 742 | 742 | t.Fatalf("register begin: status=%d body=%v", resp.StatusCode, out) | |
| 743 | 743 | } | |
| 744 | resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "keenan"}) | ||
| 744 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "keenan"}) | ||
| 745 | 745 | if resp.StatusCode != http.StatusConflict { | |
| 746 | 746 | t.Fatalf("taken name status = %d, want 409", resp.StatusCode) | |
| 747 | 747 | } | |
| 748 | resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "Not A Name"}) | ||
| 748 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "Not A Name"}) | ||
| 749 | 749 | if resp.StatusCode != http.StatusBadRequest { | |
| 750 | 750 | t.Fatalf("invalid name status = %d, want 400", resp.StatusCode) | |
| 751 | 751 | } | |
| @@ -759,15 +759,15 @@ func TestOpenRegistrationPagesAndBegin(t *testing.T) { | |||
| 759 | 759 | func TestOpenRegistrationIsOffByDefault(t *testing.T) { | |
| 760 | 760 | t.Parallel() | |
| 761 | 761 | f := browserFixture(t) | |
| 762 | resp, body := f.browserGet(t, "/login", "") | ||
| 762 | resp, body := f.browserGet(t, "/-/login", "") | ||
| 763 | 763 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "Create one") { | |
| 764 | 764 | t.Fatalf("login page offered registration while disabled: status=%d body=%s", resp.StatusCode, body) | |
| 765 | 765 | } | |
| 766 | resp, _ = f.browserGet(t, "/register", "") | ||
| 766 | resp, _ = f.browserGet(t, "/-/register", "") | ||
| 767 | 767 | if resp.StatusCode != http.StatusNotFound { | |
| 768 | 768 | t.Fatalf("register page status = %d, want 404", resp.StatusCode) | |
| 769 | 769 | } | |
| 770 | resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie"}) | ||
| 770 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"}) | ||
| 771 | 771 | if resp.StatusCode != http.StatusServiceUnavailable { | |
| 772 | 772 | t.Fatalf("register begin status = %d, want 503", resp.StatusCode) | |
| 773 | 773 | } | |
| @@ -784,7 +784,7 @@ func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) { | |||
| 784 | 784 | } | |
| 785 | 785 | fp := ssh.FingerprintSHA256(k) | |
| 786 | 786 | ||
| 787 | resp, body := f.browserPost(t, "/account/keys", cookie, url.Values{ | ||
| 787 | resp, body := f.browserPost(t, "/-/account/keys", cookie, url.Values{ | ||
| 788 | 788 | "csrf": {sess.CSRF}, "key": {pub}, | |
| 789 | 789 | }) | |
| 790 | 790 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -804,7 +804,7 @@ func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) { | |||
| 804 | 804 | t.Fatalf("added key %s is not on the account: %+v", fp, a.Keys) | |
| 805 | 805 | } | |
| 806 | 806 | ||
| 807 | resp, body = f.browserPost(t, "/account/keys/remove", cookie, url.Values{ | ||
| 807 | resp, body = f.browserPost(t, "/-/account/keys/remove", cookie, url.Values{ | ||
| 808 | 808 | "csrf": {sess.CSRF}, "fingerprint": {fp}, | |
| 809 | 809 | }) | |
| 810 | 810 | if resp.StatusCode != http.StatusSeeOther { | |
| @@ -824,7 +824,7 @@ func TestDisplayNameRouteIsGone(t *testing.T) { | |||
| 824 | 824 | t.Parallel() | |
| 825 | 825 | f := browserFixture(t) | |
| 826 | 826 | _, cookie := f.browserSession(t, f.ownerID) | |
| 827 | resp, _ := f.browserPost(t, "/account/display", cookie, url.Values{"display": {"Keenan O'Brien"}}) | ||
| 827 | resp, _ := f.browserPost(t, "/-/account/display", cookie, url.Values{"display": {"Keenan O'Brien"}}) | ||
| 828 | 828 | if resp.StatusCode != http.StatusNotFound { | |
| 829 | 829 | t.Fatalf("display route status = %d, want 404", resp.StatusCode) | |
| 830 | 830 | } | |
| @@ -837,7 +837,7 @@ func TestLogoutRefusesAnOversizedBody(t *testing.T) { | |||
| 837 | 837 | f := browserFixture(t) | |
| 838 | 838 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 839 | 839 | ||
| 840 | req, err := http.NewRequest("POST", f.http.URL+"/logout", | ||
| 840 | req, err := http.NewRequest("POST", f.http.URL+"/-/logout", | ||
| 841 | 841 | strings.NewReader("csrf="+sess.CSRF+"&padding="+strings.Repeat("a", 128<<10))) | |
| 842 | 842 | if err != nil { | |
| 843 | 843 | t.Fatal(err) | |
| @@ -865,7 +865,7 @@ func TestLoginBeginIsRateLimited(t *testing.T) { | |||
| 865 | 865 | ||
| 866 | 866 | var sawOK, sawLimited bool | |
| 867 | 867 | for i := 0; i < 100; i++ { | |
| 868 | resp, _ := f.browserJSON(t, "/auth/login/begin", "", "", map[string]string{"username": "nobody"}) | ||
| 868 | resp, _ := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "nobody"}) | ||
| 869 | 869 | switch resp.StatusCode { | |
| 870 | 870 | case http.StatusOK: | |
| 871 | 871 | if sawLimited { | |
| @@ -895,7 +895,7 @@ func TestAccountPageIsNotCached(t *testing.T) { | |||
| 895 | 895 | t.Parallel() | |
| 896 | 896 | f := browserFixture(t) | |
| 897 | 897 | _, cookie := f.browserSession(t, f.ownerID) | |
| 898 | resp, _ := f.browserGet(t, "/account", cookie) | ||
| 898 | resp, _ := f.browserGet(t, "/-/account", cookie) | ||
| 899 | 899 | if got := resp.Header.Get("Cache-Control"); got != "no-store" { | |
| 900 | 900 | t.Fatalf("account page Cache-Control = %q", got) | |
| 901 | 901 | } | |
| @@ -914,7 +914,7 @@ func TestCrossOriginPostIsRefused(t *testing.T) { | |||
| 914 | 914 | ||
| 915 | 915 | post := func(origin string) *http.Response { | |
| 916 | 916 | body := strings.NewReader(url.Values{"csrf": {sess.CSRF}}.Encode()) | |
| 917 | req, err := http.NewRequest("POST", f.http.URL+"/logout", body) | ||
| 917 | req, err := http.NewRequest("POST", f.http.URL+"/-/logout", body) | ||
| 918 | 918 | if err != nil { | |
| 919 | 919 | t.Fatal(err) | |
| 920 | 920 | } | |