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 lastError string } 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) RecordOAuthTokenError(_ string, message string) error { m.mu.Lock() defer m.mu.Unlock() m.lastError = message return 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) } } // invalid_grant means the saved authorisation is finished — retrying cannot // help, and the user has to reconnect. Anything else is worth retrying, so // misclassifying it would push the user through consent for a network blip. func TestNeedsReconnect(t *testing.T) { cases := []struct { err error want bool }{ {errors.New(`oauth2: "invalid_grant" "Bad Request"`), true}, {errors.New("refreshing token: oauth2: \"invalid_grant\" \"Token has been expired or revoked.\""), true}, {errors.New("Post \"https://oauth2.googleapis.com/token\": dial tcp: i/o timeout"), false}, {errors.New("oauth2: cannot fetch token: 503 Service Unavailable"), false}, {nil, false}, } for _, c := range cases { if got := NeedsReconnect(c.err); got != c.want { t.Errorf("NeedsReconnect(%v) = %v, want %v", c.err, got, c.want) } } } // A failed refresh must be recorded, or settings keeps showing "connected" // while every background fetch fails — which is how Google Tasks appeared // healthy for a month while returning nothing. func TestDBTokenSource_RecordsRefreshFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Token has been expired or revoked."}`)) })) defer srv.Close() 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.Fatal("expected the refresh to fail") } if store.lastError == "" { t.Fatal("the failure was not recorded") } if !strings.Contains(store.lastError, "reconnect") { t.Errorf("recorded error should tell the user to reconnect, got %q", store.lastError) } } // A successful refresh must clear a previously recorded failure; the store's // SaveOAuthToken does that, so this pins that saving is what happens on success. func TestDBTokenSource_SuccessSavesToken(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) } if store.saved == 0 { t.Error("a refreshed token should be persisted") } }