| @@ -0,0 +1,213 @@ |
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "net/http" |
| 5 | + "net/http/httptest" |
| 6 | + "net/url" |
| 7 | + "os" |
| 8 | + "os/exec" |
| 9 | + "path/filepath" |
| 10 | + "strings" |
| 11 | + "testing" |
| 12 | +) |
| 13 | + |
| 14 | +func testServer(t *testing.T) (*Server, http.Handler) { |
| 15 | + t.Helper() |
| 16 | + root := t.TempDir() |
| 17 | + store, err := openStore(filepath.Join(root, "gitpocket.db"), false) |
| 18 | + if err != nil { |
| 19 | + t.Fatal(err) |
| 20 | + } |
| 21 | + s := newServer(store, root, "example.org", "gitpocket", "git") |
| 22 | + return s, s.handler() |
| 23 | +} |
| 24 | + |
| 25 | +func post(h http.Handler, path string, form url.Values, cookie string) *httptest.ResponseRecorder { |
| 26 | + r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) |
| 27 | + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 28 | + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 29 | + r.RemoteAddr = "192.0.2.1:1000" |
| 30 | + if cookie != "" { |
| 31 | + r.Header.Set("Cookie", "session="+cookie) |
| 32 | + } |
| 33 | + w := httptest.NewRecorder() |
| 34 | + h.ServeHTTP(w, r) |
| 35 | + return w |
| 36 | +} |
| 37 | + |
| 38 | +func get(h http.Handler, path, cookie string) *httptest.ResponseRecorder { |
| 39 | + r := httptest.NewRequest("GET", path, nil) |
| 40 | + r.RemoteAddr = "192.0.2.1:1000" |
| 41 | + if cookie != "" { |
| 42 | + r.Header.Set("Cookie", "session="+cookie) |
| 43 | + } |
| 44 | + w := httptest.NewRecorder() |
| 45 | + h.ServeHTTP(w, r) |
| 46 | + return w |
| 47 | +} |
| 48 | + |
| 49 | +func session(w *httptest.ResponseRecorder) string { |
| 50 | + for _, c := range w.Result().Cookies() { |
| 51 | + if c.Name == "session" { |
| 52 | + return c.Value |
| 53 | + } |
| 54 | + } |
| 55 | + return "" |
| 56 | +} |
| 57 | + |
| 58 | +func TestRegistrationModes(t *testing.T) { |
| 59 | + s, h := testServer(t) |
| 60 | + if w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}}, ""); w.Code != http.StatusSeeOther || session(w) == "" { |
| 61 | + t.Fatalf("open registration failed: %d", w.Code) |
| 62 | + } |
| 63 | + s.store.SetSetting("registration", "invite") |
| 64 | + if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}}, ""); w.Code != 400 { |
| 65 | + t.Errorf("invite mode let bob in without a code: %d", w.Code) |
| 66 | + } |
| 67 | + code, _ := s.store.CreateInvite() |
| 68 | + if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "invite": {code}}, ""); w.Code != http.StatusSeeOther { |
| 69 | + t.Errorf("invite mode refused a valid code: %d", w.Code) |
| 70 | + } |
| 71 | + s.store.SetSetting("registration", "closed") |
| 72 | + if w := post(h, "/register", url.Values{"name": {"carol"}, "password": {"correct horse"}}, ""); w.Code != 403 { |
| 73 | + t.Errorf("closed mode allowed registration: %d", w.Code) |
| 74 | + } |
| 75 | + if w := get(h, "/", ""); strings.Contains(w.Body.String(), `href="/register"`) { |
| 76 | + t.Error("closed mode still links to registration") |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +func TestLoginAndHeaders(t *testing.T) { |
| 81 | + s, h := testServer(t) |
| 82 | + s.store.CreateUser("alice", "correct horse") |
| 83 | + if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"wrong password"}}, ""); w.Code != 401 { |
| 84 | + t.Errorf("wrong password gave %d", w.Code) |
| 85 | + } |
| 86 | + w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, "") |
| 87 | + if session(w) == "" { |
| 88 | + t.Fatal("login did not start a session") |
| 89 | + } |
| 90 | + for _, header := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options"} { |
| 91 | + if w.Header().Get(header) == "" { |
| 92 | + t.Errorf("missing %s", header) |
| 93 | + } |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +func TestCrossOriginPostRefused(t *testing.T) { |
| 98 | + s, h := testServer(t) |
| 99 | + u, _ := s.store.CreateUser("alice", "correct horse") |
| 100 | + id, _ := s.store.CreateSession(u.ID) |
| 101 | + r := httptest.NewRequest("POST", "/settings/profile", strings.NewReader("display=pwned")) |
| 102 | + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 103 | + r.Header.Set("Sec-Fetch-Site", "cross-site") |
| 104 | + r.Header.Set("Cookie", "session="+id) |
| 105 | + w := httptest.NewRecorder() |
| 106 | + h.ServeHTTP(w, r) |
| 107 | + if w.Code != 403 || s.store.Profile(u.ID).Display == "pwned" { |
| 108 | + t.Errorf("cross site post accepted: %d", w.Code) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +func TestPermissions(t *testing.T) { |
| 113 | + s, h := testServer(t) |
| 114 | + alice, _ := s.store.CreateUser("alice", "correct horse") |
| 115 | + bob, _ := s.store.CreateUser("bob", "correct horse") |
| 116 | + secret, err := s.store.CreateRepo(alice, "secret", "", Private, "") |
| 117 | + if err != nil { |
| 118 | + t.Fatal(err) |
| 119 | + } |
| 120 | + os.MkdirAll(filepath.Dir(s.repoDir(secret)), 0o755) |
| 121 | + initRepo(s.repoDir(secret)) |
| 122 | + if w := get(h, "/~alice/secret", ""); w.Code != 404 { |
| 123 | + t.Errorf("anonymous read of a private repo: %d", w.Code) |
| 124 | + } |
| 125 | + bobSession, _ := s.store.CreateSession(bob.ID) |
| 126 | + if w := get(h, "/~alice/secret", bobSession); w.Code != 404 { |
| 127 | + t.Errorf("stranger read a private repo: %d", w.Code) |
| 128 | + } |
| 129 | + if s.store.CanWrite(secret, &bob) { |
| 130 | + t.Error("stranger can write") |
| 131 | + } |
| 132 | + org, _ := s.store.CreateOrg("team") |
| 133 | + s.store.SetMember(org.ID, bob.ID, true) |
| 134 | + shared, _ := s.store.CreateRepo(org, "shared", "", Private, "") |
| 135 | + if !s.store.CanWrite(shared, &bob) || !s.store.Owns(shared, &bob) || s.store.CanWrite(shared, &alice) { |
| 136 | + t.Error("org membership not respected") |
| 137 | + } |
| 138 | + if _, ok := s.store.CheckPassword("team", ""); ok { |
| 139 | + t.Error("an organisation can log in") |
| 140 | + } |
| 141 | + if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, ""); w.Code != http.StatusSeeOther { |
| 142 | + t.Errorf("anonymous org edit not redirected to login: %d", w.Code) |
| 143 | + } |
| 144 | + aliceSession, _ := s.store.CreateSession(alice.ID) |
| 145 | + if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, aliceSession); w.Code != 403 { |
| 146 | + t.Errorf("non member edited an org: %d", w.Code) |
| 147 | + } |
| 148 | + s.store.AddRedirect("alice", "old", shared.ID) |
| 149 | + if w := get(h, "/~alice/old", ""); w.Code != 404 { |
| 150 | + t.Errorf("redirect leaked a private repo: %d", w.Code) |
| 151 | + } |
| 152 | + if w := get(h, "/~alice/old", bobSession); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/~team/shared" { |
| 153 | + t.Errorf("redirect for a member: %d %s", w.Code, w.Header().Get("Location")) |
| 154 | + } |
| 155 | + if w := get(h, "/admin", bobSession); w.Code != 404 { |
| 156 | + t.Errorf("admin page open to a normal user: %d", w.Code) |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +func TestMergeStrategies(t *testing.T) { |
| 161 | + if _, err := exec.LookPath("git"); err != nil { |
| 162 | + t.Skip("git not installed") |
| 163 | + } |
| 164 | + dir := filepath.Join(t.TempDir(), "r.git") |
| 165 | + if err := initRepo(dir); err != nil { |
| 166 | + t.Fatal(err) |
| 167 | + } |
| 168 | + g := Git{Dir: dir} |
| 169 | + base := commitFile(t, g, "main", "", "a.txt", "one\n") |
| 170 | + feature := commitFile(t, g, "feature", base, "b.txt", "two\n") |
| 171 | + if !g.Mergeable("main", feature) { |
| 172 | + t.Fatal("clean merge reported as conflicting") |
| 173 | + } |
| 174 | + if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err != nil { |
| 175 | + t.Fatal(err) |
| 176 | + } |
| 177 | + if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err == nil { |
| 178 | + t.Error("merged the same head twice") |
| 179 | + } |
| 180 | + left := commitFile(t, g, "left", base, "a.txt", "left\n") |
| 181 | + right := commitFile(t, g, "right", base, "a.txt", "right\n") |
| 182 | + if _, err := g.mergeTree(base, left, right); err == nil { |
| 183 | + t.Error("conflict not detected") |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +func commitFile(t *testing.T, g Git, branch, parent, name, content string) string { |
| 188 | + t.Helper() |
| 189 | + blob, err := g.run([]byte(content), "hash-object", "-w", "--stdin") |
| 190 | + if err != nil { |
| 191 | + t.Fatal(err) |
| 192 | + } |
| 193 | + index := filepath.Join(t.TempDir(), "index") |
| 194 | + env := []string{"GIT_INDEX_FILE=" + index} |
| 195 | + if parent != "" { |
| 196 | + g.runEnv(env, nil, "read-tree", parent) |
| 197 | + } |
| 198 | + if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", "100644,"+strings.TrimSpace(string(blob))+","+name); err != nil { |
| 199 | + t.Fatal(err) |
| 200 | + } |
| 201 | + tree, _ := g.runEnv(env, nil, "write-tree") |
| 202 | + args := []string{"commit-tree", strings.TrimSpace(string(tree))} |
| 203 | + if parent != "" { |
| 204 | + args = append(args, "-p", parent) |
| 205 | + } |
| 206 | + out, err := g.runEnv(ident("T", "t@example.org", timeNow()), []byte("c"), args...) |
| 207 | + if err != nil { |
| 208 | + t.Fatal(err) |
| 209 | + } |
| 210 | + hash := strings.TrimSpace(string(out)) |
| 211 | + g.run(nil, "update-ref", "refs/heads/"+branch, hash) |
| 212 | + return hash |
| 213 | +} |