e2e/accounts_test.go

v1.18.1
gitbay/e2e/accounts_test.go history · blame · raw

264 lines · 9674 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"net/http/cookiejar"
  9	"net/url"
 10	"os"
 11	"path/filepath"
 12	"strings"
 13	"testing"
 14)
 15
 16// browser is an HTTP client with a cookie jar, standing in for a logged-in
 17// user's browser.
 18func newBrowser(t *testing.T) *http.Client {
 19	t.Helper()
 20	jar, err := cookiejar.New(nil)
 21	if err != nil {
 22		t.Fatal(err)
 23	}
 24	return &http.Client{Jar: jar}
 25}
 26
 27func (i *instance) base() string { return fmt.Sprintf("http://127.0.0.1:%d", i.httpPort) }
 28
 29func browserGet(t *testing.T, c *http.Client, url string) (int, string) {
 30	t.Helper()
 31	resp, err := c.Get(url)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	defer resp.Body.Close()
 36	body, _ := io.ReadAll(resp.Body)
 37	return resp.StatusCode, string(body)
 38}
 39
 40func browserPost(t *testing.T, c *http.Client, u string, form url.Values) (int, string) {
 41	t.Helper()
 42	resp, err := c.PostForm(u, form)
 43	if err != nil {
 44		t.Fatal(err)
 45	}
 46	defer resp.Body.Close()
 47	body, _ := io.ReadAll(resp.Body)
 48	return resp.StatusCode, string(body)
 49}
 50
 51func TestWebAccounts(t *testing.T) {
 52	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
 53
 54	aliceKey := inst.newKey(t, "alice")
 55	inst.admin(t, "admin", "user", "create", "alice",
 56		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 57
 58	// A repo with one file to edit.
 59	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
 60		t.Fatalf("repo create: %s", errOut)
 61	}
 62	work := t.TempDir()
 63	env := inst.gitEnv(aliceKey)
 64	mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
 65	dir := filepath.Join(work, "w")
 66	os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("original\n"), 0o644)
 67	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 68	mustGit(t, dir, env, "add", ".")
 69	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 70	mustGit(t, dir, env, "push", "-q", "origin", "main")
 71
 72	// SSH-minted login URL.
 73	out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
 74	if code != 0 {
 75		t.Fatalf("web login: %s", errOut)
 76	}
 77	var env2 struct {
 78		Data struct {
 79			URL string `json:"url"`
 80		} `json:"data"`
 81	}
 82	if err := json.Unmarshal([]byte(out), &env2); err != nil {
 83		t.Fatalf("web login JSON: %v\n%s", err, out)
 84	}
 85	// The URL carries the configured site host; rewrite to the test port.
 86	loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
 87
 88	browser := newBrowser(t)
 89	status, body := browserGet(t, browser, inst.base()+loginPath)
 90	// The rail's footer carries the signed-in account now.
 91	if status != 200 || !strings.Contains(body, ">Dashboard</h1>") ||
 92		!strings.Contains(body, `class="railuser" href="/alice"`) {
 93		t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
 94	}
 95
 96	// The token is single-use.
 97	fresh := newBrowser(t)
 98	_, body = browserGet(t, fresh, inst.base()+loginPath)
 99	if !strings.Contains(body, "invalid, expired, or already used") {
100		t.Fatalf("token reuse not refused:\n%s", body)
101	}
102
103	// Create a repo through the web.
104	status, _ = browserPost(t, browser, inst.base()+"/new",
105		url.Values{"name": {"webborn"}, "visibility": {"private"}})
106	if status != 200 {
107		t.Fatalf("web repo create: %d", status)
108	}
109	if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/webborn"); code != 0 {
110		t.Fatalf("web-created repo missing over ssh: %s", out)
111	}
112
113	// Logged-in viewer sees their private repo; anonymous still gets 404.
114	if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
115		t.Fatalf("owner blocked from private repo page: %d", status)
116	}
117	if status, _ := inst.get(t, "/alice/webborn"); status != 404 {
118		t.Fatalf("anonymous sees private repo: %d", status)
119	}
120
121	// File edit: form loads with current content, POST commits.
122	status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
123	if status != 200 || !strings.Contains(body, "original") {
124		t.Fatalf("edit form: %d\n%s", status, body)
125	}
126	status, _ = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
127		url.Values{"content": {"edited from the web\n"}, "message": {"web edit"}})
128	if status != 200 {
129		t.Fatalf("edit submit: %d", status)
130	}
131
132	// The edit is a real commit: authored with the verified email, and it
133	// displays as unsigned — the honest outcome for a server-side commit.
134	logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
135	if code != 0 {
136		t.Fatal("repo log failed")
137	}
138	var logEnv struct {
139		Data []struct {
140			Subject     string `json:"subject"`
141			AuthorEmail string `json:"author_email"`
142			Signature   struct {
143				State string `json:"state"`
144			} `json:"signature"`
145		} `json:"data"`
146	}
147	if err := json.Unmarshal([]byte(logOut), &logEnv); err != nil || len(logEnv.Data) == 0 {
148		t.Fatalf("log JSON: %v\n%s", err, logOut)
149	}
150	tip := logEnv.Data[0]
151	if tip.Subject != "web edit" || tip.AuthorEmail != "alice@example.test" || tip.Signature.State != "unsigned" {
152		t.Fatalf("web edit commit wrong: %+v", tip)
153	}
154	if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from the web") {
155		t.Fatalf("edited content not served: %d %q", status, body)
156	}
157
158	// Editing is a command, so it works from the CLI too — the web is one
159	// rendering of it. This is the capability that used to be web-only.
160	if _, errOut, code := inst.ssh(t, aliceKey, "edited from ssh\n",
161		"repo", "commit-file", "alice/site", "notes.txt",
162		"--ref", "main", "--message", "'ssh edit'", "--file", "-"); code != 0 {
163		t.Fatalf("repo commit-file: %s", errOut)
164	}
165	if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from ssh") {
166		t.Fatalf("ssh edit not served: %d %q", status, body)
167	}
168	// A path cannot climb out of the repository.
169	if _, _, code := inst.ssh(t, aliceKey, "x", "repo", "commit-file", "alice/site",
170		"../../etc/passwd", "--ref", "main", "--file", "-"); code == 0 {
171		t.Error("commit-file escaped the repository")
172	}
173	// A stranger with no write access cannot commit.
174	strangerKey := inst.newKey(t, "mallory")
175	inst.admin(t, "admin", "user", "create", "mallory",
176		"--key", strangerKey+".pub", "--email", "mallory@example.test", "--verified")
177	if _, _, code := inst.ssh(t, strangerKey, "x", "repo", "commit-file", "alice/site",
178		"notes.txt", "--ref", "main", "--file", "-"); code == 0 {
179		t.Error("a stranger committed to a repository they cannot write")
180	}
181
182	// A require-signed repo refuses web edits instead of violating itself.
183	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 {
184		t.Fatal("require-signed failed")
185	}
186	_, body = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
187		url.Values{"content": {"x"}, "message": {"x"}})
188	if !strings.Contains(body, "requires signed commits") {
189		t.Fatalf("require-signed web edit not refused:\n%s", body)
190	}
191
192	// Issue participation through the web.
193	if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
194		t.Fatal("issue create failed")
195	}
196	status, _ = browserPost(t, browser, inst.base()+"/alice/site/issues/1/comment",
197		url.Values{"body": {"web comment"}})
198	if status != 200 {
199		t.Fatalf("web comment: %d", status)
200	}
201	showOut, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/site", "1")
202	if !strings.Contains(showOut, "web comment") {
203		t.Fatalf("web comment missing over ssh:\n%s", showOut)
204	}
205
206	// Cross-origin POSTs are refused.
207	req, _ := http.NewRequest("POST", inst.base()+"/alice/site/issues/1/comment",
208		strings.NewReader("body=evil"))
209	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
210	req.Header.Set("Origin", "https://evil.example")
211	resp, err := browser.Do(req)
212	if err != nil {
213		t.Fatal(err)
214	}
215	resp.Body.Close()
216	if resp.StatusCode != 403 {
217		t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
218	}
219
220	// Logout kills the session.
221	if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
222		t.Fatalf("logout: %d", status)
223	}
224	if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 404 {
225		t.Fatalf("session survived logout: %d", status)
226	}
227}
228
229// TestViewOnlyHasNoLoginOnTheWire is the M8 negative: in view_only mode the
230// login route does not exist and web login over ssh is refused.
231func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) {
232	inst := startInstance(t) // default: view_only
233	aliceKey := inst.newKey(t, "alice")
234	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
235
236	if status, _ := inst.get(t, "/login"); status != 404 {
237		t.Fatalf("view_only /login = %d, want 404", status)
238	}
239	_, errOut, code := inst.ssh(t, aliceKey, "", "web", "login")
240	if code != 4 || !strings.Contains(errOut, "view-only") {
241		t.Fatalf("web login in view_only: exit %d, %s", code, errOut)
242	}
243}
244
245// TestTitleIsNotAHostname pins the split between the instance's display name
246// and its hostname: the login page prints a command to paste into a terminal,
247// so it must name the host even when the operator has set a display title.
248func TestTitleIsNotAHostname(t *testing.T) {
249	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\ntitle = \"GitBay\"\n")
250
251	status, body := inst.get(t, "/login")
252	if status != 200 {
253		t.Fatalf("/login = %d", status)
254	}
255	if strings.Contains(body, "ssh git@GitBay") {
256		t.Fatal("login page tells you to ssh to the display title")
257	}
258	if !strings.Contains(body, "ssh git@gitbay.test web login") {
259		t.Fatalf("login page does not name the host:\n%s", body)
260	}
261	if !strings.Contains(body, "GitBay") {
262		t.Fatal("login page dropped the display title entirely")
263	}
264}