package forge import ( "net/http" "net/http/httptest" "net/url" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" ) func pagesGet(h http.Handler, host, path string) *httptest.ResponseRecorder { r := httptest.NewRequest("GET", path, nil) r.Host = host r.RemoteAddr = "192.0.2.1:1000" w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func addEntry(t *testing.T, g Git, branch, mode, name, content string) string { t.Helper() parent, _ := g.Resolve("refs/heads/" + branch) blob, err := g.run([]byte(content), "hash-object", "-w", "--stdin") if err != nil { t.Fatal(err) } index := filepath.Join(t.TempDir(), "index") env := []string{"GIT_INDEX_FILE=" + index} if parent != "" { g.runEnv(env, nil, "read-tree", parent) } if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", mode+","+strings.TrimSpace(string(blob))+","+name); err != nil { t.Fatal(err) } tree, _ := g.runEnv(env, nil, "write-tree") args := []string{"commit-tree", strings.TrimSpace(string(tree))} if parent != "" { args = append(args, "-p", parent) } out, err := g.runEnv(ident("T", "t@example.org", timeNow()), []byte("add "+name), args...) if err != nil { t.Fatal(err) } hash := strings.TrimSpace(string(out)) g.run(nil, "update-ref", "refs/heads/"+branch, hash) return hash } func newRepo(t *testing.T, s *Server, owner User, name string, vis int) (Repo, Git) { t.Helper() repo, err := s.store.CreateRepo(owner, name, "", vis, "") if err != nil { t.Fatal(err) } g := Git{Dir: s.repoDir(repo)} if err := initRepo(g.Dir); err != nil { t.Fatal(err) } return repo, g } func age(t *testing.T, s *Server, u User) { t.Helper() if _, err := s.store.db.Exec(`update users set created = ? where id = ?`, time.Now().Add(-48*time.Hour).Unix(), u.ID); err != nil { t.Fatal(err) } } func TestPages(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") age(t, s, alice) _, g := newRepo(t, s, alice, "pages", Public) main := g.Head() addEntry(t, g, main, "100644", "index.html", "

home

") addEntry(t, g, main, "100644", "about.html", "about me") addEntry(t, g, main, "100644", "style.css", "body{}") addEntry(t, g, main, "100644", "docs/index.html", "docs") addEntry(t, g, main, "120000", "link", "/etc/passwd") addEntry(t, g, main, "100644", "404.html", "missing") host := "alice.example.org" cases := []struct { path, body, ctype string code int }{ {"/", "

home

", "text/html", 200}, {"/index.html", "

home

", "text/html", 200}, {"/about", "about me", "text/html", 200}, {"/style.css", "body{}", "text/css", 200}, {"/docs/", "docs", "text/html", 200}, {"/link", "missing", "text/html", 404}, {"/nope", "missing", "text/html", 404}, {"/../../../etc/passwd", "", "", 404}, {"/%2e%2e/%2e%2e/etc/passwd", "", "", 404}, } for _, c := range cases { w := pagesGet(h, host, c.path) if w.Code != c.code || (c.body != "" && w.Body.String() != c.body) || !strings.HasPrefix(w.Header().Get("Content-Type"), c.ctype) { t.Errorf("%s: %d %q %s", c.path, w.Code, w.Body.String(), w.Header().Get("Content-Type")) } if len(w.Result().Cookies()) > 0 || w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Errorf("%s: cookies or missing nosniff", c.path) } } if w := pagesGet(h, host, "/docs"); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/docs/" { t.Errorf("directory without slash: %d %s", w.Code, w.Header().Get("Location")) } r := httptest.NewRequest("POST", "/", nil) r.Host = host w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusMethodNotAllowed { t.Errorf("post to pages: %d", w.Code) } _, pg := newRepo(t, s, alice, "proj", Public) addEntry(t, pg, pg.Head(), "100644", "README.md", "readme") addEntry(t, pg, "pages", "100644", "index.html", "project site") if w := pagesGet(h, host, "/proj/"); w.Body.String() != "project site" { t.Errorf("project site: %d %q", w.Code, w.Body.String()) } if w := pagesGet(h, host, "/proj"); w.Code != http.StatusMovedPermanently { t.Errorf("project root redirect: %d", w.Code) } _, secret := newRepo(t, s, alice, "secret", Private) addEntry(t, secret, "pages", "100644", "index.html", "private site") if w := pagesGet(h, host, "/secret/"); strings.Contains(w.Body.String(), "private site") { t.Error("private repository served as pages") } for _, host := range []string{"www.example.org", "a.b.example.org", "x_y.example.org"} { if w := pagesGet(h, host, "/"); w.Code != 404 { t.Errorf("%s: %d", host, w.Code) } } if w := pagesGet(h, "alice.example.org.", "/"); w.Body.String() != "

home

" { t.Error("trailing dot host not served as pages") } if w := pagesGet(h, "example.org", "/"); w.Header().Get("Content-Security-Policy") == "" { t.Error("main site lost its headers") } bob, _ := s.store.CreateUser("bob", "correct horse") _, bg := newRepo(t, s, bob, "pages", Public) addEntry(t, bg, bg.Head(), "100644", "index.html", "fresh") if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { t.Errorf("new account served pages: %d", w.Code) } age(t, s, bob) if w := pagesGet(h, "bob.example.org", "/"); w.Code != 200 { t.Errorf("aged account not served: %d", w.Code) } s.store.SetPages("bob", false) if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { t.Errorf("disabled pages served: %d", w.Code) } s.store.SetPages("bob", true) s.store.SetBanned("bob", true) if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { t.Errorf("banned account served pages: %d", w.Code) } } func TestReservedNames(t *testing.T) { s, _ := testServer(t) for _, name := range []string{"www", "mail", "pages"} { if _, err := s.store.CreateUser(name, "correct horse"); err == nil { t.Errorf("registered reserved name %s", name) } } } func TestModeration(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") bob, _ := s.store.CreateUser("bob", "correct horse") boss, _ := s.store.CreateUser("boss", "correct horse") s.store.SetAdmin("boss", true) s.store.CreateRepo(alice, "proj", "", Public, "") s.store.CreateRepo(alice, "hidden", "", Private, "") a, _ := s.store.CreateSession(alice.ID) b, _ := s.store.CreateSession(bob.ID) admin, _ := s.store.CreateSession(boss.ID) if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"spam"}, "body": {"x"}}, b); w.Code != http.StatusSeeOther { t.Fatalf("issue: %d", w.Code) } if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 200 { t.Fatalf("report: %d %s", w.Code, w.Body.String()) } if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 400 { t.Error("duplicate report accepted") } if w := post(h, "/report", url.Values{"kind": {"repo"}, "target": {"alice/hidden"}, "reason": {"x"}}, b); w.Code != 404 { t.Error("report reveals a private repository") } if w := get(h, "/admin?s=reports", admin); !strings.Contains(w.Body.String(), "alice/proj#1") { t.Error("report missing from admin") } if w := get(h, "/admin?s=reports", b); w.Code == 200 { t.Error("non-admin sees reports") } rep := s.store.Reports("open")[0] if w := post(h, "/admin", url.Values{"s": {"reports"}, "action": {"moderate"}, "report": {itoa(rep.ID)}, "do": {"ban"}}, admin); strings.Contains(w.Header().Get("Location"), "error") { t.Fatalf("ban: %s", w.Header().Get("Location")) } if !s.store.IsBanned(bob.ID) || s.store.IsBanned(alice.ID) { t.Error("ban hit the wrong account") } s.store.SetBanned("bob", false) b, _ = s.store.CreateSession(bob.ID) if len(s.store.Reports("open")) != 0 { t.Error("report still open") } if w := post(h, "/block", url.Values{"name": {"bob"}}, a); w.Code != http.StatusSeeOther { t.Fatalf("block: %d", w.Code) } if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"again"}}, b); w.Code != 403 { t.Errorf("blocked user opened an issue: %d", w.Code) } if w := post(h, "/~alice/proj/issues/1/comment", url.Values{"body": {"hi"}}, b); w.Code != 403 { t.Errorf("blocked user commented: %d", w.Code) } if w := post(h, "/~alice/proj/star", nil, b); w.Code != 403 { t.Errorf("blocked user starred: %d", w.Code) } if w := post(h, "/~alice/follow", nil, b); w.Code != 403 { t.Errorf("blocked user followed: %d", w.Code) } if w := post(h, "/~alice/proj/fork", nil, b); w.Code != 403 { t.Errorf("blocked user forked: %d", w.Code) } if !strings.Contains(get(h, "/settings", a).Body.String(), "~bob") { t.Error("blocked list missing from settings") } post(h, "/block", url.Values{"name": {"bob"}, "action": {"unblock"}}, a) if s.store.Blocked(alice.ID, bob.ID) { t.Error("unblock failed") } if w := post(h, "/block", url.Values{"name": {"bob"}}, b); w.Code != 400 { t.Error("blocked yourself") } for i := 0; i < threadsPerHour; i++ { s.store.CreateIssue(Repo{ID: 1}, bob, "t", "") } if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"flood"}}, b); w.Code != 429 { t.Errorf("issue flood not limited: %d", w.Code) } } func itoa(n int64) string { return strconv.FormatInt(n, 10) }