1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports/mocks"
16)
17
18// Passkeys add an anonymous way to start a session, and the Actions pages add
19// POSTs that cancel and re-run runs. The two must not meet: the only POSTs an
20// anonymous browser may make are the two passkey login endpoints (and a login
21// link), so the run actions still need the Origin of the public URL and a
22// session, whether that session came from a link or from a passkey.
23func TestPasskeysDoNotOpenTheActionsPosts(t *testing.T) {
24 t.Parallel()
25 repo := repoName(t, "proj")
26 runs := mocks.NewRunService(t) // a call the gate should have stopped fails the test
27 sessions := newSessions(t, "tok")
28 srv := newTestServer(t, Config{
29 Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
30 PublicURL: forgeOrigin, Sessions: sessions, Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
31 Now: func() time.Time { return fixedNow },
32 })
33 f := &passkeyFixture{srv: srv}
34 actions := []string{
35 "/proj/actions/runs/1/cancel", "/proj/actions/runs/1/rerun", "/proj/actions/runs/1/rerun-failed",
36 }
37 do := func(target, origin, cookie string) *httptest.ResponseRecorder {
38 return f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/x-www-form-urlencoded"})
39 }
40
41 for _, target := range actions {
42 // Anonymous, with or without the right Origin, and with a cookie the
43 // sessions do not know: forbidden, before any use case runs.
44 for _, tc := range []struct{ origin, cookie string }{
45 {forgeOrigin, ""}, {"", ""}, {forgeOrigin, "stale"},
46 } {
47 rec := do(target, tc.origin, tc.cookie)
48 assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, tc.origin, tc.cookie)
49 }
50 // A signed-in browser from another origin: forbidden as well.
51 for _, origin := range []string{"", "null", "https://evil.example"} {
52 assert.Equal(t, http.StatusForbidden, do(target, origin, "tok").Code, "%s origin=%q", target, origin)
53 }
54 }
55
56 // The same paths are not passkey endpoints: lookalikes of the anonymous
57 // ones do not pass either.
58 for _, target := range []string{"/proj/actions/runs/1/cancel/-/passkeys/login/begin", "/-/passkeys/login/begin/../../proj/actions/runs/1/cancel"} {
59 assert.NotEqual(t, http.StatusSeeOther, do(target, forgeOrigin, "").Code, target)
60 }
61
62 // A browser signed in (by a link or by a passkey, the cookie is the same)
63 // reaches the use case as the owner, once per action.
64 owner := mock.MatchedBy(func(p domain.Principal) bool { return p.IsOwner() })
65 runs.EXPECT().CancelRun(mock.Anything, owner, repo, int64(1)).Return(domain.CIRun{}, nil).Once()
66 runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), false).Return(domain.CIRun{}, nil).Once()
67 runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), true).Return(domain.CIRun{}, nil).Once()
68 for _, target := range actions {
69 rec := do(target, forgeOrigin, "tok")
70 assert.Equal(t, http.StatusSeeOther, rec.Code, target)
71 assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"), target)
72 }
73}
74
75// The refusal of the use case still shows as the page of a forbidden action
76// for a signed-in browser.
77func TestPasskeySessionRefusedByTheRunUseCase(t *testing.T) {
78 t.Parallel()
79 repo := repoName(t, "proj")
80 runs := mocks.NewRunService(t)
81 runs.EXPECT().CancelRun(mock.Anything, mock.Anything, repo, int64(1)).Return(domain.CIRun{}, domain.ErrForbidden).Once()
82 srv := newTestServer(t, Config{
83 Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
84 PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
85 })
86 f := &passkeyFixture{srv: srv}
87 rec := f.do(t, req{method: "POST", target: "/proj/actions/runs/1/cancel", origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
88 assert.Equal(t, http.StatusForbidden, rec.Code)
89}