package main
import (
"bytes"
"encoding/json"
"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, " "}}, c)
if body := p.Body.String(); !strings.Contains(body, "hi") || strings.Contains(body, "