summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth_test.go
blob: b4ea6f370767a9906ee0300350a13df768a3c7f3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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)
	}
}