package auth_test import ( "context" "net" "net/http" "net/http/httptest" "regexp" "sync" "testing" "time" "golang.org/x/oauth2" "gitea.d-ma.be/mathias/tapir/internal/auth" ) // fakeWriter is a TokenWriter capturing the persisted (ref, value). type fakeWriter struct { mu sync.Mutex ref, val string calls int } func (w *fakeWriter) Put(ref, value string) error { w.mu.Lock() defer w.mu.Unlock() w.ref, w.val, w.calls = ref, value, w.calls+1 return nil } // tokenServer fakes Google's token endpoint, returning the given JSON body for // any POST. No live Google contact. func tokenServer(t *testing.T, body string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(body)) })) } func cfg(srvURL string) auth.Config { return auth.Config{ ClientID: "cid", ClientSecret: "csecret", RedirectURL: "http://localhost:18099/callback", TokenRef: "youtube/refresh_token", Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"}, } } func TestExchange_PersistsRefreshToken(t *testing.T) { srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`) defer srv.Close() w := &fakeWriter{} if err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code"); err != nil { t.Fatalf("Exchange: %v", err) } if w.ref != "youtube/refresh_token" { t.Errorf("persisted ref = %q", w.ref) } if w.val != "rt-secret" { t.Errorf("persisted token = %q, want rt-secret", w.val) } } func TestExchange_RejectsMissingRefreshToken(t *testing.T) { srv := tokenServer(t, `{"access_token":"at","token_type":"Bearer","expires_in":3600}`) defer srv.Close() w := &fakeWriter{} err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code") if err == nil { t.Fatal("want error when no refresh token returned") } if w.calls != 0 { t.Errorf("nothing should be persisted on failure; Put called %d times", w.calls) } } // stateRe pulls the CSRF state out of the printed consent URL. var stateRe = regexp.MustCompile(`[?&]state=([a-f0-9]+)`) // urlWriter forwards each Write to a channel so the test can read the consent // URL Run prints before it blocks on the redirect. type urlWriter struct{ ch chan string } func (w urlWriter) Write(p []byte) (int, error) { w.ch <- string(p) return len(p), nil } func TestRun_FullFlow(t *testing.T) { srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`) defer srv.Close() // Ensure the fixed redirect port is free before binding. if ln, err := net.Listen("tcp", "localhost:18099"); err == nil { _ = ln.Close() } else { t.Skipf("redirect port 18099 unavailable: %v", err) } w := &fakeWriter{} out := urlWriter{ch: make(chan string, 4)} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() runErr := make(chan error, 1) go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }() // First message carries the consent URL with the state param. var state string select { case msg := <-out.ch: m := stateRe.FindStringSubmatch(msg) if m == nil { t.Fatalf("no state in consent message: %q", msg) } state = m[1] case <-time.After(3 * time.Second): t.Fatal("timed out waiting for consent URL") } // Simulate the browser hitting the local redirect with code + state. resp, err := http.Get("http://localhost:18099/callback?state=" + state + "&code=the-code") if err != nil { t.Fatalf("callback GET: %v", err) } _ = resp.Body.Close() select { case err := <-runErr: if err != nil { t.Fatalf("Run: %v", err) } case <-time.After(3 * time.Second): t.Fatal("Run did not complete after callback") } if w.val != "rt-secret" { t.Errorf("persisted token = %q, want rt-secret", w.val) } } func TestRun_RejectsStateMismatch(t *testing.T) { srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`) defer srv.Close() if ln, err := net.Listen("tcp", "localhost:18099"); err == nil { _ = ln.Close() } else { t.Skipf("redirect port 18099 unavailable: %v", err) } w := &fakeWriter{} out := urlWriter{ch: make(chan string, 4)} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() runErr := make(chan error, 1) go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }() select { case <-out.ch: // drain consent URL case <-time.After(3 * time.Second): t.Fatal("timed out waiting for consent URL") } resp, err := http.Get("http://localhost:18099/callback?state=WRONG&code=the-code") if err != nil { t.Fatalf("callback GET: %v", err) } _ = resp.Body.Close() select { case err := <-runErr: if err == nil { t.Fatal("want error on state mismatch") } case <-time.After(3 * time.Second): t.Fatal("Run did not return after bad callback") } if w.calls != 0 { t.Errorf("no token should be persisted on state mismatch; Put called %d times", w.calls) } }