Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Code

395 commit(s) on main

update readme Keenan Nemetz · 29m ago 32290114
history blame raw 38.8 KiB
1package server_test
2
3import (
4 "crypto/rand"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "golang.org/x/crypto/ssh"
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"
22)
23
24var noRedirectAuth = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
25 return http.ErrUseLastResponse
26}}
27
28func 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
35func (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
44func (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
62func (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
81func (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
108func TestBrowserLoginAndInvitePages(t *testing.T) {
109 t.Parallel()
110 f := browserFixture(t)
111
112 resp, body := f.browserGet(t, "/-/login?next=/demo", "")
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 }
116 if !strings.Contains(body, `data-next="/demo"`) {
117 t.Fatalf("login page lost the next path: %s", body)
118 }
119
120 _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
121 if err != nil {
122 t.Fatal(err)
123 }
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.
127 resp, body = f.browserGet(t, "/-/invite", "")
128 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create a passkey") {
129 t.Fatalf("invite page: status=%d body=%s", resp.StatusCode, body)
130 }
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.
138 resp, _ = f.browserGet(t, "/-/invite/"+inv.Token, "")
139 if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/-/invite#"+inv.Token {
140 t.Fatalf("legacy invite link = %d %q", resp.StatusCode, resp.Header.Get("Location"))
141 }
142}
143
144func TestLoginIsDisabledWithoutHTTPS(t *testing.T) {
145 t.Parallel()
146 f := newFixture(t)
147 resp, body := f.browserGet(t, "/-/login", "")
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 }
151 resp, body = f.browserGet(t, "/-/invite", "")
152 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") {
153 t.Fatalf("disabled invite page: status=%d body=%s", resp.StatusCode, body)
154 }
155}
156
157func 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.
163 resp, _ := f.browserGet(t, "/"+f.repo, "")
164 if resp.StatusCode != http.StatusNotFound {
165 t.Fatalf("anonymous private repo status = %d, want 404", resp.StatusCode)
166 }
167 resp, body := f.browserGet(t, "/"+f.repo, cookie)
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.
173 resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{})
174 if resp.StatusCode != http.StatusForbidden {
175 t.Fatalf("logout without CSRF status = %d, want 403", resp.StatusCode)
176 }
177 resp, _ = f.browserPost(t, "/-/logout", cookie, url.Values{"csrf": {sess.CSRF}})
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
186func TestWebIssueAndCommentWritesUseSessionIdentity(t *testing.T) {
187 t.Parallel()
188 f := browserFixture(t)
189 sess, cookie := f.browserSession(t, f.ownerID)
190
191 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
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")
198 if !strings.HasPrefix(loc, "/"+f.repo+"/issues/") {
199 t.Fatalf("create issue redirect = %q", loc)
200 }
201 id := strings.TrimPrefix(loc, "/"+f.repo+"/issues/")
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.
228 resp, _ = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
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
236func 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)
242 req, err := metadata.NewRequest("A change", "please review", "wilbur", "main", head, time.Now().UTC())
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)
251 resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie,
252 url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}})
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
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.
269func 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)
285 resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie,
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 }
299 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+req.ID+"/approve", cookie,
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
316func 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 }
331 resp, out := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "keenan"})
332 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
333 t.Fatalf("login begin: status=%d body=%v", resp.StatusCode, out)
334 }
335
336 _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
337 if err != nil {
338 t.Fatal(err)
339 }
340 resp, out = f.browserJSON(t, "/-/auth/invite/begin", "", "", map[string]string{"token": inv.Token})
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)
347 resp, _ = f.browserJSON(t, "/-/account/passkeys/begin", cookie, "", map[string]string{"label": "phone"})
348 if resp.StatusCode != http.StatusForbidden {
349 t.Fatalf("passkey begin without CSRF status = %d, want 403", resp.StatusCode)
350 }
351 resp, out = f.browserJSON(t, "/-/account/passkeys/begin", cookie, sess.CSRF, map[string]string{"label": "phone"})
352 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
353 t.Fatalf("passkey begin: status=%d body=%v", resp.StatusCode, out)
354 }
355}
356
357func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) {
358 t.Parallel()
359 f := browserFixture(t)
360
361 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_contrib"))
362 contrib, err := f.srv.Identity.Create("contrib", pub, "test")
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 }
371 if err := a.SetEntry(contrib.Name, "contribute"); err != nil {
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"})
383 // The namespace is the account name, chosen at creation and fixed forever.
384 headRef := "contrib/" + contrib.Name + "/fix"
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
395 sess, cookie := f.browserSession(t, contrib.Name)
396 // Contributors can discuss without access to shared branches.
397 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
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
404 resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
405 if resp.StatusCode != http.StatusOK || !strings.Contains(body, headRef) || !strings.Contains(body, "Open change") {
406 t.Fatalf("request form: status=%d body=%s", resp.StatusCode, body)
407 }
408
409 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
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 }
420 if len(list) != 1 || list[0].Author != "contrib" || list[0].Base != "main" {
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.
430 resp, _ = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
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
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
441// same namespace rule the create form uses, and only while the request is open.
442func TestContributorRepointsRequestTarget(t *testing.T) {
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)
474 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
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
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).
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 }
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 }
509
510 // The control is offered, with the contributor's own branch as a choice.
511 resp, body = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
512 if resp.StatusCode != http.StatusOK {
513 t.Fatalf("request page: status=%d body=%s", resp.StatusCode, body)
514 }
515 if !strings.Contains(body, `action="/`+f.repo+`/changes/`+id+`/target"`) || !strings.Contains(body, headRef) {
516 t.Fatalf("the request page does not offer the target control:\n%s", body)
517 }
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)
521 }
522
523 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{
524 "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef},
525 })
526 if resp.StatusCode != http.StatusSeeOther {
527 t.Fatalf("repoint target: status=%d body=%s", resp.StatusCode, body)
528 }
529 got, err := metadata.GetRequest(m.Git(), id)
530 if err != nil {
531 t.Fatal(err)
532 }
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)
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.
539 resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{
540 "csrf": {sess.CSRF}, "base": {"main"}, "head": {"main"},
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")
552 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", ownerCookie, url.Values{
553 "csrf": {ownerSess.CSRF}, "base": {"main"}, "head": {"main"},
554 })
555 if resp.StatusCode != http.StatusSeeOther {
556 t.Fatalf("maintainer repoint: status=%d body=%s", resp.StatusCode, body)
557 }
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)
560 }
561
562 // A stacked request can be retargeted at the branch it should land on now. The
563 // same control carries the base change, and the change moves the diff and starts
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 }
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 t.Fatalf("the request page does not offer the target control:\n%s", body)
571 }
572 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{
573 "csrf": {sess.CSRF}, "base": {"parent"}, "head": {"main"},
574 })
575 if resp.StatusCode != http.StatusSeeOther {
576 t.Fatalf("retarget: status=%d body=%s", resp.StatusCode, body)
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.
583 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
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 }
589 resp, _ = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/target", cookie, url.Values{
590 "csrf": {sess.CSRF}, "base": {"main"}, "head": {headRef},
591 })
592 if resp.StatusCode != http.StatusForbidden {
593 t.Fatalf("a closed request changed its target: status=%d", resp.StatusCode)
594 }
595}
596
597func 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"))
602 contrib, err := f.srv.Identity.Create("ci-contrib", pub, "test")
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 }
611 if err := a.SetEntry(contrib.Name, "contribute"); err != nil {
612 t.Fatal(err)
613 }
614 if err := m.SetACL(a, "test"); err != nil {
615 t.Fatal(err)
616 }
617
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 }
622 work := workRepo(t, workflowFile(`version: 1
623jobs:
624 test:
625 image: alpine:3.22
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)
635 ref := "contrib/" + contrib.Name + "/ci"
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
654 contribSess, contribCookie := f.browserSession(t, contrib.Name)
655 resp, body := f.browserPost(t, "/"+f.repo+"/changes", contribCookie, url.Values{
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
667 requestURL := "/" + f.repo + "/changes/" + requestID
668
669 // The Runs tab is history only; the approval belongs with the code under review.
670 resp, body = f.browserGet(t, "/"+f.repo+"/runs", contribCookie)
671 if resp.StatusCode != http.StatusOK || strings.Contains(body, "waiting for a maintainer") || strings.Contains(body, "Allow CI</button>") {
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)
675 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "waiting for a maintainer") {
676 t.Fatalf("contributor request CI status: status=%d body=%s", resp.StatusCode, body)
677 }
678 if strings.Contains(body, "Allow CI</button>") {
679 t.Fatalf("contributor saw the Allow CI control: body=%s", body)
680 }
681 resp, _ = f.browserPost(t, "/"+f.repo+"/ci/allow", contribCookie, url.Values{
682 "csrf": {contribSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID},
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)
689 resp, body = f.browserGet(t, requestURL, ownerCookie)
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)
694 }
695 resp, runsPage := f.browserGet(t, "/"+f.repo+"/runs", ownerCookie)
696 if resp.StatusCode != http.StatusOK || strings.Contains(runsPage, "Allow CI</button>") || strings.Contains(runsPage, "Awaiting CI approval") {
697 t.Fatalf("runs tab should not show contributor CI approval: status=%d body=%s", resp.StatusCode, runsPage)
698 }
699 resp, codePage := f.browserGet(t, "/"+f.repo+"/code", ownerCookie)
700 if resp.StatusCode != http.StatusOK || strings.Contains(codePage, "Allow CI</button>") {
701 t.Fatalf("contributor CI control should not be in the code browser: status=%d body=%s", resp.StatusCode, codePage)
702 }
703 resp, body = f.browserPost(t, "/"+f.repo+"/ci/allow", ownerCookie, url.Values{
704 "csrf": {ownerSess.CSRF}, "ref": {"refs/heads/" + ref}, "request": {requestID},
705 })
706 if resp.StatusCode != http.StatusSeeOther {
707 t.Fatalf("maintainer allow: status=%d body=%s", resp.StatusCode, body)
708 }
709 if loc := resp.Header.Get("Location"); loc != requestURL {
710 t.Fatalf("allow redirect = %q, want %q", loc, requestURL)
711 }
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 }
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 }
723}
724
725func 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
732 resp, body := f.browserGet(t, "/-/login", "")
733 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create one") {
734 t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body)
735 }
736 resp, body = f.browserGet(t, "/-/register", "")
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 }
740 resp, out := f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"})
741 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
742 t.Fatalf("register begin: status=%d body=%v", resp.StatusCode, out)
743 }
744 resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "keenan"})
745 if resp.StatusCode != http.StatusConflict {
746 t.Fatalf("taken name status = %d, want 409", resp.StatusCode)
747 }
748 resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "Not A Name"})
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
759func TestOpenRegistrationIsOffByDefault(t *testing.T) {
760 t.Parallel()
761 f := browserFixture(t)
762 resp, body := f.browserGet(t, "/-/login", "")
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 }
766 resp, _ = f.browserGet(t, "/-/register", "")
767 if resp.StatusCode != http.StatusNotFound {
768 t.Fatalf("register page status = %d, want 404", resp.StatusCode)
769 }
770 resp, _ = f.browserJSON(t, "/-/auth/register/begin", "", "", map[string]string{"username": "newbie"})
771 if resp.StatusCode != http.StatusServiceUnavailable {
772 t.Fatalf("register begin status = %d, want 503", resp.StatusCode)
773 }
774}
775
776func 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
787 resp, body := f.browserPost(t, "/-/account/keys", cookie, url.Values{
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
807 resp, body = f.browserPost(t, "/-/account/keys/remove", cookie, url.Values{
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}
820
821// TestDisplayNameRouteIsGone: an account is only its registered name, so there
822// is no second label to change.
823func TestDisplayNameRouteIsGone(t *testing.T) {
824 t.Parallel()
825 f := browserFixture(t)
826 _, cookie := f.browserSession(t, f.ownerID)
827 resp, _ := f.browserPost(t, "/-/account/display", cookie, url.Values{"display": {"Keenan O'Brien"}})
828 if resp.StatusCode != http.StatusNotFound {
829 t.Fatalf("display route status = %d, want 404", resp.StatusCode)
830 }
831}
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.
835func TestLogoutRefusesAnOversizedBody(t *testing.T) {
836 t.Parallel()
837 f := browserFixture(t)
838 sess, cookie := f.browserSession(t, f.ownerID)
839
840 req, err := http.NewRequest("POST", f.http.URL+"/-/logout",
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.
862func TestLoginBeginIsRateLimited(t *testing.T) {
863 t.Parallel()
864 f := browserFixture(t)
865
866 var sawOK, sawLimited bool
867 for i := 0; i < 100; i++ {
868 resp, _ := f.browserJSON(t, "/-/auth/login/begin", "", "", map[string]string{"username": "nobody"})
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.
894func TestAccountPageIsNotCached(t *testing.T) {
895 t.Parallel()
896 f := browserFixture(t)
897 _, cookie := f.browserSession(t, f.ownerID)
898 resp, _ := f.browserGet(t, "/-/account", cookie)
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}
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.
910func 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())
917 req, err := http.NewRequest("POST", f.http.URL+"/-/logout", body)
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}
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.
947func 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.
1006func 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}
1052