vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
4581690d
Keenan Nemetz
13d ago
|
1 | package metadata |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | "time" | |
| 7 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
8 | "rodent.software/vole/core/git" |
|
4581690d
Keenan Nemetz
13d ago
|
9 | ) |
| 10 | ||
| 11 | // limitsRepo is a bare repository with an empty metadata branch. | |
| 12 | func limitsRepo(t *testing.T) *git.Repo { | |
| 13 | t.Helper() | |
| 14 | root := t.TempDir() | |
| 15 | if err := git.InitBare(root, "main"); err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | g := git.Open(root) | |
| 19 | seed, err := g.CommitTree(mustTree(t, g), "", "seed") | |
| 20 | if err != nil { | |
| 21 | t.Fatal(err) | |
| 22 | } | |
| 23 | if err := g.UpdateRef(Ref, seed); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | return g | |
| 27 | } | |
| 28 | ||
| 29 | // putIssueFile writes raw issue bytes exactly as a hand-edited push would, which | |
| 30 | // is the only way to produce a metadata branch the writers would have refused. | |
| 31 | func putIssueFile(t *testing.T, g *git.Repo, path string, body []byte) string { | |
| 32 | t.Helper() | |
| 33 | commit, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | |
| 34 | files[path] = body | |
| 35 | return nil | |
| 36 | }, "issue: hand written", "keenan") | |
| 37 | if err != nil { | |
| 38 | t.Fatal(err) | |
| 39 | } | |
| 40 | return commit | |
| 41 | } | |
| 42 | ||
| 43 | func issueDoc(title, author, body string) []byte { | |
| 44 | return []byte("---\nid: 01M5JR2JK29KKMAA1T9MDNTPBZ\ntitle: " + title + | |
| 45 | "\nstate: open\nauthor: " + author + "\ncreated: 2026-10-27T12:00:00Z\n---\n\n" + body) | |
| 46 | } | |
| 47 | ||
| 48 | // TestLoadIssuesRefusesAnOversizedFile is the read-side half of the limit: an | |
| 49 | // anonymous page render must not allocate a file a contributor pushed. Before | |
| 50 | // the cap, LoadIssues read every blob in the tree in full. | |
| 51 | func TestLoadIssuesRefusesAnOversizedFile(t *testing.T) { | |
| 52 | t.Parallel() | |
| 53 | g := limitsRepo(t) | |
| 54 | huge := strings.Repeat("x", MaxFileBytes+1) | |
| 55 | putIssueFile(t, g, IssuesDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/issue.md", | |
| 56 | issueDoc("huge", "keenan", huge)) | |
| 57 | ||
| 58 | _, err := LoadIssues(g, Ref) | |
| 59 | if err == nil { | |
| 60 | t.Fatal("LoadIssues read a file past MaxFileBytes") | |
| 61 | } | |
| 62 | if !strings.Contains(err.Error(), "limit") { | |
| 63 | t.Fatalf("refusal did not name the limit: %v", err) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | // TestLoadRequestsRefusesAnOversizedFile is the same rule for change requests, | |
| 68 | // which had no entry cap at all before this. | |
| 69 | func TestLoadRequestsRefusesAnOversizedFile(t *testing.T) { | |
| 70 | t.Parallel() | |
| 71 | g := limitsRepo(t) | |
| 72 | huge := strings.Repeat("x", MaxFileBytes+1) | |
|
b492b557
Keenan Nemetz
3d ago
|
73 | putIssueFile(t, g, ChangesDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/change.md", |
|
4581690d
Keenan Nemetz
13d ago
|
74 | []byte("---\nid: 01M5JR2JK29KKMAA1T9MDNTPBZ\ntitle: huge\nauthor: keenan\nbase: main\nhead: "+ |
| 75 | strings.Repeat("a", 40)+"\ncreated: 2026-10-27T12:00:00Z\n---\n\n"+huge)) | |
| 76 | ||
| 77 | if _, err := LoadRequests(g, Ref); err == nil { | |
| 78 | t.Fatal("LoadRequests read a file past MaxFileBytes") | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | // TestValidateIssuesRefusesAnOversizedTree is the push-side half: pre-receive | |
| 83 | // runs this on the pushed commit, so the author learns why instead of finding | |
| 84 | // that the issue list stopped loading. | |
| 85 | func TestValidateIssuesRefusesAnOversizedTree(t *testing.T) { | |
| 86 | t.Parallel() | |
| 87 | g := limitsRepo(t) | |
| 88 | commit := putIssueFile(t, g, IssuesDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/issue.md", | |
| 89 | issueDoc("huge", "keenan", strings.Repeat("x", MaxFileBytes+1))) | |
| 90 | ||
| 91 | problems := ValidateIssues(g, commit) | |
| 92 | if len(problems) == 0 { | |
| 93 | t.Fatal("ValidateIssues accepted a file past MaxFileBytes") | |
| 94 | } | |
| 95 | if !strings.Contains(problems[0], "limit") { | |
| 96 | t.Fatalf("rejection did not name the limit: %v", problems) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | // TestOversizedBodyIsNotWritten keeps the CLI and the web form in step with the | |
| 101 | // push boundary: a body that cannot be pushed should not be committable either. | |
| 102 | func TestOversizedBodyIsNotWritten(t *testing.T) { | |
| 103 | t.Parallel() | |
| 104 | iss, err := NewIssue("title", strings.Repeat("x", MaxBodyBytes+1), "keenan", nil, nil, | |
| 105 | time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)) | |
| 106 | if err != nil { | |
| 107 | t.Fatal(err) | |
| 108 | } | |
| 109 | if err := validateIssueForWrite(iss); err == nil { | |
| 110 | t.Fatal("an oversized issue body passed local validation") | |
| 111 | } | |
| 112 | if _, err := NewIssueComment(strings.Repeat("x", MaxBodyBytes+1), "keenan", time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)); err == nil { | |
| 113 | t.Fatal("an oversized comment body passed local validation") | |
| 114 | } | |
| 115 | if _, err := NewRequestComment(strings.Repeat("x", MaxBodyBytes+1), "keenan", time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)); err == nil { | |
| 116 | t.Fatal("an oversized request comment passed local validation") | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | // TestParseRefusesOversizedBody covers a body that arrives in a file rather than | |
| 121 | // through a writer: the parse path is what pre-receive uses. | |
| 122 | func TestParseRefusesOversizedBody(t *testing.T) { | |
| 123 | t.Parallel() | |
| 124 | _, prob := ParseComment("01M5JR2JK29KKMAA1T9MDNTPBZ", | |
| 125 | "---\nauthor: keenan\ncreated: 2026-10-27T12:00:00Z\n---\n\n"+strings.Repeat("x", MaxBodyBytes+1), | |
| 126 | "issues/01M5JR2JK29KKMAA1T9MDNTPBZ/comments/01M5JR2JK29KKMAA1T9MDNTPBZ.md") | |
| 127 | if prob == "" { | |
| 128 | t.Fatal("ParseComment accepted a body past MaxBodyBytes") | |
| 129 | } | |
| 130 | } | |
|
5cecc618
Keenan Nemetz
10d ago
|
131 | |
| 132 | // TestLoadRequestsReadsEveryFile guards the batched read of the metadata tree: | |
| 133 | // many requests and comments come back complete, and two files with identical | |
| 134 | // bytes (one object in the pack, two paths in the tree) each get their content. | |
| 135 | // The list page reads this tree on every render, so the loader is also the place | |
| 136 | // where a per-file git process would show up as a page that costs as much as the | |
| 137 | // repository has issues. | |
| 138 | func TestLoadRequestsReadsEveryFile(t *testing.T) { | |
| 139 | t.Parallel() | |
| 140 | g := limitsRepo(t) | |
| 141 | ||
| 142 | const n = 12 | |
| 143 | // One character distinguishes the ids, which keeps every id a well-formed | |
| 144 | // ULID of the same length. | |
| 145 | const ulidPrefix = "01M5JR2JK29KKMAA1T9MDNTRB" | |
| 146 | id := func(i int) string { | |
| 147 | return ulidPrefix + string("0123456789ABCDEFGHJKMNPQRSTVWXYZ"[i]) | |
| 148 | } | |
| 149 | files := map[string][]byte{} | |
| 150 | for i := 0; i < n; i++ { | |
|
b492b557
Keenan Nemetz
3d ago
|
151 | // Odd and even requests share a body, so their change.md files are the |
|
5cecc618
Keenan Nemetz
10d ago
|
152 | // same object under different paths. |
| 153 | body := requestBody(headSHA) | |
| 154 | if i%2 == 1 { | |
| 155 | body = requestBody(sideSHA) | |
| 156 | } | |
|
b492b557
Keenan Nemetz
3d ago
|
157 | files[ChangesDir+"/"+id(i)+"/change.md"] = []byte(body) |
| 158 | files[ChangesDir+"/"+id(i)+"/comments/"+id(i+n)+".md"] = []byte( | |
|
5cecc618
Keenan Nemetz
10d ago
|
159 | "---\nauthor: keenan\ncreated: 2026-10-22T22:00:00Z\n---\n\ncomment " + string('a'+rune(i)) + "\n") |
| 160 | } | |
| 161 | if _, err := git.CommitFiles(g, Ref, func(f map[string][]byte) error { | |
| 162 | for path, body := range files { | |
| 163 | f[path] = body | |
| 164 | } | |
| 165 | return nil | |
| 166 | }, "requests: batch", "keenan"); err != nil { | |
| 167 | t.Fatal(err) | |
| 168 | } | |
| 169 | ||
| 170 | list, err := LoadRequests(g, Ref) | |
| 171 | if err != nil { | |
| 172 | t.Fatalf("LoadRequests: %v", err) | |
| 173 | } | |
| 174 | if len(list) != n { | |
| 175 | t.Fatalf("got %d requests, want %d", len(list), n) | |
| 176 | } | |
| 177 | for _, req := range list { | |
| 178 | if len(req.Comments) != 1 { | |
| 179 | t.Fatalf("%s has %d comments, want 1", req.ID, len(req.Comments)) | |
| 180 | } | |
| 181 | if req.Title != "Land the retry" { | |
| 182 | t.Fatalf("%s title = %q", req.ID, req.Title) | |
| 183 | } | |
| 184 | } | |
| 185 | } |