package forge
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"os"
"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 TestSeal(t *testing.T) {
root := t.TempDir()
box, err := seal(root, "hunter2")
if err != nil || bytes.Contains(box, []byte("hunter2")) {
t.Fatalf("seal: %v", err)
}
if got, err := unseal(root, box); err != nil || got != "hunter2" {
t.Errorf("unseal: %q %v", got, err)
}
box[len(box)-1] ^= 1
if _, err := unseal(root, box); err == nil {
t.Error("tampered credential accepted")
}
if fi, _ := os.Stat(filepath.Join(root, "mirror.key")); fi.Mode().Perm() != 0o600 {
t.Errorf("key mode %v", fi.Mode().Perm())
}
}
func TestMirrorSettings(t *testing.T) {
s, h := testServer(t)
alice, _ := s.store.CreateUser("alice", "correct horse")
bob, _ := s.store.CreateUser("bob", "correct horse")
repo, g := newRepo(t, s, alice, "proj", Public)
commitFile(t, g, g.Head(), "", "a.txt", "one\n")
own, _ := s.store.CreateSession(alice.ID)
other, _ := s.store.CreateSession(bob.ID)
for _, bad := range []string{"http://example.com/x.git", "https://127.0.0.1/x.git", "https://user:pw@example.com/x.git", "file:///etc", "https://localhost/x"} {
if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {bad}}, own); !strings.Contains(w.Header().Get("Location"), "error") {
t.Errorf("mirror to %s accepted", bad)
}
}
if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {"https://example.com/x.git"}, "token": {"tok"}}, other); w.Code != 403 {
t.Errorf("non-owner added a mirror: %d", w.Code)
}
if len(s.store.Mirrors(repo.ID)) != 0 {
t.Fatal("mirror stored after refusals")
}
if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"delete"}, "id": {"1"}}, other); w.Code != 403 {
t.Errorf("non-owner touched mirrors: %d", w.Code)
}
if w := post(h, "/~alice/proj/mirrors", url.Values{"action": {"add"}, "url": {"https://example.com/x.git"}, "login": {"bad:name"}}, own); !strings.Contains(w.Header().Get("Location"), "error") {
t.Error("login with a colon accepted")
}
box, _ := seal(s.root, "s3cr3t-token-value")
s.store.AddMirror(repo.ID, "push", "https://example.com/x.git", "oauth2", box, 0)
if body := get(h, "/~alice/proj/settings", own).Body.String(); strings.Contains(body, "s3cr3t") || !strings.Contains(body, "https://example.com/x.git") {
t.Error("settings page leaks a token")
}
if s.store.IsPullMirror(repo.ID) {
t.Error("push mirror counted as pull mirror")
}
s.store.AddMirror(repo.ID, "pull", "https://example.com/y.git", "oauth2", nil, time.Hour)
if err := s.store.AddMirror(repo.ID, "pull", "https://example.com/z.git", "oauth2", nil, time.Hour); err == nil {
t.Error("second pull mirror accepted")
}
if body := get(h, "/~alice/proj/new/"+g.Head()+"/x.txt", own); body.Code != 403 {
t.Errorf("web edit on a pull mirror: %d", body.Code)
}
}
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) }