package web import ( "context" "encoding/base64" "errors" "fmt" "html" "io/fs" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/tiennm99/ghglance/internal/github" ) // testToken is a syntactically valid token that must never reach disk. const testToken = "ghp_testTOKENvalue0123456789abcdef" type fetchCall struct { token string login string cfg github.CollectConfig } // fakeFetcher stands in for GitHub. When gate is set, Fetch blocks until // it is closed, so tests can observe a running job. When stall is set, // Fetch waits out the job deadline and then returns a partial profile with // no error, as Collect does when it only warns about a late stage. // tokens maps a token to the account behind it; an unknown token belongs // to viewer and cannot read private repos. type fakeFetcher struct { mu sync.Mutex calls []fetchCall viewer string tokens map[string]github.TokenInfo gate chan struct{} stall bool err error } func (f *fakeFetcher) Fetch(ctx context.Context, token, login string, cfg github.CollectConfig) (*github.Profile, error) { f.mu.Lock() f.calls = append(f.calls, fetchCall{token: token, login: login, cfg: cfg}) gate, err, stall := f.gate, f.err, f.stall f.mu.Unlock() if stall { <-ctx.Done() return &github.Profile{Login: login}, nil } if gate != nil { select { case <-gate: case <-ctx.Done(): return nil, ctx.Err() } } if err != nil { return nil, err } return &github.Profile{Login: login, Name: "Test User", WeekStart: cfg.WeekStart}, nil } func (f *fakeFetcher) TokenInfo(_ context.Context, token string) (github.TokenInfo, error) { f.mu.Lock() defer f.mu.Unlock() if info, ok := f.tokens[token]; ok { return info, nil } return github.TokenInfo{Login: f.viewer}, nil } func (f *fakeFetcher) callCount() int { f.mu.Lock() defer f.mu.Unlock() return len(f.calls) } func (f *fakeFetcher) lastCall() fetchCall { f.mu.Lock() defer f.mu.Unlock() return f.calls[len(f.calls)-1] } func newTestServer(t *testing.T, f *fakeFetcher) *Server { t.Helper() return newTestServerTimeout(t, f, time.Minute) } func newTestServerTimeout(t *testing.T, f *fakeFetcher, timeout time.Duration) *Server { t.Helper() return newServerWith(t, f, newFakeGitHub(t), timeout) } // testOAuth points sign-in at the fake GitHub g, so no test ever revokes a // token on the real one. func testOAuth(g *fakeGitHub) OAuthConfig { return OAuthConfig{ ClientID: testClientID, ClientSecret: testClientSecret, PublicURL: testPublicURL + "/", WebURL: g.srv.URL, APIURL: g.srv.URL, } } func newServerWith(t *testing.T, f *fakeFetcher, g *fakeGitHub, timeout time.Duration) *Server { t.Helper() s, err := New(Config{ DataDir: t.TempDir(), Cooldown: time.Hour, Workers: 2, JobTimeout: timeout, Fetcher: f, OAuth: testOAuth(g), }) if err != nil { t.Fatal(err) } t.Cleanup(s.Close) return s } // waitIdle blocks until login's job has finished. func waitIdle(t *testing.T, q *Queue, login string) JobStatus { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if st := q.Status(login); !st.Active() { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("job for %s did not finish", login) return JobStatus{} } func form(kv ...string) func(string) string { v := url.Values{} for i := 0; i+1 < len(kv); i += 2 { v.Set(kv[i], kv[i+1]) } return v.Get } // signedIn parses a form and attaches token, as the sign-in callback does. func signedIn(t *testing.T, token string, kv ...string) submission { t.Helper() sub, _, err := parseSubmission(form(kv...)) if err != nil { t.Fatal(err) } sub.Token = token return sub } func TestValidUsername(t *testing.T) { good := []string{"a", "octocat", "tiennm99", "a-b", "A1-b2-C3", strings.Repeat("a", 39)} bad := []string{"", "-a", "a-", "a--b", strings.Repeat("a", 40), "..", "../etc", "a/b", `a\b`, "a.b", "a_b", ".gen", "a b", "%2e%2e", "é"} for _, s := range good { if !validUsername(s) { t.Errorf("validUsername(%q) = false, want true", s) } } for _, s := range bad { if validUsername(s) { t.Errorf("validUsername(%q) = true, want false", s) } } } func TestValidThemeAndCard(t *testing.T) { if !validTheme("dracula") || !validTheme("github_dark") { t.Error("known themes rejected") } for _, s := range []string{"", "..", "../dracula", "Dracula", "dracula/"} { if validTheme(s) { t.Errorf("validTheme(%q) = true", s) } } if !validCard("stats.svg") || !validCard("profile-details.svg") { t.Error("known cards rejected") } for _, s := range []string{"", "stats", "meta.json", "../meta.json", "stats.svg/..", "STATS.svg"} { if validCard(s) { t.Errorf("validCard(%q) = true", s) } } } func TestParseSubmissionKeepsTicks(t *testing.T) { sub, _, err := parseSubmission(form( "user", "octocat", "tz", "Asia/Saigon", "start_of_week", "Mon", "include_private", "1", "include_org_repos", "1", "include_forks", "1", )) if err != nil { t.Fatal(err) } o := sub.Options if !o.IncludePrivate || !o.IncludeOrgRepos || !o.IncludeForks || o.StartOfWeek != "monday" || o.TZ != "Asia/Saigon" { t.Errorf("options = %+v", o) } if o.CommitsPerRepo != defaultCommitsPerRepo || sub.Token != "" || sub.Pasted { t.Errorf("submission = %+v", sub) } sub, f, err := parseSubmission(form("user", "octocat", "token", " "+testToken+" ")) if err != nil || sub.Token != testToken || !sub.Pasted { t.Errorf("pasted token = %+v, %v", sub, err) } if strings.Contains(fmt.Sprintf("%+v", f), testToken) { t.Error("form values carry the pasted token") } if sub, _, err := parseSubmission(form("user", "octocat", "commits_per_repo", "0")); err != nil || sub.Options.CommitsPerRepo != 0 { t.Errorf("every commit = %+v, %v", sub.Options, err) } } func TestParseSubmissionRejects(t *testing.T) { cases := map[string]func(string) string{ "bad user": form("user", "../etc"), "empty user": form("user", ""), "bad tz": form("user", "a", "tz", "../../etc/passwd"), "local tz": form("user", "a", "tz", "Local"), "unknown tz": form("user", "a", "tz", "Mars/Olympus"), "bad week": form("user", "a", "start_of_week", "moonday"), "negative commits": form("user", "a", "commits_per_repo", "-1"), "huge commits": form("user", "a", "commits_per_repo", "999999"), "short token": form("user", "a", "token", "ghp_short"), "header injection": form("user", "a", "token", testToken+"\r\nX-Evil: 1"), "token with space": form("user", "a", "token", "ghp_abc def0123456789abcdef"), } for name, get := range cases { if _, _, err := parseSubmission(get); err == nil { t.Errorf("%s: accepted", name) } } } func publishTest(t *testing.T, s *Store, login string) { t.Helper() err := s.Publish(&github.Profile{Login: login}, Meta{Login: login, GeneratedAt: time.Now().UTC(), Scope: "public"}) if err != nil { t.Fatal(err) } } func TestStorePublishReplacesAtomically(t *testing.T) { dir := t.TempDir() s, err := OpenStore(dir) if err != nil { t.Fatal(err) } defer s.Close() publishTest(t, s, "Octo-Cat") first, _ := os.Readlink(filepath.Join(dir, "octo-cat")) publishTest(t, s, "octo-cat") second, _ := os.Readlink(filepath.Join(dir, "octo-cat")) if first == "" || first == second { t.Fatalf("link not replaced: %q -> %q", first, second) } gens, _ := os.ReadDir(filepath.Join(dir, genDir)) if len(gens) != 1 { t.Errorf("%d generations left, want 1", len(gens)) } f, err := s.OpenCard("OCTO-CAT", "dracula", "stats.svg") if err != nil { t.Fatalf("open card: %v", err) } f.Close() if m, err := s.Meta("octo-cat"); err != nil || m.Login != "octo-cat" { t.Errorf("meta = %+v, %v", m, err) } } func TestStoreRejectsTraversal(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.svg"), []byte("x"), 0o644); err != nil { t.Fatal(err) } s, err := OpenStore(dir) if err != nil { t.Fatal(err) } defer s.Close() publishTest(t, s, "octocat") probes := [][3]string{ {"..", "dracula", "stats.svg"}, {"octocat", "..", "secret.svg"}, {"octocat", "dracula", "../meta.json"}, {"octocat", "dracula", "meta.json"}, {".gen", "dracula", "stats.svg"}, {"octocat/..", "dracula", "stats.svg"}, } for _, p := range probes { if f, err := s.OpenCard(p[0], p[1], p[2]); !errors.Is(err, fs.ErrNotExist) { if f != nil { f.Close() } t.Errorf("OpenCard(%q) err = %v, want not-exist", p, err) } } if _, err := s.Meta("../octocat"); !errors.Is(err, fs.ErrNotExist) { t.Errorf("Meta(traversal) err = %v", err) } } func TestStoreSweepsOrphans(t *testing.T) { dir := t.TempDir() s, err := OpenStore(dir) if err != nil { t.Fatal(err) } publishTest(t, s, "octocat") s.Close() orphan := filepath.Join(dir, genDir, "ghost-1") if err := os.MkdirAll(orphan, 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(".gen/ghost-1", filepath.Join(dir, ".link-ghost-1")); err != nil { t.Fatal(err) } s, err = OpenStore(dir) if err != nil { t.Fatal(err) } defer s.Close() if _, err := os.Stat(orphan); !os.IsNotExist(err) { t.Error("orphan generation survived") } if _, err := os.Lstat(filepath.Join(dir, ".link-ghost-1")); !os.IsNotExist(err) { t.Error("temporary link survived") } if _, err := s.Meta("octocat"); err != nil { t.Errorf("live generation swept: %v", err) } } func TestStoreExpiresOldCards(t *testing.T) { dir := t.TempDir() s, err := OpenStore(dir) if err != nil { t.Fatal(err) } defer s.Close() publishTest(t, s, "fresh") old := time.Now().UTC().Add(-25 * time.Hour) if err := s.Publish(&github.Profile{Login: "stale"}, Meta{Login: "stale", GeneratedAt: old, Scope: "public"}); err != nil { t.Fatal(err) } if n := s.Expire(0, time.Now()); n != 0 { t.Errorf("retention 0 removed %d users", n) } if n := s.Expire(24*time.Hour, time.Now()); n != 1 { t.Errorf("removed %d users, want 1", n) } if _, err := s.Meta("stale"); !errors.Is(err, fs.ErrNotExist) { t.Errorf("stale meta err = %v, want not-exist", err) } if _, err := s.Meta("fresh"); err != nil { t.Errorf("fresh cards expired: %v", err) } gens, _ := os.ReadDir(filepath.Join(dir, genDir)) if len(gens) != 1 { t.Errorf("%d generations left, want 1", len(gens)) } } func TestQueueDedupsPerUser(t *testing.T) { f := &fakeFetcher{gate: make(chan struct{})} s := newTestServer(t, f) sub := signedIn(t, testToken, "user", "octocat") if created, err := s.queue.Submit(sub); !created || err != nil { t.Fatalf("first submit = %v, %v", created, err) } again := signedIn(t, testToken, "user", "OctoCat") if created, err := s.queue.Submit(again); created || err != nil { t.Fatalf("duplicate submit = %v, %v; want deduped", created, err) } close(f.gate) if st := waitIdle(t, s.queue, "octocat"); st.State != stateDone { t.Fatalf("state = %+v", st) } if n := f.callCount(); n != 1 { t.Errorf("fetched %d times, want 1", n) } if created, _ := s.queue.Submit(sub); !created { t.Error("finished job blocked a new one") } waitIdle(t, s.queue, "octocat") } func TestQueueOwnSignIn(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{tokens: map[string]github.TokenInfo{testToken: {Login: "boss", CanReadPrivate: true}}} s := newServerWith(t, f, g, time.Minute) // A job without a sign-in token never fetches. sub := signedIn(t, "", "user", "octocat") s.queue.Submit(sub) if st := waitIdle(t, s.queue, "octocat"); st.State != stateFailed || st.Error != errNoToken.Error() { t.Fatalf("token-less job = %+v", st) } if f.callCount() != 0 { t.Fatal("fetched without a sign-in token") } // A sign-in as the target account keeps private scope; the token is // used for the job, revoked, and never persisted. s.queue.Submit(signedIn(t, testToken, "user", "boss", "include_private", "1")) if st := waitIdle(t, s.queue, "boss"); st.State != stateDone { t.Fatalf("own sign-in job = %+v", st) } c := f.lastCall() if c.token != testToken || !c.cfg.Options.IncludePrivate || !c.cfg.Strict { t.Errorf("own sign-in call = %+v", c) } m, err := s.store.Meta("boss") if err != nil || m.Scope != "private" { t.Errorf("meta = %+v, %v", m, err) } raw, _ := os.ReadFile(filepath.Join(s.store.dir, "boss", metaFile)) if strings.Contains(string(raw), testToken) { t.Error("token written to meta.json") } s.queue.mu.Lock() leftover := s.queue.jobs["boss"].token s.queue.mu.Unlock() if leftover != "" { t.Error("token kept in memory after the job ended") } if got := g.revokedTokens(); len(got) != 1 || got[0] != testToken { t.Errorf("revoked = %q, want the sign-in token once", got) } } func TestHandlers(t *testing.T) { f := &fakeFetcher{} s := newTestServer(t, f) h := s.Handler() get := func(path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec } rec := get("/") if rec.Code != 200 || !strings.Contains(rec.Body.String(), `action="/auth/start"`) { t.Fatalf("index = %d", rec.Code) } body := rec.Body.String() signInAt := strings.Index(body, "Sign in with GitHub") tokenAt := strings.Index(body, `
`) if signInAt < 0 || tokenAt < signInAt || strings.Contains(body, `
Or use your own token`, `name="token" type="password"`, `href="https://github.com/settings/tokens/new?scopes=repo,read:user&description=ghglance"`, `target="_blank" rel="noopener noreferrer"`, "never stored, never logged", "visible on this site to anyone with the page link", } { if !strings.Contains(body, want) { t.Errorf("index lacks %q", want) } } // Enter in any field presses the first submit button: the sign-in one. if strings.Count(body, `type="submit"`) != 2 || strings.Index(body, `type="submit"`) > signInAt { t.Error("index submit buttons are not sign-in first, then the token button") } csp := rec.Header().Get("Content-Security-Policy") if !strings.Contains(csp, "img-src 'self' data:;") || !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "script-src 'self';") || rec.Header().Get("X-Content-Type-Options") != "nosniff" { t.Errorf("index security headers: CSP %q", csp) } if rec := get("/healthz"); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "ok" { t.Errorf("healthz = %d %q", rec.Code, rec.Body.String()) } if rec := get("/static/app.js"); rec.Code != 200 { t.Errorf("static = %d", rec.Code) } if rec := get("/u/nobody"); rec.Code != 404 || !strings.Contains(rec.Body.String(), "No cards for nobody") { t.Errorf("unknown user = %d", rec.Code) } if rec := get("/u/bad--name"); rec.Code != 404 { t.Errorf("invalid user = %d", rec.Code) } publishTest(t, s.store, "octocat") rec = get("/u/octocat?theme=github_dark") // html/template writes "+" in attributes as "+"; compare decoded. body = html.UnescapeString(rec.Body.String()) if rec.Code != 200 { t.Fatalf("user page = %d", rec.Code) } want, err := os.ReadFile(filepath.Join(s.store.dir, "octocat", "github_dark", "stats.svg")) if err != nil { t.Fatal(err) } if !strings.Contains(body, `src="data:image/svg+xml;base64,`+base64.StdEncoding.EncodeToString(want)+`"`) { t.Error("user page does not inline the stats card as a data: URI") } if n := strings.Count(body, `src="data:image/svg+xml;base64,`); n != 16 { t.Errorf("user page inlines %d cards, want 16", n) } if !strings.Contains(body, `alt="stats card for octocat"`) { t.Error("cards lack alt text") } // The page is shareable but suggests copying nothing: no copy buttons, // no Markdown or HTML snippet, no card URL, no README or embed advice. for _, leak := range []string{ "/u/octocat/github_dark/", "/u/octocat/dracula/", "stats.svg", "![", "