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/handlers/google_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/handlers/google_oauth_test.go')
| -rw-r--r-- | internal/handlers/google_oauth_test.go | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/internal/handlers/google_oauth_test.go b/internal/handlers/google_oauth_test.go new file mode 100644 index 0000000..c01bbc4 --- /dev/null +++ b/internal/handlers/google_oauth_test.go @@ -0,0 +1,161 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" + + "task-dashboard/internal/api" +) + +func withSession(t *testing.T, h *Handler, req *http.Request) *http.Request { + t.Helper() + ctx, err := h.sessions.Load(req.Context(), "") + if err != nil { + t.Fatal(err) + } + return req.WithContext(ctx) +} + +func TestHandleGoogleTasksOAuthStart_NotConfigured_Returns503(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/connect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthStart(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } +} + +func TestHandleGoogleTasksOAuthStart_RedirectsToGoogleAndStoresState(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/connect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthStart(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + if loc == "" { + t.Fatal("no Location header set") + } + + state := h.sessions.GetString(req.Context(), sessionKeyGoogleOAuthState) + if state == "" { + t.Error("expected a state value to be stored in session") + } +} + +func TestHandleGoogleTasksOAuthCallback_StateMismatch_Returns400(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := httptest.NewRequest("GET", "/settings/google-tasks/callback?state=wrong&code=abc", nil) + req = withSession(t, h, req) + h.sessions.Put(req.Context(), sessionKeyGoogleOAuthState, "expected-state") + + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthCallback(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if tok, _ := h.store.GetOAuthToken(api.GoogleTasksOAuthSource); tok != nil { + t.Error("no token should have been saved on state mismatch") + } +} + +func TestHandleGoogleTasksOAuthCallback_GoogleReturnedError_RedirectsWithError(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings/google-tasks/callback?error=access_denied", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthCallback(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + loc := w.Header().Get("Location") + if loc != "/settings?google_tasks_error=access_denied" { + t.Errorf("Location = %q", loc) + } +} + +func TestHandleGoogleTasksOAuthDisconnect_RemovesStoredToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, &oauth2.Token{AccessToken: "a", RefreshToken: "r"}); err != nil { + t.Fatal(err) + } + + req := withSession(t, h, httptest.NewRequest("POST", "/settings/google-tasks/disconnect", nil)) + w := httptest.NewRecorder() + h.HandleGoogleTasksOAuthDisconnect(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", w.Code) + } + tok, err := h.store.GetOAuthToken(api.GoogleTasksOAuthSource) + if err != nil { + t.Fatal(err) + } + if tok != nil { + t.Error("token should be gone after disconnect") + } +} + +func TestHandleSettingsPage_ReflectsGoogleTasksConnectionState(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + h.googleTasksOAuthConfig = api.GoogleTasksOAuthConfig("client-id", "client-secret", "https://doot.terst.org/settings/google-tasks/callback") + + req := withSession(t, h, httptest.NewRequest("GET", "/settings", nil)) + w := httptest.NewRecorder() + h.HandleSettingsPage(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + mock := h.renderer.(*MockRenderer) + data := mock.Calls[len(mock.Calls)-1].Data + type settingsData struct { + GoogleTasksOAuthReady bool + GoogleTasksConnected bool + } + jsonBytes, _ := json.Marshal(data) + var got settingsData + _ = json.Unmarshal(jsonBytes, &got) + if !got.GoogleTasksOAuthReady { + t.Error("GoogleTasksOAuthReady = false, want true (config was set)") + } + if got.GoogleTasksConnected { + t.Error("GoogleTasksConnected = true, want false (no token saved)") + } + + if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, &oauth2.Token{AccessToken: "a", RefreshToken: "r"}); err != nil { + t.Fatal(err) + } + w2 := httptest.NewRecorder() + h.HandleSettingsPage(w2, req) + data2 := mock.Calls[len(mock.Calls)-1].Data + jsonBytes2, _ := json.Marshal(data2) + var got2 settingsData + _ = json.Unmarshal(jsonBytes2, &got2) + if !got2.GoogleTasksConnected { + t.Error("GoogleTasksConnected = false, want true after saving a token") + } +} |
