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