From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge 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. --- issues_test.go | 345 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 issues_test.go (limited to 'issues_test.go') diff --git a/issues_test.go b/issues_test.go new file mode 100644 index 0000000..1dacb5e --- /dev/null +++ b/issues_test.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// The loser of a race has to read the counter again rather than reuse the +// number it already had. +func TestIssueIdsUnderConcurrency(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + + const n = 10 + var wg sync.WaitGroup + codes := make([]int, n) + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + form := url.Values{"csrf": {token}, "title": {fmt.Sprintf("issue %d", i)}, "body": {"body"}} + codes[i] = post(h, "/notes/issues", form, session).Code + }() + } + wg.Wait() + for i, code := range codes { + if code != http.StatusSeeOther { + t.Fatalf("issue %d: status %d", i, code) + } + } + + dir := f.issuesDir("notes") + head, err := resolveRef(dir, branch) + if err != nil || head == "" { + t.Fatalf("no commits in the tracker: %v", err) + } + paths, err := listTree(dir, head, "issues/") + if err != nil { + t.Fatal(err) + } + seen := map[int]bool{} + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + blob, err := catFile(dir, head+":"+p) + if err != nil { + t.Fatal(err) + } + var issue Issue + if err := json.Unmarshal(blob, &issue); err != nil { + t.Fatalf("%s does not parse: %v", p, err) + } + if seen[issue.ID] { + t.Errorf("id %d was handed out twice", issue.ID) + } + seen[issue.ID] = true + } + for id := 1; id <= n; id++ { + if !seen[id] { + t.Errorf("id %d is missing", id) + } + } +} + +// The compare and swap protects the branch; this protects the record. Two +// people acting at once must not undo each other. +func TestIssueNoLostUpdate(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Labels: []string{"bug"}, Grants: map[string]string{"alice": grantWrite}}) + h := f.routes() + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + post(h, "/notes/issues", url.Values{"csrf": {token}, "title": {"t"}, "body": {"b"}}, session) + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); post(h, "/notes/issues/1/state", url.Values{"csrf": {token}}, session) }() + go func() { + defer wg.Done() + post(h, "/notes/issues/1/labels", url.Values{"csrf": {token}, "label": {"bug"}}, session) + }() + wg.Wait() + + dir := f.issuesDir("notes") + head, _ := resolveRef(dir, branch) + issue, err := readIssue(dir, head, 1) + if err != nil { + t.Fatal(err) + } + if issue.State != issueClosed { + t.Errorf("the close was lost: state %q", issue.State) + } + if len(issue.Labels) != 1 || issue.Labels[0] != "bug" { + t.Errorf("the label was lost: %v", issue.Labels) + } +} + +// The tracker is the one repository nothing else can collect: it is written +// with plumbing, and it refuses every push. +// +// Git estimates the loose object count from one shard multiplied by 256, so a +// shard of empty files puts a small repository over gc.auto. +func TestTrackerIsCollected(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + + dir := f.issuesDir("notes") + shard := filepath.Join(dir, "objects", "17") + if err := os.MkdirAll(shard, 0700); err != nil { + t.Fatal(err) + } + for i := range 30 { + if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("%038x", i)), nil, 0600); err != nil { + t.Fatal(err) + } + } + + form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"one"}, "body": {"b"}} + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening an issue: status %d", code) + } + + // gc.autoDetach puts the work in the background, so this waits for it. + for range 40 { + if packs, _ := filepath.Glob(filepath.Join(dir, "objects/pack/*.pack")); len(packs) > 0 { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Error("the tracker is still unpacked two seconds after a write that should have collected it") +} + +// A miss has to be a miss, a tracker that will not open has to be a failure +// rather than an empty page, and a query that could never be an argument is +// refused before anything is exec'd. +func TestIssueSearch(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + for _, issue := range [][2]string{ + {"zone transfer fails", "the secondary does not answer axfr"}, + {"something else", "unrelated"}, + } { + form := url.Values{"csrf": {token}, "title": {issue[0]}, "body": {issue[1]}} + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening %q: status %d", issue[0], code) + } + } + + for _, c := range []struct { + q string + code int + hit bool + }{ + {"axfr", http.StatusOK, true}, // matched in the body + {"zone transfer", http.StatusOK, true}, // and in the title + {"nothing matches this", http.StatusOK, false}, + {"", http.StatusOK, true}, // no search at all lists everything + {"a\x00b", http.StatusBadRequest, false}, + {"a\nb", http.StatusBadRequest, false}, + } { + w := get(h, "/notes/issues?q="+url.QueryEscape(c.q), nil) + if w.Code != c.code { + t.Errorf("q=%q: status %d, wanted %d", c.q, w.Code, c.code) + } + if hit := strings.Contains(w.Body.String(), "zone transfer fails"); hit != c.hit { + t.Errorf("q=%q: found the issue %v, wanted %v", c.q, hit, c.hit) + } + } + + if err := os.RemoveAll(f.issuesDir("notes")); err != nil { + t.Fatal(err) + } + if code := get(h, "/notes/issues?q=axfr", nil).Code; code != http.StatusInternalServerError { + t.Errorf("a search of a tracker that will not open: status %d", code) + } +} + +// The server is the tracker's only writer, so a record that does not parse +// means a bad restore or a hand edit. Dropping it silently is an issue that +// stops existing with nothing anywhere to say why. +func TestARecordThatDoesNotParseIsNamed(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "title": {"readable"}, "body": {"b"}, + } + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening the readable issue: status %d", code) + } + + dir := f.issuesDir("notes") + author := f.store.forgeAuthor() + err := commitWith(dir, author, author, func(base string) ([]change, string, error) { + return []change{ + {path: "issues/2.json", data: []byte("not json\n")}, + {path: "issues/1/0001.json", data: []byte("not json either\n")}, + }, "issues: written by hand", nil + }) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + list := get(h, "/notes/issues", nil) + show := get(h, "/notes/issues/1", nil) + log.SetOutput(old) + + if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "readable") { + t.Errorf("the unreadable record took the readable one with it: status %d", list.Code) + } + if show.Code != http.StatusOK { + t.Errorf("an issue with an unreadable comment: status %d", show.Code) + } + for _, want := range []string{"issues/2.json", "issues/1/0001.json"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("%s was left out without a word:\n%s", want, buf.String()) + } + } +} + +func TestIssueRateLimit(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + form := url.Values{"csrf": {token}, "title": {"spam"}, "body": {"spam"}} + + for i := range issuesPerHour { + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("issue %d: status %d", i, code) + } + } + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusTooManyRequests { + t.Errorf("issue %d in an hour: status %d", issuesPerHour+1, code) + } +} + +// A title is bounded in length, and has to be bounded in shape too: it becomes +// a mail subject, and the none driver writes whole messages to the log, so a +// title carrying a second line writes that line into the log itself. +func TestIssueTitleCannotForgeALogLine(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + confirm(t, f, "bob", "bob@example.com", true) + addRepo(t, f, "notes", Repo{ + Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Grants: map[string]string{"bob": grantRead}, + }) + h := f.routes() + session := signIn(t, f, h, "alice") + open := func(title string) int { + return post(h, "/notes/issues", url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "title": {title}, "body": {"b"}, + }, session).Code + } + + forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 POST /admin/user/delete 303 0 1ms" + if code := open(forged); code != http.StatusBadRequest { + t.Errorf("a title with a line break in it: status %d", code) + } + for _, title := range []string{"a\rb", "a\x00b", "a\x7fb"} { + if code := open(title); code != http.StatusBadRequest { + t.Errorf("a title holding %q: status %d", title, code) + } + } + if code := open("a real title, with punctuation: and éncoding"); code != http.StatusSeeOther { + t.Fatalf("an ordinary title was refused: status %d", code) + } + + // Nothing the refused titles carried reached the log by way of mail. + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + f.mail.drainOnce() + log.SetOutput(old) + for _, line := range strings.Split(buf.String(), "\n") { + if strings.Contains(line, "POST /admin/user/delete") { + t.Errorf("a line the user wrote is in the log: %q", line) + } + } +} + +// The form is the one page template nothing else here draws, so a field +// renamed out from under it would be a 500 that only the container test sees. +func TestIssueFormIsDrawn(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{ + Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Labels: []string{"bug", "docs"}, + }) + h := f.routes() + + // It is a form for writing, so a visitor who may not write does not get it. + if code := get(h, "/notes/issues/new", nil).Code; code != http.StatusForbidden { + t.Errorf("a visitor got the form: status %d", code) + } + w := get(h, "/notes/issues/new", signIn(t, f, h, "alice")) + if w.Code != http.StatusOK { + t.Fatalf("the form: status %d", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `action="/notes/issues"`, `name="csrf"`, `name="title"`, `name="body"`, + `value="bug"`, `value="docs"`, + } { + if !strings.Contains(body, want) { + t.Errorf("the form has no %s:\n%s", want, body) + } + } +} -- cgit v1.2.3