package forge import ( "bytes" "encoding/json" "fmt" "image" "image/png" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" ) func testServer(t *testing.T) (*Server, http.Handler) { t.Helper() root := t.TempDir() store, err := openStore(filepath.Join(root, "gitpocket.db"), false) if err != nil { t.Fatal(err) } s := newServer(store, root, "example.org", "gitpocket", "git") return s, s.handler() } func post(h http.Handler, path string, form url.Values, cookie string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Sec-Fetch-Site", "same-origin") r.RemoteAddr = "192.0.2.1:1000" if cookie != "" { r.Header.Set("Cookie", "session="+cookie) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func get(h http.Handler, path, cookie string) *httptest.ResponseRecorder { r := httptest.NewRequest("GET", path, nil) r.RemoteAddr = "192.0.2.1:1000" if cookie != "" { r.Header.Set("Cookie", "session="+cookie) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func session(w *httptest.ResponseRecorder) string { for _, c := range w.Result().Cookies() { if c.Name == "session" { return c.Value } } return "" } func TestRegistrationModes(t *testing.T) { s, h := testServer(t) if w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"alice@example.org"}}, ""); w.Code != http.StatusSeeOther || session(w) == "" { t.Fatalf("open registration failed: %d", w.Code) } s.store.SetSetting("registration", "invite") if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "email": {"bob@example.org"}}, ""); w.Code != 400 { t.Errorf("invite mode let bob in without a code: %d", w.Code) } code, _ := s.store.CreateInvite() if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "email": {"bob@example.org"}, "invite": {code}}, ""); w.Code != http.StatusSeeOther { t.Errorf("invite mode refused a valid code: %d", w.Code) } s.store.SetSetting("registration", "closed") if w := post(h, "/register", url.Values{"name": {"carol"}, "password": {"correct horse"}, "email": {"carol@example.org"}}, ""); w.Code != 403 { t.Errorf("closed mode allowed registration: %d", w.Code) } if w := get(h, "/", ""); strings.Contains(w.Body.String(), `href="/register"`) { t.Error("closed mode still links to registration") } } func TestLoginAndHeaders(t *testing.T) { s, h := testServer(t) s.store.CreateUser("alice", "correct horse") if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"wrong password"}}, ""); w.Code != 401 { t.Errorf("wrong password gave %d", w.Code) } w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}, "email": {"alice@example.org"}}, "") if session(w) == "" { t.Fatal("login did not start a session") } for _, header := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options"} { if w.Header().Get(header) == "" { t.Errorf("missing %s", header) } } } func TestCrossOriginPostRefused(t *testing.T) { s, h := testServer(t) u, _ := s.store.CreateUser("alice", "correct horse") id, _ := s.store.CreateSession(u.ID) r := httptest.NewRequest("POST", "/settings/profile", strings.NewReader("display=pwned")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Sec-Fetch-Site", "cross-site") r.Header.Set("Cookie", "session="+id) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 403 || s.store.Profile(u.ID).Display == "pwned" { t.Errorf("cross site post accepted: %d", w.Code) } } func TestPermissions(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") bob, _ := s.store.CreateUser("bob", "correct horse") secret, err := s.store.CreateRepo(alice, "secret", "", Private, "") if err != nil { t.Fatal(err) } os.MkdirAll(filepath.Dir(s.repoDir(secret)), 0o755) initRepo(s.repoDir(secret)) if w := get(h, "/~alice/secret", ""); w.Code != 404 { t.Errorf("anonymous read of a private repo: %d", w.Code) } bobSession, _ := s.store.CreateSession(bob.ID) if w := get(h, "/~alice/secret", bobSession); w.Code != 404 { t.Errorf("stranger read a private repo: %d", w.Code) } if s.store.CanWrite(secret, &bob) { t.Error("stranger can write") } org, _ := s.store.CreateOrg("team") s.store.SetMember(org.ID, bob.ID, true) shared, _ := s.store.CreateRepo(org, "shared", "", Private, "") if !s.store.CanWrite(shared, &bob) || !s.store.Owns(shared, &bob) || s.store.CanWrite(shared, &alice) { t.Error("org membership not respected") } if _, ok := s.store.CheckPassword("team", ""); ok { t.Error("an organisation can log in") } if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, ""); w.Code != http.StatusSeeOther { t.Errorf("anonymous org edit not redirected to login: %d", w.Code) } aliceSession, _ := s.store.CreateSession(alice.ID) if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, aliceSession); w.Code != 403 { t.Errorf("non member edited an org: %d", w.Code) } s.store.AddRedirect("alice", "old", shared.ID) if w := get(h, "/~alice/old", ""); w.Code != 404 { t.Errorf("redirect leaked a private repo: %d", w.Code) } if w := get(h, "/~alice/old", bobSession); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/~team/shared" { t.Errorf("redirect for a member: %d %s", w.Code, w.Header().Get("Location")) } if w := get(h, "/admin", bobSession); w.Code != 404 { t.Errorf("admin page open to a normal user: %d", w.Code) } } func TestMergeStrategies(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } dir := filepath.Join(t.TempDir(), "r.git") if err := initRepo(dir); err != nil { t.Fatal(err) } g := Git{Dir: dir} base := commitFile(t, g, "main", "", "a.txt", "one\n") feature := commitFile(t, g, "feature", base, "b.txt", "two\n") if !g.Mergeable("main", feature) { t.Fatal("clean merge reported as conflicting") } if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err != nil { t.Fatal(err) } if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err == nil { t.Error("merged the same head twice") } left := commitFile(t, g, "left", base, "a.txt", "left\n") right := commitFile(t, g, "right", base, "a.txt", "right\n") if _, err := g.mergeTree(base, left, right); err == nil { t.Error("conflict not detected") } } func commitFile(t *testing.T, g Git, branch, parent, name, content string) string { t.Helper() 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", "100644,"+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("c"), args...) if err != nil { t.Fatal(err) } hash := strings.TrimSpace(string(out)) g.run(nil, "update-ref", "refs/heads/"+branch, hash) return hash } func TestWorkplaceLink(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") s.store.CreateOrg("team") s.store.UpdateProfile(alice.ID, Profile{Workplace: "@Team"}) if body := get(h, "/~alice", "").Body.String(); !strings.Contains(body, `@Team`) { t.Error("workplace naming an org is not linked") } s.store.UpdateProfile(alice.ID, Profile{Workplace: "Nowhere Ltd"}) if body := get(h, "/~alice", "").Body.String(); !strings.Contains(body, "Nowhere Ltd") || strings.Contains(body, `">Nowhere Ltd`) { t.Error("unknown workplace should be plain text") } } func TestSignupEmail(t *testing.T) { s, h := testServer(t) if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}}, ""); w.Code != 400 { t.Errorf("registration without an email allowed: %d", w.Code) } if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "email": {"b@example.org"}}, ""); w.Code != http.StatusSeeOther || session(w) == "" { t.Errorf("registration with an email failed: %d", w.Code) } bob, _ := s.store.UserByName("bob") if a := s.store.Account(bob.ID); a.Email != "b@example.org" || a.Verified { t.Error("email not stored unverified") } } func TestAdminSections(t *testing.T) { s, h := testServer(t) admin, _ := s.store.CreateUser("root", "correct horse") s.store.SetAdmin("root", true) id, _ := s.store.CreateSession(admin.ID) for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "bogus"} { if w := get(h, "/admin?s="+sec, id); w.Code != 200 { t.Errorf("section %s: %d", sec, w.Code) } } if w := post(h, "/admin", url.Values{"s": {"signup"}, "action": {"signup"}, "mode": {"closed"}}, id); w.Code != http.StatusSeeOther || s.store.Registration() != "closed" { t.Errorf("registration mode not saved: %d %s", w.Code, s.store.Registration()) } if w := post(h, "/admin", url.Values{"s": {"signup"}, "action": {"signup"}, "mode": {"sometimes"}}, id); !strings.Contains(w.Header().Get("Location"), "error") { t.Error("bad registration mode accepted") } } func TestPins(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") mallory, _ := s.store.CreateUser("mallory", "correct horse") var ids []int64 for _, n := range []string{"a", "b", "c", "d", "e", "f", "g"} { r, _ := s.store.CreateRepo(alice, n, "", Public, "") ids = append(ids, r.ID) } if strings.Contains(get(h, "/~alice", "").Body.String(), `href="/~alice/a"`) { t.Error("unpinned repos shown on the profile") } own, _ := s.store.CreateSession(alice.ID) other, _ := s.store.CreateSession(mallory.ID) names := map[int64]string{} for i, id := range ids { names[id] = string(rune('a' + i)) } pin := func(id int64, cookie string) *httptest.ResponseRecorder { return post(h, "/~alice/"+names[id]+"/settings", url.Values{"visibility": {"public"}, "pinned": {"on"}}, cookie) } if pin(ids[0], other); len(s.store.Pins(alice.ID)) != 0 { t.Error("someone else pinned alice's repo") } pin(ids[2], own) pin(ids[0], own) body := get(h, "/~alice", "").Body.String() if !strings.Contains(body, `href="/~alice/c"`) || strings.Index(body, `href="/~alice/c"`) > strings.Index(body, `href="/~alice/a"`) || strings.Contains(body, `href="/~alice/b"`) { t.Error("profile does not show exactly the pinned repos in pin order") } for _, id := range ids[1:] { pin(id, own) } if n := len(s.store.Pins(alice.ID)); n != maxPins { t.Errorf("%d pins, want the cap of %d", n, maxPins) } post(h, "/~alice/a/settings", url.Values{"visibility": {"public"}}, own) if s.store.Pins(alice.ID)[ids[0]] != 0 { t.Error("unticking the box did not unpin") } if w := get(h, "/~alice/repos", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `href="/~alice/g"`) { t.Error("repos page does not list every repo") } } func TestProfileReadme(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") repo, _ := s.store.CreateRepo(alice, "alice", "", Public, "") g := Git{Dir: s.repoDir(repo)} if err := initRepo(g.Dir); err != nil { t.Fatal(err) } commitFile(t, g, g.Head(), "", "README.md", "# Hello\n\n\n") body := get(h, "/~alice", "").Body.String() if !strings.Contains(body, `class="intro"`) || !strings.Contains(body, "Hello") || strings.Contains(body, " ![a](" + got.URL + ")"}}, c) if body := p.Body.String(); !strings.Contains(body, "hi") || strings.Contains(body, "1<`) { t.Fatalf("owner not notified:\n%s", body) } if strings.Contains(get(h, "/", cb).Body.String(), `class="badge"`) { t.Error("actor notified about their own action") } s.store.AddIssueComment(issue.ID, carol, "same here") s.store.Notify(watch(), carol, repo, "issue", issue.Number, issue.Title, "commented on") if n := s.store.Notifications(alice.ID, 100); len(n) != 1 || n[0].Actor != "carol" || !n[0].Unread { t.Errorf("thread not collapsed into one entry: %+v", n) } s.store.Notify(watch(), alice, repo, "issue", issue.Number, issue.Title, "closed") if n := s.store.Notifications(carol.ID, 100); len(n) != 1 || n[0].Verb != "closed" { t.Errorf("participant not notified: %+v", n) } get(h, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), cb) if n := s.store.Notifications(bob.ID, 100); len(n) != 1 || n[0].Unread { t.Errorf("viewing the issue left it unread: %+v", n) } if w := post(h, "/notifications", url.Values{}, cc); w.Code != http.StatusSeeOther || s.store.Notifications(carol.ID, 100)[0].Unread { t.Error("mark all as read did nothing") } s.store.UpdateRepo(repo.ID, "", Private, "") if n := s.store.Notifications(carol.ID, 100); len(n) != 0 { t.Errorf("private repository leaked through notifications: %+v", n) } } func TestProfileLinks(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") c, _ := s.store.CreateSession(alice.ID) form := url.Values{"display": {"Alice"}, "website": {"example.org", "", "https://a.example/x", "http://b.example", "c.example", "d.example"}} if w := post(h, "/settings/profile", form, c); w.Code != http.StatusSeeOther { t.Fatalf("saving profile gave %d", w.Code) } links := s.store.Profile(alice.ID).Links() if len(links) != 4 || links[0] != "https://example.org" || links[2] != "http://b.example" { t.Errorf("links stored as %q", links) } if body := get(h, "/~alice", "").Body.String(); strings.Count(body, `rel="nofollow me"`) != 4 { t.Error("profile does not show four links") } } func TestPostThrottle(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") c, _ := s.store.CreateSession(alice.ID) limited := false for i := 0; i < 80 && !limited; i++ { limited = post(h, "/notifications", url.Values{}, c).Code == http.StatusTooManyRequests } if !limited { t.Error("a logged in user can post without limit") } if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, ""); w.Code == http.StatusTooManyRequests { t.Error("anonymous posts share the user bucket") } } func TestFollows(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") bob, _ := s.store.CreateUser("bob", "correct horse") org, _ := s.store.CreateOrg("crew") c, _ := s.store.CreateSession(alice.ID) if w := post(h, "/~bob/follow", nil, c); w.Code != http.StatusSeeOther { t.Fatalf("follow gave %d", w.Code) } post(h, "/~alice/follow", nil, c) post(h, "/~crew/follow", nil, c) if followers, following := s.store.FollowCounts(bob.ID); followers != 1 || following != 0 { t.Errorf("bob has %d followers, %d following", followers, following) } if s.store.Follows(alice.ID, alice.ID) { t.Error("followed self") } if !s.store.Follows(alice.ID, org.ID) { t.Error("could not follow an organization") } if body := get(h, "/~crew/followers", "").Body.String(); !strings.Contains(body, "~alice") { t.Error("organization follower not listed") } if get(h, "/~crew/following", "").Code != http.StatusNotFound { t.Error("organization has a following page") } if body := get(h, "/~bob/followers", "").Body.String(); !strings.Contains(body, "~alice") { t.Error("follower not listed") } if body := get(h, "/~bob", c).Body.String(); !strings.Contains(body, "Unfollow") { t.Error("profile does not offer unfollow") } post(h, "/~bob/follow", nil, c) if s.store.Follows(alice.ID, bob.ID) { t.Error("second post did not unfollow") } if w := post(h, "/~bob/follow", nil, ""); w.Code == http.StatusSeeOther && s.store.Follows(0, bob.ID) { t.Error("anonymous follow stored") } } func TestRename(t *testing.T) { s, h := testServer(t) alice, _ := s.store.CreateUser("alice", "correct horse") s.store.CreateUser("bob", "correct horse") c, _ := s.store.CreateSession(alice.ID) if w := post(h, "/create", url.Values{"name": {"proj"}, "visibility": {"public"}}, c); w.Code != http.StatusSeeOther { t.Fatalf("create gave %d", w.Code) } post(h, "/settings/username", url.Values{"name": {"bob"}, "password": {"correct horse"}}, c) post(h, "/settings/username", url.Values{"name": {"alicia"}, "password": {"wrong"}}, c) if u, _ := s.store.userByID(alice.ID); u.Name != "alice" { t.Fatalf("renamed to %s despite a taken name or a wrong password", u.Name) } if w := post(h, "/settings/username", url.Values{"name": {"alicia"}, "password": {"correct horse"}}, c); w.Code != http.StatusSeeOther { t.Fatalf("rename gave %d", w.Code) } if get(h, "/~alicia/proj", "").Code != http.StatusOK { t.Error("repository not served under the new name") } if w := get(h, "/~alice/proj/log", ""); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/~alicia/proj/log" { t.Errorf("old path gave %d to %q", w.Code, w.Header().Get("Location")) } if repo, ok := s.store.Redirect("alice", "proj"); !ok || repo.Owner.Name != "alicia" { t.Error("clone path under the old name does not resolve") } s.store.CreateUser("alice", "correct horse") if get(h, "/~alice", "").Code != http.StatusOK { t.Error("new owner of the old name is still redirected") } if _, ok := s.store.Redirect("alice", "proj"); ok { t.Error("old repository path still resolves after the name was taken") } }