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