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)
}
}
}
type pushRig struct {
t *testing.T
s *Server
url string
work string
}
func (p pushRig) git(args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", p.work}, args...)...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=B", "GIT_AUTHOR_EMAIL=b@example.org", "GIT_COMMITTER_NAME=B", "GIT_COMMITTER_EMAIL=b@example.org")
out, err := cmd.CombinedOutput()
return string(out), err
}
func (p pushRig) must(args ...string) string {
p.t.Helper()
out, err := p.git(args...)
if err != nil {
p.t.Fatalf("git %v: %v\n%s", args, err, out)
}
return out
}
func (p pushRig) commit(name, content, message string) {
p.t.Helper()
os.WriteFile(filepath.Join(p.work, name), []byte(content), 0o644)
p.must("add", name)
p.must("commit", "-q", "-m", message)
}
func remote(base, user, repo string) string {
u, _ := url.Parse(base)
u.User = url.UserPassword(user, "correct horse")
u.Path = "/~" + repo
return u.String()
}
func TestAgit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
if testing.Short() {
t.Skip("builds the binary")
}
s, h := testServer(t)
bin := filepath.Join(t.TempDir(), "gitpocket")
if out, err := exec.Command("go", "build", "-o", bin, "..").CombinedOutput(); err != nil {
t.Fatalf("build: %v\n%s", err, out)
}
for _, kind := range []string{"pre-receive", "proc-receive"} {
if err := writeHook(filepath.Join(hooksDir(s.root), kind), hookScript(s.root, "example.org", bin, kind)); err != nil {
t.Fatal(err)
}
}
srv := httptest.NewServer(h)
defer srv.Close()
alice, _ := s.store.CreateUser("alice", "correct horse")
bob, _ := s.store.CreateUser("bob", "correct horse")
repo, g := newRepo(t, s, alice, "proj", Public)
base := commitFile(t, g, g.Head(), "", "a.txt", "one\n")
p := pushRig{t: t, s: s, work: t.TempDir()}
p.must("init", "-q", "-b", "main")
p.must("fetch", "-q", remote(srv.URL, "bob", "alice/proj"), "main")
p.must("reset", "-q", "--hard", "FETCH_HEAD")
p.commit("b.txt", "two\n", "Add b\n\nBody text")
bobURL := remote(srv.URL, "bob", "alice/proj")
for _, ref := range []string{"HEAD:refs/heads/main", "HEAD:refs/heads/evil", "HEAD:refs/tags/v1", "HEAD:refs/pull/9/head"} {
if out, err := p.git("push", bobURL, ref); err == nil {
t.Errorf("non-writer push to %s accepted\n%s", ref, out)
}
}
if head, _ := g.Resolve("refs/heads/main"); head != base {
t.Fatal("main moved after refused pushes")
}
if out, err := p.git("push", "-o", "title=Hello there", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "pulls/1") {
t.Fatalf("agit push failed: %v\n%s", err, out)
}
pull, err := s.store.Pull(repo, 1)
if err != nil || pull.Agit != "main" || pull.Author.ID != bob.ID || pull.Title != "Hello there" || pull.TargetBranch != "main" {
t.Fatalf("pull request not created as expected: %+v %v", pull, err)
}
first := strings.TrimSpace(p.must("rev-parse", "HEAD"))
if got, _ := g.Resolve(pull.Ref()); got != first {
t.Fatalf("pull ref %s, want %s", got, first)
}
p.commit("b.txt", "three\n", "Add b, again")
if out, err := p.git("push", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "updated pull request #1") {
t.Fatalf("agit update failed: %v\n%s", err, out)
}
second := strings.TrimSpace(p.must("rev-parse", "HEAD"))
if got, _ := g.Resolve(pull.Ref()); got != second {
t.Error("pull ref not updated")
}
if n := len(g.RecordRevision(pull)); n != 2 {
t.Errorf("revisions %d, want 2", n)
}
if out, err := p.git("push", bobURL, "HEAD:refs/for/main/other"); err != nil {
t.Fatalf("topic push failed: %v\n%s", err, out)
}
if pr, err := s.store.Pull(repo, 2); err != nil || pr.Agit != "other" || pr.Title != "Add b, again" {
t.Errorf("topic pull request: %+v %v", pr, err)
}
if _, err := p.git("push", bobURL, "HEAD:refs/for/nosuch"); err == nil {
t.Error("push for a missing branch accepted")
}
if _, err := p.git("push", bobURL, base+":refs/for/main/stale"); err == nil {
t.Error("push without new commits accepted")
}
aliceURL := remote(srv.URL, "alice", "alice/proj")
if _, err := p.git("push", "-f", aliceURL, base+":refs/pull/1/head"); err == nil {
t.Error("writer overwrote a pull ref directly")
}
if _, err := p.git("push", aliceURL, "HEAD:refs/heads/main"); err != nil {
t.Error("writer push refused")
}
own, _ := s.store.CreateSession(alice.ID)
if body := get(h, "/~alice/proj/pulls/1", own).Body.String(); !strings.Contains(body, "~bob:main") || strings.Contains(body, "Update from source") {
t.Error("agit pull page does not show the topic")
}
s.store.SetBlock(alice.ID, bob.ID, true)
p.commit("c.txt", "c\n", "Add c")
if _, err := p.git("push", bobURL, "HEAD:refs/for/main/blocked"); err == nil {
t.Error("blocked user opened a pull request")
}
s.store.SetBlock(alice.ID, bob.ID, false)
secret, _ := newRepo(t, s, alice, "secret", Private)
commitFile(t, Git{Dir: s.repoDir(secret)}, "main", "", "a.txt", "x\n")
if _, err := p.git("push", remote(srv.URL, "bob", "alice/secret"), "HEAD:refs/for/main"); err == nil {
t.Error("agit push into a private repository accepted")
}
s.store.AddMirror(repo.ID, "pull", "https://example.com/x.git", "oauth2", nil, time.Hour)
if _, err := p.git("push", aliceURL, "HEAD:refs/heads/mirrored"); err == nil {
t.Error("push into a pull mirror accepted")
}
s.store.db.Exec(`delete from mirrors`)
os.Remove(filepath.Join(hooksDir(s.root), "proc-receive"))
if _, err := p.git("push", aliceURL, "HEAD:refs/heads/nohooks"); err == nil {
t.Error("push accepted without hooks installed")
}
}
func TestProcReceiveParsing(t *testing.T) {
var out bytes.Buffer
for _, in := range []string{"", "0003", "ffff", "zzzz", "0008abc", "000eversion=2\n0000"} {
out.Reset()
if procReceive(t.TempDir(), strings.NewReader(in), &out, &out) == 0 {
t.Errorf("accepted %q", in)
}
}
var buf bytes.Buffer
writePkt(&buf, "version=1\x00push-options")
writePkt(&buf, "")
writePkt(&buf, zeroOID+" "+strings.Repeat("a", 40)+" refs/for/main")
writePkt(&buf, "")
writePkt(&buf, "title=x")
writePkt(&buf, "")
out.Reset()
if procReceive(t.TempDir(), &buf, &out, &out) != 0 || !strings.Contains(out.String(), "ng refs/for/main") {
t.Errorf("command without gitpocket context not refused: %q", out.String())
}
}
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) }
func TestDomains(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
s, h := testServer(t)
for _, bad := range []string{"", "localhost", "127.0.0.1", "example.org", "x.example.org", "a..b", "-a.com", "a_b.com", strings.Repeat("a", 64) + ".com"} {
if _, err := s.cleanDomain(bad); err == nil {
t.Errorf("accepted %q", bad)
}
}
if d, err := s.cleanDomain(" HTTPS://Blog.Example.COM. "); err != nil || d != "blog.example.com" {
t.Errorf("clean: %q %v", d, err)
}
alice, _ := s.store.CreateUser("alice", "correct horse")
bob, _ := s.store.CreateUser("bob", "correct horse")
age(t, s, alice)
_, g := newRepo(t, s, alice, "pages", Public)
addEntry(t, g, g.Head(), "100644", "index.html", "alice blog")
if err := s.store.ClaimDomain(alice.ID, "blog.example.com"); err != nil {
t.Fatal(err)
}
if err := s.store.ClaimDomain(bob.ID, "blog.example.com"); err == nil {
t.Error("two accounts claimed one domain")
}
if w := pagesGet(h, "blog.example.com", "/"); strings.Contains(w.Body.String(), "alice blog") {
t.Error("unverified domain served")
}
own, _ := s.store.CreateSession(alice.ID)
if body := get(h, "/settings", own).Body.String(); !strings.Contains(body, "_gitpocket-pages.blog.example.com") {
t.Error("settings do not show the verification record")
}
s.store.MarkDomain(alice.ID, true)
if w := pagesGet(h, "Blog.Example.com.", "/"); w.Body.String() != "alice blog" || len(w.Result().Cookies()) > 0 {
t.Errorf("verified domain: %d %q", w.Code, w.Body.String())
}
if w := post(h, "/settings/pages", url.Values{"action": {"claim"}, "domain": {"alice.example.org"}}, own); !strings.Contains(w.Header().Get("Location"), "error") {
t.Error("claimed a subdomain of the host")
}
post(h, "/settings/pages", url.Values{"action": {"remove"}}, own)
if _, ok := s.store.DomainOwner("blog.example.com"); ok {
t.Error("removed domain still served")
}
}