e2e/web_test.go

v1.42.0
gitbay/e2e/web_test.go history · blame · raw

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 &lt;text&gt; &amp; 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}