e2e/accounts_test.go
294 lines · 11134 bytes
7 symbols in this file
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 t.Parallel()
53 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
54
55 aliceKey := inst.newKey(t, "alice")
56 inst.admin(t, "admin", "user", "create", "alice",
57 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
58
59 // A repo with one file to edit.
60 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
61 t.Fatalf("repo create: %s", errOut)
62 }
63 work := t.TempDir()
64 env := inst.gitEnv(aliceKey)
65 mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
66 dir := filepath.Join(work, "w")
67 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("original\n"), 0o644)
68 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
69 mustGit(t, dir, env, "add", ".")
70 mustGit(t, dir, env, "commit", "-q", "-m", "base")
71 mustGit(t, dir, env, "push", "-q", "origin", "main")
72
73 // SSH-minted login URL.
74 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
75 if code != 0 {
76 t.Fatalf("web login: %s", errOut)
77 }
78 var env2 struct {
79 Data struct {
80 URL string `json:"url"`
81 } `json:"data"`
82 }
83 if err := json.Unmarshal([]byte(out), &env2); err != nil {
84 t.Fatalf("web login JSON: %v\n%s", err, out)
85 }
86 // The URL carries the configured site host; rewrite to the test port.
87 loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
88
89 browser := newBrowser(t)
90 status, body := browserGet(t, browser, inst.base()+loginPath)
91 // The rail's footer carries the signed-in account now.
92 if status != 200 || !strings.Contains(body, ">Dashboard</h1>") ||
93 !strings.Contains(body, `class="railuser" href="/alice"`) {
94 t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
95 }
96
97 // The token is single-use.
98 fresh := newBrowser(t)
99 _, body = browserGet(t, fresh, inst.base()+loginPath)
100 if !strings.Contains(body, "invalid, expired, or already used") {
101 t.Fatalf("token reuse not refused:\n%s", body)
102 }
103
104 // Create a repo through the web.
105 status, _ = browserPost(t, browser, inst.base()+"/new",
106 url.Values{"name": {"webborn"}, "visibility": {"private"}})
107 if status != 200 {
108 t.Fatalf("web repo create: %d", status)
109 }
110 if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/webborn"); code != 0 {
111 t.Fatalf("web-created repo missing over ssh: %s", out)
112 }
113
114 // Logged-in viewer sees their private repo; anonymous still gets 404.
115 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
116 t.Fatalf("owner blocked from private repo page: %d", status)
117 }
118 if status, _ := inst.get(t, "/alice/webborn"); status != 404 {
119 t.Fatalf("anonymous sees private repo: %d", status)
120 }
121
122 // File edit: form loads with current content, POST commits.
123 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
124 if status != 200 || !strings.Contains(body, "original") {
125 t.Fatalf("edit form: %d\n%s", status, body)
126 }
127 status, _ = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
128 url.Values{"content": {"edited from the web\n"}, "message": {"web edit"}})
129 if status != 200 {
130 t.Fatalf("edit submit: %d", status)
131 }
132
133 // A branch that does not exist is a 404; a path that does not exist
134 // on a real branch is a new-file form that says so.
135 if status, _ := browserGet(t, browser, inst.base()+"/alice/site/edit/nope/notes.txt"); status != 404 {
136 t.Fatalf("edit form on a missing branch: %d", status)
137 }
138 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/new.txt")
139 if status != 200 || !strings.Contains(body, "does not exist on main; committing creates it") || !strings.Contains(body, "<textarea") {
140 t.Fatalf("edit form for a new file: %d\n%s", status, body)
141 }
142
143 // The edit is a real commit: authored with the verified email, and it
144 // displays as unsigned — the honest outcome for a server-side commit.
145 logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
146 if code != 0 {
147 t.Fatal("repo log failed")
148 }
149 var logEnv struct {
150 Data []struct {
151 Subject string `json:"subject"`
152 AuthorEmail string `json:"author_email"`
153 Signature struct {
154 State string `json:"state"`
155 } `json:"signature"`
156 } `json:"data"`
157 }
158 if err := json.Unmarshal([]byte(logOut), &logEnv); err != nil || len(logEnv.Data) == 0 {
159 t.Fatalf("log JSON: %v\n%s", err, logOut)
160 }
161 tip := logEnv.Data[0]
162 if tip.Subject != "web edit" || tip.AuthorEmail != "alice@example.test" || tip.Signature.State != "unsigned" {
163 t.Fatalf("web edit commit wrong: %+v", tip)
164 }
165 if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from the web") {
166 t.Fatalf("edited content not served: %d %q", status, body)
167 }
168
169 // Editing is a command, so it works from the CLI too — the web is one
170 // rendering of it. This is the capability that used to be web-only.
171 if _, errOut, code := inst.ssh(t, aliceKey, "edited from ssh\n",
172 "repo", "commit-file", "alice/site", "notes.txt",
173 "--ref", "main", "--message", "'ssh edit'", "--file", "-"); code != 0 {
174 t.Fatalf("repo commit-file: %s", errOut)
175 }
176 if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from ssh") {
177 t.Fatalf("ssh edit not served: %d %q", status, body)
178 }
179 // A path cannot climb out of the repository.
180 if _, _, code := inst.ssh(t, aliceKey, "x", "repo", "commit-file", "alice/site",
181 "../../etc/passwd", "--ref", "main", "--file", "-"); code == 0 {
182 t.Error("commit-file escaped the repository")
183 }
184 // A stranger with no write access cannot commit.
185 strangerKey := inst.newKey(t, "mallory")
186 inst.admin(t, "admin", "user", "create", "mallory",
187 "--key", strangerKey+".pub", "--email", "mallory@example.test", "--verified")
188 if _, _, code := inst.ssh(t, strangerKey, "x", "repo", "commit-file", "alice/site",
189 "notes.txt", "--ref", "main", "--file", "-"); code == 0 {
190 t.Error("a stranger committed to a repository they cannot write")
191 }
192
193 // A require-signed repo refuses web edits instead of violating itself.
194 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 {
195 t.Fatal("require-signed failed")
196 }
197 _, body = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
198 url.Values{"content": {"x"}, "message": {"x"}})
199 if !strings.Contains(body, "requires signed commits") {
200 t.Fatalf("require-signed web edit not refused:\n%s", body)
201 }
202
203 // With signed commits required the editor cannot succeed, so the GET
204 // form says so instead of offering a textarea.
205 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
206 if status != 200 || !strings.Contains(body, "requires signed commits") || strings.Contains(body, "<textarea") {
207 t.Fatalf("edit page under require-signed: %d\n%s", status, body)
208 }
209
210 // Issue participation through the web.
211 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
212 t.Fatal("issue create failed")
213 }
214 status, _ = browserPost(t, browser, inst.base()+"/alice/site/issues/1/comment",
215 url.Values{"body": {"web comment"}})
216 if status != 200 {
217 t.Fatalf("web comment: %d", status)
218 }
219 showOut, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/site", "1")
220 if !strings.Contains(showOut, "web comment") {
221 t.Fatalf("web comment missing over ssh:\n%s", showOut)
222 }
223
224 // Cross-origin POSTs are refused.
225 req, _ := http.NewRequest("POST", inst.base()+"/alice/site/issues/1/comment",
226 strings.NewReader("body=evil"))
227 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
228 req.Header.Set("Origin", "https://evil.example")
229 resp, err := browser.Do(req)
230 if err != nil {
231 t.Fatal(err)
232 }
233 resp.Body.Close()
234 if resp.StatusCode != 403 {
235 t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
236 }
237
238 // Logging out is confirmed first: the GET renders the page and leaves
239 // the session alone, and only the POST ends it.
240 status, body = browserGet(t, browser, inst.base()+"/logout")
241 if status != 200 || !strings.Contains(body, `action="/logout"`) {
242 t.Fatalf("logout confirmation: %d\n%s", status, body)
243 }
244 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
245 t.Fatalf("GET /logout ended the session: %d", status)
246 }
247
248 // Logout kills the session.
249 if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
250 t.Fatalf("logout: %d", status)
251 }
252 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 404 {
253 t.Fatalf("session survived logout: %d", status)
254 }
255}
256
257// TestViewOnlyHasNoLoginOnTheWire is the M8 negative: in view_only mode the
258// login route does not exist and web login over ssh is refused.
259func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) {
260 t.Parallel()
261 inst := startInstance(t) // default: view_only
262 aliceKey := inst.newKey(t, "alice")
263 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
264
265 if status, _ := inst.get(t, "/login"); status != 404 {
266 t.Fatalf("view_only /login = %d, want 404", status)
267 }
268 _, errOut, code := inst.ssh(t, aliceKey, "", "web", "login")
269 if code != 4 || !strings.Contains(errOut, "view-only") {
270 t.Fatalf("web login in view_only: exit %d, %s", code, errOut)
271 }
272}
273
274// TestTitleIsNotAHostname pins the split between the instance's display name
275// and its hostname: the login page prints a command to paste into a terminal,
276// so it must name the host even when the operator has set a display title.
277func TestTitleIsNotAHostname(t *testing.T) {
278 t.Parallel()
279 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\ntitle = \"GitBay\"\n")
280
281 status, body := inst.get(t, "/login")
282 if status != 200 {
283 t.Fatalf("/login = %d", status)
284 }
285 if strings.Contains(body, "ssh git@GitBay") {
286 t.Fatal("login page tells you to ssh to the display title")
287 }
288 if !strings.Contains(body, "ssh git@gitbay.test web login") {
289 t.Fatalf("login page does not name the host:\n%s", body)
290 }
291 if !strings.Contains(body, "GitBay") {
292 t.Fatal("login page dropped the display title entirely")
293 }
294}