aboutsummaryrefslogtreecommitdiff
path: root/httpgit_test.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /httpgit_test.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'httpgit_test.go')
-rw-r--r--httpgit_test.go457
1 files changed, 457 insertions, 0 deletions
diff --git a/httpgit_test.go b/httpgit_test.go
new file mode 100644
index 0000000..307d724
--- /dev/null
+++ b/httpgit_test.go
@@ -0,0 +1,457 @@
+package main
+
+import (
+ "bytes"
+ "crypto/rand"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// runGit drives the real git binary, which is what will be talking to the
+// forge.
+func runGit(t *testing.T, dir string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_TERMINAL_PROMPT=0", // fail rather than block on a prompt
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func addRepo(t *testing.T, f *Forge, path string, repo Repo) {
+ t.Helper()
+ if repo.Labels == nil {
+ repo.Labels = []string{}
+ }
+ if repo.Grants == nil {
+ repo.Grants = map[string]string{}
+ }
+ if err := f.store.CreateRepo(path, repo, f.store.forgeAuthor()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func addToken(t *testing.T, f *Forge, name, repo string) string {
+ t.Helper()
+ secret, token := mintToken(repo)
+ err := f.store.UpdateUsers("users: mint a push token for "+name, f.store.forgeAuthor(),
+ func(m map[string]User) error {
+ u := m[name]
+ u.Tokens = append(u.Tokens, token)
+ m[name] = u
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return secret
+}
+
+func TestGitOverHTTP(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addUser(t, f, "eve", "hunter2hunter2")
+ addRepo(t, f, "infra/dns", Repo{
+ Desc: "zones", Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled})
+
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ token := addToken(t, f, "alice", "infra/dns")
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+token+"@", 1)
+
+ refs := func(url string) *http.Response {
+ t.Helper()
+ resp, err := srv.Client().Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ return resp
+ }
+
+ // A public repository read anonymously must never see a challenge, or
+ // every clone starts with a password prompt.
+ public := refs(srv.URL + "/notes.git/info/refs?service=git-upload-pack")
+ if public.StatusCode != http.StatusOK {
+ t.Errorf("anonymous read of a public repo: %d", public.StatusCode)
+ }
+ if got := public.Header.Get("WWW-Authenticate"); got != "" {
+ t.Errorf("anonymous read of a public repo challenged: %q", got)
+ }
+
+ // A private one must, so that git knows to send credentials.
+ private := refs(srv.URL + "/infra/dns.git/info/refs?service=git-upload-pack")
+ if private.StatusCode != http.StatusUnauthorized {
+ t.Errorf("anonymous read of a private repo: %d", private.StatusCode)
+ }
+ if private.Header.Get("WWW-Authenticate") == "" {
+ t.Error("anonymous read of a private repo did not challenge")
+ }
+
+ // The tracker is readable and unpushable, even by an account that may push
+ // code.
+ if got := refs(authed + "/infra/dns.issues.git/info/refs?service=git-receive-pack"); got.StatusCode != http.StatusForbidden {
+ t.Errorf("push to the issues repo: %d", got.StatusCode)
+ }
+ // A wiki that is disabled has no repository at all, which is said to an
+ // account that may read the repository.
+ if got := refs(authed + "/infra/dns.wiki.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound {
+ t.Errorf("a disabled wiki: %d", got.StatusCode)
+ }
+ // Anonymous is challenged for whatever it may not read, there or not, so a
+ // 401 beside a 404 cannot say which private names are taken.
+ for _, path := range []string{"/infra/dns.wiki.git", "/nothing.git", "/infra/nothing.git"} {
+ got := refs(srv.URL + path + "/info/refs?service=git-upload-pack")
+ if got.StatusCode != http.StatusUnauthorized || got.Header.Get("WWW-Authenticate") == "" {
+ t.Errorf("anonymous read of %s: %d, wanted a challenge", path, got.StatusCode)
+ }
+ }
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/infra/dns.git", "dns"); err != nil {
+ t.Fatalf("clone with a token: %v\n%s", err, out)
+ }
+ clone := filepath.Join(work, "dns")
+ if err := os.WriteFile(filepath.Join(clone, "zone"), []byte("example.com. IN A 192.0.2.1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := runGit(t, clone, "add", "zone"); err != nil {
+ t.Fatalf("add: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, clone, "commit", "-m", "dns: add a zone"); err != nil {
+ t.Fatalf("commit: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, clone, "push", "origin", "master"); err != nil {
+ t.Fatalf("push with a token: %v\n%s", err, out)
+ }
+
+ bare := filepath.Join(f.store.reposDir, "infra/dns.git")
+ if out, err := runGit(t, bare, "rev-parse", "master"); err != nil {
+ t.Fatalf("the push did not land: %v\n%s", err, out)
+ }
+
+ // receive.unpackLimit is passed in the environment, so the push arrives as
+ // one packfile and the repository's own config is still untouched.
+ packs, err := filepath.Glob(filepath.Join(bare, "objects/pack/*.pack"))
+ if err != nil || len(packs) != 1 {
+ t.Errorf("expected one packfile, got %v", packs)
+ }
+ config, err := os.ReadFile(filepath.Join(bare, "config"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(config), "unpackLimit") || strings.Contains(string(config), "gc") {
+ t.Errorf("git-forge wrote into the repository's config:\n%s", config)
+ }
+
+ // A token is scoped to one repository and is not a password.
+ wrong := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+ if out, err := runGit(t, clone, "push", wrong+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("a token for another repository pushed:\n%s", out)
+ }
+ byPassword := strings.Replace(srv.URL, "http://", "http://alice:hunter2hunter2@", 1)
+ if out, err := runGit(t, clone, "push", byPassword+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("an account password pushed:\n%s", out)
+ }
+ // A grant on another repository is not a grant on this one.
+ eve := strings.Replace(srv.URL, "http://", "http://eve:"+addToken(t, f, "eve", "infra/dns")+"@", 1)
+ if out, err := runGit(t, clone, "push", eve+"/infra/dns.git", "master"); err == nil {
+ t.Errorf("an account with no write grant pushed:\n%s", out)
+ }
+ // An account that may not read it is told what the web tells it: nothing.
+ if got := refs(eve + "/infra/dns.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound {
+ t.Errorf("a token without a read grant: %d", got.StatusCode)
+ }
+}
+
+// git sends whatever the credential helper holds, and the name is folded to
+// find the account. Everything after that keys off the account rather than what
+// was typed, or users.json gains a record it would refuse to load next start.
+func TestTokenNameIsTheAccountsOwn(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ secret := addToken(t, f, "alice", "notes")
+
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://Alice:"+secret+"@", 1)
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil {
+ t.Fatalf("a token sent under a capitalised name: %v\n%s", err, out)
+ }
+
+ if users := f.store.Users(); len(users) != 1 {
+ t.Errorf("one authentication left %d accounts: %v", len(users), users)
+ }
+ if u, _ := f.store.User("alice"); len(u.Tokens) != 1 || u.Tokens[0].Used != today() {
+ t.Errorf("the token that was used is not the one marked used: %+v", u.Tokens)
+ }
+ if _, err := openStore(f.store.dir); err != nil {
+ t.Errorf("the datadir no longer opens: %v", err)
+ }
+}
+
+// git sends anything past its http.postBuffer with chunked transfer encoding,
+// and net/http/cgi refuses a chunked body. Every other push here is small
+// enough to hide it, so this one is over the line and is checked for coming
+// back whole rather than merely being accepted.
+func TestPushPastThePostBuffer(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"alice": grantWrite},
+ })
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil {
+ t.Fatalf("clone: %v\n%s", err, out)
+ }
+ // Random, so that it does not compress back under the buffer.
+ blob := make([]byte, 4<<20)
+ if _, err := rand.Read(blob); err != nil {
+ t.Fatal(err)
+ }
+ code := filepath.Join(work, "code")
+ if err := os.WriteFile(filepath.Join(code, "blob"), blob, 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "notes: a big one"}} {
+ if out, err := runGit(t, code, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ if out, err := runGit(t, code, "push", "origin", "master"); err != nil {
+ t.Fatalf("a push past the post buffer was refused: %v\n%s", err, out)
+ }
+
+ back := t.TempDir()
+ if out, err := runGit(t, back, "clone", authed+"/notes.git", "again"); err != nil {
+ t.Fatalf("cloning it back: %v\n%s", err, out)
+ }
+ got, err := os.ReadFile(filepath.Join(back, "again", "blob"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, blob) {
+ t.Errorf("%d bytes came back, of the %d that went in", len(got), len(blob))
+ }
+}
+
+// A token is a credential and not a permission: minting asks only that the
+// account can reach the repository, and what it may do is decided on arrival.
+// Gating the mint on the write grant instead would lock a read grant out of
+// cloning and a wiki editor out of pushing a page.
+func TestTokenCarriesTheAccountsOwnAccess(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "docs", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featMembers,
+ Grants: map[string]string{"docs": grantRead},
+ })
+ h := f.routes()
+ session := signIn(t, f, h, "docs")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}}
+
+ w := post(h, "/settings/tokens", form, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("a read grant could not mint a token: status %d", w.Code)
+ }
+ secret := between(w.Body.String(), `<pre id="token">`, "</pre>")
+ if secret == "" {
+ t.Fatal("no token was shown")
+ }
+
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://docs:"+secret+"@", 1)
+ work := t.TempDir()
+
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil {
+ t.Fatalf("a read grant could not clone a private repository: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil {
+ t.Fatalf("cloning the wiki: %v\n%s", err, out)
+ }
+
+ // The wiki knob says this account may write documentation.
+ wiki := filepath.Join(work, "wiki")
+ if err := os.WriteFile(filepath.Join(wiki, "index.md"), []byte("# hello\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-m", "wiki: a page"}} {
+ if out, err := runGit(t, wiki, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil {
+ t.Errorf("a wiki editor could not push the wiki: %v\n%s", err, out)
+ }
+
+ // The same token must not push code, because the account cannot.
+ code := filepath.Join(work, "code")
+ if out, err := runGit(t, code, "commit", "--allow-empty", "-m", "nope"); err != nil {
+ t.Fatalf("commit: %v\n%s", err, out)
+ }
+ if out, err := runGit(t, code, "push", "origin", "master"); err == nil {
+ t.Errorf("a read grant pushed code with a token:\n%s", out)
+ }
+}
+
+// git has no opinion about how large a push may be. The wiki is the case that
+// matters, since its knob can be set to any account at all.
+func TestPushIsBounded(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ srv := httptest.NewServer(f.routes())
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1)
+
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil {
+ t.Fatalf("clone: %v\n%s", err, out)
+ }
+ wiki := filepath.Join(work, "wiki")
+ commit := func(name string, body []byte) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(wiki, name), body, 0600); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "wiki: " + name}} {
+ if out, err := runGit(t, wiki, args...); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ }
+
+ commit("index.md", []byte("# a page\n"))
+ if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil {
+ t.Fatalf("a page sized push was refused: %v\n%s", err, out)
+ }
+
+ // Random, so that it does not compress: the bound is on the pack, and a
+ // megabyte of zeroes would sail through one meant to stop a gigabyte.
+ big := make([]byte, maxPushWiki+1<<20)
+ if _, err := rand.Read(big); err != nil {
+ t.Fatal(err)
+ }
+ commit("big.md", big)
+ out, err := runGit(t, wiki, "push", "origin", "master")
+ if err == nil {
+ t.Fatalf("a push past the bound was accepted:\n%s", out)
+ }
+ if !strings.Contains(out, "maximum allowed size") {
+ t.Errorf("refused for some reason other than the bound:\n%s", out)
+ }
+}
+
+// Any account can reach every public repository, so without a bound a loop
+// grows users.json, and the config repo's history with it, without end.
+func TestTokensAreBounded(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "bob", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+ session := signIn(t, f, h, "bob")
+ form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}}
+
+ // Fill the account through the store, so that what is being measured is
+ // the bound and not the rate limit that also guards this route.
+ for range maxTokens {
+ addToken(t, f, "bob", "notes")
+ }
+ if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusConflict {
+ t.Errorf("minting past the bound: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens {
+ t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens)
+ }
+
+ // Revoking one makes room for exactly one more.
+ u, _ := f.store.User("bob")
+ revoke := url.Values{"csrf": {form.Get("csrf")}, "id": {u.Tokens[0].ID}}
+ if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("revoking: status %d", w.Code)
+ }
+ if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusOK {
+ t.Errorf("minting into the room a revoke made: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens {
+ t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens)
+ }
+}
+
+// The whole token life through the pages, then a push with what came out.
+func TestTokenMintAndRevoke(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "bob", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Grants: map[string]string{"bob": grantWrite},
+ })
+ addRepo(t, f, "closed", Repo{Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled})
+ h := f.routes()
+
+ session := signIn(t, f, h, "bob")
+ token := f.csrf(withCookies("/", session))
+
+ // A repository the account cannot reach at all mints nothing.
+ if w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"closed"}}, session); w.Code != http.StatusForbidden {
+ t.Errorf("minting for a repository the account cannot read: status %d", w.Code)
+ }
+ w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"notes"}}, session)
+ if w.Code != http.StatusOK {
+ t.Fatalf("minting: status %d", w.Code)
+ }
+ secret := between(w.Body.String(), `<pre id="token">`, "</pre>")
+ if secret == "" {
+ t.Fatal("no token was shown")
+ }
+ u, _ := f.store.User("bob")
+ if len(u.Tokens) != 1 || u.Tokens[0].Repo != "notes" || u.Tokens[0].Hash == secret {
+ t.Fatalf("the token record is wrong: %+v", u.Tokens)
+ }
+
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ authed := strings.Replace(srv.URL, "http://", "http://bob:"+secret+"@", 1)
+ work := t.TempDir()
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil {
+ t.Fatalf("clone with the minted token: %v\n%s", err, out)
+ }
+
+ // Revoking it stops it at once.
+ revoke := url.Values{"csrf": {token}, "id": {u.Tokens[0].ID}}
+ if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther {
+ t.Fatalf("revoking: status %d", w.Code)
+ }
+ if u, _ := f.store.User("bob"); len(u.Tokens) != 0 {
+ t.Errorf("the token survived revocation: %+v", u.Tokens)
+ }
+ if out, err := runGit(t, work, "clone", authed+"/notes.git", "again"); err == nil {
+ t.Errorf("a revoked token still cloned:\n%s", out)
+ }
+}