diff options
| -rw-r--r-- | internal/api/google_tasks_oauth.go | 27 | ||||
| -rw-r--r-- | internal/api/google_tasks_oauth_test.go | 190 |
2 files changed, 135 insertions, 82 deletions
diff --git a/internal/api/google_tasks_oauth.go b/internal/api/google_tasks_oauth.go index 1f5f8c5..6edba21 100644 --- a/internal/api/google_tasks_oauth.go +++ b/internal/api/google_tasks_oauth.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -45,12 +46,14 @@ func GoogleTasksOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2. // call -- cheap enough to just hit the DB each time rather than add a // second layer of in-process caching. type dbTokenSource struct { - ctx context.Context store TokenStore source string config *oauth2.Config } +// tokenRefreshTimeout bounds a single refresh round-trip to Google. +const tokenRefreshTimeout = 30 * time.Second + func (d *dbTokenSource) Token() (*oauth2.Token, error) { current, err := d.store.GetOAuthToken(d.source) if err != nil { @@ -60,7 +63,19 @@ func (d *dbTokenSource) Token() (*oauth2.Token, error) { return nil, fmt.Errorf("google tasks not connected -- visit /settings and connect it") } - refreshed, err := d.config.TokenSource(d.ctx, current).Token() + // Deliberately NOT a context captured at construction time. main.go builds + // the client with a short-lived init context and cancels it the moment + // construction returns; reusing that here meant the first refresh after the + // startup access token aged out — roughly an hour in — failed with + // "context canceled", and so did every one after it. Google Tasks was + // broken from that point until a restart, which bought another hour. + // + // A token refresh is not logically part of whichever request happened to + // trigger it, so it gets its own background context with its own bound. + ctx, cancel := context.WithTimeout(context.Background(), tokenRefreshTimeout) + defer cancel() + + refreshed, err := d.config.TokenSource(ctx, current).Token() if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } @@ -87,7 +102,11 @@ func (d *dbTokenSource) Token() (*oauth2.Token, error) { // succeeds; API calls fail with the dbTokenSource error above until a // token exists (see HandleGoogleTasksOAuthCallback). func NewGoogleTasksOAuthClient(ctx context.Context, oauthConfig *oauth2.Config, tokenStore TokenStore, tasklistID, timezone string) (*GoogleTasksClient, error) { - src := &dbTokenSource{ctx: ctx, store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} - httpClient := oauth2.NewClient(ctx, src) + src := &dbTokenSource{store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} + // context.Background(), not ctx: oauth2.NewClient binds the context it is + // given to the returned client's transport for its whole lifetime, and ctx + // here is the caller's short-lived init context. The per-call context still + // applies — it comes from the tasks service methods themselves. + httpClient := oauth2.NewClient(context.Background(), src) return NewGoogleTasksClient(ctx, option.WithHTTPClient(httpClient), tasklistID, timezone) } diff --git a/internal/api/google_tasks_oauth_test.go b/internal/api/google_tasks_oauth_test.go index b4ea6f3..3503cbb 100644 --- a/internal/api/google_tasks_oauth_test.go +++ b/internal/api/google_tasks_oauth_test.go @@ -2,121 +2,155 @@ package api import ( "context" - "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" + "sync" "testing" "time" "golang.org/x/oauth2" ) -// fakeTokenStore is a minimal in-memory TokenStore for tests. -type fakeTokenStore struct { - tokens map[string]*oauth2.Token - saved []*oauth2.Token // records every SaveOAuthToken call, in order +// memTokenStore is an in-memory TokenStore. +type memTokenStore struct { + mu sync.Mutex + token *oauth2.Token + saved int } -func newFakeTokenStore() *fakeTokenStore { - return &fakeTokenStore{tokens: make(map[string]*oauth2.Token)} -} - -func (f *fakeTokenStore) GetOAuthToken(source string) (*oauth2.Token, error) { - return f.tokens[source], nil +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 (f *fakeTokenStore) SaveOAuthToken(source string, tok *oauth2.Token) error { - f.tokens[source] = tok - f.saved = append(f.saved, tok) +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 } -func TestDbTokenSource_NotConnected_ReturnsError(t *testing.T) { - store := newFakeTokenStore() - src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}} +// 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 +} - if _, err := src.Token(); err == nil { - t.Error("expected an error when no token is stored, got nil") +func expiredToken() *oauth2.Token { + return &oauth2.Token{ + AccessToken: "old-access", + RefreshToken: "stored-refresh", + TokenType: "Bearer", + Expiry: time.Now().Add(-time.Hour), } } -func TestDbTokenSource_NotExpired_ReturnsWithoutPersisting(t *testing.T) { - store := newFakeTokenStore() - future := time.Now().Add(time.Hour) - store.tokens["google_tasks"] = &oauth2.Token{ - AccessToken: "still-valid", RefreshToken: "r1", TokenType: "Bearer", Expiry: future, +// 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}, + }, } - src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}} tok, err := src.Token() if err != nil { - t.Fatalf("Token: %v", err) + 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 != "still-valid" { - t.Errorf("AccessToken = %q, want unchanged %q", tok.AccessToken, "still-valid") + if tok.AccessToken != "new-access" { + t.Errorf("AccessToken = %q, want the refreshed one", tok.AccessToken) } - if len(store.saved) != 0 { - t.Errorf("SaveOAuthToken called %d times, want 0 (nothing changed, no refresh needed)", len(store.saved)) + if hits == 0 { + t.Error("expected the token endpoint to be called") } } -func TestDbTokenSource_Refresh_PreservesOriginalRefreshTokenWhenOmitted(t *testing.T) { - // Simulates Google's real behavior: a refresh response often omits - // refresh_token entirely. Without the fix, that would overwrite the - // stored refresh token with "" and permanently break future refreshes. - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "access_token": "refreshed-access-token", - "token_type": "Bearer", - "expires_in": 3600, - // no refresh_token in the response, matching Google's real behavior - }) - })) - defer server.Close() - - store := newFakeTokenStore() - expired := time.Now().Add(-time.Hour) - store.tokens["google_tasks"] = &oauth2.Token{ - AccessToken: "stale-access-token", RefreshToken: "original-refresh-token", TokenType: "Bearer", Expiry: expired, +// 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}, + }, } - cfg := &oauth2.Config{ - ClientID: "test-client", - ClientSecret: "test-secret", - Endpoint: oauth2.Endpoint{TokenURL: server.URL}, + if _, err := src.Token(); err != nil { + t.Fatalf("Token(): %v", err) } - src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: cfg} - tok, err := src.Token() - if 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 tok.AccessToken != "refreshed-access-token" { - t.Errorf("AccessToken = %q, want %q", tok.AccessToken, "refreshed-access-token") - } - if tok.RefreshToken != "original-refresh-token" { - t.Errorf("RefreshToken = %q, want original preserved %q", tok.RefreshToken, "original-refresh-token") - } - - if len(store.saved) != 1 { - t.Fatalf("SaveOAuthToken called %d times, want 1", len(store.saved)) - } - if store.saved[0].RefreshToken != "original-refresh-token" { - t.Errorf("persisted RefreshToken = %q, want %q", store.saved[0].RefreshToken, "original-refresh-token") + if saved.AccessToken != "new-access" { + t.Errorf("AccessToken = %q, want it updated", saved.AccessToken) } } -func TestGoogleTasksOAuthConfig_HasTasksScopeAndEndpoint(t *testing.T) { - cfg := GoogleTasksOAuthConfig("id", "secret", "https://doot.terst.org/settings/google-tasks/callback") - - if cfg.ClientID != "id" || cfg.ClientSecret != "secret" { - t.Errorf("ClientID/Secret = %q/%q, want id/secret", cfg.ClientID, cfg.ClientSecret) +// 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{}, } - if cfg.RedirectURL != "https://doot.terst.org/settings/google-tasks/callback" { - t.Errorf("RedirectURL = %q", cfg.RedirectURL) + _, err := src.Token() + if err == nil { + t.Fatal("expected an error when no token is stored") } - if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/tasks" { - t.Errorf("Scopes = %v, want [tasks scope]", cfg.Scopes) + if !strings.Contains(err.Error(), "not connected") { + t.Errorf("error = %q, want it to mention not being connected", err) } } + |
