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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Give every account a profile page and a directory of accounts

a6b42433c087ab0c936e34a1d05cf488db7a92d6 · Keenan Nemetz · 8d ago · parent 727df386

Give every account a profile page and a directory of accounts

Clicking a name now opens /u/<name>: the issues and changes somebody
opened, the reviews they gave, the comments they left, and the
repositories that work is in. The numbers and the feed are grouped reads
of the same event rows the activity feed pages through, so a profile
cannot disagree with the feed, and every read is limited to the
repositories the reader may open. A private repository's work is not a
number an outsider gets to learn, so two readers can see different
totals and the page says what its numbers cover.

/u lists the roster. Account names are public on this instance; their
activity is not. Both pages ask crawlers to leave them out of an index.

@name in an issue, a comment, a change request, or a release note links
to the account it addresses, using the mention scanner CI already
evaluates: what reads as an address on the page is what a workflow sees.
A name that is not an account stays text, and code samples are skipped.

Git author names are not linked and commit counts are not shown. A
commit author is a name somebody typed, not the credential that proves
an account, and the index only keeps the top eight authors of each
default branch anyway.

server/web/profile_test.go +370

@@ -0,0 +1,370 @@
1 package 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
8 import (
9 "context"
10 "net/http"
11 "net/http/httptest"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/rodent-software/vole/forge/access"
18 "github.com/rodent-software/vole/forge/config"
19 "github.com/rodent-software/vole/forge/identity"
20 "github.com/rodent-software/vole/forge/index"
21 "github.com/rodent-software/vole/forge/repo"
22 "github.com/rodent-software/vole/forge/session"
23 "github.com/rodent-software/vole/metadata"
24 )
25
26 // site is a forge with a roster and two repositories: "demo" is readable by
27 // anybody, "secret" only by alice. Each holds one issue, so a profile's feed
28 // either names the hidden one or it does not.
29 type site struct {
30 web *Web
31 ident *identity.Store
32 acls *access.Store
33 }
34
35 func newSite(t *testing.T) *site {
36 t.Helper()
37 root := t.TempDir()
38 users := filepath.Join(root, "users.db")
39 if err := identity.Init(users); err != nil {
40 t.Fatal(err)
41 }
42 ident, err := identity.Open(users)
43 if err != nil {
44 t.Fatal(err)
45 }
46 t.Cleanup(func() { ident.Close() })
47 ident.AuditPath = filepath.Join(root, "audit.log")
48
49 acls, err := access.Open(users)
50 if err != nil {
51 t.Fatal(err)
52 }
53 t.Cleanup(func() { acls.Close() })
54 acls.AuditPath = filepath.Join(root, "audit.log")
55
56 ix, err := index.Open(filepath.Join(root, "index.db"))
57 if err != nil {
58 t.Fatal(err)
59 }
60 t.Cleanup(func() { ix.Close() })
61
62 w, err := New(repo.New(filepath.Join(root, "repos"), "", acls), ix, ident, nil, &config.Config{DataDir: root})
63 if err != nil {
64 t.Fatal(err)
65 }
66 for _, name := range []string{"alice", "adam", "bob"} {
67 if _, _, err := ident.CreateWithInvite(name, "setup", time.Hour); err != nil {
68 t.Fatalf("create %s: %v", name, err)
69 }
70 }
71 s := &site{web: w, ident: ident, acls: acls}
72 s.repo(t, "demo", "alice", true, "Public work")
73 s.repo(t, "secret", "alice", false, "Hidden work")
74 return s
75 }
76
77 func (s *site) repo(t *testing.T, name, owner string, readableByAnyone bool, issueTitle string) {
78 t.Helper()
79 m, err := s.web.Repos.Create(name, owner, "")
80 if err != nil {
81 t.Fatal(err)
82 }
83 if readableByAnyone {
84 acl, err := m.ACL()
85 if err != nil {
86 t.Fatal(err)
87 }
88 acl.Default = "read"
89 if err := s.acls.Set(m.ID, acl, owner, time.Now()); err != nil {
90 t.Fatal(err)
91 }
92 }
93 if err := s.web.Index.Upsert(m); err != nil {
94 t.Fatal(err)
95 }
96 iss, err := metadata.NewIssue(issueTitle, "body", "alice", nil, nil, time.Now().UTC())
97 if err != nil {
98 t.Fatal(err)
99 }
100 if err := metadata.CreateIssue(m.Git(), iss, owner); err != nil {
101 t.Fatal(err)
102 }
103 if err := s.web.Index.IndexRepo(m); err != nil {
104 t.Fatal(err)
105 }
106 }
107
108 // serve calls a handler through a mux, because the handlers read the account name
109 // from the route pattern. A nil account is an anonymous reader.
110 func (s *site) serve(t *testing.T, as *identity.Account, path string) *httptest.ResponseRecorder {
111 t.Helper()
112 mux := http.NewServeMux()
113 mux.HandleFunc("GET /u", s.web.PeoplePage)
114 mux.HandleFunc("GET /u/{$}", s.web.PeoplePage)
115 mux.HandleFunc("GET /u/{name}", s.web.ProfilePage)
116 r := httptest.NewRequest("GET", path, nil)
117 if as != nil {
118 r = r.WithContext(context.WithValue(r.Context(), principalKey{},
119 &principal{account: as, session: &session.Session{}}))
120 }
121 rw := httptest.NewRecorder()
122 mux.ServeHTTP(rw, r)
123 return rw
124 }
125
126 func (s *site) account(t *testing.T, name string) *identity.Account {
127 t.Helper()
128 a, err := s.ident.Find(name)
129 if err != nil {
130 t.Fatal(err)
131 }
132 return a
133 }
134
135 // TestProfileCountsOnlyWhatTheViewerCanRead is the privacy rule of the page: the
136 // same account, two viewers, and no sign of the repository the anonymous reader
137 // cannot open.
138 func TestProfileCountsOnlyWhatTheViewerCanRead(t *testing.T) {
139 t.Parallel()
140 s := newSite(t)
141
142 anon := s.serve(t, nil, "/u/alice")
143 if anon.Code != http.StatusOK {
144 t.Fatalf("anonymous profile = %d: %s", anon.Code, anon.Body)
145 }
146 body := anon.Body.String()
147 if !strings.Contains(body, "Public work") {
148 t.Fatalf("the anonymous profile is missing the public issue:\n%s", body)
149 }
150 if strings.Contains(body, "Hidden work") {
151 t.Fatalf("the anonymous profile leaked a private issue:\n%s", body)
152 }
153 // The page says the numbers are scoped rather than pretending they are total.
154 if !strings.Contains(body, "Counted across the repositories you can read.") {
155 t.Fatalf("the profile does not say what the numbers cover:\n%s", body)
156 }
157 // A profile is public, but a scraped roster is not what public has to mean.
158 if !strings.Contains(body, `<meta name="robots" content="noindex">`) {
159 t.Fatalf("the profile does not ask crawlers to leave it alone:\n%s", body)
160 }
161
162 signed := s.serve(t, s.account(t, "alice"), "/u/alice")
163 if signed.Code != http.StatusOK {
164 t.Fatalf("signed-in profile = %d", signed.Code)
165 }
166 if !strings.Contains(signed.Body.String(), "Hidden work") {
167 t.Fatalf("the owner's profile is missing her own private work:\n%s", signed.Body)
168 }
169 }
170
171 func TestProfileOfAnUnknownNameIsNotFound(t *testing.T) {
172 t.Parallel()
173 s := newSite(t)
174 rw := s.serve(t, nil, "/u/nobody")
175 if rw.Code != http.StatusNotFound {
176 t.Fatalf("unknown name = %d, want 404", rw.Code)
177 }
178 // The 404 is the one every missing page uses, so it cannot be used to learn
179 // that a name is not an account rather than that a page is missing.
180 if !strings.Contains(rw.Body.String(), "That page does not exist") {
181 t.Fatalf("unknown name rendered the wrong page:\n%s", rw.Body)
182 }
183 }
184
185 func TestRetiredProfileStillOpens(t *testing.T) {
186 t.Parallel()
187 s := newSite(t)
188 if _, err := s.ident.Retire("bob", "alice"); err != nil {
189 t.Fatal(err)
190 }
191 rw := s.serve(t, nil, "/u/bob")
192 if rw.Code != http.StatusOK {
193 t.Fatalf("retired profile = %d", rw.Code)
194 }
195 if !strings.Contains(rw.Body.String(), "no longer active") {
196 t.Fatalf("a retired profile does not say so:\n%s", rw.Body)
197 }
198 }
199
200 func TestProfileFilterLinksAndHeading(t *testing.T) {
201 t.Parallel()
202 s := newSite(t)
203 body := s.serve(t, nil, "/u/alice").Body.String()
204 for _, want := range []string{
205 `href="/u/alice?type=issues"`,
206 `href="/u/alice?type=changes"`,
207 `href="/u/alice?type=reviews"`,
208 `href="/u/alice?type=comments"`,
209 "Issues opened",
210 "Changes reviewed",
211 } {
212 if !strings.Contains(body, want) {
213 t.Fatalf("profile is missing %q:\n%s", want, body)
214 }
215 }
216 // The filtered page says which filter is on, so a bookmarked URL is not a
217 // mystery.
218 filtered := s.serve(t, nil, "/u/alice?type=reviews").Body.String()
219 if !strings.Contains(filtered, `class="chip active" href="/u/alice?type=reviews"`) {
220 t.Fatalf("the reviews filter is not marked active:\n%s", filtered)
221 }
222 }
223
224 func TestPeopleListsEveryAccountAndFilters(t *testing.T) {
225 t.Parallel()
226 s := newSite(t)
227
228 all := s.serve(t, nil, "/u")
229 if all.Code != http.StatusOK {
230 t.Fatalf("directory = %d: %s", all.Code, all.Body)
231 }
232 body := all.Body.String()
233 for _, want := range []string{"alice", "adam", "bob", "Everyone with an account here",
234 "Counts only include repositories you can read."} {
235 if !strings.Contains(body, want) {
236 t.Fatalf("directory is missing %q:\n%s", want, body)
237 }
238 }
239 if strings.Contains(body, `<meta name="robots" content="noindex">`) == false {
240 t.Fatal("the directory does not ask crawlers to leave it alone")
241 }
242
243 filtered := s.serve(t, nil, "/u?q=ali").Body.String()
244 if !strings.Contains(filtered, "alice") || strings.Contains(filtered, "adam") {
245 t.Fatalf("the name filter returned the wrong people:\n%s", filtered)
246 }
247
248 empty := s.serve(t, nil, "/u?q=nobody").Body.String()
249 if !strings.Contains(empty, "No one matches") {
250 t.Fatalf("an empty search has no empty state:\n%s", empty)
251 }
252 }
253
254 func TestRetiredAccountsSitApartInTheDirectory(t *testing.T) {
255 t.Parallel()
256 s := newSite(t)
257 if _, err := s.ident.Retire("bob", "alice"); err != nil {
258 t.Fatal(err)
259 }
260 body := s.serve(t, nil, "/u").Body.String()
261 retired := strings.Index(body, "No longer active")
262 if retired < 0 {
263 t.Fatalf("the directory has no retired group:\n%s", body)
264 }
265 if strings.Index(body, `href="/u/alice"`) > retired {
266 t.Fatalf("an active account is listed after the retired group:\n%s", body)
267 }
268 if strings.Index(body, `href="/u/bob"`) < retired {
269 t.Fatalf("a retired account is listed with the active ones:\n%s", body)
270 }
271 }
272
273 // TestProfileTotals counts the grouped rows the way the panel and the cards must
274 // agree on, and drops a release: its actor is a tag's tagger, not a credential.
275 func TestProfileTotals(t *testing.T) {
276 t.Parallel()
277 names := map[string]string{"r1": "demo", "r2": "other"}
278 counts := []index.ActorCount{
279 {RepoID: "r1", Kind: "issue", Type: "open", N: 3},
280 {RepoID: "r1", Kind: "issue", Type: "state", State: "closed", N: 4},
281 {RepoID: "r1", Kind: "change", Type: "open", N: 2},
282 {RepoID: "r1", Kind: "change", Type: "approval", N: 1},
283 {RepoID: "r1", Kind: "issue", Type: "comment", N: 5},
284 {RepoID: "r1", Kind: "change", Type: "comment", N: 2},
285 {RepoID: "r2", Kind: "change", Type: "state", State: "merged", N: 1},
286 {RepoID: "r2", Kind: "release", Type: "release", N: 9},
287 }
288 stats, repos := profileTotals(counts, names)
289 if stats.Issues != 3 || stats.Changes != 2 || stats.Reviews != 1 || stats.Comments != 7 {
290 t.Fatalf("stats = %+v", stats)
291 }
292 if len(repos) != 1 || repos[0].Name != "demo" {
293 t.Fatalf("repos = %+v, want only demo (other's work is state changes and a release)", repos)
294 }
295 if repos[0].Issues != 3 || repos[0].Changes != 2 || repos[0].Reviews != 1 || repos[0].Comments != 7 {
296 t.Fatalf("demo card = %+v", repos[0])
297 }
298
299 // An empty answer is empty, not a zeroed card.
300 if stats, repos = profileTotals(nil, names); len(repos) != 0 || stats != (profileStats{}) {
301 t.Fatalf("no rows = %+v, %+v", stats, repos)
302 }
303 }
304
305 func TestParseProfileFilter(t *testing.T) {
306 t.Parallel()
307 cases := []struct {
308 in string
309 kind string
310 typ string
311 headline string
312 query string
313 }{
314 {"", "", "", "", ""},
315 {"issues", "issue", "", "Issues", "issues"},
316 {"changes", "change", "", "Changes", "changes"},
317 {"reviews", "", "approval", "Reviews", "reviews"},
318 {"comments", "", "comment", "Comments", "comments"},
319 // An unknown filter is the whole feed rather than an error, so a stale
320 // link still shows something — and keeps nothing in its page links.
321 {"everything", "", "", "", ""},
322 // The page's spelling is normalised, so `?type=Reviews` still marks the
323 // Reviews chip active and pages as `?type=reviews`.
324 {"Reviews", "", "approval", "Reviews", "reviews"},
325 }
326 for _, c := range cases {
327 got := parseProfileFilter(c.in)
328 if got.kind != c.kind || got.typ != c.typ || got.label != c.headline || got.query != c.query {
329 t.Errorf("parseProfileFilter(%q) = %+v", c.in, got)
330 }
331 }
332 }
333
334 func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) {
335 t.Parallel()
336 if got := profileURL("alice", "")(1); got != "/u/alice" {
337 t.Fatalf("first page = %q", got)
338 }
339 // A filtered page keeps the filter the reader chose, in the page's spelling.
340 if got := profileURL("alice", "reviews")(2); got != "/u/alice?page=2&type=reviews" {
341 t.Fatalf("filtered page = %q", got)
342 }
343 if got := peopleURL("")(1); got != "/u" {
344 t.Fatalf("directory first page = %q", got)
345 }
346 if got := peopleURL("ali")(2); got != "/u?page=2&q=ali" {
347 t.Fatalf("searched page = %q", got)
348 }
349 }
350
351 // TestUserRefLinksOnlyAccounts: a name becomes a link only when it belongs to an
352 // account, so a commit author who never registered and a typo in a mention stay
353 // text instead of becoming a link that 404s.
354 func TestUserRefLinksOnlyAccounts(t *testing.T) {
355 t.Parallel()
356 s := newSite(t)
357 w := s.web
358
359 known := w.userRef("alice")
360 if !known.Known || known.Href != "/u/alice" || known.Name != "alice" {
361 t.Fatalf("known account = %+v", known)
362 }
363 unknown := w.userRef("someone-who-never-registered")
364 if unknown.Known || unknown.Href != "" || unknown.Name != "someone-who-never-registered" {
365 t.Fatalf("unknown name = %+v", unknown)
366 }
367 if empty := w.userRef(""); empty.Known || empty.Name != "" {
368 t.Fatalf("empty name = %+v", empty)
369 }
370 }