package api import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "golang.org/x/oauth2" ) // memTokenStore is an in-memory TokenStore. type memTokenStore struct { mu sync.Mutex token *oauth2.Token saved int } func (m *memTokenStore) GetOAuthToken(string) (*oauth2.Token, error) { m.mu.Lock() defer m.mu.Unlock() if m.token == nil { return nil, nil } cp := *m.token return &cp, nil } func (m *memTokenStore) SaveOAuthToken(_ string, tok *oauth2.Token) error { m.mu.Lock() defer m.mu.Unlock() cp := *tok m.token = &cp m.saved++ return nil } // tokenServer stands in for Google's token endpoint. func tokenServer(t *testing.T, hits *int) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { *hits++ w.Header().Set("Content-Type", "application/json") // A refresh response that omits refresh_token, as Google's does. _, _ = w.Write([]byte(`{"access_token":"new-access","token_type":"Bearer","expires_in":3600}`)) })) t.Cleanup(srv.Close) return srv } func expiredToken() *oauth2.Token { return &oauth2.Token{ AccessToken: "old-access", RefreshToken: "stored-refresh", TokenType: "Bearer", Expiry: time.Now().Add(-time.Hour), } } // The context handed to the client constructor is short-lived: main.go builds // it with a timeout and cancels it as soon as construction returns. Capturing // it and reusing it for every later token refresh means the first refresh // after the initial access token expires — about an hour after startup — fails // with "context canceled", and every one after it, permanently. // // This is what actually broke Google Tasks in production: four task lists, the // same error on every fetch, from the moment the startup token aged out. func TestDBTokenSource_SurvivesACancelledConstructionContext(t *testing.T) { hits := 0 srv := tokenServer(t, &hits) // The construction context is cancelled immediately, exactly as main.go // does. Nothing the token source does later may depend on it. ctx, cancel := context.WithCancel(context.Background()) cancel() if ctx.Err() == nil { t.Fatal("test setup: the construction context should already be cancelled") } store := &memTokenStore{token: expiredToken()} src := &dbTokenSource{ store: store, source: GoogleTasksOAuthSource, config: &oauth2.Config{ ClientID: "id", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: srv.URL}, }, } tok, err := src.Token() if err != nil { if errors.Is(err, context.Canceled) { t.Fatalf("token refresh used the cancelled construction context: %v", err) } t.Fatalf("Token() failed: %v", err) } if tok.AccessToken != "new-access" { t.Errorf("AccessToken = %q, want the refreshed one", tok.AccessToken) } if hits == 0 { t.Error("expected the token endpoint to be called") } } // Google omits refresh_token on a refresh response. Overwriting the stored one // with an empty string would permanently break every later refresh. func TestDBTokenSource_KeepsRefreshTokenWhenResponseOmitsIt(t *testing.T) { hits := 0 srv := tokenServer(t, &hits) store := &memTokenStore{token: expiredToken()} src := &dbTokenSource{ store: store, source: GoogleTasksOAuthSource, config: &oauth2.Config{ ClientID: "id", ClientSecret: "secret", Endpoint: oauth2.Endpoint{TokenURL: srv.URL}, }, } if _, err := src.Token(); err != nil { t.Fatalf("Token(): %v", err) } saved, _ := store.GetOAuthToken(GoogleTasksOAuthSource) if saved.RefreshToken != "stored-refresh" { t.Errorf("RefreshToken = %q, want the stored one preserved", saved.RefreshToken) } if saved.AccessToken != "new-access" { t.Errorf("AccessToken = %q, want it updated", saved.AccessToken) } } // Before the user has connected, the error must say so rather than surfacing // as an opaque transport failure. func TestDBTokenSource_NotConnected(t *testing.T) { src := &dbTokenSource{ store: &memTokenStore{}, source: GoogleTasksOAuthSource, config: &oauth2.Config{}, } _, err := src.Token() if err == nil { t.Fatal("expected an error when no token is stored") } if !strings.Contains(err.Error(), "not connected") { t.Errorf("error = %q, want it to mention not being connected", err) } }