diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-16 00:03:51 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-16 00:03:51 +0000 |
| commit | 3660486153a16760d2b980e546bbbd29408fb8d4 (patch) | |
| tree | e29199d88b7365eca0438e41ac23fa0bd533f7c2 /internal/api/google_tasks_oauth_test.go | |
| parent | b55cfbbd433bed6035dfa228ee700e2cca060ca4 (diff) | |
Replace Google Tasks service-account auth with real OAuth
Service-account auth structurally cannot see a regular user's personal
task lists (no equivalent of Calendar's per-item sharing model) --
confirmed via GetTaskLists returning exactly the service account's own
empty "My Tasks" list, never the real user's three lists. Zero rows
were ever cached in production as a result.
Adds a standard 3-legged OAuth flow: /settings/google-tasks/connect
redirects to Google's consent screen (AccessTypeOffline+ApprovalForce
so a refresh_token is always issued), /callback exchanges the code and
persists the token (new oauth_tokens table), /disconnect clears it.
GoogleTasksClient now takes an option.ClientOption instead of a
credentials file path; NewGoogleTasksOAuthClient wraps it with a
dbTokenSource that reloads/refreshes from the DB on each access-token
expiry and re-persists -- carefully preserving the original
refresh_token when Google's refresh response omits one (it usually
does), which would otherwise silently and permanently break future
refreshes.
Settings page shows connection status and a Connect/Disconnect
button. Calendar keeps using service-account auth (that one actually
works). Requires a one-time manual step: create an OAuth 2.0 Client ID
in Google Cloud Console and set GOOGLE_OAUTH_CLIENT_ID/SECRET in .env
-- documented in .env.example.
Diffstat (limited to 'internal/api/google_tasks_oauth_test.go')
| -rw-r--r-- | internal/api/google_tasks_oauth_test.go | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/internal/api/google_tasks_oauth_test.go b/internal/api/google_tasks_oauth_test.go new file mode 100644 index 0000000..b4ea6f3 --- /dev/null +++ b/internal/api/google_tasks_oauth_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "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 +} + +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 (f *fakeTokenStore) SaveOAuthToken(source string, tok *oauth2.Token) error { + f.tokens[source] = tok + f.saved = append(f.saved, tok) + return nil +} + +func TestDbTokenSource_NotConnected_ReturnsError(t *testing.T) { + store := newFakeTokenStore() + src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: &oauth2.Config{}} + + if _, err := src.Token(); err == nil { + t.Error("expected an error when no token is stored, got nil") + } +} + +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, + } + 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 tok.AccessToken != "still-valid" { + t.Errorf("AccessToken = %q, want unchanged %q", tok.AccessToken, "still-valid") + } + if len(store.saved) != 0 { + t.Errorf("SaveOAuthToken called %d times, want 0 (nothing changed, no refresh needed)", len(store.saved)) + } +} + +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, + } + + cfg := &oauth2.Config{ + ClientID: "test-client", + ClientSecret: "test-secret", + Endpoint: oauth2.Endpoint{TokenURL: server.URL}, + } + src := &dbTokenSource{ctx: context.Background(), store: store, source: "google_tasks", config: cfg} + + tok, err := src.Token() + if err != nil { + t.Fatalf("Token: %v", err) + } + 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") + } +} + +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) + } + if cfg.RedirectURL != "https://doot.terst.org/settings/google-tasks/callback" { + t.Errorf("RedirectURL = %q", cfg.RedirectURL) + } + if len(cfg.Scopes) != 1 || cfg.Scopes[0] != "https://www.googleapis.com/auth/tasks" { + t.Errorf("Scopes = %v, want [tasks scope]", cfg.Scopes) + } +} |
