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.
|
6c84d387
Keenan Nemetz
15d ago
|
1 | package server_test |
| 2 | ||
| 3 | import ( | |
| 4 | "crypto/rand" | |
| 5 | "encoding/json" | |
| 6 | "io" | |
| 7 | "net/http" | |
| 8 | "net/url" | |
|
53ff967b
Keenan Nemetz
13d ago
|
9 | "os" |
|
6c84d387
Keenan Nemetz
15d ago
|
10 | "path/filepath" |
| 11 | "strings" | |
| 12 | "testing" | |
| 13 | "time" | |
| 14 | ||
|
f920a9f1
Keenan Nemetz
15d ago
|
15 | "golang.org/x/crypto/ssh" |
|
d37d301f
Keenan Nemetz
15h ago
|
16 | "rodent.software/vole/ci/runs" |
| 17 | "rodent.software/vole/forge/config" | |
| 18 | "rodent.software/vole/forge/identity" | |
| 19 | "rodent.software/vole/forge/index" | |
| 20 | "rodent.software/vole/forge/session" | |
| 21 | "rodent.software/vole/metadata" | |
|
6c84d387
Keenan Nemetz
15d ago
|
22 | ) |
| 23 | ||
| 24 | var noRedirectAuth = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { | |
| 25 | return http.ErrUseLastResponse | |
| 26 | }} | |
| 27 | ||
| 28 | func browserFixture(t *testing.T) *fixture { | |
| 29 | t.Helper() | |
| 30 | return newFixtureWith(t, func(c *config.Config) { | |
| 31 | c.PublicURL = "https://forge.test" | |
| 32 | }) | |
| 33 | } | |
| 34 | ||
| 35 | func (f *fixture) browserSession(t *testing.T, accountID string) (*session.Session, string) { | |
| 36 | t.Helper() | |
| 37 | sess, err := f.srv.Sessions.Create(accountID, nil, "test-browser") | |
| 38 | if err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | return sess, "__Host-vole_session=" + sess.Token | |
| 42 | } | |
| 43 | ||
| 44 | func (f *fixture) browserGet(t *testing.T, path, cookie string) (*http.Response, string) { | |
| 45 | t.Helper() | |
| 46 | req, err := http.NewRequest("GET", f.http.URL+path, nil) | |
| 47 | if err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | if cookie != "" { | |
| 51 | req.Header.Set("Cookie", cookie) | |
| 52 | } | |
| 53 | resp, err := noRedirectAuth.Do(req) | |
| 54 | if err != nil { | |
| 55 | t.Fatal(err) | |
| 56 | } | |
| 57 | defer resp.Body.Close() | |
| 58 | body, _ := io.ReadAll(resp.Body) | |
| 59 | return resp, string(body) | |
| 60 | } | |
| 61 | ||
| 62 | func (f *fixture) browserPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) { | |
| 63 | t.Helper() | |
| 64 | req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode())) | |
| 65 | if err != nil { | |
| 66 | t.Fatal(err) | |
| 67 | } | |
| 68 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 69 | if cookie != "" { | |
| 70 | req.Header.Set("Cookie", cookie) | |
| 71 | } | |
| 72 | resp, err := noRedirectAuth.Do(req) | |
| 73 | if err != nil { | |
| 74 | t.Fatal(err) | |
| 75 | } | |
| 76 | defer resp.Body.Close() | |
| 77 | body, _ := io.ReadAll(resp.Body) | |
| 78 | return resp, string(body) | |
| 79 | } | |
| 80 | ||
| 81 | func (f *fixture) browserJSON(t *testing.T, path, cookie, csrf string, body any) (*http.Response, map[string]any) { | |
| 82 | t.Helper() | |
| 83 | raw, err := json.Marshal(body) | |
| 84 | if err != nil { | |
| 85 | t.Fatal(err) | |
| 86 | } | |
| 87 | req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(string(raw))) | |
| 88 | if err != nil { | |
| 89 | t.Fatal(err) | |
| 90 | } | |
| 91 | req.Header.Set("Content-Type", "application/json") | |
| 92 | if cookie != "" { | |
| 93 | req.Header.Set("Cookie", cookie) | |
| 94 | } | |
| 95 | if csrf != "" { | |
| 96 | req.Header.Set("X-Vole-CSRF", csrf) | |
| 97 | } | |
| 98 | resp, err := noRedirectAuth.Do(req) | |
| 99 | if err != nil { | |
| 100 | t.Fatal(err) | |
| 101 | } | |
| 102 | defer resp.Body.Close() | |
| 103 | var out map[string]any | |
| 104 | _ = json.NewDecoder(resp.Body).Decode(&out) | |
| 105 | return resp, out | |
| 106 | } | |
| 107 | ||
| 108 | func TestBrowserLoginAndInvitePages(t *testing.T) { | |
| 109 | t.Parallel() | |
| 110 | f := browserFixture(t) | |
| 111 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
112 | resp, body := f.browserGet(t, "/-/login?next=/demo", "") |
|
6c84d387
Keenan Nemetz
15d ago
|
113 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Use a passkey") { |
| 114 | t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body) | |
| 115 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
116 | if !strings.Contains(body, `data-next="/demo"`) { |
|
6c84d387
Keenan Nemetz
15d ago
|
117 | t.Fatalf("login page lost the next path: %s", body) |
| 118 | } | |
| 119 | ||
|
edf9db6e
Keenan Nemetz
12d ago
|
120 | _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour) |
|
6c84d387
Keenan Nemetz
15d ago
|
121 | if err != nil { |
| 122 | t.Fatal(err) | |
| 123 | } | |
|
edf9db6e
Keenan Nemetz
12d ago
|
124 | // The token is in the fragment, so the server sees only /invite: the page is |
| 125 | // identical for a valid link and a stale one, and nothing about the token | |
| 126 | // reaches a log. InviteBegin is what answers whether it is still good. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
127 | resp, body = f.browserGet(t, "/-/invite", "") |
|
edf9db6e
Keenan Nemetz
12d ago
|
128 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create a passkey") { |
|
6c84d387
Keenan Nemetz
15d ago
|
129 | t.Fatalf("invite page: status=%d body=%s", resp.StatusCode, body) |
| 130 | } | |
|
edf9db6e
Keenan Nemetz
12d ago
|
131 | if got := resp.Header.Get("Cache-Control"); got != "no-store" { |
| 132 | t.Fatalf("invite page cache header = %q", got) | |
| 133 | } | |
| 134 | if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" { | |
| 135 | t.Fatalf("invite page referrer policy = %q", got) | |
| 136 | } | |
| 137 | // A link in the old shape still works: it is redirected into the fragment. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
138 | resp, _ = f.browserGet(t, "/-/invite/"+inv.Token, "") |
| 139 | if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/-/invite#"+inv.Token { | |
|
edf9db6e
Keenan Nemetz
12d ago
|
140 | t.Fatalf("legacy invite link = %d %q", resp.StatusCode, resp.Header.Get("Location")) |
|
6c84d387
Keenan Nemetz
15d ago
|
141 | } |
| 142 | } | |
| 143 | ||
| 144 | func TestLoginIsDisabledWithoutHTTPS(t *testing.T) { | |
| 145 | t.Parallel() | |
| 146 | f := newFixture(t) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
147 | resp, body := f.browserGet(t, "/-/login", "") |
|
6c84d387
Keenan Nemetz
15d ago
|
148 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") { |
| 149 | t.Fatalf("disabled login page: status=%d body=%s", resp.StatusCode, body) | |
| 150 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
151 | resp, body = f.browserGet(t, "/-/invite", "") |
|
edf9db6e
Keenan Nemetz
12d ago
|
152 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") { |
| 153 | t.Fatalf("disabled invite page: status=%d body=%s", resp.StatusCode, body) | |
|
6c84d387
Keenan Nemetz
15d ago
|
154 | } |
| 155 | } | |
| 156 | ||
| 157 | func TestSessionVisibilityAndLogoutCSRF(t *testing.T) { | |
| 158 | t.Parallel() | |
| 159 | f := browserFixture(t) | |
| 160 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 161 | ||
| 162 | // Anonymous still gets a private repository's 404. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
163 | resp, _ := f.browserGet(t, "/"+f.repo, "") |
|
6c84d387
Keenan Nemetz
15d ago
|
164 | if resp.StatusCode != http.StatusNotFound { |
| 165 | t.Fatalf("anonymous private repo status = %d, want 404", resp.StatusCode) | |
| 166 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
167 | resp, body := f.browserGet(t, "/"+f.repo, cookie) |
|
6c84d387
Keenan Nemetz
15d ago
|
168 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "keenan") || !strings.Contains(body, "account") { |
| 169 | t.Fatalf("logged-in repo page: status=%d body=%s", resp.StatusCode, body) | |
| 170 | } | |
| 171 | ||
| 172 | // Logout is a state-changing POST and needs the session's CSRF token. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
173 | resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{}) |
|
6c84d387
Keenan Nemetz
15d ago
|
174 | if resp.StatusCode != http.StatusForbidden { |
| 175 | t.Fatalf("logout without CSRF status = %d, want 403", resp.StatusCode) | |
| 176 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
177 | resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{"csrf": {sess.CSRF}}) |
|
6c84d387
Keenan Nemetz
15d ago
|
178 | if resp.StatusCode != http.StatusSeeOther { |
| 179 | t.Fatalf("logout status = %d, want 303", resp.StatusCode) | |
| 180 | } | |
| 181 | if _, err := f.srv.Sessions.Lookup(sess.Token); err == nil { | |
| 182 | t.Fatal("logout did not revoke the session") | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | func TestWebIssueAndCommentWritesUseSessionIdentity(t *testing.T) { | |
| 187 | t.Parallel() | |
| 188 | f := browserFixture(t) | |
| 189 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 190 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
191 | resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ |
|
6c84d387
Keenan Nemetz
15d ago
|
192 | "csrf": {sess.CSRF}, "title": {"Browser filed issue"}, "body": {"Written from the web."}, |
| 193 | }) | |
| 194 | if resp.StatusCode != http.StatusSeeOther { | |
| 195 | t.Fatalf("create issue: status=%d body=%s", resp.StatusCode, body) | |
| 196 | } | |
| 197 | loc := resp.Header.Get("Location") | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
198 | if !strings.HasPrefix(loc, "/"+f.repo+"/issues/") { |
|
6c84d387
Keenan Nemetz
15d ago
|
199 | t.Fatalf("create issue redirect = %q", loc) |
| 200 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
201 | id := strings.TrimPrefix(loc, "/"+f.repo+"/issues/") |
|
6c84d387
Keenan Nemetz
15d ago
|
202 | |
| 203 | resp, body = f.browserGet(t, loc, cookie) | |
| 204 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Browser filed issue") || !strings.Contains(body, "Written from the web.") { | |
| 205 | t.Fatalf("issue page: status=%d body=%s", resp.StatusCode, body) | |
| 206 | } | |
| 207 | ||
| 208 | resp, body = f.browserPost(t, loc+"/comments", cookie, url.Values{ | |
| 209 | "csrf": {sess.CSRF}, "body": {"A browser comment."}, | |
| 210 | }) | |
| 211 | if resp.StatusCode != http.StatusSeeOther { | |
| 212 | t.Fatalf("comment: status=%d body=%s", resp.StatusCode, body) | |
| 213 | } | |
| 214 | _, body = f.browserGet(t, loc, cookie) | |
| 215 | if !strings.Contains(body, "A browser comment.") { | |
| 216 | t.Fatalf("comment missing from issue page: %s", body) | |
| 217 | } | |
| 218 | ||
| 219 | iss, err := index.LoadIssue(mustRepo(t, f), id) | |
| 220 | if err != nil { | |
| 221 | t.Fatal(err) | |
| 222 | } | |
| 223 | if iss.Author != "keenan" || len(iss.Comments) != 1 || iss.Comments[0].Author != "keenan" { | |
| 224 | t.Fatalf("authors came from the wrong place: %+v", iss) | |
| 225 | } | |
| 226 | ||
| 227 | // A write without the token is refused before anything is committed. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
228 | resp, _ = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ |
|
6c84d387
Keenan Nemetz
15d ago
|
229 | "title": {"forged"}, |
| 230 | }) | |
| 231 | if resp.StatusCode != http.StatusForbidden { | |
| 232 | t.Fatalf("issue without CSRF status = %d, want 403", resp.StatusCode) | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | func TestWebApprovalNamesTheSessionAccount(t *testing.T) { | |
| 237 | t.Parallel() | |
| 238 | f := browserFixture(t) | |
| 239 | f.addWriter(t) | |
| 240 | m := mustRepo(t, f) | |
| 241 | head := strings.Repeat("a", 40) | |
|
62576fa8
Keenan Nemetz
14d ago
|
242 | req, err := metadata.NewRequest("A change", "please review", "wilbur", "main", head, time.Now().UTC()) |
|
6c84d387
Keenan Nemetz
15d ago
|
243 | if err != nil { |
| 244 | t.Fatal(err) | |
| 245 | } | |
| 246 | if err := metadata.CreateRequest(m.Git(), req, "wilbur"); err != nil { | |
| 247 | t.Fatal(err) | |
| 248 | } | |
| 249 | ||
| 250 | sess, cookie := f.browserSession(t, f.ownerID) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
251 | resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, |
|
e050e14a
Keenan Nemetz
13d ago
|
252 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) |
|
6c84d387
Keenan Nemetz
15d ago
|
253 | if resp.StatusCode != http.StatusSeeOther { |
| 254 | t.Fatalf("approve: status=%d body=%s", resp.StatusCode, body) | |
| 255 | } | |
| 256 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | |
| 257 | if err != nil { | |
| 258 | t.Fatal(err) | |
| 259 | } | |
| 260 | if len(list) != 1 || len(list[0].Approvals) != 1 || list[0].Approvals[0].Principal != f.ownerID { | |
| 261 | t.Fatalf("approval was not written for the session account: %+v", list) | |
| 262 | } | |
| 263 | } | |
| 264 | ||
|
e050e14a
Keenan Nemetz
13d ago
|
265 | // A followed branch, or the request's base, can move between rendering the approval |
| 266 | // form and submitting it. The form names the target it showed, so the server refuses | |
| 267 | // a form for a base or head that is no longer current instead of approving the new | |
| 268 | // target unseen. | |
| 269 | func TestWebApprovalRefusesAStaleTarget(t *testing.T) { | |
| 270 | t.Parallel() | |
| 271 | f := browserFixture(t) | |
| 272 | f.addWriter(t) | |
| 273 | m := mustRepo(t, f) | |
| 274 | head := strings.Repeat("a", 40) | |
| 275 | stale := strings.Repeat("b", 40) | |
| 276 | req, err := metadata.NewRequest("A change", "please review", "wilbur", "main", head, time.Now().UTC()) | |
| 277 | if err != nil { | |
| 278 | t.Fatal(err) | |
| 279 | } | |
| 280 | if err := metadata.CreateRequest(m.Git(), req, "wilbur"); err != nil { | |
| 281 | t.Fatal(err) | |
| 282 | } | |
| 283 | ||
| 284 | sess, cookie := f.browserSession(t, f.ownerID) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
285 | resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, |
|
e050e14a
Keenan Nemetz
13d ago
|
286 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {stale}}) |
| 287 | if resp.StatusCode != http.StatusBadRequest { | |
| 288 | t.Fatalf("stale approval: status=%d body=%s", resp.StatusCode, body) | |
| 289 | } | |
| 290 | if !strings.Contains(body, "moved from") { | |
| 291 | t.Fatalf("the refusal does not explain the moved head:\n%s", body) | |
| 292 | } | |
| 293 | ||
| 294 | // Retargeting the request has the same effect: the base the form names is no | |
| 295 | // longer the base the request would land on. | |
| 296 | if _, err := metadata.SetRequestBase(m.Git(), req.ID, "other", f.ownerID); err != nil { | |
| 297 | t.Fatal(err) | |
| 298 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
299 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie, |
|
e050e14a
Keenan Nemetz
13d ago
|
300 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) |
| 301 | if resp.StatusCode != http.StatusBadRequest { | |
| 302 | t.Fatalf("stale base approval: status=%d body=%s", resp.StatusCode, body) | |
| 303 | } | |
| 304 | if !strings.Contains(body, "moved base from main to other") { | |
| 305 | t.Fatalf("the refusal does not explain the moved base:\n%s", body) | |
| 306 | } | |
| 307 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | |
| 308 | if err != nil { | |
| 309 | t.Fatal(err) | |
| 310 | } | |
| 311 | if len(list) != 1 || len(list[0].Approvals) != 0 { | |
| 312 | t.Fatalf("a stale form wrote an approval: %+v", list) | |
| 313 | } | |
| 314 | } | |
| 315 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
316 | func TestWebAuthnBeginEndpoints(t *testing.T) { |
| 317 | t.Parallel() | |
| 318 | f := browserFixture(t) | |
| 319 | ||
| 320 | // A credential is enough for BeginLogin; no assertion is completed here. | |
| 321 | id := make([]byte, 16) | |
| 322 | pub := make([]byte, 32) | |
| 323 | for _, b := range [][]byte{id, pub} { | |
| 324 | if _, err := rand.Read(b); err != nil { | |
| 325 | t.Fatal(err) | |
| 326 | } | |
| 327 | } | |
| 328 | if err := f.srv.Identity.AddCredential(f.ownerID, identity.Credential{ID: id, PublicKey: pub, Label: "test"}, "test"); err != nil { | |
| 329 | t.Fatal(err) | |
| 330 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
331 | resp, out := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "keenan"}) |
|
6c84d387
Keenan Nemetz
15d ago
|
332 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { |
| 333 | t.Fatalf("login begin: status=%d body=%v", resp.StatusCode, out) | |
| 334 | } | |
| 335 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
336 | _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour) |
|
6c84d387
Keenan Nemetz
15d ago
|
337 | if err != nil { |
| 338 | t.Fatal(err) | |
| 339 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
340 | resp, out = f.browserJSON(t, "/-/auth/invite/begin", "", "", map[string]string{"token": inv.Token}) |
|
6c84d387
Keenan Nemetz
15d ago
|
341 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { |
| 342 | t.Fatalf("invite begin: status=%d body=%v", resp.StatusCode, out) | |
| 343 | } | |
| 344 | ||
| 345 | // Account registration is state-changing and must demand the CSRF token. | |
| 346 | sess, cookie := f.browserSession(t, f.ownerID) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
347 | resp, _ = f.browserJSON(t, "/-/account/passkeys/begin", cookie, "", map[string]string{"label": "phone"}) |
|
6c84d387
Keenan Nemetz
15d ago
|
348 | if resp.StatusCode != http.StatusForbidden { |
| 349 | t.Fatalf("passkey begin without CSRF status = %d, want 403", resp.StatusCode) | |
| 350 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
351 | resp, out = f.browserJSON(t, "/-/account/passkeys/begin", cookie, sess.CSRF, map[string]string{"label": "phone"}) |
|
6c84d387
Keenan Nemetz
15d ago
|
352 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { |
| 353 | t.Fatalf("passkey begin: status=%d body=%v", resp.StatusCode, out) | |
| 354 | } | |
| 355 | } | |
| 356 | ||
|
474a812d
Keenan Nemetz
15d ago
|
357 | func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { |
|
6c84d387
Keenan Nemetz
15d ago
|
358 | t.Parallel() |
| 359 | f := browserFixture(t) | |
| 360 | ||
| 361 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_contrib")) | |
|
62576fa8
Keenan Nemetz
14d ago
|
362 | contrib, err := f.srv.Identity.Create("contrib", pub, "test") |
|
6c84d387
Keenan Nemetz
15d ago
|
363 | if err != nil { |
| 364 | t.Fatal(err) | |
| 365 | } | |
| 366 | m := mustRepo(t, f) | |
| 367 | a, err := m.ACL() | |
| 368 | if err != nil { | |
| 369 | t.Fatal(err) | |
| 370 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
371 | if err := a.SetEntry(contrib.Name, "contribute"); err != nil { |
|
6c84d387
Keenan Nemetz
15d ago
|
372 | t.Fatal(err) |
| 373 | } | |
| 374 | if err := m.SetACL(a, "test"); err != nil { | |
| 375 | t.Fatal(err) | |
| 376 | } | |
| 377 | ||
| 378 | base := workRepo(t, map[string]string{"readme.md": "base\n"}) | |
| 379 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 380 | t.Fatalf("owner push: %v\n%s", err, errs) | |
| 381 | } | |
| 382 | work := workRepo(t, map[string]string{"readme.md": "contribution\n"}) | |
|
474a812d
Keenan Nemetz
15d ago
|
383 | // The namespace is the account name, chosen at creation and fixed forever. |
| 384 | headRef := "contrib/" + contrib.Name + "/fix" | |
|
6c84d387
Keenan Nemetz
15d ago
|
385 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "main:refs/heads/"+headRef); err != nil { |
| 386 | t.Fatalf("contributor push: %v\n%s", err, errs) | |
| 387 | } | |
| 388 | ||
| 389 | // A different namespace is refused by the pre-receive gate. | |
| 390 | other := workRepo(t, map[string]string{"x": "x\n"}) | |
| 391 | if _, _, err := f.gitWithKey(priv, other, "push", f.sshURL(f.repo), "main:refs/heads/contrib/someone-else/x"); err == nil { | |
| 392 | t.Fatal("contributor pushed another account's namespace") | |
| 393 | } | |
| 394 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
395 | sess, cookie := f.browserSession(t, contrib.Name) |
|
6c84d387
Keenan Nemetz
15d ago
|
396 | // Contributors can discuss without access to shared branches. |
|
ed03b3d1
Keenan Nemetz
2h ago
|
397 | resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{ |
|
6c84d387
Keenan Nemetz
15d ago
|
398 | "csrf": {sess.CSRF}, "title": {"Contributor issue"}, "body": {"from outside"}, |
| 399 | }) | |
| 400 | if resp.StatusCode != http.StatusSeeOther { | |
| 401 | t.Fatalf("contributor issue: status=%d body=%s", resp.StatusCode, body) | |
| 402 | } | |
| 403 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
404 | resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie) |
|
b06184d1
Keenan Nemetz
11d ago
|
405 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, headRef) || !strings.Contains(body, "Open change") { |
|
6c84d387
Keenan Nemetz
15d ago
|
406 | t.Fatalf("request form: status=%d body=%s", resp.StatusCode, body) |
| 407 | } | |
| 408 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
409 | resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ |
|
6c84d387
Keenan Nemetz
15d ago
|
410 | "csrf": {sess.CSRF}, "title": {"Contributor patch"}, "body": {"please review"}, |
| 411 | "base": {"main"}, "head": {headRef}, | |
| 412 | }) | |
| 413 | if resp.StatusCode != http.StatusSeeOther { | |
| 414 | t.Fatalf("contributor request: status=%d body=%s", resp.StatusCode, body) | |
| 415 | } | |
| 416 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | |
| 417 | if err != nil { | |
| 418 | t.Fatal(err) | |
| 419 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
420 | if len(list) != 1 || list[0].Author != "contrib" || list[0].Base != "main" { |
|
6c84d387
Keenan Nemetz
15d ago
|
421 | t.Fatalf("request = %+v", list) |
| 422 | } | |
| 423 | want, _ := m.Git().RevParse("refs/heads/" + headRef) | |
| 424 | if list[0].Head != want { | |
| 425 | t.Fatalf("request head = %s, want %s", list[0].Head, want) | |
| 426 | } | |
| 427 | ||
| 428 | // The handler refuses a head outside the contributor namespace even if the | |
| 429 | // form is edited by hand. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
430 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ |
|
6c84d387
Keenan Nemetz
15d ago
|
431 | "csrf": {sess.CSRF}, "title": {"Wrong branch"}, "base": {"main"}, "head": {"main"}, |
| 432 | }) | |
| 433 | if resp.StatusCode != http.StatusForbidden { | |
| 434 | t.Fatalf("contributor opened a request from main: status=%d", resp.StatusCode) | |
| 435 | } | |
| 436 | } | |
| 437 | ||
|
41d1fee9
Keenan Nemetz
13d ago
|
438 | // TestContributorRepointsRequestTarget: a request opened from a branch follows it, |
| 439 | // so a fixup push moves the head without the contributor writing metadata. The | |
| 440 | // browser's single target control changes the base and head together, under the | |
|
e050e14a
Keenan Nemetz
13d ago
|
441 | // same namespace rule the create form uses, and only while the request is open. |
|
41d1fee9
Keenan Nemetz
13d ago
|
442 | func TestContributorRepointsRequestTarget(t *testing.T) { |
|
53ff967b
Keenan Nemetz
13d ago
|
443 | t.Parallel() |
| 444 | f := browserFixture(t) | |
| 445 | ||
| 446 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_contrib_head")) | |
| 447 | contrib, err := f.srv.Identity.Create("contrib", pub, "test") | |
| 448 | if err != nil { | |
| 449 | t.Fatal(err) | |
| 450 | } | |
| 451 | m := mustRepo(t, f) | |
| 452 | a, err := m.ACL() | |
| 453 | if err != nil { | |
| 454 | t.Fatal(err) | |
| 455 | } | |
| 456 | if err := a.SetEntry(contrib.Name, "contribute"); err != nil { | |
| 457 | t.Fatal(err) | |
| 458 | } | |
| 459 | if err := m.SetACL(a, "test"); err != nil { | |
| 460 | t.Fatal(err) | |
| 461 | } | |
| 462 | ||
| 463 | base := workRepo(t, map[string]string{"readme.md": "base\n"}) | |
| 464 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 465 | t.Fatalf("owner push: %v\n%s", err, errs) | |
| 466 | } | |
| 467 | work := workRepo(t, map[string]string{"readme.md": "contribution\n"}) | |
| 468 | headRef := "contrib/" + contrib.Name + "/fix" | |
| 469 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "main:refs/heads/"+headRef); err != nil { | |
| 470 | t.Fatalf("contributor push: %v\n%s", err, errs) | |
| 471 | } | |
| 472 | ||
| 473 | sess, cookie := f.browserSession(t, contrib.Name) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
474 | resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{ |
|
53ff967b
Keenan Nemetz
13d ago
|
475 | "csrf": {sess.CSRF}, "title": {"Contributor patch"}, "base": {"main"}, "head": {headRef}, |
| 476 | }) | |
| 477 | if resp.StatusCode != http.StatusSeeOther { | |
| 478 | t.Fatalf("open: status=%d body=%s", resp.StatusCode, body) | |
| 479 | } | |
| 480 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | |
| 481 | if err != nil || len(list) != 1 { | |
| 482 | t.Fatalf("requests = %v, %v", list, err) | |
| 483 | } | |
| 484 | id := list[0].ID | |
| 485 | first := list[0].Head | |
| 486 | ||
|
e050e14a
Keenan Nemetz
13d ago
|
487 | // A fixup lands on the contributor's branch; the request follows it, without a |
| 488 | // metadata write from the contributor (who could not push one anyway). | |
|
53ff967b
Keenan Nemetz
13d ago
|
489 | if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("contribution v2\n"), 0o644); err != nil { |
| 490 | t.Fatal(err) | |
| 491 | } | |
| 492 | if _, errs, err := runGit(t, work, "commit", "-qam", "fixup"); err != nil { | |
| 493 | t.Fatalf("commit: %s", errs) | |
| 494 | } | |
| 495 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/"+headRef); err != nil { | |
| 496 | t.Fatalf("contributor push 2: %v\n%s", err, errs) | |
| 497 | } | |
| 498 | second, err := m.Git().RevParse("refs/heads/" + headRef) | |
| 499 | if err != nil || second == first { | |
| 500 | t.Fatalf("second tip = %q, %v (first %s)", second, err, first) | |
| 501 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
502 | followed, err := metadata.GetRequest(m.Git(), id) |
| 503 | if err != nil { | |
| 504 | t.Fatal(err) | |
| 505 | } | |
| 506 | if followed.Head != second { | |
| 507 | t.Fatalf("request head = %s, want the pushed tip %s", followed.Head, second) | |
| 508 | } | |
|
53ff967b
Keenan Nemetz
13d ago
|
509 | |
| 510 | // The control is offered, with the contributor's own branch as a choice. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
511 | resp, body = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie) |
|
53ff967b
Keenan Nemetz
13d ago
|
512 | if resp.StatusCode != http.StatusOK { |
| 513 | t.Fatalf("request page: status=%d body=%s", resp.StatusCode, body) | |
| 514 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
515 | if !strings.Contains(body, `action="/`+f.repo+`/changes/`+id+`/target"`) || !strings.Contains(body, headRef) { |
|
41d1fee9
Keenan Nemetz
13d ago
|
516 | t.Fatalf("the request page does not offer the target control:\n%s", body) |
|
6db4cdbd
Keenan Nemetz
11d ago
|
517 | } |
|
59c0625b
Keenan Nemetz
11d ago
|
518 | // The Review header carries the head commit under review. |
| 519 | if !strings.Contains(body, `title="commit under review"`) { | |
| 520 | t.Fatalf("the Review header does not show the head commit:\n%s", body) | |
|
53ff967b
Keenan Nemetz
13d ago
|
521 | } |
| 522 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
523 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ |
|
41d1fee9
Keenan Nemetz
13d ago
|
524 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef}, |
|
53ff967b
Keenan Nemetz
13d ago
|
525 | }) |
| 526 | if resp.StatusCode != http.StatusSeeOther { | |
|
41d1fee9
Keenan Nemetz
13d ago
|
527 | t.Fatalf("repoint target: status=%d body=%s", resp.StatusCode, body) |
|
53ff967b
Keenan Nemetz
13d ago
|
528 | } |
| 529 | got, err := metadata.GetRequest(m.Git(), id) | |
| 530 | if err != nil { | |
| 531 | t.Fatal(err) | |
| 532 | } | |
|
e050e14a
Keenan Nemetz
13d ago
|
533 | if got.Head != second || got.HeadRef != headRef { |
| 534 | t.Fatalf("head = %s on %q, want the branch tip %s on %s", got.Head, got.HeadRef, second, headRef) | |
|
53ff967b
Keenan Nemetz
13d ago
|
535 | } |
| 536 | ||
| 537 | // The namespace rule is the one the create form applies: a contributor cannot | |
| 538 | // point their request at a branch they could not have pushed. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
539 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ |
|
41d1fee9
Keenan Nemetz
13d ago
|
540 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {"main"}, |
|
53ff967b
Keenan Nemetz
13d ago
|
541 | }) |
| 542 | if resp.StatusCode != http.StatusForbidden { | |
| 543 | t.Fatalf("contributor moved the head to main: status=%d", resp.StatusCode) | |
| 544 | } | |
| 545 | if after, _ := metadata.GetRequest(m.Git(), id); after.Head != second { | |
| 546 | t.Fatalf("head moved to %s despite the refusal", after.Head) | |
| 547 | } | |
| 548 | ||
| 549 | // A maintainer may move any open request, including a contributor's. | |
| 550 | ownerSess, ownerCookie := f.browserSession(t, f.ownerID) | |
| 551 | mainTip, _ := m.Git().RevParse("refs/heads/main") | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
552 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", ownerCookie, url.Values{ |
|
41d1fee9
Keenan Nemetz
13d ago
|
553 | "csrf": {ownerSess.CSRF}, "base": {"main"}, "head": {"main"}, |
|
53ff967b
Keenan Nemetz
13d ago
|
554 | }) |
| 555 | if resp.StatusCode != http.StatusSeeOther { | |
|
41d1fee9
Keenan Nemetz
13d ago
|
556 | t.Fatalf("maintainer repoint: status=%d body=%s", resp.StatusCode, body) |
|
53ff967b
Keenan Nemetz
13d ago
|
557 | } |
|
e050e14a
Keenan Nemetz
13d ago
|
558 | if got, _ := metadata.GetRequest(m.Git(), id); got.Head != mainTip || got.HeadRef != "main" { |
| 559 | t.Fatalf("head = %s on %q, want the maintainer's choice %s on main", got.Head, got.HeadRef, mainTip) | |
|
53ff967b
Keenan Nemetz
13d ago
|
560 | } |
| 561 | ||
|
e050e14a
Keenan Nemetz
13d ago
|
562 | // A stacked request can be retargeted at the branch it should land on now. The |
|
41d1fee9
Keenan Nemetz
13d ago
|
563 | // same control carries the base change, and the change moves the diff and starts |
|
e050e14a
Keenan Nemetz
13d ago
|
564 | // approvals over. |
| 565 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/parent"); err != nil { | |
| 566 | t.Fatalf("parent push: %v\n%s", err, errs) | |
| 567 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
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"`) { | |
|
41d1fee9
Keenan Nemetz
13d ago
|
570 | t.Fatalf("the request page does not offer the target control:\n%s", body) |
|
e050e14a
Keenan Nemetz
13d ago
|
571 | } |
|
ed03b3d1
Keenan Nemetz
2h ago
|
572 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ |
|
41d1fee9
Keenan Nemetz
13d ago
|
573 | "csrf": {sess.CSRF}, "base": {"parent"}, "head": {"main"}, |
|
e050e14a
Keenan Nemetz
13d ago
|
574 | }) |
| 575 | if resp.StatusCode != http.StatusSeeOther { | |
|
41d1fee9
Keenan Nemetz
13d ago
|
576 | t.Fatalf("retarget: status=%d body=%s", resp.StatusCode, body) |
|
e050e14a
Keenan Nemetz
13d ago
|
577 | } |
| 578 | if got, _ := metadata.GetRequest(m.Git(), id); got.Base != "parent" { | |
| 579 | t.Fatalf("base = %s, want parent", got.Base) | |
| 580 | } | |
| 581 | ||
| 582 | // Closed requests are settled: the head and base are history, not settings. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
583 | resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{ |
|
53ff967b
Keenan Nemetz
13d ago
|
584 | "csrf": {sess.CSRF}, "state": {"closed"}, |
| 585 | }) | |
| 586 | if resp.StatusCode != http.StatusSeeOther { | |
| 587 | t.Fatalf("close: status=%d body=%s", resp.StatusCode, body) | |
| 588 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
589 | resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{ |
|
41d1fee9
Keenan Nemetz
13d ago
|
590 | "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef}, |
|
53ff967b
Keenan Nemetz
13d ago
|
591 | }) |
| 592 | if resp.StatusCode != http.StatusForbidden { | |
|
41d1fee9
Keenan Nemetz
13d ago
|
593 | t.Fatalf("a closed request changed its target: status=%d", resp.StatusCode) |
|
e050e14a
Keenan Nemetz
13d ago
|
594 | } |
|
53ff967b
Keenan Nemetz
13d ago
|
595 | } |
| 596 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
597 | func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) { |
| 598 | t.Parallel() | |
| 599 | f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 }) | |
| 600 | ||
| 601 | pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_ci_contrib")) | |
|
62576fa8
Keenan Nemetz
14d ago
|
602 | contrib, err := f.srv.Identity.Create("ci-contrib", pub, "test") |
|
6c84d387
Keenan Nemetz
15d ago
|
603 | if err != nil { |
| 604 | t.Fatal(err) | |
| 605 | } | |
| 606 | m := mustRepo(t, f) | |
| 607 | a, err := m.ACL() | |
| 608 | if err != nil { | |
| 609 | t.Fatal(err) | |
| 610 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
611 | if err := a.SetEntry(contrib.Name, "contribute"); err != nil { |
|
6c84d387
Keenan Nemetz
15d ago
|
612 | t.Fatal(err) |
| 613 | } | |
| 614 | if err := m.SetACL(a, "test"); err != nil { | |
| 615 | t.Fatal(err) | |
| 616 | } | |
| 617 | ||
|
564acd4b
Keenan Nemetz
15d ago
|
618 | base := workRepo(t, map[string]string{"readme.md": "base\n"}) |
| 619 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil { | |
| 620 | t.Fatalf("owner base push: %v\n%s", err, errs) | |
| 621 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
622 | work := workRepo(t, workflowFile(`version: 1 |
| 623 | jobs: | |
| 624 | test: | |
|
70b70264
Keenan Nemetz
15d ago
|
625 | image: alpine:3.22 |
|
6c84d387
Keenan Nemetz
15d ago
|
626 | steps: |
| 627 | - name: say hello | |
| 628 | run: echo "held until allowed" | |
| 629 | `)) | |
| 630 | head, _, err := runGit(t, work, "rev-parse", "HEAD") | |
| 631 | if err != nil { | |
| 632 | t.Fatal(err) | |
| 633 | } | |
| 634 | head = strings.TrimSpace(head) | |
|
474a812d
Keenan Nemetz
15d ago
|
635 | ref := "contrib/" + contrib.Name + "/ci" |
|
6c84d387
Keenan Nemetz
15d ago
|
636 | if _, errs, err := f.gitWithKey(priv, work, "push", f.sshURL(f.repo), "HEAD:refs/heads/"+ref); err != nil { |
| 637 | t.Fatalf("contributor push: %v\n%s", err, errs) | |
| 638 | } | |
| 639 | ||
| 640 | // The spool ticker is one second; two seconds is enough to see that ingestion | |
| 641 | // deliberately skipped the tip rather than merely not having reached it. | |
| 642 | deadline := time.Now().Add(2 * time.Second) | |
| 643 | for time.Now().Before(deadline) { | |
| 644 | if list, err := f.srv.Runs.Recent(f.repo, 20); err == nil { | |
| 645 | for _, r := range list { | |
| 646 | if r.SHA == head { | |
| 647 | t.Fatalf("CI ran for an unapproved contributor tip: %+v", r) | |
| 648 | } | |
| 649 | } | |
| 650 | } | |
| 651 | time.Sleep(100 * time.Millisecond) | |
| 652 | } | |
| 653 | ||
|
62576fa8
Keenan Nemetz
14d ago
|
654 | contribSess, contribCookie := f.browserSession(t, contrib.Name) |
|
ed03b3d1
Keenan Nemetz
2h ago
|
655 | resp, body := f.browserPost(t, "/"+f.repo+"/changes", contribCookie, url.Values{ |
|
564acd4b
Keenan Nemetz
15d ago
|
656 | "csrf": {contribSess.CSRF}, "title": {"CI gate"}, "body": {"please review"}, |
| 657 | "base": {"main"}, "head": {ref}, | |
| 658 | }) | |
| 659 | if resp.StatusCode != http.StatusSeeOther { | |
| 660 | t.Fatalf("contributor request: status=%d body=%s", resp.StatusCode, body) | |
| 661 | } | |
| 662 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | |
| 663 | if err != nil || len(list) != 1 { | |
| 664 | t.Fatalf("requests = %v, err = %v", list, err) | |
| 665 | } | |
| 666 | requestID := list[0].ID | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
667 | requestURL := "/" + f.repo + "/changes/" + requestID |
|
564acd4b
Keenan Nemetz
15d ago
|
668 | |
| 669 | // The Runs tab is history only; the approval belongs with the code under review. | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
670 | resp, body = f.browserGet(t, "/"+f.repo+"/runs", contribCookie) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
671 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "waiting for a maintainer") || strings.Contains(body, "Allow CI</button>") { |
|
564acd4b
Keenan Nemetz
15d ago
|
672 | t.Fatalf("runs tab still owns CI approval: status=%d body=%s", resp.StatusCode, body) |
| 673 | } | |
| 674 | resp, body = f.browserGet(t, requestURL, contribCookie) | |
|
6c84d387
Keenan Nemetz
15d ago
|
675 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "waiting for a maintainer") { |
|
564acd4b
Keenan Nemetz
15d ago
|
676 | t.Fatalf("contributor request CI status: status=%d body=%s", resp.StatusCode, body) |
| 677 | } | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
678 | if strings.Contains(body, "Allow CI</button>") { |
|
564acd4b
Keenan Nemetz
15d ago
|
679 | t.Fatalf("contributor saw the Allow CI control: body=%s", body) |
|
6c84d387
Keenan Nemetz
15d ago
|
680 | } |
|
ed03b3d1
Keenan Nemetz
2h ago
|
681 | resp, _ = f.browserPost(t, "/"+f.repo+"/ci/allow", contribCookie, url.Values{ |
|
564acd4b
Keenan Nemetz
15d ago
|
682 | "csrf": {contribSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID}, |
|
6c84d387
Keenan Nemetz
15d ago
|
683 | }) |
| 684 | if resp.StatusCode != http.StatusForbidden { | |
| 685 | t.Fatalf("contributor allowed their own CI: status=%d", resp.StatusCode) | |
| 686 | } | |
| 687 | ||
| 688 | ownerSess, ownerCookie := f.browserSession(t, f.ownerID) | |
|
564acd4b
Keenan Nemetz
15d ago
|
689 | resp, body = f.browserGet(t, requestURL, ownerCookie) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
690 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Allow CI</button>") || |
| 691 | !strings.Contains(body, `class="warn"`) || !strings.Contains(body, "Approve</button>") || | |
| 692 | !strings.Contains(body, `class="success"`) || !strings.Contains(body, "View runs") { | |
| 693 | t.Fatalf("maintainer actions missing from request: status=%d body=%s", resp.StatusCode, body) | |
|
564acd4b
Keenan Nemetz
15d ago
|
694 | } |
|
ed03b3d1
Keenan Nemetz
2h ago
|
695 | resp, runsPage := f.browserGet(t, "/"+f.repo+"/runs", ownerCookie) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
696 | if resp.StatusCode != http.StatusOK || strings.Contains(runsPage, "Allow CI</button>") || strings.Contains(runsPage, "Awaiting CI approval") { |
|
564acd4b
Keenan Nemetz
15d ago
|
697 | t.Fatalf("runs tab should not show contributor CI approval: status=%d body=%s", resp.StatusCode, runsPage) |
|
6c84d387
Keenan Nemetz
15d ago
|
698 | } |
|
ed03b3d1
Keenan Nemetz
2h ago
|
699 | resp, codePage := f.browserGet(t, "/"+f.repo+"/code", ownerCookie) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
700 | if resp.StatusCode != http.StatusOK || strings.Contains(codePage, "Allow CI</button>") { |
|
a464f892
Keenan Nemetz
15d ago
|
701 | t.Fatalf("contributor CI control should not be in the code browser: status=%d body=%s", resp.StatusCode, codePage) |
| 702 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
703 | resp, body = f.browserPost(t, "/"+f.repo+"/ci/allow", ownerCookie, url.Values{ |
|
564acd4b
Keenan Nemetz
15d ago
|
704 | "csrf": {ownerSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID}, |
|
6c84d387
Keenan Nemetz
15d ago
|
705 | }) |
| 706 | if resp.StatusCode != http.StatusSeeOther { | |
| 707 | t.Fatalf("maintainer allow: status=%d body=%s", resp.StatusCode, body) | |
| 708 | } | |
|
564acd4b
Keenan Nemetz
15d ago
|
709 | if loc := resp.Header.Get("Location"); loc != requestURL { |
| 710 | t.Fatalf("allow redirect = %q, want %q", loc, requestURL) | |
| 711 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
712 | run := f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.SHA == head }) |
| 713 | if run.Ref != "refs/heads/"+ref || run.Status != runs.StatusQueued { | |
| 714 | t.Fatalf("queued run = %+v", run) | |
| 715 | } | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
716 | |
| 717 | resp, body = f.browserGet(t, requestURL+"/runs", ownerCookie) | |
| 718 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "/runs/"+run.ID) || | |
| 719 | !strings.Contains(body, "queued") || !strings.Contains(body, "Workflow results") || | |
| 720 | !strings.Contains(body, "View runs") || !strings.Contains(body, "</html>") { | |
| 721 | t.Fatalf("request runs tab: status=%d body=%s", resp.StatusCode, body) | |
| 722 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
723 | } |
|
f920a9f1
Keenan Nemetz
15d ago
|
724 | |
| 725 | func TestOpenRegistrationPagesAndBegin(t *testing.T) { | |
| 726 | t.Parallel() | |
| 727 | f := newFixtureWith(t, func(c *config.Config) { | |
| 728 | c.PublicURL = "https://forge.test" | |
| 729 | c.OpenRegistration = true | |
| 730 | }) | |
| 731 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
732 | resp, body := f.browserGet(t, "/-/login", "") |
|
f920a9f1
Keenan Nemetz
15d ago
|
733 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create one") { |
| 734 | t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body) | |
| 735 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
736 | resp, body = f.browserGet(t, "/-/register", "") |
|
f920a9f1
Keenan Nemetz
15d ago
|
737 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create account and passkey") { |
| 738 | t.Fatalf("register page: status=%d body=%s", resp.StatusCode, body) | |
| 739 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
740 | resp, out := f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"}) |
|
f920a9f1
Keenan Nemetz
15d ago
|
741 | if resp.StatusCode != http.StatusOK || out["publicKey"] == nil { |
| 742 | t.Fatalf("register begin: status=%d body=%v", resp.StatusCode, out) | |
| 743 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
744 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "keenan"}) |
|
f920a9f1
Keenan Nemetz
15d ago
|
745 | if resp.StatusCode != http.StatusConflict { |
| 746 | t.Fatalf("taken name status = %d, want 409", resp.StatusCode) | |
| 747 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
748 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "Not A Name"}) |
|
f920a9f1
Keenan Nemetz
15d ago
|
749 | if resp.StatusCode != http.StatusBadRequest { |
| 750 | t.Fatalf("invalid name status = %d, want 400", resp.StatusCode) | |
| 751 | } | |
| 752 | ||
| 753 | // An account is not created until the authenticator completes the ceremony. | |
| 754 | if _, err := f.srv.Identity.Find("newbie"); err == nil { | |
| 755 | t.Fatal("registration begin created an account before a credential existed") | |
| 756 | } | |
| 757 | } | |
| 758 | ||
| 759 | func TestOpenRegistrationIsOffByDefault(t *testing.T) { | |
| 760 | t.Parallel() | |
| 761 | f := browserFixture(t) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
762 | resp, body := f.browserGet(t, "/-/login", "") |
|
f920a9f1
Keenan Nemetz
15d ago
|
763 | if resp.StatusCode != http.StatusOK || strings.Contains(body, "Create one") { |
| 764 | t.Fatalf("login page offered registration while disabled: status=%d body=%s", resp.StatusCode, body) | |
| 765 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
766 | resp, _ = f.browserGet(t, "/-/register", "") |
|
f920a9f1
Keenan Nemetz
15d ago
|
767 | if resp.StatusCode != http.StatusNotFound { |
| 768 | t.Fatalf("register page status = %d, want 404", resp.StatusCode) | |
| 769 | } | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
770 | resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"}) |
|
f920a9f1
Keenan Nemetz
15d ago
|
771 | if resp.StatusCode != http.StatusServiceUnavailable { |
| 772 | t.Fatalf("register begin status = %d, want 503", resp.StatusCode) | |
| 773 | } | |
| 774 | } | |
| 775 | ||
| 776 | func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) { | |
| 777 | t.Parallel() | |
| 778 | f := browserFixture(t) | |
| 779 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 780 | pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_self_service")) | |
| 781 | k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub)) | |
| 782 | if err != nil { | |
| 783 | t.Fatal(err) | |
| 784 | } | |
| 785 | fp := ssh.FingerprintSHA256(k) | |
| 786 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
787 | resp, body := f.browserPost(t, "/-/account/keys", cookie, url.Values{ |
|
f920a9f1
Keenan Nemetz
15d ago
|
788 | "csrf": {sess.CSRF}, "key": {pub}, |
| 789 | }) | |
| 790 | if resp.StatusCode != http.StatusSeeOther { | |
| 791 | t.Fatalf("add key: status=%d body=%s", resp.StatusCode, body) | |
| 792 | } | |
| 793 | a, err := f.srv.Identity.Find(f.ownerID) | |
| 794 | if err != nil { | |
| 795 | t.Fatal(err) | |
| 796 | } | |
| 797 | found := false | |
| 798 | for _, key := range a.Keys { | |
| 799 | if key.Fingerprint == fp { | |
| 800 | found = true | |
| 801 | } | |
| 802 | } | |
| 803 | if !found { | |
| 804 | t.Fatalf("added key %s is not on the account: %+v", fp, a.Keys) | |
| 805 | } | |
| 806 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
807 | resp, body = f.browserPost(t, "/-/account/keys/remove", cookie, url.Values{ |
|
f920a9f1
Keenan Nemetz
15d ago
|
808 | "csrf": {sess.CSRF}, "fingerprint": {fp}, |
| 809 | }) | |
| 810 | if resp.StatusCode != http.StatusSeeOther { | |
| 811 | t.Fatalf("remove key: status=%d body=%s", resp.StatusCode, body) | |
| 812 | } | |
| 813 | a, _ = f.srv.Identity.Find(f.ownerID) | |
| 814 | for _, key := range a.Keys { | |
| 815 | if key.Fingerprint == fp { | |
| 816 | t.Fatal("removed key is still on the account") | |
| 817 | } | |
| 818 | } | |
| 819 | } | |
|
474a812d
Keenan Nemetz
15d ago
|
820 | |
|
62576fa8
Keenan Nemetz
14d ago
|
821 | // TestDisplayNameRouteIsGone: an account is only its registered name, so there |
| 822 | // is no second label to change. | |
| 823 | func TestDisplayNameRouteIsGone(t *testing.T) { | |
|
474a812d
Keenan Nemetz
15d ago
|
824 | t.Parallel() |
| 825 | f := browserFixture(t) | |
|
62576fa8
Keenan Nemetz
14d ago
|
826 | _, cookie := f.browserSession(t, f.ownerID) |
|
ed03b3d1
Keenan Nemetz
2h ago
|
827 | resp, _ := f.browserPost(t, "/-/account/display", cookie, url.Values{"display": {"Keenan O'Brien"}}) |
|
62576fa8
Keenan Nemetz
14d ago
|
828 | if resp.StatusCode != http.StatusNotFound { |
| 829 | t.Fatalf("display route status = %d, want 404", resp.StatusCode) | |
|
474a812d
Keenan Nemetz
15d ago
|
830 | } |
| 831 | } | |
|
edf9db6e
Keenan Nemetz
12d ago
|
832 | |
| 833 | // The body of a sign-out is bounded before it is parsed. A request that carries | |
| 834 | // more than a CSRF field is refused rather than read. | |
| 835 | func TestLogoutRefusesAnOversizedBody(t *testing.T) { | |
| 836 | t.Parallel() | |
| 837 | f := browserFixture(t) | |
| 838 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 839 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
840 | req, err := http.NewRequest("POST", f.http.URL+"/-/logout", |
|
edf9db6e
Keenan Nemetz
12d ago
|
841 | strings.NewReader("csrf="+sess.CSRF+"&padding="+strings.Repeat("a", 128<<10))) |
| 842 | if err != nil { | |
| 843 | t.Fatal(err) | |
| 844 | } | |
| 845 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 846 | req.Header.Set("Cookie", cookie) | |
| 847 | resp, err := noRedirectAuth.Do(req) | |
| 848 | if err != nil { | |
| 849 | t.Fatal(err) | |
| 850 | } | |
| 851 | resp.Body.Close() | |
| 852 | if resp.StatusCode != http.StatusBadRequest { | |
| 853 | t.Fatalf("oversized logout body = %d, want 400", resp.StatusCode) | |
| 854 | } | |
| 855 | if _, err := f.srv.Sessions.Lookup(sess.Token); err != nil { | |
| 856 | t.Fatal("a refused logout revoked the session") | |
| 857 | } | |
| 858 | } | |
| 859 | ||
| 860 | // The begin endpoint answers with the same shape whether or not the account | |
| 861 | // exists, and stops answering at all once an address has spent its budget. | |
| 862 | func TestLoginBeginIsRateLimited(t *testing.T) { | |
| 863 | t.Parallel() | |
| 864 | f := browserFixture(t) | |
| 865 | ||
| 866 | var sawOK, sawLimited bool | |
| 867 | for i := 0; i < 100; i++ { | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
868 | resp, _ := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "nobody"}) |
|
edf9db6e
Keenan Nemetz
12d ago
|
869 | switch resp.StatusCode { |
| 870 | case http.StatusOK: | |
| 871 | if sawLimited { | |
| 872 | t.Fatal("a limited address was served again without waiting") | |
| 873 | } | |
| 874 | sawOK = true | |
| 875 | case http.StatusTooManyRequests: | |
| 876 | sawLimited = true | |
| 877 | if resp.Header.Get("Retry-After") == "" { | |
| 878 | t.Fatal("the rate limit response has no Retry-After") | |
| 879 | } | |
| 880 | default: | |
| 881 | t.Fatalf("status = %d", resp.StatusCode) | |
| 882 | } | |
| 883 | if sawLimited { | |
| 884 | break | |
| 885 | } | |
| 886 | } | |
| 887 | if !sawOK || !sawLimited { | |
| 888 | t.Fatalf("ok=%v limited=%v; the login endpoint is not rate limited", sawOK, sawLimited) | |
| 889 | } | |
| 890 | } | |
| 891 | ||
| 892 | // An account page carries the session and the passkeys on it; neither is | |
| 893 | // something a cache or a referrer should keep. | |
| 894 | func TestAccountPageIsNotCached(t *testing.T) { | |
| 895 | t.Parallel() | |
| 896 | f := browserFixture(t) | |
| 897 | _, cookie := f.browserSession(t, f.ownerID) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
898 | resp, _ := f.browserGet(t, "/-/account", cookie) |
|
edf9db6e
Keenan Nemetz
12d ago
|
899 | if got := resp.Header.Get("Cache-Control"); got != "no-store" { |
| 900 | t.Fatalf("account page Cache-Control = %q", got) | |
| 901 | } | |
| 902 | if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" { | |
| 903 | t.Fatalf("account page Referrer-Policy = %q", got) | |
| 904 | } | |
| 905 | } | |
|
701232cf
Keenan Nemetz
12d ago
|
906 | |
| 907 | // A cross-origin form post is refused even with a valid CSRF token: the token is | |
| 908 | // the first lock, and the browser's own statement about where the request came | |
| 909 | // from is the second. A same-origin post is unaffected. | |
| 910 | func TestCrossOriginPostIsRefused(t *testing.T) { | |
| 911 | t.Parallel() | |
| 912 | f := browserFixture(t) | |
| 913 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 914 | ||
| 915 | post := func(origin string) *http.Response { | |
| 916 | body := strings.NewReader(url.Values{"csrf": {sess.CSRF}}.Encode()) | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
917 | req, err := http.NewRequest("POST", f.http.URL+"/-/logout", body) |
|
701232cf
Keenan Nemetz
12d ago
|
918 | if err != nil { |
| 919 | t.Fatal(err) | |
| 920 | } | |
| 921 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 922 | req.Header.Set("Cookie", cookie) | |
| 923 | req.Header.Set("Origin", origin) | |
| 924 | resp, err := noRedirectAuth.Do(req) | |
| 925 | if err != nil { | |
| 926 | t.Fatal(err) | |
| 927 | } | |
| 928 | resp.Body.Close() | |
| 929 | return resp | |
| 930 | } | |
| 931 | ||
| 932 | if resp := post("https://evil.example"); resp.StatusCode != http.StatusForbidden { | |
| 933 | t.Fatalf("cross-origin logout = %d, want 403", resp.StatusCode) | |
| 934 | } | |
| 935 | if _, err := f.srv.Sessions.Lookup(sess.Token); err != nil { | |
| 936 | t.Fatal("a refused cross-origin request revoked the session") | |
| 937 | } | |
| 938 | // public_url is the instance's origin, which is what the browser says. | |
| 939 | if resp := post("https://forge.test"); resp.StatusCode != http.StatusSeeOther { | |
| 940 | t.Fatalf("same-origin logout = %d, want 303", resp.StatusCode) | |
| 941 | } | |
| 942 | } | |
|
7d7718fa
Keenan Nemetz
45m ago
|
943 | |
| 944 | // A passkey can be relabelled and removed from the account page. The writes are the | |
| 945 | // person's own, scoped by the session account, and a removal takes its sessions with | |
| 946 | // it. | |
| 947 | func TestAccountRenamesAndRemovesItsOwnPasskeys(t *testing.T) { | |
| 948 | t.Parallel() | |
| 949 | f := browserFixture(t) | |
| 950 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 951 | laptop := identity.Credential{ID: []byte("passkey-laptop"), PublicKey: []byte("key-laptop"), Label: "laptop"} | |
| 952 | phone := identity.Credential{ID: []byte("passkey-phone"), PublicKey: []byte("key-phone"), Label: "phone"} | |
| 953 | for _, cred := range []identity.Credential{laptop, phone} { | |
| 954 | if err := f.srv.Identity.AddCredential(f.ownerID, cred, "test"); err != nil { | |
| 955 | t.Fatal(err) | |
| 956 | } | |
| 957 | } | |
| 958 | ||
| 959 | resp, body := f.browserPost(t, "/-/account/passkeys/rename", cookie, url.Values{ | |
| 960 | "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(laptop.ID)}, "label": {"desk key"}, | |
| 961 | }) | |
| 962 | if resp.StatusCode != http.StatusSeeOther { | |
| 963 | t.Fatalf("rename: status=%d body=%s", resp.StatusCode, body) | |
| 964 | } | |
| 965 | creds, err := f.srv.Identity.Credentials(f.ownerID) | |
| 966 | if err != nil { | |
| 967 | t.Fatal(err) | |
| 968 | } | |
| 969 | labels := map[string]string{} | |
| 970 | for _, cred := range creds { | |
| 971 | labels[identity.CredentialIDString(cred.ID)] = cred.Label | |
| 972 | } | |
| 973 | if got := labels[identity.CredentialIDString(laptop.ID)]; got != "desk key" { | |
| 974 | t.Fatalf("label = %q, want desk key", got) | |
| 975 | } | |
| 976 | if got := labels[identity.CredentialIDString(phone.ID)]; got != "phone" { | |
| 977 | t.Fatalf("the other passkey's label changed to %q", got) | |
| 978 | } | |
| 979 | ||
| 980 | // One of several goes, and the account keeps the way in it still has. | |
| 981 | resp, body = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{ | |
| 982 | "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(laptop.ID)}, | |
| 983 | }) | |
| 984 | if resp.StatusCode != http.StatusSeeOther { | |
| 985 | t.Fatalf("remove: status=%d body=%s", resp.StatusCode, body) | |
| 986 | } | |
| 987 | if creds, _ = f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 { | |
| 988 | t.Fatalf("after the removal the account has %d passkey(s), want 1", len(creds)) | |
| 989 | } | |
| 990 | ||
| 991 | // The forms are writes, so they carry the token like every other one. | |
| 992 | resp, _ = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{ | |
| 993 | "csrf": {"not-the-token"}, "credential": {identity.CredentialIDString(phone.ID)}, | |
| 994 | }) | |
| 995 | if resp.StatusCode != http.StatusForbidden { | |
| 996 | t.Fatalf("a post without the token: status=%d, want 403", resp.StatusCode) | |
| 997 | } | |
| 998 | if creds, _ = f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 { | |
| 999 | t.Fatal("a post without the token removed a passkey") | |
| 1000 | } | |
| 1001 | } | |
| 1002 | ||
| 1003 | // The last passkey is the last way into the account. With an SSH key the account can | |
| 1004 | // still push, so the removal is allowed; with nothing else it is refused rather than | |
| 1005 | // locking somebody out of a page they are reading. | |
| 1006 | func TestTheLastPasskeyIsOnlyRemovedWhenSomethingElseGetsIn(t *testing.T) { | |
| 1007 | t.Parallel() | |
| 1008 | f := browserFixture(t) | |
| 1009 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 1010 | only := identity.Credential{ID: []byte("passkey-only"), PublicKey: []byte("key-only"), Label: "only"} | |
| 1011 | if err := f.srv.Identity.AddCredential(f.ownerID, only, "test"); err != nil { | |
| 1012 | t.Fatal(err) | |
| 1013 | } | |
| 1014 | // Model an account whose only credential is the passkey: the fixture's owner was | |
| 1015 | // created with an SSH key. | |
| 1016 | a, err := f.srv.Identity.Find(f.ownerID) | |
| 1017 | if err != nil { | |
| 1018 | t.Fatal(err) | |
| 1019 | } | |
| 1020 | for _, key := range a.Keys { | |
| 1021 | if err := f.srv.Identity.RemoveKey(f.ownerID, key.Fingerprint, "test"); err != nil { | |
| 1022 | t.Fatal(err) | |
| 1023 | } | |
| 1024 | } | |
| 1025 | ||
| 1026 | resp, body := f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{ | |
| 1027 | "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(only.ID)}, | |
| 1028 | }) | |
| 1029 | if resp.StatusCode != http.StatusConflict { | |
| 1030 | t.Fatalf("removing the only way in: status=%d body=%s", resp.StatusCode, body) | |
| 1031 | } | |
| 1032 | if !strings.Contains(body, "only way into your account") { | |
| 1033 | t.Fatalf("the refusal does not explain itself:\n%s", body) | |
| 1034 | } | |
| 1035 | if creds, _ := f.srv.Identity.Credentials(f.ownerID); len(creds) != 1 { | |
| 1036 | t.Fatal("the refused removal took the passkey anyway") | |
| 1037 | } | |
| 1038 | ||
| 1039 | // An SSH key is a way in — it pushes and clones — so the same removal is allowed. | |
| 1040 | pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_last_way_in")) | |
| 1041 | resp, body = f.browserPost(t, "/-/account/keys", cookie, url.Values{"csrf": {sess.CSRF}, "key": {pub}}) | |
| 1042 | if resp.StatusCode != http.StatusSeeOther { | |
| 1043 | t.Fatalf("add key: status=%d body=%s", resp.StatusCode, body) | |
| 1044 | } | |
| 1045 | resp, body = f.browserPost(t, "/-/account/passkeys/remove", cookie, url.Values{ | |
| 1046 | "csrf": {sess.CSRF}, "credential": {identity.CredentialIDString(only.ID)}, | |
| 1047 | }) | |
| 1048 | if resp.StatusCode != http.StatusSeeOther { | |
| 1049 | t.Fatalf("removing a passkey with an SSH key on the account: status=%d body=%s", resp.StatusCode, body) | |
| 1050 | } | |
| 1051 | } |