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