josie / simplegit

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)
	}
}