internal/httpd/settingsparity_test.go

main
gitbay/internal/httpd/settingsparity_test.go history · blame · raw

298 lines · 10761 bytes

15 symbols in this file
  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}