diff options
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) + } +} |
