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 · 26m ago 4b8c9e1d
history blame raw 16.8 KiB
1package web
2
3// Profiles and the account directory. The point of these tests is the visibility
4// rule: an account is public, its work is not. The same page must count one
5// repository for an anonymous reader and two for somebody who can read both,
6// without ever hinting that the second one exists.
7
8import (
9 "context"
10 "fmt"
11 "net/http"
12 "net/http/httptest"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "rodent.software/vole/forge/access"
19 "rodent.software/vole/forge/config"
20 "rodent.software/vole/forge/identity"
21 "rodent.software/vole/forge/index"
22 "rodent.software/vole/forge/repo"
23 "rodent.software/vole/forge/session"
24 "rodent.software/vole/metadata"
25)
26
27// site is a forge with a roster and two repositories: "demo" is readable by
28// anybody, "secret" only by alice. Each holds one issue, so a profile's feed
29// either names the hidden one or it does not.
30type site struct {
31 web *Web
32 ident *identity.Store
33 acls *access.Store
34}
35
36func newSite(t *testing.T) *site {
37 t.Helper()
38 root := t.TempDir()
39 users := filepath.Join(root, "users.db")
40 if err := identity.Init(users); err != nil {
41 t.Fatal(err)
42 }
43 ident, err := identity.Open(users)
44 if err != nil {
45 t.Fatal(err)
46 }
47 t.Cleanup(func() { ident.Close() })
48 ident.AuditPath = filepath.Join(root, "audit.log")
49
50 acls, err := access.Open(users)
51 if err != nil {
52 t.Fatal(err)
53 }
54 t.Cleanup(func() { acls.Close() })
55 acls.AuditPath = filepath.Join(root, "audit.log")
56
57 ix, err := index.Open(filepath.Join(root, "index.db"))
58 if err != nil {
59 t.Fatal(err)
60 }
61 t.Cleanup(func() { ix.Close() })
62
63 w, err := New(repo.New(filepath.Join(root, "repos"), "", acls), ix, ident, nil, &config.Config{DataDir: root})
64 if err != nil {
65 t.Fatal(err)
66 }
67 for _, name := range []string{"alice", "adam", "bob"} {
68 if _, _, err := ident.CreateWithInvite(name, "setup", time.Hour); err != nil {
69 t.Fatalf("create %s: %v", name, err)
70 }
71 }
72 s := &site{web: w, ident: ident, acls: acls}
73 s.repo(t, "demo", "alice", true, "Public work")
74 s.repo(t, "secret", "alice", false, "Hidden work")
75 return s
76}
77
78func (s *site) repo(t *testing.T, name, owner string, readableByAnyone bool, issueTitle string) {
79 t.Helper()
80 m, err := s.web.Repos.Create(name, owner, "")
81 if err != nil {
82 t.Fatal(err)
83 }
84 if readableByAnyone {
85 acl, err := m.ACL()
86 if err != nil {
87 t.Fatal(err)
88 }
89 acl.Default = "read"
90 if err := s.acls.Set(m.ID, acl, owner, time.Now()); err != nil {
91 t.Fatal(err)
92 }
93 }
94 if err := s.web.Index.Upsert(m); err != nil {
95 t.Fatal(err)
96 }
97 iss, err := metadata.NewIssue(issueTitle, "body", "alice", nil, nil, time.Now().UTC())
98 if err != nil {
99 t.Fatal(err)
100 }
101 if err := metadata.CreateIssue(m.Git(), iss, owner); err != nil {
102 t.Fatal(err)
103 }
104 if err := s.web.Index.IndexRepo(m); err != nil {
105 t.Fatal(err)
106 }
107}
108
109// serve calls a handler through a mux, because the handlers read the account name
110// from the route pattern. A nil account is an anonymous reader.
111func (s *site) serve(t *testing.T, as *identity.Account, path string) *httptest.ResponseRecorder {
112 t.Helper()
113 mux := http.NewServeMux()
114 mux.HandleFunc("GET /-/u", s.web.PeoplePage)
115 mux.HandleFunc("GET /-/u/{$}", s.web.PeoplePage)
116 mux.HandleFunc("GET /-/u/{name}", s.web.ProfilePage)
117 r := httptest.NewRequest("GET", path, nil)
118 if as != nil {
119 r = r.WithContext(context.WithValue(r.Context(), principalKey{},
120 &principal{account: as, session: &session.Session{}}))
121 }
122 rw := httptest.NewRecorder()
123 mux.ServeHTTP(rw, r)
124 return rw
125}
126
127func (s *site) account(t *testing.T, name string) *identity.Account {
128 t.Helper()
129 a, err := s.ident.Find(name)
130 if err != nil {
131 t.Fatal(err)
132 }
133 return a
134}
135
136// TestProfileCountsOnlyWhatTheViewerCanRead is the privacy rule of the page: the
137// same account, two viewers, and no sign of the repository the anonymous reader
138// cannot open.
139func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) {
140 t.Parallel()
141 s := newSite(t)
142
143 anon := s.serve(t, nil, "/-/u/alice")
144 if anon.Code != http.StatusOK {
145 t.Fatalf("anonymous profile = %d: %s", anon.Code, anon.Body)
146 }
147 body := anon.Body.String()
148 if !strings.Contains(body, "Public work") {
149 t.Fatalf("the anonymous profile is missing the public issue:\n%s", body)
150 }
151 if strings.Contains(body, "Hidden work") {
152 t.Fatalf("the anonymous profile leaked a private issue:\n%s", body)
153 }
154 // The page says the numbers are scoped rather than pretending they are total.
155 if !strings.Contains(body, "Counted across the repositories you can read.") {
156 t.Fatalf("the profile does not say what the numbers cover:\n%s", body)
157 }
158 // A profile is public, but a scraped roster is not what public has to mean.
159 if !strings.Contains(body, `<meta name="robots" content="noindex">`) {
160 t.Fatalf("the profile does not ask crawlers to leave it alone:\n%s", body)
161 }
162
163 signed := s.serve(t, s.account(t, "alice"), "/-/u/alice")
164 if signed.Code != http.StatusOK {
165 t.Fatalf("signed-in profile = %d", signed.Code)
166 }
167 if !strings.Contains(signed.Body.String(), "Hidden work") {
168 t.Fatalf("the owner's profile is missing her own private work:\n%s", signed.Body)
169 }
170}
171
172func TestProfileOfAnUnknownNameIsNotFound(t *testing.T) {
173 t.Parallel()
174 s := newSite(t)
175 rw := s.serve(t, nil, "/-/u/nobody")
176 if rw.Code != http.StatusNotFound {
177 t.Fatalf("unknown name = %d, want 404", rw.Code)
178 }
179 // The 404 is the one every missing page uses, so it cannot be used to learn
180 // that a name is not an account rather than that a page is missing.
181 if !strings.Contains(rw.Body.String(), "That page does not exist") {
182 t.Fatalf("unknown name rendered the wrong page:\n%s", rw.Body)
183 }
184}
185
186func TestRetiredProfileStillOpens(t *testing.T) {
187 t.Parallel()
188 s := newSite(t)
189 if _, err := s.ident.Retire("bob", "alice"); err != nil {
190 t.Fatal(err)
191 }
192 rw := s.serve(t, nil, "/-/u/bob")
193 if rw.Code != http.StatusOK {
194 t.Fatalf("retired profile = %d", rw.Code)
195 }
196 if !strings.Contains(rw.Body.String(), "no longer active") {
197 t.Fatalf("a retired profile does not say so:\n%s", rw.Body)
198 }
199}
200
201func TestProfileFilterLinksAndHeading(t *testing.T) {
202 t.Parallel()
203 s := newSite(t)
204 body := s.serve(t, nil, "/-/u/alice").Body.String()
205 for _, want := range []string{
206 `href="/-/u/alice?type=issues"`,
207 `href="/-/u/alice?type=changes"`,
208 `href="/-/u/alice?type=reviews"`,
209 `href="/-/u/alice?type=comments"`,
210 "Issues opened",
211 "Changes reviewed",
212 } {
213 if !strings.Contains(body, want) {
214 t.Fatalf("profile is missing %q:\n%s", want, body)
215 }
216 }
217 // The filtered page says which filter is on, so a bookmarked URL is not a
218 // mystery.
219 filtered := s.serve(t, nil, "/-/u/alice?type=reviews").Body.String()
220 if !strings.Contains(filtered, `class="chip active" href="/-/u/alice?type=reviews"`) {
221 t.Fatalf("the reviews filter is not marked active:\n%s", filtered)
222 }
223}
224
225func TestPeopleListsEveryAccountAndFilters(t *testing.T) {
226 t.Parallel()
227 s := newSite(t)
228
229 all := s.serve(t, nil, "/-/u")
230 if all.Code != http.StatusOK {
231 t.Fatalf("directory = %d: %s", all.Code, all.Body)
232 }
233 body := all.Body.String()
234 for _, want := range []string{"alice", "adam", "bob", "Everyone with an account here",
235 "Counts only include repositories you can read."} {
236 if !strings.Contains(body, want) {
237 t.Fatalf("directory is missing %q:\n%s", want, body)
238 }
239 }
240 if strings.Contains(body, `<meta name="robots" content="noindex">`) == false {
241 t.Fatal("the directory does not ask crawlers to leave it alone")
242 }
243
244 filtered := s.serve(t, nil, "/-/u?q=ali").Body.String()
245 if !strings.Contains(filtered, "alice") || strings.Contains(filtered, "adam") {
246 t.Fatalf("the name filter returned the wrong people:\n%s", filtered)
247 }
248
249 empty := s.serve(t, nil, "/-/u?q=nobody").Body.String()
250 if !strings.Contains(empty, "No one matches") {
251 t.Fatalf("an empty search has no empty state:\n%s", empty)
252 }
253}
254
255// The directory is one table now, so a retired account is marked in its row and
256// the status chips are how a reader narrows the list to one kind.
257func TestRetiredAccountsAreMarkedAndFilterable(t *testing.T) {
258 t.Parallel()
259 s := newSite(t)
260 if _, err := s.ident.Retire("bob", "alice"); err != nil {
261 t.Fatal(err)
262 }
263
264 everyone := s.serve(t, nil, "/-/u").Body.String()
265 if !strings.Contains(everyone, `href="/-/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) {
266 t.Fatalf("the retired account is not marked in the table:\n%s", everyone)
267 }
268
269 active := s.serve(t, nil, "/-/u?status=active").Body.String()
270 if strings.Contains(active, `href="/-/u/bob"`) {
271 t.Fatalf("the active filter kept a retired account:\n%s", active)
272 }
273 retired := s.serve(t, nil, "/-/u?status=retired").Body.String()
274 if !strings.Contains(retired, `href="/-/u/bob"`) || strings.Contains(retired, `href="/-/u/alice"`) {
275 t.Fatalf("the retired filter returned the wrong accounts:\n%s", retired)
276 }
277}
278
279// The header is the control: each column links to itself in the other direction,
280// and the current one is marked for a screen reader.
281func TestDirectorySortsByItsHeaders(t *testing.T) {
282 t.Parallel()
283 s := newSite(t)
284
285 // Default is the name, A to Z. alice opened the fixture's issues; adam and bob
286 // have none, so a count order is a different question.
287 body := s.serve(t, nil, "/-/u").Body.String()
288 adam := strings.Index(body, `href="/-/u/adam"`)
289 alice := strings.Index(body, `href="/-/u/alice"`)
290 bob := strings.Index(body, `href="/-/u/bob"`)
291 if adam < 0 || alice < 0 || bob < 0 || !(adam < alice && alice < bob) {
292 t.Fatalf("the default order is not A to Z: adam=%d alice=%d bob=%d", adam, alice, bob)
293 }
294 if !strings.Contains(body, `aria-sort="ascending"`) {
295 t.Fatalf("the active column is not marked:\n%s", body)
296 }
297 // A count column starts with the largest first when it is first chosen.
298 if !strings.Contains(body, "sort=issues") || !strings.Contains(body, "dir=desc") {
299 t.Fatalf("the Issues header does not ask for the largest first:\n%s", body)
300 }
301
302 ordered := s.serve(t, nil, "/-/u?sort=issues&dir=desc").Body.String()
303 if strings.Index(ordered, `href="/-/u/alice"`) > strings.Index(ordered, `href="/-/u/adam"`) {
304 t.Fatalf("the issues order does not put the only author first:\n%s", ordered)
305 }
306 if !strings.Contains(ordered, `aria-sort="descending"`) {
307 t.Fatalf("the issues order is not marked:\n%s", ordered)
308 }
309 // Clicking the same header again flips it.
310 if !strings.Contains(ordered, "dir=asc") {
311 t.Fatalf("the active header does not flip the direction:\n%s", ordered)
312 }
313 // A search keeps the order it was made in.
314 if !strings.Contains(ordered, `name="sort" value="issues"`) ||
315 !strings.Contains(ordered, `name="dir" value="desc"`) {
316 t.Fatalf("the search form drops the order:\n%s", ordered)
317 }
318}
319
320func TestParsePeopleSort(t *testing.T) {
321 t.Parallel()
322 cases := []struct {
323 in, dir string
324 key string
325 desc bool
326 }{
327 {"", "", sortName, false},
328 {"name", "desc", sortName, true},
329 {"issues", "desc", sortIssues, true},
330 {"issues", "asc", sortIssues, false},
331 // A count starts with the largest first when no direction is given.
332 {"reviews", "", sortReviews, true},
333 {"joined", "", sortJoined, true},
334 // An unknown key or direction is the default rather than an error.
335 {"bogus", "sideways", sortName, false},
336 }
337 for _, c := range cases {
338 got := parsePeopleSort(c.in, c.dir)
339 if got.key != c.key || got.desc != c.desc {
340 t.Errorf("parsePeopleSort(%q, %q) = %+v", c.in, c.dir, got)
341 }
342 }
343}
344
345func TestSortPeopleFallsBackToTheName(t *testing.T) {
346 t.Parallel()
347 rows := []peopleRow{
348 {Name: "carol", Summary: index.ActorSummary{Issues: 2}},
349 {Name: "alice", Summary: index.ActorSummary{Issues: 2}},
350 {Name: "bob", Summary: index.ActorSummary{Issues: 1}},
351 }
352 sortPeople(rows, peopleSort{key: sortIssues, desc: true})
353 if rows[0].Name != "alice" || rows[1].Name != "carol" || rows[2].Name != "bob" {
354 t.Fatalf("sorted rows = %v", rows)
355 }
356 sortPeople(rows, peopleSort{key: sortName})
357 if rows[0].Name != "alice" || rows[1].Name != "bob" || rows[2].Name != "carol" {
358 t.Fatalf("name order = %v", rows)
359 }
360}
361
362// TestProfileTotals counts the grouped rows the way the panel and the cards must
363// agree on, and drops a release: its actor is a tag's tagger, not a credential.
364func TestProfileTotals(t *testing.T) {
365 t.Parallel()
366 names := map[string]string{"r1": "demo", "r2": "other"}
367 counts := []index.ActorCount{
368 {RepoID: "r1", Kind: "issue", Type: "open", N: 3},
369 {RepoID: "r1", Kind: "issue", Type: "state", State: "closed", N: 4},
370 {RepoID: "r1", Kind: "change", Type: "open", N: 2},
371 {RepoID: "r1", Kind: "change", Type: "approval", N: 1},
372 {RepoID: "r1", Kind: "issue", Type: "comment", N: 5},
373 {RepoID: "r1", Kind: "change", Type: "comment", N: 2},
374 {RepoID: "r2", Kind: "change", Type: "state", State: "merged", N: 1},
375 {RepoID: "r2", Kind: "release", Type: "release", N: 9},
376 }
377 stats, repos := profileTotals(counts, names)
378 if stats.Issues != 3 || stats.Changes != 2 || stats.Reviews != 1 || stats.Comments != 7 {
379 t.Fatalf("stats = %+v", stats)
380 }
381 if len(repos) != 1 || repos[0].Name != "demo" {
382 t.Fatalf("repos = %+v, want only demo (other's work is state changes and a release)", repos)
383 }
384 if repos[0].Issues != 3 || repos[0].Changes != 2 || repos[0].Reviews != 1 || repos[0].Comments != 7 {
385 t.Fatalf("demo card = %+v", repos[0])
386 }
387
388 // An empty answer is empty, not a zeroed card.
389 if stats, repos = profileTotals(nil, names); len(repos) != 0 || stats != (profileStats{}) {
390 t.Fatalf("no rows = %+v, %+v", stats, repos)
391 }
392}
393
394func TestParseProfileFilter(t *testing.T) {
395 t.Parallel()
396 cases := []struct {
397 in string
398 kind string
399 typ string
400 headline string
401 query string
402 }{
403 {"", "", "", "", ""},
404 {"issues", "issue", "", "Issues", "issues"},
405 {"changes", "change", "", "Changes", "changes"},
406 {"reviews", "", "approval", "Reviews", "reviews"},
407 {"comments", "", "comment", "Comments", "comments"},
408 // An unknown filter is the whole feed rather than an error, so a stale
409 // link still shows something — and keeps nothing in its page links.
410 {"everything", "", "", "", ""},
411 // The page's spelling is normalised, so `?type=Reviews` still marks the
412 // Reviews chip active and pages as `?type=reviews`.
413 {"Reviews", "", "approval", "Reviews", "reviews"},
414 }
415 for _, c := range cases {
416 got := parseProfileFilter(c.in)
417 if got.kind != c.kind || got.typ != c.typ || got.label != c.headline || got.query != c.query {
418 t.Errorf("parseProfileFilter(%q) = %+v", c.in, got)
419 }
420 }
421}
422
423func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) {
424 t.Parallel()
425 if got := profileURL("alice", "")(1); got != "/-/u/alice" {
426 t.Fatalf("first page = %q", got)
427 }
428 // A filtered page keeps the filter the reader chose, in the page's spelling.
429 if got := profileURL("alice", "reviews")(2); got != "/-/u/alice?page=2&type=reviews" {
430 t.Fatalf("filtered page = %q", got)
431 }
432 // The default directory URL stays short.
433 if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/-/u" {
434 t.Fatalf("directory first page = %q", got)
435 }
436 if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/-/u?page=2&q=ali" {
437 t.Fatalf("searched page = %q", got)
438 }
439 // The search, the status and the order all survive a page link.
440 if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/-/u?dir=desc&page=2&q=a&sort=reviews&status=active" {
441 t.Fatalf("filtered and sorted page = %q", got)
442 }
443}
444
445// TestUserRefLinksOnlyAccounts: a name becomes a link only when it belongs to an
446// account, so a commit author who never registered and a typo in a mention stay
447// text instead of becoming a link that 404s.
448func TestUserRefLinksOnlyAccounts(t *testing.T) {
449 t.Parallel()
450 s := newSite(t)
451 w := s.web
452
453 known := w.userRef("alice")
454 if !known.Known || known.Href != "/-/u/alice" || known.Name != "alice" {
455 t.Fatalf("known account = %+v", known)
456 }
457 unknown := w.userRef("someone-who-never-registered")
458 if unknown.Known || unknown.Href != "" || unknown.Name != "someone-who-never-registered" {
459 t.Fatalf("unknown name = %+v", unknown)
460 }
461 if empty := w.userRef(""); empty.Known || empty.Name != "" {
462 t.Fatalf("empty name = %+v", empty)
463 }
464}
465
466// A directory of a large project pages server-side, and the page link keeps the
467// question the reader asked: the order, the status and the search.
468func TestDirectoryPaginates(t *testing.T) {
469 t.Parallel()
470 s := newSite(t)
471 for i := 0; i < peoplePage+5; i++ {
472 if _, _, err := s.ident.CreateWithInvite(fmt.Sprintf("user-%03d", i), "setup", time.Hour); err != nil {
473 t.Fatal(err)
474 }
475 }
476
477 first := s.serve(t, nil, "/-/u").Body.String()
478 if got := strings.Count(first, `class="people-name"`); got != peoplePage {
479 t.Fatalf("first page shows %d rows, want %d", got, peoplePage)
480 }
481 if !strings.Contains(first, `href="/-/u?page=2"`) {
482 t.Fatalf("the first page has no link to the second:\n%s", first)
483 }
484
485 second := s.serve(t, nil, "/-/u?sort=joined&dir=desc&page=2").Body.String()
486 want := 3 + peoplePage + 5 - peoplePage // the fixture's accounts plus the extra ones
487 if got := strings.Count(second, `class="people-name"`); got != want {
488 t.Fatalf("second page shows %d rows, want %d", got, want)
489 }
490 if !strings.Contains(second, "sort=joined") || !strings.Contains(second, "dir=desc") {
491 t.Fatalf("a page link drops the order:\n%s", second)
492 }
493}
494