summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-16 00:03:51 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-16 00:03:51 +0000
commit3660486153a16760d2b980e546bbbd29408fb8d4 (patch)
treee29199d88b7365eca0438e41ac23fa0bd533f7c2 /internal/api/google_tasks_oauth_test.go
parentb55cfbbd433bed6035dfa228ee700e2cca060ca4 (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.go122
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)
+ }
+}