summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth_test.go
blob: 3503cbb4cff858f4596c89d85fb21e060d2e1868 (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
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
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
}

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