internal/httpd/settingsparity_test.go
298 lines · 10761 bytes
15 symbols in this file
settingsEnvnewSettingsEnvsettingsEnv.postsettingsEnv.pageTestSettingsAccessGrantRevokeTestSettingsAccessRefusalShownTestSettingsWebhookSecretStaysOffThePageTestSettingsWebhookRedeliverTestSettingsRenameTestSettingsDeleteNeedsTypedPathTestSettingsTransferTestSettingsNonAdminSeesNoFormsTestNewImportRefusalShownTestSettingsDeleteRenameNeedRecentSignInTestSettingsDeleteTransferFlash
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/store"
16)
17
18type settingsEnv struct {
19 s *Server
20 st *store.Store
21 alice store.User
22 bob store.User
23 repo store.Repo
24}
25
26func newSettingsEnv(t *testing.T) *settingsEnv {
27 t.Helper()
28 st, err := store.Open(":memory:")
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 aid, _ := st.CreateUser("alice", false)
37 bid, _ := st.CreateUser("bob", false)
38 if _, err := st.CreateRepo("user", aid, "app", "public"); err != nil {
39 t.Fatal(err)
40 }
41 repo, err := st.RepoByPath("alice/app")
42 if err != nil {
43 t.Fatal(err)
44 }
45 if err := st.GrantAccess(repo.ID, bid, "read"); err != nil {
46 t.Fatal(err)
47 }
48 cfg := config.Default()
49 cfg.Web.Mode = "accounts"
50 cfg.Server.Root = t.TempDir()
51 cfg.Webhooks.AllowLocal = true
52 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
53 t.Fatal(err)
54 }
55 now := time.Now()
56 return &settingsEnv{
57 s: New(cfg, st, nil), st: st, repo: repo,
58 alice: store.User{ID: aid, Username: "alice", SignedInAt: now},
59 bob: store.User{ID: bid, Username: "bob", SignedInAt: now},
60 }
61}
62
63func (e *settingsEnv) post(u store.User, form url.Values) *httptest.ResponseRecorder {
64 req := httptest.NewRequest("POST", "/alice/app/settings", strings.NewReader(form.Encode()))
65 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
66 req.SetPathValue("owner", "alice")
67 req.SetPathValue("repo", "app")
68 rr := httptest.NewRecorder()
69 e.s.settingsSubmit(rr, req, u)
70 return rr
71}
72
73func (e *settingsEnv) page(u store.User) *httptest.ResponseRecorder {
74 req := httptest.NewRequest("GET", "/alice/app/settings", nil)
75 req.SetPathValue("owner", "alice")
76 req.SetPathValue("repo", "app")
77 rr := httptest.NewRecorder()
78 e.s.settingsForm(rr, req, u)
79 return rr
80}
81
82func TestSettingsAccessGrantRevoke(t *testing.T) {
83 e := newSettingsEnv(t)
84 cid, _ := e.st.CreateUser("carol", false)
85 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"carol"}, "role": {"write"}})
86 if rr.Code != http.StatusSeeOther {
87 t.Fatalf("grant: %d %s", rr.Code, rr.Body.String())
88 }
89 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "write" {
90 t.Fatalf("role %q", role)
91 }
92 body := e.page(e.alice).Body.String()
93 for _, want := range []string{"carol", "direct", `value="access-revoke"`, "owner"} {
94 if !strings.Contains(body, want) {
95 t.Errorf("page lacks %q", want)
96 }
97 }
98 rr = e.post(e.alice, url.Values{"field": {"access-revoke"}, "user": {"carol"}})
99 if rr.Code != http.StatusSeeOther {
100 t.Fatalf("revoke: %d %s", rr.Code, rr.Body.String())
101 }
102 if role, _ := e.st.AccessRole(e.repo.ID, cid); role != "" {
103 t.Fatalf("still has %q", role)
104 }
105}
106
107func TestSettingsAccessRefusalShown(t *testing.T) {
108 e := newSettingsEnv(t)
109 rr := e.post(e.alice, url.Values{"field": {"access-grant"}, "user": {"nobody"}, "role": {"read"}})
110 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no such user "nobody"") {
111 t.Fatalf("%d %s", rr.Code, rr.Body.String())
112 }
113}
114
115func TestSettingsWebhookSecretStaysOffThePage(t *testing.T) {
116 e := newSettingsEnv(t)
117 const secret = "s3cr3t-value-xyz"
118 rr := e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/hook"},
119 "events": {"push"}, "secret": {secret}})
120 if rr.Code != http.StatusSeeOther {
121 t.Fatalf("add: %d %s", rr.Code, rr.Body.String())
122 }
123 hooks, _ := e.st.ListWebhooks(e.repo.ID)
124 if len(hooks) != 1 || hooks[0].Secret != secret || hooks[0].Events != "push" {
125 t.Fatalf("stored %+v", hooks)
126 }
127 if strings.Contains(rr.Header().Get("Location"), secret) || strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), ";"), secret) {
128 t.Fatal("secret in redirect or flash")
129 }
130 body := e.page(e.alice).Body.String()
131 if strings.Contains(body, secret) || !strings.Contains(body, "signed") || !strings.Contains(body, "127.0.0.1:9/hook") {
132 t.Fatalf("page: %s", body)
133 }
134
135 // A refused add re-renders the form without the secret.
136 rr = e.post(e.alice, url.Values{"field": {"webhook-add"}, "url": {"ftp://x"}, "events": {"push"}, "secret": {secret}})
137 if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), secret) {
138 t.Fatalf("refusal: %d, secret echoed: %v", rr.Code, strings.Contains(rr.Body.String(), secret))
139 }
140 if !strings.Contains(rr.Body.String(), `role="alert"`) {
141 t.Fatal("no error shown")
142 }
143
144 rr = e.post(e.alice, url.Values{"field": {"webhook-remove"}, "id": {"1"}})
145 if rr.Code != http.StatusSeeOther {
146 t.Fatalf("remove: %d %s", rr.Code, rr.Body.String())
147 }
148 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
149 t.Fatalf("still %+v", hooks)
150 }
151}
152
153func TestSettingsWebhookRedeliver(t *testing.T) {
154 e := newSettingsEnv(t)
155 rr := e.post(e.alice, url.Values{"field": {"webhook-redeliver"}, "delivery": {"99"}})
156 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "no delivery 99") {
157 t.Fatalf("%d %s", rr.Code, rr.Body.String())
158 }
159}
160
161func TestSettingsRename(t *testing.T) {
162 e := newSettingsEnv(t)
163 rr := e.post(e.alice, url.Values{"field": {"rename"}, "name": {"Bad Name"}})
164 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `role="alert"`) {
165 t.Fatalf("refusal: %d", rr.Code)
166 }
167 rr = e.post(e.alice, url.Values{"field": {"rename"}, "name": {"tool"}})
168 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/tool/settings" {
169 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
170 }
171 if _, err := os.Stat(control.RepoDir(e.s.cfg.Server.Root, "alice", "tool")); err != nil {
172 t.Fatal(err)
173 }
174}
175
176func TestSettingsDeleteNeedsTypedPath(t *testing.T) {
177 e := newSettingsEnv(t)
178 rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"app"}})
179 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
180 t.Fatalf("%d %s", rr.Code, rr.Body.String())
181 }
182 if _, err := e.st.RepoByPath("alice/app"); err != nil {
183 t.Fatal("deleted without confirmation")
184 }
185 rr = e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
186 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice" {
187 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
188 }
189 if _, err := e.st.RepoByPath("alice/app"); err == nil {
190 t.Fatal("not deleted")
191 }
192}
193
194func TestSettingsTransfer(t *testing.T) {
195 e := newSettingsEnv(t)
196 if _, msg, ok := e.s.runControl(e.alice, []string{"org", "create", "krz"}); !ok {
197 t.Fatal(msg)
198 }
199 rr := e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}})
200 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "type alice/app to confirm") {
201 t.Fatalf("unconfirmed: %d", rr.Code)
202 }
203 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"nowhere"}, "confirm": {"alice/app"}})
204 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "cannot transfer to "nowhere"") {
205 t.Fatalf("refusal: %d %s", rr.Code, rr.Body.String())
206 }
207 rr = e.post(e.alice, url.Values{"field": {"transfer"}, "new-owner": {"krz"}, "confirm": {"alice/app"}})
208 if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/krz/app/settings" {
209 t.Fatalf("%d %q", rr.Code, rr.Header().Get("Location"))
210 }
211 if _, err := e.st.RepoByPath("krz/app"); err != nil {
212 t.Fatal(err)
213 }
214}
215
216// Only a repository admin reaches the page or the forms; a reader is
217// refused before any command runs.
218func TestSettingsNonAdminSeesNoForms(t *testing.T) {
219 e := newSettingsEnv(t)
220 if rr := e.page(e.bob); rr.Code != http.StatusForbidden || strings.Contains(rr.Body.String(), "webhook-add") {
221 t.Fatalf("page: %d", rr.Code)
222 }
223 for _, form := range []url.Values{
224 {"field": {"webhook-add"}, "url": {"http://127.0.0.1:9/h"}},
225 {"field": {"access-grant"}, "user": {"bob"}, "role": {"admin"}},
226 {"field": {"delete"}, "confirm": {"alice/app"}},
227 {"field": {"rename"}, "name": {"x"}},
228 } {
229 if rr := e.post(e.bob, form); rr.Code != http.StatusForbidden {
230 t.Errorf("%v: %d", form, rr.Code)
231 }
232 }
233 if hooks, _ := e.st.ListWebhooks(e.repo.ID); len(hooks) != 0 {
234 t.Fatal("a reader added a webhook")
235 }
236 if role, _ := e.st.AccessRole(e.repo.ID, e.bob.ID); role != "read" {
237 t.Fatalf("role became %q", role)
238 }
239 if _, err := e.st.RepoByPath("alice/app"); err != nil {
240 t.Fatal("a reader deleted it")
241 }
242 body := e.page(e.alice).Body.String()
243 for _, want := range []string{`value="webhook-add"`, `value="access-grant"`, `value="rename"`, `value="transfer"`, `value="delete"`} {
244 if !strings.Contains(body, want) {
245 t.Errorf("admin page lacks %s", want)
246 }
247 }
248}
249
250func TestNewImportRefusalShown(t *testing.T) {
251 e := newSettingsEnv(t)
252 form := url.Values{"field": {"import"}, "owner": {"alice"}, "name": {"copy"},
253 "from": {"https://user:pw@example.org/r.git"}, "token": {"tok-abc"}}
254 req := httptest.NewRequest("POST", "/new", strings.NewReader(form.Encode()))
255 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
256 rr := httptest.NewRecorder()
257 e.s.newSubmit(rr, req, e.alice)
258 body := rr.Body.String()
259 if rr.Code != http.StatusOK || !strings.Contains(body, "do not embed credentials in the URL") {
260 t.Fatalf("%d %s", rr.Code, body)
261 }
262 if strings.Contains(body, "tok-abc") {
263 t.Fatal("token echoed")
264 }
265 if !strings.Contains(body, `name="field" value="import"`) {
266 t.Fatal("import form missing")
267 }
268}
269
270// A session older than the reauth window cannot delete or rename: the
271// form comes back with the refusal and nothing changes.
272func TestSettingsDeleteRenameNeedRecentSignIn(t *testing.T) {
273 e := newSettingsEnv(t)
274 stale := e.alice
275 stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute)
276 rr := e.post(stale, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
277 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") {
278 t.Fatalf("delete: %d", rr.Code)
279 }
280 if _, err := e.st.RepoByPath("alice/app"); err != nil {
281 t.Fatal("a stale session deleted the repository")
282 }
283 rr = e.post(stale, url.Values{"field": {"rename"}, "name": {"tool"}})
284 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Sign in again") {
285 t.Fatalf("rename: %d", rr.Code)
286 }
287 if _, err := e.st.RepoByPath("alice/app"); err != nil {
288 t.Fatal("a stale session renamed the repository")
289 }
290}
291
292func TestSettingsDeleteTransferFlash(t *testing.T) {
293 e := newSettingsEnv(t)
294 rr := e.post(e.alice, url.Values{"field": {"delete"}, "confirm": {"alice/app"}})
295 if c := strings.Join(rr.Header().Values("Set-Cookie"), ";"); !strings.Contains(c, "Deleted") {
296 t.Fatalf("no flash: %s", c)
297 }
298}