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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
|
package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"golang.org/x/oauth2"
)
// memTokenStore is an in-memory TokenStore.
type memTokenStore struct {
mu sync.Mutex
token *oauth2.Token
saved int
lastError string
}
func (m *memTokenStore) GetOAuthToken(string) (*oauth2.Token, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.token == nil {
return nil, nil
}
cp := *m.token
return &cp, nil
}
func (m *memTokenStore) RecordOAuthTokenError(_ string, message string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.lastError = message
return nil
}
func (m *memTokenStore) SaveOAuthToken(_ string, tok *oauth2.Token) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *tok
m.token = &cp
m.saved++
return nil
}
// tokenServer stands in for Google's token endpoint.
func tokenServer(t *testing.T, hits *int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*hits++
w.Header().Set("Content-Type", "application/json")
// A refresh response that omits refresh_token, as Google's does.
_, _ = w.Write([]byte(`{"access_token":"new-access","token_type":"Bearer","expires_in":3600}`))
}))
t.Cleanup(srv.Close)
return srv
}
func expiredToken() *oauth2.Token {
return &oauth2.Token{
AccessToken: "old-access",
RefreshToken: "stored-refresh",
TokenType: "Bearer",
Expiry: time.Now().Add(-time.Hour),
}
}
// The context handed to the client constructor is short-lived: main.go builds
// it with a timeout and cancels it as soon as construction returns. Capturing
// it and reusing it for every later token refresh means the first refresh
// after the initial access token expires — about an hour after startup — fails
// with "context canceled", and every one after it, permanently.
//
// This is what actually broke Google Tasks in production: four task lists, the
// same error on every fetch, from the moment the startup token aged out.
func TestDBTokenSource_SurvivesACancelledConstructionContext(t *testing.T) {
hits := 0
srv := tokenServer(t, &hits)
// The construction context is cancelled immediately, exactly as main.go
// does. Nothing the token source does later may depend on it.
ctx, cancel := context.WithCancel(context.Background())
cancel()
if ctx.Err() == nil {
t.Fatal("test setup: the construction context should already be cancelled")
}
store := &memTokenStore{token: expiredToken()}
src := &dbTokenSource{
store: store,
source: GoogleTasksOAuthSource,
config: &oauth2.Config{
ClientID: "id",
ClientSecret: "secret",
Endpoint: oauth2.Endpoint{TokenURL: srv.URL},
},
}
tok, err := src.Token()
if err != nil {
if errors.Is(err, context.Canceled) {
t.Fatalf("token refresh used the cancelled construction context: %v", err)
}
t.Fatalf("Token() failed: %v", err)
}
if tok.AccessToken != "new-access" {
t.Errorf("AccessToken = %q, want the refreshed one", tok.AccessToken)
}
if hits == 0 {
t.Error("expected the token endpoint to be called")
}
}
// Google omits refresh_token on a refresh response. Overwriting the stored one
// with an empty string would permanently break every later refresh.
func TestDBTokenSource_KeepsRefreshTokenWhenResponseOmitsIt(t *testing.T) {
hits := 0
srv := tokenServer(t, &hits)
store := &memTokenStore{token: expiredToken()}
src := &dbTokenSource{
store: store,
source: GoogleTasksOAuthSource,
config: &oauth2.Config{
ClientID: "id",
ClientSecret: "secret",
Endpoint: oauth2.Endpoint{TokenURL: srv.URL},
},
}
if _, err := src.Token(); err != nil {
t.Fatalf("Token(): %v", err)
}
saved, _ := store.GetOAuthToken(GoogleTasksOAuthSource)
if saved.RefreshToken != "stored-refresh" {
t.Errorf("RefreshToken = %q, want the stored one preserved", saved.RefreshToken)
}
if saved.AccessToken != "new-access" {
t.Errorf("AccessToken = %q, want it updated", saved.AccessToken)
}
}
// Before the user has connected, the error must say so rather than surfacing
// as an opaque transport failure.
func TestDBTokenSource_NotConnected(t *testing.T) {
src := &dbTokenSource{
store: &memTokenStore{},
source: GoogleTasksOAuthSource,
config: &oauth2.Config{},
}
_, err := src.Token()
if err == nil {
t.Fatal("expected an error when no token is stored")
}
if !strings.Contains(err.Error(), "not connected") {
t.Errorf("error = %q, want it to mention not being connected", err)
}
}
// invalid_grant means the saved authorisation is finished — retrying cannot
// help, and the user has to reconnect. Anything else is worth retrying, so
// misclassifying it would push the user through consent for a network blip.
func TestNeedsReconnect(t *testing.T) {
cases := []struct {
err error
want bool
}{
{errors.New(`oauth2: "invalid_grant" "Bad Request"`), true},
{errors.New("refreshing token: oauth2: \"invalid_grant\" \"Token has been expired or revoked.\""), true},
{errors.New("Post \"https://oauth2.googleapis.com/token\": dial tcp: i/o timeout"), false},
{errors.New("oauth2: cannot fetch token: 503 Service Unavailable"), false},
{nil, false},
}
for _, c := range cases {
if got := NeedsReconnect(c.err); got != c.want {
t.Errorf("NeedsReconnect(%v) = %v, want %v", c.err, got, c.want)
}
}
}
// A failed refresh must be recorded, or settings keeps showing "connected"
// while every background fetch fails — which is how Google Tasks appeared
// healthy for a month while returning nothing.
func TestDBTokenSource_RecordsRefreshFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Token has been expired or revoked."}`))
}))
defer srv.Close()
store := &memTokenStore{token: expiredToken()}
src := &dbTokenSource{
store: store,
source: GoogleTasksOAuthSource,
config: &oauth2.Config{
ClientID: "id",
ClientSecret: "secret",
Endpoint: oauth2.Endpoint{TokenURL: srv.URL},
},
}
if _, err := src.Token(); err == nil {
t.Fatal("expected the refresh to fail")
}
if store.lastError == "" {
t.Fatal("the failure was not recorded")
}
if !strings.Contains(store.lastError, "reconnect") {
t.Errorf("recorded error should tell the user to reconnect, got %q", store.lastError)
}
}
// A successful refresh must clear a previously recorded failure; the store's
// SaveOAuthToken does that, so this pins that saving is what happens on success.
func TestDBTokenSource_SuccessSavesToken(t *testing.T) {
hits := 0
srv := tokenServer(t, &hits)
store := &memTokenStore{token: expiredToken()}
src := &dbTokenSource{
store: store,
source: GoogleTasksOAuthSource,
config: &oauth2.Config{
ClientID: "id",
ClientSecret: "secret",
Endpoint: oauth2.Endpoint{TokenURL: srv.URL},
},
}
if _, err := src.Token(); err != nil {
t.Fatalf("Token(): %v", err)
}
if store.saved == 0 {
t.Error("a refreshed token should be persisted")
}
}
|