package web import ( "bytes" "cmp" "encoding/base64" "encoding/json" "io/fs" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "slices" "strings" "sync" "testing" "time" "github.com/tiennm99/ghglance/internal/github" ) const ( testClientID = "Iv1testclientid" testClientSecret = "test-client-secret-value" testPublicURL = "https://ghglance.example" testCode = "good-code" // oauthToken is what the fake GitHub issues for testCode. oauthToken = "gho_oauthTOKENvalue0123456789abcdef" ) // fakeGitHub plays GitHub's OAuth token and revoke endpoints. type fakeGitHub struct { srv *httptest.Server mu sync.Mutex scope string // scopes granted with the token; "" grants what was asked asked string // scopes the last sign-in asked for exchanges []url.Values revoked []string } func newFakeGitHub(t *testing.T) *fakeGitHub { t.Helper() g := &fakeGitHub{} mux := http.NewServeMux() mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { r.ParseForm() g.mu.Lock() g.exchanges = append(g.exchanges, r.PostForm) scope := cmp.Or(g.scope, strings.ReplaceAll(g.asked, " ", ",")) g.mu.Unlock() w.Header().Set("Content-Type", "application/json") f := r.PostForm if r.Header.Get("Accept") != "application/json" || f.Get("client_id") != testClientID || f.Get("client_secret") != testClientSecret || f.Get("code") != testCode || f.Get("redirect_uri") != testPublicURL+"/auth/callback" || f.Get("code_verifier") == "" { json.NewEncoder(w).Encode(map[string]string{"error": "bad_verification_code"}) return } json.NewEncoder(w).Encode(map[string]string{"access_token": oauthToken, "token_type": "bearer", "scope": scope}) }) mux.HandleFunc("DELETE /applications/{id}/token", func(w http.ResponseWriter, r *http.Request) { id, secret, ok := r.BasicAuth() if !ok || id != testClientID || secret != testClientSecret || r.PathValue("id") != testClientID { w.WriteHeader(http.StatusUnauthorized) return } var body struct { AccessToken string `json:"access_token"` } json.NewDecoder(r.Body).Decode(&body) g.mu.Lock() g.revoked = append(g.revoked, body.AccessToken) g.mu.Unlock() w.WriteHeader(http.StatusNoContent) }) g.srv = httptest.NewServer(mux) t.Cleanup(g.srv.Close) return g } func (g *fakeGitHub) exchangeCount() int { g.mu.Lock() defer g.mu.Unlock() return len(g.exchanges) } func (g *fakeGitHub) revokedTokens() []string { g.mu.Lock() defer g.mu.Unlock() return append([]string(nil), g.revoked...) } func newOAuthTestServer(t *testing.T, f *fakeFetcher, g *fakeGitHub) *Server { t.Helper() return newServerWith(t, f, g, time.Minute) } // startSignIn posts the form to /auth/start from ip. func startSignIn(h http.Handler, v url.Values, ip string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/auth/start", strings.NewReader(v.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = ip + ":1" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // signIn starts a sign-in and returns GitHub's authorize query and the // state cookie. The fake GitHub remembers the scopes asked for. func signIn(t *testing.T, h http.Handler, g *fakeGitHub, v url.Values) (url.Values, *http.Cookie) { t.Helper() rec := startSignIn(h, v, "203.0.113.50") if rec.Code != http.StatusFound { t.Fatalf("auth start = %d: %s", rec.Code, rec.Body.String()) } loc, err := url.Parse(rec.Header().Get("Location")) if err != nil { t.Fatal(err) } g.mu.Lock() g.asked = loc.Query().Get("scope") g.mu.Unlock() for _, c := range rec.Result().Cookies() { if c.Name == oauthCookie { return loc.Query(), c } } t.Fatal("no state cookie set") return nil, nil } // callback calls /auth/callback with query and an optional cookie. func callback(h http.Handler, query url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/auth/callback?"+query.Encode(), nil) if cookie != nil { req.AddCookie(cookie) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // lockedBuffer collects log output written from several goroutines. type lockedBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *lockedBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *lockedBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func captureLog(t *testing.T) *lockedBuffer { t.Helper() buf := &lockedBuffer{} log.SetOutput(buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) return buf } func TestOAuthScopesFollowTicks(t *testing.T) { cases := []struct { private, org bool want string }{ {false, false, "read:user"}, {false, true, "read:user"}, {true, false, "repo read:user"}, {true, true, "repo read:user read:org"}, } for _, c := range cases { if got := oauthScopes(Options{IncludePrivate: c.private, IncludeOrgRepos: c.org}); got != c.want { t.Errorf("oauthScopes(private=%v, org=%v) = %q, want %q", c.private, c.org, got, c.want) } } } func TestGrantScopesDowngrades(t *testing.T) { both := Options{IncludePrivate: true, IncludeOrgRepos: true} cases := []struct { asked Options granted string want Options dropped string }{ {both, "repo,read:user,read:org", both, ""}, {both, "read:org, repo, user", both, ""}, {both, "repo,read:user", Options{IncludePrivate: true}, "org"}, {both, "admin:org,repo", both, ""}, {both, "read:user", Options{}, "private"}, {Options{IncludePrivate: true}, "", Options{}, "private"}, {Options{IncludeOrgRepos: true}, "read:user", Options{IncludeOrgRepos: true}, ""}, } for _, c := range cases { got, dropped := grantScopes(c.asked, c.granted) if got != c.want || dropped != c.dropped { t.Errorf("grantScopes(%+v, %q) = %+v, %q; want %+v, %q", c.asked, c.granted, got, dropped, c.want, c.dropped) } } } func TestOAuthStartRedirectsToGitHub(t *testing.T) { g := newFakeGitHub(t) s := newOAuthTestServer(t, &fakeFetcher{}, g) h := s.Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if body := rec.Body.String(); !strings.Contains(body, `action="/auth/start"`) || !strings.Contains(body, "Sign in with GitHub") { t.Error("index lacks the sign-in form") } if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self' "+g.srv.URL+";") { t.Errorf("CSP does not allow the authorize redirect: %s", csp) } if body := rec.Body.String(); strings.Contains(body, `name="include_private" value="1" checked`) { t.Error("private repos start ticked") } q, c := signIn(t, h, g, url.Values{ "user": {"octocat"}, "include_private": {"1"}, "include_org_repos": {"1"}, "token": {" "}, // a blank token field still signs in }) want := map[string]string{ "client_id": testClientID, "redirect_uri": testPublicURL + "/auth/callback", "scope": "repo read:user read:org", "code_challenge_method": "S256", "login": "octocat", } for k, v := range want { if q.Get(k) != v { t.Errorf("authorize %s = %q, want %q", k, q.Get(k), v) } } if raw, err := base64.RawURLEncoding.DecodeString(q.Get("state")); err != nil || len(raw) < 16 { t.Errorf("state %q is not at least 128 random bits", q.Get("state")) } if q.Get("code_challenge") == "" || q.Get("client_secret") != "" { t.Errorf("authorize query = %v", q) } if c.Value != q.Get("state") || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.Domain != "" || c.MaxAge != 600 { t.Errorf("state cookie = %+v", c) } // Public data only asks for read:user, whatever the org box says. q, _ = signIn(t, h, g, url.Values{"user": {"octocat"}, "include_org_repos": {"1"}}) if q.Get("scope") != "read:user" { t.Errorf("public sign-in scope = %q", q.Get("scope")) } // The form is validated and rate limited before anything goes to GitHub. if rec := startSignIn(h, url.Values{"user": {"--bad"}}, "203.0.113.51"); rec.Code != http.StatusBadRequest { t.Errorf("invalid user = %d", rec.Code) } var last int for range submitBurst + 1 { last = startSignIn(h, url.Values{"user": {"octocat"}}, "203.0.113.52").Code } if last != http.StatusTooManyRequests { t.Errorf("sign-in past burst = %d, want 429", last) } if g.exchangeCount() != 0 { t.Error("starting a sign-in talked to the token endpoint") } } func TestOAuthFlowRevokesAfterJob(t *testing.T) { logs := captureLog(t) g := newFakeGitHub(t) f := &fakeFetcher{tokens: map[string]github.TokenInfo{oauthToken: {Login: "octocat", CanReadPrivate: true}}} s := newOAuthTestServer(t, f, g) h := s.Handler() publishTest(t, s.store, "octocat") // in cooldown: a sign-in skips it q, c := signIn(t, h, g, url.Values{"user": {"octocat"}, "tz": {"Asia/Saigon"}, "include_private": {"1"}, "commits_per_repo": {"0"}}) rec := callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/u/octocat" { t.Fatalf("callback = %d %q: %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } if st := waitIdle(t, s.queue, "octocat"); st.State != stateDone { t.Fatalf("job = %+v", st) } // PKCE: the verifier sent to the token endpoint hashes to the challenge. g.mu.Lock() verifier := g.exchanges[0].Get("code_verifier") g.mu.Unlock() if len(verifier) < 43 || pkceChallenge(verifier) != q.Get("code_challenge") { t.Errorf("verifier %q does not match challenge %q", verifier, q.Get("code_challenge")) } call := f.lastCall() if call.token != oauthToken || !call.cfg.Options.IncludePrivate || call.cfg.CommitsPerRepo != 0 { t.Errorf("fetch = %+v", call) } if m, err := s.store.Meta("octocat"); err != nil || m.Scope != "private" || m.Options.TZ != "Asia/Saigon" { t.Errorf("meta = %+v, %v", m, err) } if got := g.revokedTokens(); len(got) != 1 || got[0] != oauthToken { t.Errorf("revoked = %q, want the sign-in token once", got) } // The token, code and client secret never reach disk or the log. filepath.WalkDir(s.store.dir, func(path string, d fs.DirEntry, err error) error { if err != nil || !d.Type().IsRegular() { return err } raw, _ := os.ReadFile(path) if bytes.Contains(raw, []byte(oauthToken)) || bytes.Contains(raw, []byte(testClientSecret)) { t.Errorf("secret written to %s", path) } return nil }) out := logs.String() for _, secret := range []string{oauthToken, testClientSecret, testCode, verifier} { if strings.Contains(out, secret) { t.Errorf("log contains %q:\n%s", secret, out) } } } func TestOAuthStateChecks(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{tokens: map[string]github.TokenInfo{oauthToken: {Login: "octocat"}}} s := newOAuthTestServer(t, f, g) h := s.Handler() q, c := signIn(t, h, g, url.Values{"user": {"octocat"}}) state := q.Get("state") other := &http.Cookie{Name: oauthCookie, Value: state[:len(state)-1] + "x"} planted := &http.Cookie{Name: oauthCookieInsecure, Value: state} for name, rec := range map[string]*httptest.ResponseRecorder{ "no cookie": callback(h, url.Values{"code": {testCode}, "state": {state}}, nil), "other cookie": callback(h, url.Values{"code": {testCode}, "state": {state}}, other), "unprefixed name": callback(h, url.Values{"code": {testCode}, "state": {state}}, planted), "no state": callback(h, url.Values{"code": {testCode}}, c), "unknown state": callback(h, url.Values{"code": {testCode}, "state": {other.Value}}, other), "error, no state": callback(h, url.Values{"error": {"access_denied"}}, nil), } { if rec.Code != http.StatusBadRequest { t.Errorf("%s: callback = %d, want 400", name, rec.Code) } } if g.exchangeCount() != 0 { t.Fatal("a rejected callback reached the token endpoint") } // The real state still works once, then is spent. if rec := callback(h, url.Values{"code": {testCode}, "state": {state}}, c); rec.Code != http.StatusSeeOther { t.Fatalf("valid callback = %d", rec.Code) } waitIdle(t, s.queue, "octocat") if rec := callback(h, url.Values{"code": {testCode}, "state": {state}}, c); rec.Code != http.StatusBadRequest { t.Errorf("replayed callback = %d, want 400", rec.Code) } if n := g.exchangeCount(); n != 1 { t.Errorf("token endpoint called %d times, want 1", n) } } func TestOAuthStateExpires(t *testing.T) { g := newFakeGitHub(t) s := newOAuthTestServer(t, &fakeFetcher{}, g) h := s.Handler() q, c := signIn(t, h, g, url.Values{"user": {"octocat"}}) s.logins.now = func() time.Time { return time.Now().Add(loginTTL + time.Second) } if rec := callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c); rec.Code != http.StatusBadRequest { t.Errorf("expired callback = %d, want 400", rec.Code) } if g.exchangeCount() != 0 { t.Error("expired sign-in reached the token endpoint") } } func TestPendingLoginsCap(t *testing.T) { l := newPendingLogins() now := time.Now() l.now = func() time.Time { return now } for range maxPendingLogins { if _, err := l.add(&pendingLogin{}); err != nil { t.Fatal(err) } } if _, err := l.add(&pendingLogin{}); err != errTooManyLogins { t.Fatalf("add past the cap = %v", err) } now = now.Add(loginTTL) if _, err := l.add(&pendingLogin{}); err != nil { t.Errorf("expired entries were not swept: %v", err) } if len(l.entries) != 1 { t.Errorf("%d entries left, want 1", len(l.entries)) } } func TestOAuthAccessDeniedKeepsOptions(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{} s := newOAuthTestServer(t, f, g) h := s.Handler() q, c := signIn(t, h, g, url.Values{"user": {"octocat"}, "tz": {"Asia/Saigon"}, "commits_per_repo": {"123"}}) rec := callback(h, url.Values{"error": {"access_denied"}, "state": {q.Get("state")}}, c) body := rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(body, "cancelled") { t.Fatalf("access denied = %d: %s", rec.Code, body) } if !strings.Contains(body, `value="octocat"`) || !strings.Contains(body, "Asia/Saigon") || !strings.Contains(body, `value="123"`) { t.Error("options were not kept") } if g.exchangeCount() != 0 || s.queue.Status("octocat").State != stateNone { t.Error("a cancelled sign-in exchanged a code or queued a job") } } func TestOAuthScopeDowngrade(t *testing.T) { g := newFakeGitHub(t) g.scope = "read:user" f := &fakeFetcher{tokens: map[string]github.TokenInfo{oauthToken: {Login: "octocat"}}} s := newOAuthTestServer(t, f, g) h := s.Handler() q, c := signIn(t, h, g, url.Values{"user": {"octocat"}, "include_private": {"1"}, "include_org_repos": {"1"}}) if q.Get("scope") != "repo read:user read:org" { t.Fatalf("scope = %q", q.Get("scope")) } rec := callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) if loc := rec.Header().Get("Location"); loc != "/u/octocat?notice=granted-private" { t.Fatalf("callback redirected to %q", loc) } waitIdle(t, s.queue, "octocat") if o := f.lastCall().cfg.Options; o.IncludePrivate || o.IncludeOrgRepos { t.Errorf("job kept ungranted scope: %+v", o) } if m, err := s.store.Meta("octocat"); err != nil || m.Scope != "public" { t.Errorf("meta = %+v, %v", m, err) } page := httptest.NewRecorder() h.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/u/octocat?notice=granted-private", nil)) if !strings.Contains(page.Body.String(), "did not grant access to private repositories") { t.Error("user page does not explain the downgrade") } } func TestOAuthRevokesFailedAndForeignJobs(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{tokens: map[string]github.TokenInfo{oauthToken: {Login: "alice"}}} s := newOAuthTestServer(t, f, g) h := s.Handler() // Signed in as alice for bob: public data only, then revoked. q, c := signIn(t, h, g, url.Values{"user": {"bob"}, "include_private": {"1"}}) callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) if st := waitIdle(t, s.queue, "bob"); st.State != stateDone { t.Fatalf("foreign job = %+v", st) } if o := f.lastCall().cfg.Options; o.IncludePrivate { t.Errorf("foreign sign-in kept private scope: %+v", o) } // A failing job revokes too. f.mu.Lock() f.err = errFresh f.mu.Unlock() q, c = signIn(t, h, g, url.Values{"user": {"alice"}}) callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) if st := waitIdle(t, s.queue, "alice"); st.State != stateFailed { t.Fatalf("failing job = %+v", st) } if got := g.revokedTokens(); len(got) != 2 { t.Errorf("revoked %d tokens, want 2", len(got)) } } func TestOAuthRevokesWhenNotQueued(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{gate: make(chan struct{})} s := newOAuthTestServer(t, f, g) h := s.Handler() q, c := signIn(t, h, g, url.Values{"user": {"octocat"}}) s.queue.Submit(signedIn(t, testToken, "user", "octocat")) // a job starts while the user is on GitHub rec := callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) if loc := rec.Header().Get("Location"); loc != "/u/octocat?notice=pending" { t.Errorf("callback redirected to %q", loc) } if got := g.revokedTokens(); len(got) != 1 || got[0] != oauthToken { t.Errorf("unused token not revoked: %q", got) } close(f.gate) waitIdle(t, s.queue, "octocat") } func TestOAuthRevokesOnShutdown(t *testing.T) { g := newFakeGitHub(t) f := &fakeFetcher{gate: make(chan struct{})} s := newOAuthTestServer(t, f, g) for _, login := range []string{"a1", "a2", "a3"} { if _, err := s.queue.Submit(submission{Login: login, Token: oauthToken}); err != nil { t.Fatal(err) } } s.queue.Stop() // two running, one still queued if got := g.revokedTokens(); len(got) != 3 { t.Errorf("revoked %d tokens on shutdown, want 3", len(got)) } } func TestExtraScopes(t *testing.T) { cases := []struct { asked Options granted string want []string }{ {Options{}, "read:user", nil}, {Options{}, "repo,read:user", []string{"repo"}}, {Options{IncludeOrgRepos: true}, "read:org,read:user", []string{"read:org"}}, {Options{IncludePrivate: true}, "read:user,repo", nil}, {Options{IncludePrivate: true}, "read:org,read:user,repo", []string{"read:org"}}, {Options{IncludePrivate: true, IncludeOrgRepos: true}, "admin:org,repo", []string{"admin:org"}}, } for _, c := range cases { if got := extraScopes(c.asked, c.granted); !slices.Equal(got, c.want) { t.Errorf("extraScopes(%+v, %q) = %q, want %q", c.asked, c.granted, got, c.want) } } } func TestOAuthWiderGrantIsRefused(t *testing.T) { g := newFakeGitHub(t) g.scope = "read:user,repo" // granted to the app by an earlier private sign-in f := &fakeFetcher{tokens: map[string]github.TokenInfo{oauthToken: {Login: "alice", CanReadPrivate: true}}} s := newOAuthTestServer(t, f, g) h := s.Handler() for _, login := range []string{"alice", "bob"} { q, c := signIn(t, h, g, url.Values{"user": {login}, "tz": {"Asia/Saigon"}}) if q.Get("scope") != "read:user" { t.Fatalf("scope = %q", q.Get("scope")) } rec := callback(h, url.Values{"code": {testCode}, "state": {q.Get("state")}}, c) body := rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(body, "more access than your ticks ask for (repo)") { t.Fatalf("%s: wider grant = %d: %s", login, rec.Code, body) } if !strings.Contains(body, `value="`+login+`"`) || !strings.Contains(body, "Asia/Saigon") { t.Errorf("%s: options were not kept", login) } if s.queue.Status(login).State != stateNone { t.Errorf("%s: a job was queued with a token wider than the ticks", login) } } if got := g.revokedTokens(); len(got) != 2 { t.Errorf("revoked %d tokens, want 2", len(got)) } } func TestOAuthPlainHTTPCookie(t *testing.T) { app, err := newOAuthApp(OAuthConfig{ClientID: testClientID, ClientSecret: testClientSecret, PublicURL: "http://localhost:8080"}) if err != nil { t.Fatal(err) } if app.secureCookie || app.cookieName != oauthCookieInsecure { t.Errorf("plain-http cookie = %q secure=%v", app.cookieName, app.secureCookie) } }