aboutsummaryrefslogtreecommitdiff
path: root/issues_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 /issues_test.go
downloadgit-forge-master.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 'issues_test.go')
-rw-r--r--issues_test.go345
1 files changed, 345 insertions, 0 deletions
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)
+ }
+ }
+}