e2e/web_test.go
242 lines · 8648 bytes
1package e2e
2
3import (
4 "compress/gzip"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "golang.org/x/crypto/ssh"
14
15 "gitbay.org/gitbay/internal/sig"
16)
17
18func (i *instance) get(t *testing.T, path string) (int, string) {
19 t.Helper()
20 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path))
21 if err != nil {
22 t.Fatal(err)
23 }
24 defer resp.Body.Close()
25 body, _ := io.ReadAll(resp.Body)
26 return resp.StatusCode, string(body)
27}
28
29func TestWebUI(t *testing.T) {
30 t.Parallel()
31 inst := startInstance(t)
32
33 aliceKey := inst.newKey(t, "alice")
34 inst.admin(t, "admin", "user", "create", "alice",
35 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
36
37 // Public repo with real content: a README, a source file, a tag, and
38 // one SSHSIG-signed commit for the badge check.
39 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
40 t.Fatalf("repo create: %s", errOut)
41 }
42 work := t.TempDir()
43 env := inst.gitEnv(aliceKey)
44 mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
45 dir := filepath.Join(work, "w")
46 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hello site\n\nsome *markdown*\n"), 0o644)
47 os.MkdirAll(filepath.Join(dir, "src"), 0o755)
48 os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)
49 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
50 mustGit(t, dir, env, "add", ".")
51 mustGit(t, dir, env, "commit", "-q", "-m", "first commit")
52 mustGit(t, dir, env, "tag", "v1.0")
53 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
54
55 // A signed commit on top, built with the M4 fixture helpers.
56 sshRaw, _ := os.ReadFile(aliceKey)
57 signer, err := ssh.ParsePrivateKey(sshRaw)
58 if err != nil {
59 t.Fatal(err)
60 }
61 head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
62 buildSignedCommitOn(t, dir, env, head, "signed tip", "alice@example.test", signer)
63 mustGit(t, dir, env, "push", "-q", "origin", "main")
64
65 // Private repo must be invisible everywhere.
66 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
67 t.Fatal("create private failed")
68 }
69
70 // Explore lists the public repo, not the private one; the anonymous
71 // homepage is a landing page pointing there.
72 status, body := inst.get(t, "/")
73 if status != 200 || !strings.Contains(body, `href="/explore"`) {
74 t.Fatalf("landing: %d\n%s", status, body)
75 }
76 status, body = inst.get(t, "/explore")
77 if status != 200 || !strings.Contains(body, "alice/site") {
78 t.Fatalf("explore: %d\n%s", status, body)
79 }
80 if strings.Contains(body, "secret") {
81 t.Fatal("explore leaks private repo")
82 }
83
84 // Repo home: tree entries plus rendered README.
85 status, body = inst.get(t, "/alice/site")
86 if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
87 t.Fatalf("repo home: %d\n%s", status, body)
88 }
89 // Both clone URLs: SSH for anyone with a key, HTTPS for reading.
90 if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code></pre>") || !strings.Contains(body, "<code>git clone https://gitbay.test/alice/site.git</code>") {
91 t.Fatalf("clone URLs missing:\n%s", body)
92 }
93 if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") {
94 t.Fatalf("README not rendered:\n%s", body)
95 }
96 for _, tab := range []string{">Issues<", ">Merge requests<"} {
97 if !strings.Contains(body, tab) {
98 t.Fatalf("repo header missing %s tab", tab)
99 }
100 }
101
102 // Subdirectory tree and blob with highlighting.
103 status, body = inst.get(t, "/alice/site/tree/main/src")
104 if status != 200 || !strings.Contains(body, "main.go") {
105 t.Fatalf("tree src: %d", status)
106 }
107 status, body = inst.get(t, "/alice/site/blob/main/src/main.go")
108 if status != 200 || !strings.Contains(body, "package") {
109 t.Fatalf("blob: %d", status)
110 }
111
112 // Raw serves exact bytes with nosniff.
113 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/src/main.go", inst.httpPort))
114 if err != nil {
115 t.Fatal(err)
116 }
117 raw, _ := io.ReadAll(resp.Body)
118 resp.Body.Close()
119 if string(raw) != "package main\n\nfunc main() {}\n" {
120 t.Fatalf("raw bytes: %q", raw)
121 }
122 if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
123 t.Fatal("raw missing nosniff")
124 }
125
126 // Log: both commits, with badges matching the M4 states exactly.
127 status, body = inst.get(t, "/alice/site/log")
128 if status != 200 {
129 t.Fatalf("log: %d", status)
130 }
131 if !strings.Contains(body, "badge-verified") || !strings.Contains(body, "signed tip") {
132 t.Fatalf("log missing verified badge:\n%s", body)
133 }
134 if !strings.Contains(body, "badge-unsigned") || !strings.Contains(body, "first commit") {
135 t.Fatalf("log missing unsigned badge:\n%s", body)
136 }
137
138 // Commit page for the signed tip.
139 tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
140 status, body = inst.get(t, "/alice/site/commit/"+tip)
141 if status != 200 || !strings.Contains(body, "badge-verified") || !strings.Contains(body, "alice") {
142 t.Fatalf("commit page: %d\n%s", status, body)
143 }
144
145 // Refs page shows branch and tag.
146 status, body = inst.get(t, "/alice/site/refs")
147 if status != 200 || !strings.Contains(body, "main") || !strings.Contains(body, "v1.0") {
148 t.Fatalf("refs: %d", status)
149 }
150
151 // Archive downloads a valid gzip.
152 resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/archive/main.tar.gz", inst.httpPort))
153 if err != nil {
154 t.Fatal(err)
155 }
156 gz, err := gzip.NewReader(resp.Body)
157 if err != nil {
158 t.Fatalf("archive not gzip: %v", err)
159 }
160 tarBytes, _ := io.ReadAll(gz)
161 resp.Body.Close()
162 if !strings.Contains(string(tarBytes), "README.md") {
163 t.Fatal("archive missing content")
164 }
165
166 // README formats: org-mode renders, HTML renders sanitized, unknown
167 // extensions fall back to plaintext, and richer formats win conflicts.
168 readmeRepo := func(name, file, content string) {
169 t.Helper()
170 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/"+name); code != 0 {
171 t.Fatalf("repo create %s failed", name)
172 }
173 w := t.TempDir()
174 mustGit(t, w, env, "clone", inst.sshURL("alice/"+name), "r")
175 d := filepath.Join(w, "r")
176 os.WriteFile(filepath.Join(d, file), []byte(content), 0o644)
177 mustGit(t, d, env, "checkout", "-q", "-b", "main")
178 mustGit(t, d, env, "add", ".")
179 mustGit(t, d, env, "commit", "-q", "-m", "readme")
180 mustGit(t, d, env, "push", "-q", "origin", "main")
181 }
182
183 readmeRepo("orgdoc", "README.org", "* Heading\n\nSome /emphasis/ here.\n")
184 status, body = inst.get(t, "/alice/orgdoc")
185 if status != 200 || !strings.Contains(body, "headline-1") || !strings.Contains(body, "<em>emphasis</em>") {
186 t.Fatalf("org README not rendered:\n%s", body)
187 }
188
189 readmeRepo("htmldoc", "README.html", "<p id=\"ok\">fine</p><script>alert(1)</script>")
190 status, body = inst.get(t, "/alice/htmldoc")
191 if status != 200 || !strings.Contains(body, "fine</p>") {
192 t.Fatalf("html README not rendered:\n%s", body)
193 }
194 if strings.Contains(body, "<script>alert") {
195 t.Fatal("repo HTML script survived sanitization")
196 }
197
198 readmeRepo("txtdoc", "README.txt", "plain <text> & stuff\n")
199 status, body = inst.get(t, "/alice/txtdoc")
200 if status != 200 || !strings.Contains(body, "plain <text> & stuff") {
201 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
202 }
203
204 // Owner page: the repositories tab lists visible repos only;
205 // unknown owners 404.
206 status, body = inst.get(t, "/alice/-/repositories")
207 if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") {
208 t.Fatalf("owner page: %d", status)
209 }
210 if strings.Contains(body, "secret") {
211 t.Fatal("owner page leaks private repo")
212 }
213 if status, _ := inst.get(t, "/nobody"); status != 404 {
214 t.Fatalf("unknown owner: %d, want 404", status)
215 }
216
217 // Private repo pages: 404, indistinguishable from nonexistent.
218 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} {
219 if status, _ := inst.get(t, p); status != 404 {
220 t.Errorf("GET %s = %d, want 404", p, status)
221 }
222 }
223}
224
225// buildSignedCommitOn adds one SSHSIG-signed commit on top of parent,
226// reusing the M4 fixture machinery.
227func buildSignedCommitOn(t *testing.T, dir string, env []string, parent, subject, email string, signer ssh.Signer) {
228 t.Helper()
229 tree := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", parent+"^{tree}"))
230 specs := []commitSpec{{
231 authorEmail: email,
232 subject: subject,
233 sign: func(p []byte) string {
234 s, err := sig.MarshalSSHSig(signer, p)
235 if err != nil {
236 t.Fatal(err)
237 }
238 return string(s)
239 },
240 }}
241 buildChain(t, dir, env, tree, parent, specs)
242}