Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 50m ago 4b8c9e1d
history blame raw 15.0 KiB
1package metadata
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "rodent.software/vole/forge/identity"
9)
10
11const (
12 idA = "01M5JR2JK29KKMAA1T9MDNTPBP"
13 idB = "01M5JR2JK29KKMAA1T9MDNTPBQ"
14 idC = "01M5JR2JK29KKMAA1T9MDNTPBR"
15)
16
17func validContents() map[string]string {
18 return map[string]string{
19 "issues/" + idA + "/issue.md": "---\n" +
20 `title: "It broke"` + "\n" +
21 "state: open\n" +
22 "author: keenan\n" +
23 "created: 2026-10-22T21:00:00Z\n" +
24 "labels:\n" +
25 " - bug\n" +
26 " - needs-repro\n" +
27 "---\n" +
28 "Steps to reproduce.\n",
29 "issues/" + idA + "/comments/" + idB + ".md": "---\nauthor: alice\ncreated: 2026-10-22T21:01:00Z\n---\nMe too.\n",
30 "issues/" + idA + "/comments/" + idC + ".md": "---\nauthor: bob\ncreated: 2026-10-22T21:02:00Z\n---\nFixed in main.\n",
31 }
32}
33
34func TestParseValid(t *testing.T) {
35 t.Parallel()
36 list, err := ParseIssues(validContents())
37 if err != nil {
38 t.Fatalf("unexpected error: %v", err)
39 }
40 if len(list) != 1 {
41 t.Fatalf("issues = %d, want 1", len(list))
42 }
43 iss := list[0]
44 if iss.Title != "It broke" || iss.State != "open" || iss.Author != "keenan" {
45 t.Fatalf("bad issue: %+v", iss)
46 }
47 if len(iss.Labels) != 2 || iss.Labels[0] != "bug" {
48 t.Fatalf("labels = %v", iss.Labels)
49 }
50 if iss.Description != "Steps to reproduce.\n" {
51 t.Fatalf("description = %q", iss.Description)
52 }
53 if len(iss.Comments) != 2 {
54 t.Fatalf("comments = %d, want 2", len(iss.Comments))
55 }
56 // Sorted by id, which is time-ordered.
57 if iss.Comments[0].Author != "alice" || iss.Comments[1].Author != "bob" {
58 t.Fatalf("comment order: %s then %s", iss.Comments[0].Author, iss.Comments[1].Author)
59 }
60 if strings.TrimSpace(iss.Comments[0].Body) != "Me too." {
61 t.Fatalf("comment body = %q", iss.Comments[0].Body)
62 }
63}
64
65func TestParseIgnoresUnrelatedFiles(t *testing.T) {
66 t.Parallel()
67 c := validContents()
68 c["README.md"] = "notes about the issues branch\n"
69 if _, err := ParseIssues(c); err != nil {
70 t.Fatalf("unrelated files should be ignored: %v", err)
71 }
72}
73
74// TestRenderParseRoundTrip is the format's contract: whatever the writer emits,
75// the reader reads back. The titles are the ones that break naive YAML — an
76// unquoted `#` truncates, a leading `-` reads as a sequence, `null` reads as
77// nothing — which is why free text is always quoted.
78func TestRenderParseRoundTrip(t *testing.T) {
79 t.Parallel()
80 titles := []string{
81 "crash on startup",
82 "crash on a<b> & c",
83 "fix #12 crash",
84 "panic: nil map",
85 "- not a list",
86 "*anchor",
87 "null",
88 "~",
89 "123",
90 `it's "quoted"`,
91 `path C:\temp`,
92 "café ☕ breaks",
93 strings.Repeat("long title ", 17) + "end",
94 }
95 for _, title := range titles {
96 iss := &Issue{
97 ID: idA,
98 Title: title,
99 State: "open",
100 Author: "keenan",
101 Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC),
102 Labels: []string{"bug", "null"},
103 Description: "It broke.\n",
104 }
105 raw, err := renderIssue(iss)
106 if err != nil {
107 t.Fatalf("%q: render: %v", title, err)
108 }
109 got, err := ParseIssues(map[string]string{"issues/" + idA + "/issue.md": string(raw)})
110 if err != nil {
111 t.Fatalf("%q: parse:\n%s\nerror: %v", title, raw, err)
112 }
113 if got[0].Title != title {
114 t.Fatalf("title came back as %q, want %q:\n%s", got[0].Title, title, raw)
115 }
116 if got[0].Description != "It broke.\n" {
117 t.Fatalf("%q: description = %q", title, got[0].Description)
118 }
119 if len(got[0].Labels) != 2 || got[0].Labels[1] != "null" {
120 t.Fatalf("%q: labels = %v", title, got[0].Labels)
121 }
122 }
123}
124
125// TestAssigneesAreAccountNames: an assignee names a person, so the value has to
126// be something the roster could issue. Anything else — a capital, a dot, a
127// leading @ — is a typo that would render as a filter link to nobody.
128func TestAssigneesAreAccountNames(t *testing.T) {
129 t.Parallel()
130 // Build the files the way a writer would, so the validator is what refuses
131 // rather than YAML refusing to parse the line first.
132 mk := func(assignees ...string) map[string]string {
133 d := NewDoc()
134 d.Text("title", "assigned")
135 d.Word("state", "open")
136 d.Word("author", "keenan")
137 d.Word("created", "2026-10-22T21:00:00Z")
138 d.List("assignees", assignees)
139 raw, err := d.Bytes("body\n")
140 if err != nil {
141 t.Fatal(err)
142 }
143 c := validContents()
144 c["issues/"+idA+"/issue.md"] = string(raw)
145 return c
146 }
147
148 list, err := ParseIssues(mk("wilbur", "keenan"))
149 if err != nil {
150 t.Fatalf("valid assignees refused: %v", err)
151 }
152 if got := list[0].Assignees; len(got) != 2 || got[0] != "keenan" || got[1] != "wilbur" {
153 t.Fatalf("assignees = %v, want sorted [keenan wilbur]", got)
154 }
155
156 for _, bad := range []string{"Wilbur", "wil.bur", "@wilbur", "w", strings.Repeat("a", 33), "wil bur"} {
157 _, err := ParseIssues(mk(bad))
158 ve, ok := err.(*ValidationError)
159 if !ok {
160 t.Fatalf("%q was accepted: %v", bad, err)
161 }
162 if !strings.Contains(ve.Problems[0], "invalid assignee") || !strings.Contains(ve.Problems[0], bad) {
163 t.Fatalf("%q: unhelpful refusal: %v", bad, ve.Problems)
164 }
165 }
166}
167
168// TestAssigneeRuleMatchesAccountNames keeps the format's copy of the roster's
169// name rule honest. A name the roster would accept but an issue would reject
170// (or the reverse) is a bug that only shows up as a rejected push.
171func TestAssigneeRuleMatchesAccountNames(t *testing.T) {
172 t.Parallel()
173 for _, s := range []string{"keenan", "wilbur-2", "a_b", "x1", "@keenan", "Keenan", "a.b",
174 "a", strings.Repeat("a", 32), strings.Repeat("a", 33), "with space"} {
175 want := identity.ValidateName(s) == nil
176 if got := assigneeRE.MatchString(s); got != want {
177 t.Fatalf("%q: issue format says %v, roster says %v", s, got, want)
178 }
179 }
180}
181
182// TestAssigneeRoundTrip: what the writer emits is what the reader reads, and the
183// list is written sorted so an assignment is one added line.
184func TestAssigneeRoundTrip(t *testing.T) {
185 t.Parallel()
186 iss := &Issue{
187 ID: idA,
188 Title: "assigned",
189 State: "open",
190 Author: "keenan",
191 Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC),
192 Assignees: []string{"wilbur", "keenan"},
193 Description: "body\n",
194 }
195 raw, err := renderIssue(iss)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if !strings.Contains(string(raw), "assignees:\n - keenan\n - wilbur\n") {
200 t.Fatalf("assignees not written sorted, one per line:\n%s", raw)
201 }
202 got, err := ParseIssues(map[string]string{"issues/" + idA + "/issue.md": string(raw)})
203 if err != nil {
204 t.Fatalf("parse: %v\n%s", raw, err)
205 }
206 if len(got[0].Assignees) != 2 || got[0].Assignees[0] != "keenan" || got[0].Assignees[1] != "wilbur" {
207 t.Fatalf("assignees came back as %v", got[0].Assignees)
208 }
209}
210
211// TestAnchorRoundTrips: an anchored comment is four extra fields on the same file
212// shape, and the writer and reader have to agree about all of them.
213func TestAnchorRoundTrips(t *testing.T) {
214 t.Parallel()
215 c := &Comment{
216 ID: idB,
217 Author: "keenan",
218 Created: time.Date(2026, 10, 23, 9, 0, 0, 0, time.UTC),
219 Body: "this line is wrong\n",
220 Anchor: &Anchor{
221 Path: "hook/hook.go",
222 Side: "new",
223 Line: 412,
224 Blob: "0123456789abcdef0123456789abcdef01234567",
225 },
226 }
227 raw, err := RenderComment(c)
228 if err != nil {
229 t.Fatal(err)
230 }
231 got, prob := ParseComment(idB, string(raw), "issues/"+idA+"/comments/"+idB+".md")
232 if prob != "" {
233 t.Fatalf("parse: %s\n%s", prob, raw)
234 }
235 if got.Anchor == nil {
236 t.Fatalf("the anchor was lost:\n%s", raw)
237 }
238 if *got.Anchor != *c.Anchor {
239 t.Fatalf("anchor = %+v, want %+v", *got.Anchor, *c.Anchor)
240 }
241 // A comment with no anchor stays a comment with no anchor.
242 plain, err := RenderComment(&Comment{ID: idC, Author: "keenan", Created: c.Created, Body: "hi\n"})
243 if err != nil {
244 t.Fatal(err)
245 }
246 if strings.Contains(string(plain), "path:") {
247 t.Fatalf("an anchorless comment grew an anchor:\n%s", plain)
248 }
249}
250
251// TestAnchorMustBeWhole: half an anchor, or one pointing outside the repository,
252// is a mistake that would render as a link to nowhere.
253func TestAnchorMustBeWhole(t *testing.T) {
254 t.Parallel()
255 const blob = "0123456789abcdef0123456789abcdef01234567"
256 cases := []struct{ name, head string }{
257 {"line alone", "author: keenan\ncreated: 2026-10-23T09:00:00Z\nline: 4\n"},
258 {"path without line", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\n"},
259 {"bad side", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: middle\nline: 4\nblob: " + blob + "\n"},
260 {"line zero", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 0\nblob: " + blob + "\n"},
261 {"short blob", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 4\nblob: abc123\n"},
262 {"absolute path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: /etc/passwd\nside: new\nline: 4\nblob: " + blob + "\n"},
263 {"escaping path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: ../secret\nside: new\nline: 4\nblob: " + blob + "\n"},
264 }
265 for _, c := range cases {
266 _, prob := ParseComment(idB, "---\n"+c.head+"---\nbody\n", "changes/"+idA+"/comments/"+idB+".md")
267 if prob == "" {
268 t.Fatalf("%s was accepted", c.name)
269 }
270 if !strings.Contains(prob, "comments/"+idB+".md") {
271 t.Fatalf("%s: refusal does not name the file: %s", c.name, prob)
272 }
273 }
274}
275
276// TestAuthorOfReadsAnyMetadataFile: the hook asks this before letting anybody
277// change a comment, so it has to read an author out of a file whose other fields
278// belong to a different entity — an issue file's title, state and labels are not
279// the reader's business.
280func TestAuthorOfReadsAnyMetadataFile(t *testing.T) {
281 t.Parallel()
282 comment := "---\nauthor: wilbur\ncreated: 2026-10-22T21:01:00Z\n---\nwords\n"
283 if got, err := AuthorOf([]byte(comment)); err != nil || got != "wilbur" {
284 t.Fatalf("comment author = %q, %v", got, err)
285 }
286 issue := "---\ntitle: \"bug\"\nstate: open\nauthor: keenan\ncreated: 2026-10-22T21:00:00Z\n" +
287 "labels:\n - bug\nassignees:\n - wilbur\n---\nbody\n"
288 if got, err := AuthorOf([]byte(issue)); err != nil || got != "keenan" {
289 t.Fatalf("issue author = %q, %v", got, err)
290 }
291 if _, err := AuthorOf([]byte("---\ncreated: 2026-10-22T21:00:00Z\n---\nbody\n")); err == nil {
292 t.Fatal("a file with no author was accepted")
293 }
294 if _, err := AuthorOf([]byte("no frontmatter\n")); err == nil {
295 t.Fatal("a file with no frontmatter was accepted")
296 }
297}
298
299// TestCommentPathMatchesTheReader: the writer and the validator have to agree
300// about where a comment lives, or an edit commits to a path nobody reads.
301func TestCommentPathMatchesTheReader(t *testing.T) {
302 t.Parallel()
303 path := IssueCommentPath(idA, idB)
304 if path != "issues/"+idA+"/comments/"+idB+".md" {
305 t.Fatalf("path = %q", path)
306 }
307 if !IsIssueCommentPath(path) {
308 t.Fatal("a comment path was not recognised")
309 }
310 if IsIssueCommentPath("issues/" + idA + "/issue.md") {
311 t.Fatal("an issue file was mistaken for a comment")
312 }
313 if IsIssueCommentPath("changes/" + idA + "/change.md") {
314 t.Fatal("a request file was mistaken for a comment")
315 }
316}
317
318// TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with
319// " # " would otherwise store a shorter title and say nothing.
320func TestUnquotedTitleIsRefused(t *testing.T) {
321 t.Parallel()
322 c := validContents()
323 c["issues/"+idA+"/issue.md"] = "---\ntitle: fix #12 crash\nstate: open\nauthor: keenan\n" +
324 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
325 _, err := ParseIssues(c)
326 ve, ok := err.(*ValidationError)
327 if !ok {
328 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
329 }
330 if !strings.Contains(err.Error(), "must be double-quoted") {
331 t.Fatalf("the refusal does not explain the quotes: %v", ve.Problems)
332 }
333}
334
335// TestTitleOnOneLine: the writer refuses control characters, so the reader must
336// too — otherwise a tree the server accepts is a tree nobody can edit again.
337func TestTitleOnOneLine(t *testing.T) {
338 t.Parallel()
339 c := validContents()
340 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"two\\nlines\"\nstate: open\nauthor: keenan\n" +
341 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
342 if _, err := ParseIssues(c); err == nil {
343 t.Fatal("a title with a newline was accepted")
344 }
345}
346
347func TestValidationFailures(t *testing.T) {
348 t.Parallel()
349 for name, mutate := range map[string]func(map[string]string){
350 "bad ulid directory": func(c map[string]string) {
351 c["issues/not-a-ulid/issue.md"] = "---\ntitle: \"x\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
352 },
353 "missing issue file": func(c map[string]string) {
354 delete(c, "issues/"+idA+"/issue.md")
355 },
356 "empty title": func(c map[string]string) {
357 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
358 },
359 "long title": func(c map[string]string) {
360 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"" + strings.Repeat("x", 300) + "\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
361 },
362 "bad state": func(c map[string]string) {
363 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: wontfix\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
364 },
365 "missing author": func(c map[string]string) {
366 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\ncreated: 2026-10-22T21:00:00Z\n---\n"
367 },
368 "bad date": func(c map[string]string) {
369 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: yesterday\n---\n"
370 },
371 "unknown frontmatter field": func(c map[string]string) {
372 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nsneaky: true\n---\n"
373 },
374 "bad label": func(c map[string]string) {
375 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nlabels:\n - \"Has Spaces\"\n---\n"
376 },
377 "no frontmatter": func(c map[string]string) {
378 c["issues/"+idA+"/issue.md"] = "just text\n"
379 },
380 "unterminated frontmatter": func(c map[string]string) {
381 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nbody\n"
382 },
383 "comment without frontmatter": func(c map[string]string) {
384 c["issues/"+idA+"/comments/"+idB+".md"] = "just text\n"
385 },
386 "comment unknown field": func(c map[string]string) {
387 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nedited: \"yes\"\n---\nx\n"
388 },
389 "comment unterminated": func(c map[string]string) {
390 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nx\n"
391 },
392 "stray file in issue dir": func(c map[string]string) {
393 c["issues/"+idA+"/notes.txt"] = "nope"
394 },
395 "nested too deep": func(c map[string]string) {
396 c["issues/"+idA+"/comments/x/y.md"] = "nope"
397 },
398 } {
399 t.Run(name, func(t *testing.T) {
400 c := validContents()
401 mutate(c)
402 if _, err := ParseIssues(c); err == nil {
403 t.Fatal("expected a validation error")
404 } else if _, ok := err.(*ValidationError); !ok {
405 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
406 }
407 })
408 }
409}
410
411func TestAllProblemsReported(t *testing.T) {
412 t.Parallel()
413 c := validContents()
414 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: nope\nauthor: \"\"\ncreated: \"\"\n---\n"
415 _, err := ParseIssues(c)
416 ve, ok := err.(*ValidationError)
417 if !ok {
418 t.Fatalf("error is %T: %v", err, err)
419 }
420 if len(ve.Problems) < 4 {
421 t.Fatalf("want several problems, got %d: %v", len(ve.Problems), ve.Problems)
422 }
423}
424