97f81af03f521ebc51c64ec84a8649e28d7fa8a0 / internal/web/git_test.go · 9978 bytes · raw
package web
import (
"database/sql"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"git.josie-c.com/josie/simplegit/internal/db"
)
func addUser(t *testing.T, database *sql.DB, username, password string) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if _, err := db.CreateUser(database, username, string(hash)); err != nil {
t.Fatalf("create user %s: %v", username, err)
}
}
func loginAs(t *testing.T, httpServer *httptest.Server, username, password string) *http.Client {
t.Helper()
client := &http.Client{Transport: httpServer.Client().Transport}
client.Jar, _ = cookiejar.New(nil)
resp, err := client.PostForm(httpServer.URL+"/login",
url.Values{"username": {username}, "password": {password}})
if err != nil {
t.Fatalf("POST /login %s: %v", username, err)
}
if body := readAll(t, resp); !strings.Contains(body, `href="/`+username+`"`) {
t.Fatalf("login as %s failed: %q", username, body)
}
return client
}
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func createRepo(t *testing.T, client *http.Client, server *httptest.Server, name, visibility string) {
t.Helper()
resp, err := client.PostForm(server.URL+"/new", url.Values{
"name": {name}, "visibility": {visibility},
})
if err != nil {
t.Fatalf("POST /new %s: %v", name, err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("create %s: status %d body %q", name, resp.StatusCode, body)
}
}
func TestGitPublicCloneAnonymous(t *testing.T) {
httpServer, _, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack")
if err != nil {
t.Fatalf("anonymous clone refs: %v", err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200: %q", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-git-upload-pack-advertisement") {
t.Errorf("Content-Type = %q", ct)
}
}
func TestGitPrivateGate(t *testing.T) {
httpServer, _, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-upload-pack"
resp, err := (&http.Client{}).Get(refsURL)
if err != nil {
t.Fatalf("anonymous private refs: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("anonymous status = %d, want 401", resp.StatusCode)
}
if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
t.Errorf("WWW-Authenticate = %q, want Basic challenge", resp.Header.Get("WWW-Authenticate"))
}
badReq, _ := http.NewRequest("GET", refsURL, nil)
badReq.SetBasicAuth("josie", "wrong")
badResp, err := (&http.Client{}).Do(badReq)
if err != nil {
t.Fatalf("bad basic refs: %v", err)
}
readAll(t, badResp)
if badResp.StatusCode != http.StatusUnauthorized {
t.Errorf("bad basic status = %d, want 401", badResp.StatusCode)
}
goodReq, _ := http.NewRequest("GET", refsURL, nil)
goodReq.SetBasicAuth("josie", "hunter2")
goodResp, err := (&http.Client{}).Do(goodReq)
if err != nil {
t.Fatalf("good basic refs: %v", err)
}
readAll(t, goodResp)
if goodResp.StatusCode != http.StatusOK {
t.Errorf("basic-auth status = %d, want 200", goodResp.StatusCode)
}
signedIn := newLoggedInClient(t, httpServer)
resp, err = signedIn.Get(refsURL)
if err != nil {
t.Fatalf("session refs: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusOK {
t.Errorf("session-cookie status = %d, want 200", resp.StatusCode)
}
}
func TestGitUnknownPaths(t *testing.T) {
httpServer, _, _ := newTestServer(t)
for _, path := range []string{
"/josie/nope.git/info/refs?service=git-upload-pack",
"/other/pub.git/info/refs?service=git-upload-pack",
"/josie/pub/info/refs?service=git-upload-pack",
"/josie/pub.git/not-a-service",
} {
resp, err := (&http.Client{}).Get(httpServer.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, resp.StatusCode)
}
}
}
func TestGitSmartOnly(t *testing.T) {
httpServer, _, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
for _, path := range []string{
"/josie/pub.git/info/refs",
"/josie/pub.git/info/refs?service=nonsense",
} {
resp, err := (&http.Client{}).Get(httpServer.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
body := readAll(t, resp)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("GET %s = %d, want 403: %q", path, resp.StatusCode, body)
}
}
}
func TestGitReceivePackAuth(t *testing.T) {
httpServer, database, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
addUser(t, database, "mallory", "pw")
refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-receive-pack"
resp, err := (&http.Client{}).Get(refsURL)
if err != nil {
t.Fatalf("anonymous receive-pack refs: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("anonymous status = %d, want 401 challenge", resp.StatusCode)
}
ownerReq, _ := http.NewRequest("GET", refsURL, nil)
ownerReq.SetBasicAuth("josie", "hunter2")
ownerResp, err := (&http.Client{}).Do(ownerReq)
if err != nil {
t.Fatalf("owner receive-pack refs: %v", err)
}
readAll(t, ownerResp)
if ownerResp.StatusCode != http.StatusOK {
t.Errorf("owner status = %d, want 200", ownerResp.StatusCode)
}
otherReq, _ := http.NewRequest("GET", refsURL, nil)
otherReq.SetBasicAuth("mallory", "pw")
otherResp, err := (&http.Client{}).Do(otherReq)
if err != nil {
t.Fatalf("non-owner receive-pack refs: %v", err)
}
readAll(t, otherResp)
if otherResp.StatusCode != http.StatusForbidden {
t.Errorf("non-owner status = %d, want 403", otherResp.StatusCode)
}
}
func TestGitRefsPinsAuthorizedService(t *testing.T) {
httpServer, database, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
addUser(t, database, "mallory", "pw")
refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack&service=git-receive-pack"
req, _ := http.NewRequest("GET", refsURL, nil)
req.SetBasicAuth("mallory", "pw")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("dup-service refs: %v", err)
}
readAll(t, resp)
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "git-upload-pack-advertisement") {
t.Errorf("Content-Type = %q, want upload-pack advertisement (read auth pins the service)", ct)
}
}
func TestGitPushOwner(t *testing.T) {
httpServer, _, dataDir := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
u, err := url.Parse(httpServer.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
repoURL := "http://josie:hunter2@" + u.Host + "/josie/pub.git"
work := filepath.Join(t.TempDir(), "work")
runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
runGit(t, work, "add", ".")
runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "first")
runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
if out, err := exec.Command("git", "-C", bare, "rev-parse", "--verify", "refs/heads/main").CombinedOutput(); err != nil {
t.Fatalf("pushed ref missing: %v: %s", err, out)
}
}
func TestGitPushNonOwnerDenied(t *testing.T) {
httpServer, database, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
addUser(t, database, "mallory", "pw")
req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub.git/git-receive-pack",
strings.NewReader("x"))
req.SetBasicAuth("mallory", "pw")
req.Header.Set("Content-Type", "application/x-git-receive-pack-request")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("POST receive-pack as non-owner: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("non-owner push = %d, want 403", resp.StatusCode)
}
}
// Basic-auth failures on the git endpoints share the login budget: the web
// password is a git credential, so guessing here is throttled like /login.
func TestGitBasicAuthRateLimited(t *testing.T) {
httpServer, _, _ := newTestServer(t)
createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-receive-pack"
for i := 0; i < loginAttempts; i++ {
req, err := http.NewRequest("GET", refsURL, nil)
if err != nil {
t.Fatalf("request %d: %v", i+1, err)
}
req.SetBasicAuth("josie", "wrong")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("attempt %d: %v", i+1, err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
}
}
req, _ := http.NewRequest("GET", refsURL, nil)
req.SetBasicAuth("josie", "wrong")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("limited attempt: %v", err)
}
readAll(t, resp)
if resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
}
}