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)
}
}
|