Go · 35839 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package profile_test
4
5 import (
6 "context"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/cookiejar"
12 "net/http/httptest"
13 "net/url"
14 "strconv"
15 "strings"
16 "testing"
17 "testing/fstest"
18 "time"
19
20 "github.com/go-chi/chi/v5"
21 "github.com/jackc/pgx/v5/pgxpool"
22
23 authpkg "github.com/tenseleyFlow/shithub/internal/auth"
24 "github.com/tenseleyFlow/shithub/internal/infra/storage"
25 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
26 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
27 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
28 profileh "github.com/tenseleyFlow/shithub/internal/web/handlers/profile"
29 "github.com/tenseleyFlow/shithub/internal/web/middleware"
30 "github.com/tenseleyFlow/shithub/internal/web/render"
31 )
32
33 type profileEnv struct {
34 srv *httptest.Server
35 pool *pgxpool.Pool
36 q *usersdb.Queries
37 repoFS *storage.RepoFS
38 }
39
40 func setupProfileEnv(t *testing.T) *profileEnv {
41 return setupProfileEnvWithStore(t, nil)
42 }
43
44 func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *profileEnv {
45 return setupProfileEnvWithDeps(t, objectStore, nil)
46 }
47
48 func setupProfileEnvWithRepoFS(t *testing.T) *profileEnv {
49 t.Helper()
50 repoFS, err := storage.NewRepoFS(t.TempDir())
51 if err != nil {
52 t.Fatalf("NewRepoFS: %v", err)
53 }
54 return setupProfileEnvWithDeps(t, nil, repoFS)
55 }
56
57 func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repoFS *storage.RepoFS) *profileEnv {
58 t.Helper()
59 pool := dbtest.NewTestDB(t)
60
61 tmplFS := fstest.MapFS{
62 "_layout.html": {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
63 "hello.html": {Data: []byte(`{{ define "page" }}home{{ end }}`)},
64 "profile/view.html": {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowersCount}} FOLLOWINGCOUNT={{.FollowingCount}} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} PERIOD={{.Contributions.Period}} PRIVATE={{.Contributions.IncludePrivateContributions}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} YEARLINKS={{range .Contributions.Years}}{{.Year}}:{{.Active}}:{{.Href}};{{end}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} CANDIDATENAMES={{range .PinCandidates}}{{.OwnerSlug}}/{{.Name}};{{end}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1 ACTION={{.ContributionSettingsAction}} RETURN={{.ContributionSettingsReturn}}{{ end }}{{ end }}`)},
65 "profile/follows_tab.html": {Data: []byte(`{{ define "page" }}FOLLOWTAB={{.ActiveTab}} USER={{.User.Username}} TOTAL={{len .Items}} ITEMS={{range .Items}}{{.Kind}}:{{.Username}};{{end}}{{ end }}`)},
66 "profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
67 "orgs/profile.html": {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}}{{ if .IsFollowing }} FOLLOWING=1{{ end }} FOLLOWERS={{.FollowerCount}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
68 "orgs/repositories.html": {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)},
69 "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)},
70 "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)},
71 }
72 rr, err := render.New(tmplFS, render.Options{})
73 if err != nil {
74 t.Fatalf("render.New: %v", err)
75 }
76
77 h, err := profileh.New(profileh.Deps{
78 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
79 Render: rr, Pool: pool,
80 RepoFS: repoFS,
81 ObjectStore: objectStore,
82 })
83 if err != nil {
84 t.Fatalf("profileh.New: %v", err)
85 }
86
87 r := chi.NewRouter()
88 r.Use(func(next http.Handler) http.Handler {
89 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 rawID := r.Header.Get("X-Test-User-ID")
91 if rawID == "" {
92 next.ServeHTTP(w, r)
93 return
94 }
95 id, err := strconv.ParseInt(rawID, 10, 64)
96 if err != nil {
97 t.Fatalf("bad X-Test-User-ID: %v", err)
98 }
99 u := middleware.CurrentUser{
100 ID: id,
101 Username: r.Header.Get("X-Test-Username"),
102 }
103 next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), u)))
104 })
105 })
106 r.Get("/login", func(w http.ResponseWriter, _ *http.Request) {
107 _, _ = w.Write([]byte("login-handler"))
108 })
109 h.MountAvatars(r)
110 h.MountOrgRepositories(r)
111 h.MountProfile(r)
112
113 srv := httptest.NewServer(r)
114 t.Cleanup(srv.Close)
115 return &profileEnv{srv: srv, pool: pool, q: usersdb.New(), repoFS: repoFS}
116 }
117
118 // fixtureHash is a static PHC test fixture (zero salt, zero key) — not a real credential.
119 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
120 "AAAAAAAAAAAAAAAA$" +
121 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
122
123 func (e *profileEnv) insertUser(t *testing.T, username, display, bio string) usersdb.User {
124 t.Helper()
125 ctx := context.Background()
126 user, err := e.q.CreateUser(ctx, e.pool, usersdb.CreateUserParams{
127 Username: username,
128 DisplayName: display,
129 PasswordHash: fixtureHash,
130 })
131 if err != nil {
132 t.Fatalf("CreateUser: %v", err)
133 }
134 if bio != "" {
135 if _, err := e.pool.Exec(ctx, "UPDATE users SET bio = $1 WHERE id = $2", bio, user.ID); err != nil {
136 t.Fatalf("set bio: %v", err)
137 }
138 }
139 return user
140 }
141
142 func (e *profileEnv) insertVerifiedEmail(t *testing.T, userID int64, email string) {
143 t.Helper()
144 if _, err := e.q.CreateUserEmail(context.Background(), e.pool, usersdb.CreateUserEmailParams{
145 UserID: userID,
146 Email: email,
147 IsPrimary: true,
148 Verified: true,
149 }); err != nil {
150 t.Fatalf("CreateUserEmail: %v", err)
151 }
152 }
153
154 func (e *profileEnv) insertOrg(t *testing.T, slug, display, desc string, creator usersdb.User) int64 {
155 t.Helper()
156 ctx := context.Background()
157 var orgID int64
158 if err := e.pool.QueryRow(ctx,
159 `INSERT INTO orgs (slug, display_name, description, created_by_user_id)
160 VALUES ($1, $2, $3, $4)
161 RETURNING id`,
162 slug, display, desc, creator.ID).Scan(&orgID); err != nil {
163 t.Fatalf("insert org: %v", err)
164 }
165 if _, err := e.pool.Exec(ctx,
166 `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
167 orgID, creator.ID); err != nil {
168 t.Fatalf("insert org member: %v", err)
169 }
170 return orgID
171 }
172
173 func (e *profileEnv) insertOrgRepo(t *testing.T, orgID int64, name, desc, visibility, language string, stars, forks int64, topics ...string) int64 {
174 t.Helper()
175 ctx := context.Background()
176 var repoID int64
177 if err := e.pool.QueryRow(ctx,
178 `INSERT INTO repos (
179 owner_org_id, name, description, visibility, default_branch,
180 primary_language, star_count, fork_count, updated_at
181 )
182 VALUES ($1, $2, $3, $4, 'trunk', $5, $6, $7, now())
183 RETURNING id`,
184 orgID, name, desc, visibility, language, stars, forks).Scan(&repoID); err != nil {
185 t.Fatalf("insert org repo: %v", err)
186 }
187 for _, topic := range topics {
188 if _, err := e.pool.Exec(ctx,
189 `INSERT INTO repo_topics (repo_id, topic) VALUES ($1, $2)`,
190 repoID, topic); err != nil {
191 t.Fatalf("insert topic: %v", err)
192 }
193 }
194 return repoID
195 }
196
197 func (e *profileEnv) insertUserRepo(t *testing.T, userID int64, name, desc, visibility, language string, stars, forks int64) int64 {
198 t.Helper()
199 var repoID int64
200 if err := e.pool.QueryRow(context.Background(),
201 `INSERT INTO repos (
202 owner_user_id, name, description, visibility, default_branch,
203 primary_language, star_count, fork_count, updated_at
204 )
205 VALUES ($1, $2, $3, $4, 'trunk', $5, $6, $7, now())
206 RETURNING id`,
207 userID, name, desc, visibility, language, stars, forks).Scan(&repoID); err != nil {
208 t.Fatalf("insert user repo: %v", err)
209 }
210 return repoID
211 }
212
213 func (e *profileEnv) insertRepoCollaborator(t *testing.T, repoID, userID int64, role string) {
214 t.Helper()
215 if _, err := e.pool.Exec(context.Background(),
216 `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, $3)`,
217 repoID, userID, role); err != nil {
218 t.Fatalf("insert repo collaborator: %v", err)
219 }
220 }
221
222 func (e *profileEnv) writeInitialCommit(t *testing.T, owner, repoName, authorName, authorEmail string, when time.Time) string {
223 t.Helper()
224 if e.repoFS == nil {
225 t.Fatal("repoFS not configured")
226 }
227 ctx := context.Background()
228 gitDir, err := e.repoFS.RepoPath(owner, repoName)
229 if err != nil {
230 t.Fatalf("RepoPath: %v", err)
231 }
232 if err := e.repoFS.InitBare(ctx, gitDir); err != nil {
233 t.Fatalf("InitBare: %v", err)
234 }
235 oid, err := repogit.InitialCommit{
236 GitDir: gitDir,
237 AuthorName: authorName,
238 AuthorEmail: authorEmail,
239 Message: "Initial commit",
240 Branch: "trunk",
241 When: when,
242 Files: []repogit.FileEntry{{
243 Path: "README.md",
244 Body: []byte("# " + repoName + "\n"),
245 }},
246 }.Build(ctx)
247 if err != nil {
248 t.Fatalf("InitialCommit.Build: %v", err)
249 }
250 return oid
251 }
252
253 func (e *profileEnv) insertRedirect(t *testing.T, oldname string, userID int64) {
254 t.Helper()
255 if _, err := e.pool.Exec(context.Background(),
256 "INSERT INTO username_redirects (old_username, user_id) VALUES ($1, $2)",
257 oldname, userID); err != nil {
258 t.Fatalf("redirect insert: %v", err)
259 }
260 }
261
262 func (e *profileEnv) suspend(t *testing.T, userID int64) {
263 t.Helper()
264 if _, err := e.pool.Exec(context.Background(),
265 "UPDATE users SET suspended_at = now(), suspended_reason = 'test' WHERE id = $1",
266 userID); err != nil {
267 t.Fatalf("suspend: %v", err)
268 }
269 }
270
271 func newNonRedirClient(t *testing.T) *http.Client {
272 t.Helper()
273 jar, err := cookiejar.New(nil)
274 if err != nil {
275 t.Fatalf("jar: %v", err)
276 }
277 return &http.Client{
278 Jar: jar,
279 CheckRedirect: func(req *http.Request, via []*http.Request) error {
280 return http.ErrUseLastResponse
281 },
282 }
283 }
284
285 func (e *profileEnv) getAs(t *testing.T, path string, user usersdb.User) string {
286 t.Helper()
287 req, err := http.NewRequest(http.MethodGet, e.srv.URL+path, nil)
288 if err != nil {
289 t.Fatalf("request: %v", err)
290 }
291 if user.ID != 0 {
292 req.Header.Set("X-Test-User-ID", strconv.FormatInt(user.ID, 10))
293 req.Header.Set("X-Test-Username", user.Username)
294 }
295 resp, err := newNonRedirClient(t).Do(req)
296 if err != nil {
297 t.Fatalf("GET: %v", err)
298 }
299 defer func() { _ = resp.Body.Close() }()
300 if resp.StatusCode != http.StatusOK {
301 t.Fatalf("status %d, want 200", resp.StatusCode)
302 }
303 body, _ := io.ReadAll(resp.Body)
304 return string(body)
305 }
306
307 func (e *profileEnv) postPins(t *testing.T, path string, user usersdb.User, repoIDs ...int64) *http.Response {
308 t.Helper()
309 form := url.Values{}
310 for _, repoID := range repoIDs {
311 form.Add("repo_id", strconv.FormatInt(repoID, 10))
312 }
313 req, err := http.NewRequest(http.MethodPost, e.srv.URL+path, strings.NewReader(form.Encode()))
314 if err != nil {
315 t.Fatalf("request: %v", err)
316 }
317 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
318 req.Header.Set("X-Test-User-ID", strconv.FormatInt(user.ID, 10))
319 req.Header.Set("X-Test-Username", user.Username)
320 resp, err := newNonRedirClient(t).Do(req)
321 if err != nil {
322 t.Fatalf("POST: %v", err)
323 }
324 t.Cleanup(func() { _ = resp.Body.Close() })
325 return resp
326 }
327
328 func (e *profileEnv) postContributionSettings(t *testing.T, path string, user usersdb.User, includePrivate bool, returnTo string) *http.Response {
329 t.Helper()
330 include := "0"
331 if includePrivate {
332 include = "1"
333 }
334 form := url.Values{
335 "include_private_contributions": {include},
336 "return_to": {returnTo},
337 }
338 return e.postFormAs(t, path, user, form)
339 }
340
341 func (e *profileEnv) postFormAs(t *testing.T, path string, user usersdb.User, form url.Values) *http.Response {
342 t.Helper()
343 if form == nil {
344 form = url.Values{}
345 }
346 req, err := http.NewRequest(http.MethodPost, e.srv.URL+path, strings.NewReader(form.Encode()))
347 if err != nil {
348 t.Fatalf("request: %v", err)
349 }
350 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
351 if user.ID != 0 {
352 req.Header.Set("X-Test-User-ID", strconv.FormatInt(user.ID, 10))
353 req.Header.Set("X-Test-Username", user.Username)
354 }
355 resp, err := newNonRedirClient(t).Do(req)
356 if err != nil {
357 t.Fatalf("POST: %v", err)
358 }
359 t.Cleanup(func() { _ = resp.Body.Close() })
360 return resp
361 }
362
363 // =============================== tests ==================================
364
365 func TestProfile_RendersForExistingUser(t *testing.T) {
366 t.Parallel()
367 env := setupProfileEnv(t)
368 env.insertUser(t, "alice", "Alice Anderson", "Hi.")
369
370 resp, err := newNonRedirClient(t).Get(env.srv.URL + "/alice")
371 if err != nil {
372 t.Fatalf("GET: %v", err)
373 }
374 defer func() { _ = resp.Body.Close() }()
375 if resp.StatusCode != 200 {
376 t.Fatalf("status %d", resp.StatusCode)
377 }
378 body, _ := io.ReadAll(resp.Body)
379 for _, want := range []string{"USER=alice", "DISPLAY=Alice Anderson", "BIO=Hi."} {
380 if !strings.Contains(string(body), want) {
381 t.Errorf("missing %q in body: %s", want, body)
382 }
383 }
384 }
385
386 func TestProfile_FollowUserRoutesUpdateCountsAndState(t *testing.T) {
387 env := setupProfileEnv(t)
388 alice := env.insertUser(t, "alice", "Alice", "")
389 bob := env.insertUser(t, "bob", "Bob", "")
390
391 resp := env.postFormAs(t, "/alice/follow", bob, url.Values{"return_to": []string{"/alice?tab=followers"}})
392 if resp.StatusCode != http.StatusSeeOther {
393 t.Fatalf("follow status %d, want 303", resp.StatusCode)
394 }
395 if loc := resp.Header.Get("Location"); loc != "/alice?tab=followers" {
396 t.Fatalf("follow redirect = %q", loc)
397 }
398 var count int
399 if err := env.pool.QueryRow(context.Background(),
400 `SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`,
401 bob.ID, alice.ID,
402 ).Scan(&count); err != nil {
403 t.Fatalf("count follow: %v", err)
404 }
405 if count != 1 {
406 t.Fatalf("follow count = %d, want 1", count)
407 }
408 body := env.getAs(t, "/alice", bob)
409 for _, want := range []string{"FOLLOWING=1", "FOLLOWERS=1", "FOLLOWINGCOUNT=0"} {
410 if !strings.Contains(body, want) {
411 t.Fatalf("missing %q in body: %s", want, body)
412 }
413 }
414 followers := env.getAs(t, "/alice?tab=followers", alice)
415 if !strings.Contains(followers, "ITEMS=user:bob;") {
416 t.Fatalf("followers tab missing bob: %s", followers)
417 }
418
419 resp = env.postFormAs(t, "/alice/unfollow", bob, nil)
420 if resp.StatusCode != http.StatusSeeOther {
421 t.Fatalf("unfollow status %d, want 303", resp.StatusCode)
422 }
423 if err := env.pool.QueryRow(context.Background(),
424 `SELECT count(*) FROM follows WHERE follower_user_id = $1 AND followee_user_id = $2`,
425 bob.ID, alice.ID,
426 ).Scan(&count); err != nil {
427 t.Fatalf("count unfollow: %v", err)
428 }
429 if count != 0 {
430 t.Fatalf("follow count after unfollow = %d, want 0", count)
431 }
432 }
433
434 func TestProfile_FollowSelfRejected(t *testing.T) {
435 env := setupProfileEnv(t)
436 alice := env.insertUser(t, "alice", "Alice", "")
437
438 resp := env.postFormAs(t, "/alice/follow", alice, nil)
439 if resp.StatusCode != http.StatusBadRequest {
440 t.Fatalf("follow self status %d, want 400", resp.StatusCode)
441 }
442 }
443
444 func TestProfile_FollowOrgRoutesUpdateCountsAndState(t *testing.T) {
445 env := setupProfileEnv(t)
446 owner := env.insertUser(t, "owner", "Owner", "")
447 bob := env.insertUser(t, "bob", "Bob", "")
448 env.insertOrg(t, "acme", "Acme", "", owner)
449
450 resp := env.postFormAs(t, "/acme/follow", bob, nil)
451 if resp.StatusCode != http.StatusSeeOther {
452 t.Fatalf("follow org status %d, want 303", resp.StatusCode)
453 }
454 var count int
455 if err := env.pool.QueryRow(context.Background(),
456 `SELECT count(*) FROM follows f JOIN orgs o ON o.id = f.followee_org_id
457 WHERE f.follower_user_id = $1 AND o.slug = 'acme'`,
458 bob.ID,
459 ).Scan(&count); err != nil {
460 t.Fatalf("count org follow: %v", err)
461 }
462 if count != 1 {
463 t.Fatalf("org follow count = %d, want 1", count)
464 }
465 body := env.getAs(t, "/acme", bob)
466 for _, want := range []string{"ORG=acme", "FOLLOWING=1", "FOLLOWERS=1"} {
467 if !strings.Contains(body, want) {
468 t.Fatalf("missing %q in org body: %s", want, body)
469 }
470 }
471 }
472
473 func TestProfile_OverviewDataUsesVisibleReposAndOrganizations(t *testing.T) {
474 t.Parallel()
475 env := setupProfileEnv(t)
476 alice := env.insertUser(t, "alice", "Alice Anderson", "Hi.")
477 env.insertOrg(t, "acme", "Acme", "", alice)
478 env.insertUserRepo(t, alice.ID, "public-repo", "visible", "public", "Go", 0, 0)
479 env.insertUserRepo(t, alice.ID, "private-repo", "hidden", "private", "Rust", 0, 0)
480
481 body := env.getAs(t, "/alice", usersdb.User{})
482 for _, want := range []string{
483 "VISIBLE=1",
484 "ORGS=1",
485 "README=false",
486 "CONTRIB=0",
487 "WEEKS=53",
488 "YEARS=4",
489 } {
490 if !strings.Contains(body, want) {
491 t.Errorf("missing %q in body: %s", want, body)
492 }
493 }
494 if strings.Contains(body, "private-repo") {
495 t.Fatalf("anonymous profile overview leaked private repo data: %s", body)
496 }
497 }
498
499 func TestProfile_ContributionsCountVerifiedAndAffiliatedImportedIdentities(t *testing.T) {
500 t.Parallel()
501 env := setupProfileEnvWithRepoFS(t)
502 alice := env.insertUser(t, "alice", "Alice Anderson", "Hi.")
503 env.insertVerifiedEmail(t, alice.ID, "alice@outlook.com")
504 env.insertUserRepo(t, alice.ID, "owned", "user repo", "public", "Go", 0, 0)
505 orgID := env.insertOrg(t, "acme", "Acme", "", alice)
506 env.insertOrgRepo(t, orgID, "team", "org repo with imported author email", "public", "Rust", 0, 0)
507 env.insertOrgRepo(t, orgID, "other", "different author", "public", "Rust", 0, 0)
508 bob := env.insertUser(t, "bob", "Bob", "")
509 env.insertUserRepo(t, bob.ID, "spoof", "public repo with same author name", "public", "Go", 0, 0)
510
511 now := time.Now().UTC()
512 env.writeInitialCommit(t, "alice", "owned", "Alice Anderson", "alice@outlook.com", now.AddDate(0, 0, -7))
513 env.writeInitialCommit(t, "acme", "team", "alice", "alice@unverified.example", now.AddDate(0, 0, -14))
514 env.writeInitialCommit(t, "acme", "other", "Bob", "bob@example.com", now.AddDate(0, 0, -5))
515 env.writeInitialCommit(t, "bob", "spoof", "alice", "alice@spoof.example", now.AddDate(0, 0, -3))
516
517 body := env.getAs(t, "/alice", usersdb.User{})
518 for _, want := range []string{
519 "CONTRIB=2",
520 "PERIOD=in the last year",
521 "WEEKS=53",
522 } {
523 if !strings.Contains(body, want) {
524 t.Errorf("missing %q in body: %s", want, body)
525 }
526 }
527 }
528
529 func TestProfile_ContributionsSelectedYearHasStableLinks(t *testing.T) {
530 t.Parallel()
531 env := setupProfileEnvWithRepoFS(t)
532 alice := env.insertUser(t, "alice", "Alice Anderson", "")
533 env.insertVerifiedEmail(t, alice.ID, "alice@example.com")
534 env.insertUserRepo(t, alice.ID, "archive", "old work", "public", "Go", 0, 0)
535
536 currentYear := time.Now().UTC().Year()
537 selectedYear := currentYear - 1
538 env.writeInitialCommit(t, "alice", "archive", "Alice Anderson", "alice@example.com",
539 time.Date(selectedYear, time.March, 10, 12, 0, 0, 0, time.UTC))
540
541 body := env.getAs(t, fmt.Sprintf("/alice?year=%d", selectedYear), usersdb.User{})
542 for _, want := range []string{
543 "CONTRIB=1",
544 "PERIOD=in " + strconv.Itoa(selectedYear),
545 fmt.Sprintf("%d:false:/alice;", currentYear),
546 fmt.Sprintf("%d:true:/alice?year=%d;", selectedYear, selectedYear),
547 } {
548 if !strings.Contains(body, want) {
549 t.Errorf("missing %q in body: %s", want, body)
550 }
551 }
552 }
553
554 func TestProfile_PrivateContributionsRequireOwnerOptIn(t *testing.T) {
555 t.Parallel()
556 env := setupProfileEnvWithRepoFS(t)
557 alice := env.insertUser(t, "alice", "Alice Anderson", "")
558 env.insertVerifiedEmail(t, alice.ID, "alice@example.com")
559 env.insertUserRepo(t, alice.ID, "public-work", "visible work", "public", "Go", 0, 0)
560 env.insertUserRepo(t, alice.ID, "private-work", "private work", "private", "Go", 0, 0)
561
562 now := time.Now().UTC()
563 env.writeInitialCommit(t, "alice", "public-work", "Alice Anderson", "alice@example.com", now.AddDate(0, 0, -2))
564 env.writeInitialCommit(t, "alice", "private-work", "Alice Anderson", "alice@example.com", now.AddDate(0, 0, -1))
565
566 body := env.getAs(t, "/alice", alice)
567 for _, want := range []string{
568 "CONTRIB=1",
569 "PRIVATE=false",
570 "CUSTOMIZE=1 ACTION=/alice/contribution-settings RETURN=/alice",
571 } {
572 if !strings.Contains(body, want) {
573 t.Errorf("missing %q in body: %s", want, body)
574 }
575 }
576 if strings.Contains(body, "private-work") {
577 t.Fatalf("self profile leaked private repo name through contribution settings: %s", body)
578 }
579
580 currentYear := time.Now().UTC().Year()
581 returnTo := fmt.Sprintf("/alice?year=%d", currentYear)
582 resp := env.postContributionSettings(t, "/alice/contribution-settings", alice, true, returnTo)
583 if resp.StatusCode != http.StatusSeeOther {
584 t.Fatalf("status %d, want 303", resp.StatusCode)
585 }
586 if loc := resp.Header.Get("Location"); loc != returnTo {
587 t.Fatalf("Location = %q, want %q", loc, returnTo)
588 }
589
590 body = env.getAs(t, "/alice", usersdb.User{})
591 for _, want := range []string{
592 "CONTRIB=2",
593 "PRIVATE=true",
594 } {
595 if !strings.Contains(body, want) {
596 t.Errorf("missing %q in body: %s", want, body)
597 }
598 }
599 if strings.Contains(body, "private-work") {
600 t.Fatalf("anonymous profile leaked private repo name after private contribution opt-in: %s", body)
601 }
602 }
603
604 func TestProfile_ContributionSettingsRequireProfileOwner(t *testing.T) {
605 t.Parallel()
606 env := setupProfileEnv(t)
607 alice := env.insertUser(t, "alice", "Alice", "")
608 bob := env.insertUser(t, "bob", "Bob", "")
609
610 resp := env.postContributionSettings(t, "/alice/contribution-settings", bob, true, "/alice")
611 if resp.StatusCode != http.StatusForbidden {
612 t.Fatalf("status %d, want 403", resp.StatusCode)
613 }
614
615 body := env.getAs(t, "/alice", alice)
616 if !strings.Contains(body, "PRIVATE=false") {
617 t.Fatalf("unexpected settings change by non-owner: %s", body)
618 }
619 }
620
621 func TestProfile_UnknownUser404(t *testing.T) {
622 t.Parallel()
623 env := setupProfileEnv(t)
624 resp, _ := http.Get(env.srv.URL + "/no-such-user")
625 defer func() { _ = resp.Body.Close() }()
626 if resp.StatusCode != http.StatusNotFound {
627 t.Fatalf("status %d, want 404", resp.StatusCode)
628 }
629 }
630
631 func TestProfile_CasingRedirect(t *testing.T) {
632 t.Parallel()
633 env := setupProfileEnv(t)
634 env.insertUser(t, "alice", "Alice", "")
635 resp, err := newNonRedirClient(t).Get(env.srv.URL + "/Alice")
636 if err != nil {
637 t.Fatalf("GET: %v", err)
638 }
639 defer func() { _ = resp.Body.Close() }()
640 if resp.StatusCode != http.StatusMovedPermanently {
641 t.Fatalf("status %d, want 301", resp.StatusCode)
642 }
643 if resp.Header.Get("Location") != "/alice" {
644 t.Fatalf("Location = %q", resp.Header.Get("Location"))
645 }
646 }
647
648 func TestProfile_UsernameRedirect(t *testing.T) {
649 t.Parallel()
650 env := setupProfileEnv(t)
651 user := env.insertUser(t, "alice", "Alice", "")
652 env.insertRedirect(t, "oldname", user.ID)
653
654 resp, err := newNonRedirClient(t).Get(env.srv.URL + "/oldname")
655 if err != nil {
656 t.Fatalf("GET: %v", err)
657 }
658 defer func() { _ = resp.Body.Close() }()
659 if resp.StatusCode != http.StatusMovedPermanently {
660 t.Fatalf("status %d", resp.StatusCode)
661 }
662 if resp.Header.Get("Location") != "/alice" {
663 t.Fatalf("Location = %q", resp.Header.Get("Location"))
664 }
665 }
666
667 func TestProfile_DispatchesOrgOverviewWithVisibleAggregates(t *testing.T) {
668 t.Parallel()
669 env := setupProfileEnv(t)
670 creator := env.insertUser(t, "alice", "Alice", "")
671 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator)
672 env.insertOrgRepo(t, orgID, "shithub", "GitHub clone", "public", "Go", 3, 1, "git", "forge")
673 env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 2, 0, "secret")
674
675 resp, err := newNonRedirClient(t).Get(env.srv.URL + "/tenseleyflow")
676 if err != nil {
677 t.Fatalf("GET: %v", err)
678 }
679 defer func() { _ = resp.Body.Close() }()
680 if resp.StatusCode != 200 {
681 t.Fatalf("status %d", resp.StatusCode)
682 }
683 body, _ := io.ReadAll(resp.Body)
684 got := string(body)
685 for _, want := range []string{
686 "ORG=tenseleyflow",
687 "REPOS=1",
688 "PINS=1",
689 "MEMBERS=1",
690 "PEOPLE=1",
691 "NAMES=shithub;",
692 "LANGS=Go=1;",
693 "TOPICS=forge=1;git=1;",
694 "VIEWAS=Public",
695 } {
696 if !strings.Contains(got, want) {
697 t.Errorf("missing %q in body: %s", want, got)
698 }
699 }
700 if strings.Contains(got, "private-roadmap") || strings.Contains(got, "Rust") {
701 t.Fatalf("anonymous org overview leaked private repo data: %s", got)
702 }
703 }
704
705 func TestProfile_OrgRepositoriesPagePaginatesVisibleRepos(t *testing.T) {
706 t.Parallel()
707 env := setupProfileEnv(t)
708 creator := env.insertUser(t, "alice", "Alice", "")
709 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator)
710 for i := 0; i < 31; i++ {
711 env.insertOrgRepo(t, orgID, fmt.Sprintf("repo-%02d", i), "public repo", "public", "Go", int64(i), 0)
712 }
713 env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 99, 0)
714
715 body := env.getAs(t, "/orgs/tenseleyflow/repositories?sort=name&page=2", usersdb.User{})
716 for _, want := range []string{
717 "ORGREPOS=tenseleyflow",
718 "ACTIVE=repositories",
719 "TOTAL=31",
720 "FILTERED=31",
721 "PAGE=2/2",
722 "SORT=name",
723 "NAMES=repo-30;",
724 "P1=false",
725 "P2=true",
726 } {
727 if !strings.Contains(body, want) {
728 t.Errorf("missing %q in body: %s", want, body)
729 }
730 }
731 if strings.Contains(body, "private-roadmap") || strings.Contains(body, "Rust") {
732 t.Fatalf("anonymous org repositories page leaked private repo data: %s", body)
733 }
734 }
735
736 func TestProfile_OrgRepositoriesPageFilters(t *testing.T) {
737 t.Parallel()
738 env := setupProfileEnv(t)
739 creator := env.insertUser(t, "alice", "Alice", "")
740 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", creator)
741 env.insertOrgRepo(t, orgID, "shithub", "GitHub clone", "public", "Go", 3, 1, "forge")
742 env.insertOrgRepo(t, orgID, "loader", "local agent loop", "public", "Python", 9, 0, "agents")
743 env.insertOrgRepo(t, orgID, "sway", "adapter research", "public", "Python", 1, 0, "llm")
744
745 body := env.getAs(t, "/orgs/tenseleyflow/repositories?q=agent&type=public&language=Python&sort=stars", usersdb.User{})
746 for _, want := range []string{
747 "FILTERED=1",
748 "TYPE=public",
749 "LANG=Python",
750 "SORT=stars",
751 "NAMES=loader;",
752 } {
753 if !strings.Contains(body, want) {
754 t.Errorf("missing %q in body: %s", want, body)
755 }
756 }
757 if strings.Contains(body, "shithub") || strings.Contains(body, "sway;") {
758 t.Fatalf("org repositories filters returned unexpected repo: %s", body)
759 }
760 }
761
762 func TestProfile_UserPinsCanBeCustomized(t *testing.T) {
763 t.Parallel()
764 env := setupProfileEnv(t)
765 alice := env.insertUser(t, "alice", "Alice", "")
766 loaderID := env.insertUserRepo(t, alice.ID, "loader", "local assistant", "public", "Python", 0, 0)
767 shithubID := env.insertUserRepo(t, alice.ID, "shithub", "GitHub clone", "public", "Go", 3, 1)
768 env.insertUserRepo(t, alice.ID, "private-roadmap", "hidden", "private", "Rust", 9, 0)
769
770 got := env.getAs(t, "/alice", alice)
771 for _, want := range []string{"SELF=1", "PINS=0", "CANDIDATES=2", "CUSTOMIZE=1"} {
772 if !strings.Contains(got, want) {
773 t.Fatalf("missing %q in body: %s", want, got)
774 }
775 }
776 if strings.Contains(got, "private-roadmap") {
777 t.Fatalf("private repo was offered as a pin candidate: %s", got)
778 }
779
780 resp := env.postPins(t, "/alice/pins", alice, shithubID, loaderID)
781 if resp.StatusCode != http.StatusSeeOther {
782 t.Fatalf("status %d, want 303", resp.StatusCode)
783 }
784 if loc := resp.Header.Get("Location"); loc != "/alice#pinned" {
785 t.Fatalf("Location = %q", loc)
786 }
787
788 got = env.getAs(t, "/alice", usersdb.User{})
789 for _, want := range []string{"PINS=2", "PINNAMES=shithub;loader;", "SELECTED=shithub;loader;"} {
790 if !strings.Contains(got, want) {
791 t.Fatalf("missing %q in body: %s", want, got)
792 }
793 }
794 if strings.Contains(got, "CUSTOMIZE=1") {
795 t.Fatalf("anonymous viewer saw customize affordance: %s", got)
796 }
797 }
798
799 func TestProfile_UserPinsIncludeAffiliatedOrgAndCollaboratorRepos(t *testing.T) {
800 t.Parallel()
801 env := setupProfileEnv(t)
802 alice := env.insertUser(t, "alice", "Alice", "")
803 bob := env.insertUser(t, "bob", "Bob", "")
804 env.insertUserRepo(t, alice.ID, "owned", "user-owned work", "public", "Go", 0, 0)
805 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", alice)
806 orgToolID := env.insertOrgRepo(t, orgID, "org-tool", "org-owned work", "public", "Go", 2, 0)
807 env.insertOrgRepo(t, orgID, "secret-org-tool", "hidden org work", "private", "Rust", 0, 0)
808 otherOrgID := env.insertOrg(t, "strangers", "Strangers", "", bob)
809 strangerRepoID := env.insertOrgRepo(t, otherOrgID, "stranger-tool", "unaffiliated public repo", "public", "Go", 0, 0)
810 collabID := env.insertUserRepo(t, bob.ID, "collab-tool", "collaborator work", "public", "Python", 1, 0)
811 env.insertRepoCollaborator(t, collabID, alice.ID, "write")
812
813 got := env.getAs(t, "/alice", alice)
814 for _, want := range []string{
815 "CANDIDATES=3",
816 "alice/owned;",
817 "bob/collab-tool;",
818 "tenseleyflow/org-tool;",
819 } {
820 if !strings.Contains(got, want) {
821 t.Fatalf("missing %q in body: %s", want, got)
822 }
823 }
824 for _, notWant := range []string{"secret-org-tool", "strangers/stranger-tool"} {
825 if strings.Contains(got, notWant) {
826 t.Fatalf("unavailable repo %q was offered as a pin candidate: %s", notWant, got)
827 }
828 }
829
830 resp := env.postPins(t, "/alice/pins", alice, orgToolID, collabID)
831 if resp.StatusCode != http.StatusSeeOther {
832 t.Fatalf("status %d, want 303", resp.StatusCode)
833 }
834 if loc := resp.Header.Get("Location"); loc != "/alice#pinned" {
835 t.Fatalf("Location = %q", loc)
836 }
837
838 got = env.getAs(t, "/alice", alice)
839 for _, want := range []string{"PINS=2", "PINNAMES=org-tool;collab-tool;", "SELECTED=org-tool;collab-tool;"} {
840 if !strings.Contains(got, want) {
841 t.Fatalf("missing %q in body: %s", want, got)
842 }
843 }
844
845 resp = env.postPins(t, "/alice/pins", alice, strangerRepoID)
846 if resp.StatusCode != http.StatusBadRequest {
847 t.Fatalf("unaffiliated repo status %d, want 400", resp.StatusCode)
848 }
849 }
850
851 func TestProfile_OrgPinsFallbackUntilCustomized(t *testing.T) {
852 t.Parallel()
853 env := setupProfileEnv(t)
854 owner := env.insertUser(t, "alice", "Alice", "")
855 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", owner)
856 env.insertOrgRepo(t, orgID, "shithub", "GitHub clone", "public", "Go", 5, 1)
857 loaderID := env.insertOrgRepo(t, orgID, "loader", "local assistant", "public", "Python", 1, 0)
858 env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 9, 0)
859
860 got := env.getAs(t, "/tenseleyflow", usersdb.User{})
861 for _, want := range []string{"PINS=2", "PINNAMES=shithub;loader;", "CANDIDATES=2"} {
862 if !strings.Contains(got, want) {
863 t.Fatalf("missing %q in body: %s", want, got)
864 }
865 }
866
867 resp := env.postPins(t, "/tenseleyflow/pins", owner, loaderID)
868 if resp.StatusCode != http.StatusSeeOther {
869 t.Fatalf("status %d, want 303", resp.StatusCode)
870 }
871 if loc := resp.Header.Get("Location"); loc != "/tenseleyflow#pinned" {
872 t.Fatalf("Location = %q", loc)
873 }
874
875 got = env.getAs(t, "/tenseleyflow", usersdb.User{})
876 for _, want := range []string{"PINS=1", "PINNAMES=loader;", "SELECTED=loader;"} {
877 if !strings.Contains(got, want) {
878 t.Fatalf("missing %q in body: %s", want, got)
879 }
880 }
881 if strings.Contains(got, "PINNAMES=shithub;") || strings.Contains(got, "PINNAMES=loader;shithub;") {
882 t.Fatalf("custom org pins fell back to the synthetic set: %s", got)
883 }
884 }
885
886 func TestProfile_PinUpdatesRequireOwnershipAndPublicRepos(t *testing.T) {
887 t.Parallel()
888 env := setupProfileEnv(t)
889 owner := env.insertUser(t, "alice", "Alice", "")
890 outsider := env.insertUser(t, "bob", "Bob", "")
891 orgID := env.insertOrg(t, "tenseleyflow", "tenseleyFlow", "workflows", owner)
892 env.insertOrgRepo(t, orgID, "public-repo", "visible", "public", "Go", 0, 0)
893 privateID := env.insertOrgRepo(t, orgID, "private-roadmap", "hidden", "private", "Rust", 0, 0)
894
895 resp := env.postPins(t, "/tenseleyflow/pins", outsider)
896 if resp.StatusCode != http.StatusForbidden {
897 t.Fatalf("outsider status %d, want 403", resp.StatusCode)
898 }
899
900 resp = env.postPins(t, "/tenseleyflow/pins", owner, privateID)
901 if resp.StatusCode != http.StatusBadRequest {
902 t.Fatalf("private repo status %d, want 400", resp.StatusCode)
903 }
904 }
905
906 func TestProfile_SuspendedRendersUnavailable(t *testing.T) {
907 t.Parallel()
908 env := setupProfileEnv(t)
909 user := env.insertUser(t, "badactor", "Bad", "")
910 env.suspend(t, user.ID)
911
912 resp, _ := http.Get(env.srv.URL + "/badactor")
913 defer func() { _ = resp.Body.Close() }()
914 if resp.StatusCode != http.StatusGone {
915 t.Fatalf("status %d, want 410", resp.StatusCode)
916 }
917 body, _ := io.ReadAll(resp.Body)
918 if !strings.Contains(string(body), "SUSPENDED=badactor") {
919 t.Fatalf("expected suspended template, got: %s", body)
920 }
921 }
922
923 func TestProfile_ReservedNameNotShadowed(t *testing.T) {
924 t.Parallel()
925 env := setupProfileEnv(t)
926 resp, _ := http.Get(env.srv.URL + "/login")
927 defer func() { _ = resp.Body.Close() }()
928 body, _ := io.ReadAll(resp.Body)
929 if string(body) != "login-handler" {
930 t.Fatalf("expected login-handler, got %q", body)
931 }
932 }
933
934 func TestProfile_ReservedShortcircuit(t *testing.T) {
935 t.Parallel()
936 env := setupProfileEnv(t)
937 resp, _ := http.Get(env.srv.URL + "/admin")
938 defer func() { _ = resp.Body.Close() }()
939 if resp.StatusCode != http.StatusNotFound {
940 t.Fatalf("reserved path status: %d, want 404", resp.StatusCode)
941 }
942 }
943
944 func TestProfile_AvatarReturnsIdenticonForNoKey(t *testing.T) {
945 t.Parallel()
946 env := setupProfileEnv(t)
947 env.insertUser(t, "alice", "Alice", "")
948 resp, _ := http.Get(env.srv.URL + "/avatars/alice")
949 defer func() { _ = resp.Body.Close() }()
950 if resp.StatusCode != 200 {
951 t.Fatalf("status %d", resp.StatusCode)
952 }
953 if resp.Header.Get("Content-Type") != "image/svg+xml" {
954 t.Fatalf("content-type %q", resp.Header.Get("Content-Type"))
955 }
956 body, _ := io.ReadAll(resp.Body)
957 if !strings.Contains(string(body), "<svg") {
958 t.Fatalf("expected svg in body")
959 }
960 }
961
962 func TestProfile_AvatarStreamsOrgAvatar(t *testing.T) {
963 t.Parallel()
964 store := storage.NewMemoryStore()
965 env := setupProfileEnvWithStore(t, store)
966 owner := env.insertUser(t, "alice", "Alice", "")
967 orgID := env.insertOrg(t, "acme", "Acme", "", owner)
968 key := "avatars/orgs/acme/test.png"
969 if _, err := store.Put(context.Background(), key, strings.NewReader("org-avatar"), storage.PutOpts{
970 ContentType: "image/png",
971 ContentLength: int64(len("org-avatar")),
972 }); err != nil {
973 t.Fatalf("store.Put: %v", err)
974 }
975 if _, err := env.pool.Exec(context.Background(),
976 `UPDATE orgs SET avatar_object_key = $1 WHERE id = $2`,
977 key, orgID,
978 ); err != nil {
979 t.Fatalf("set org avatar: %v", err)
980 }
981
982 resp, _ := http.Get(env.srv.URL + "/avatars/acme")
983 defer func() { _ = resp.Body.Close() }()
984 if resp.StatusCode != http.StatusOK {
985 t.Fatalf("status %d", resp.StatusCode)
986 }
987 if resp.Header.Get("Content-Type") != "image/png" {
988 t.Fatalf("content-type %q", resp.Header.Get("Content-Type"))
989 }
990 body, _ := io.ReadAll(resp.Body)
991 if string(body) != "org-avatar" {
992 t.Fatalf("body=%q", body)
993 }
994 }
995
996 // TestReservedNameList_HasReasonableContents is the route-audit test: it
997 // asserts every top-level path segment shithub registers as of S09 is on
998 // the reserved list. When a future sprint adds a new top-level route,
999 // this test fails until reserved.go is updated.
1000 func TestReservedNameList_HasReasonableContents(t *testing.T) {
1001 t.Parallel()
1002 mustReserved := []string{
1003 "signup", "login", "logout",
1004 "password", "verify-email",
1005 "settings", "api", "admin",
1006 "static", "healthz", "readyz", "metrics",
1007 "keys", "tokens",
1008 "avatars",
1009 }
1010 for _, n := range mustReserved {
1011 if !authpkg.IsReserved(n) {
1012 t.Errorf("expected %q to be on the reserved list", n)
1013 }
1014 }
1015 }
1016