diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /httpgit_test.go | |
| download | git-forge-master.tar.gz | |
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.go | 457 |
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) + } +} |