gogcli/internal/googleapi/client_more_test.go
laihenyi 449beff88b
fix(googleapi): replace Client.Timeout with transport-level ResponseHeaderTimeout (#425)
* feat(drive): include shortcutDetails in drive get fields

Add shortcutDetails to the Drive Get API fields to enable resolving
shortcut target file IDs and MIME types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(googleapi): replace Client.Timeout with transport-level ResponseHeaderTimeout

The global http.Client.Timeout (30s) applied to the entire request
lifecycle, causing large Drive file downloads (videos, backups, etc.)
to time out. Replace it with http.Transport.ResponseHeaderTimeout
which only limits the time waiting for the server to begin responding.
Once response headers arrive and the body starts streaming, there is
no hard cap — large transfers complete naturally.

- Set ResponseHeaderTimeout=30s on the base transport
- Remove http.Client.Timeout from the API client
- Keep a dedicated tokenExchangeTimeout=30s for OAuth2 token refreshes
- Add tests verifying the new transport configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:44:37 +00:00

344 lines
9.6 KiB
Go

package googleapi
import (
"context"
"crypto/tls"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/99designs/keyring"
"golang.org/x/oauth2"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/secrets"
)
var (
errBoom = errors.New("boom")
errNope = errors.New("nope")
errMissingCreds = errors.New("missing creds")
)
type stubStore struct {
lastClient string
lastEmail string
tok secrets.Token
err error
}
func (s *stubStore) Keys() ([]string, error) { return nil, nil }
func (s *stubStore) SetToken(string, string, secrets.Token) error { return nil }
func (s *stubStore) DeleteToken(string, string) error { return nil }
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
func (s *stubStore) GetDefaultAccount(string) (string, error) { return "", nil }
func (s *stubStore) SetDefaultAccount(string, string) error { return nil }
func (s *stubStore) GetToken(client string, email string) (secrets.Token, error) {
s.lastClient = client
s.lastEmail = email
if s.err != nil {
return secrets.Token{}, s.err
}
return s.tok, nil
}
func TestTokenSourceForAccountScopes_StoreErrors(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
openSecretsStore = func() (secrets.Store, error) {
return nil, errBoom
}
_, err := tokenSourceForAccountScopes(context.Background(), "svc", "a@b.com", "default", "id", "secret", []string{"s1"})
if err == nil || !errors.Is(err, errBoom) {
t.Fatalf("expected boom, got: %v", err)
}
}
func TestTokenSourceForAccountScopes_KeyNotFound(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
openSecretsStore = func() (secrets.Store, error) {
return &stubStore{err: keyring.ErrKeyNotFound}, nil
}
_, err := tokenSourceForAccountScopes(context.Background(), "gmail", "a@b.com", "default", "id", "secret", []string{"s1"})
if err == nil {
t.Fatalf("expected error")
}
var are *AuthRequiredError
if !errors.As(err, &are) {
t.Fatalf("expected AuthRequiredError, got: %T %v", err, err)
}
if are.Service != "gmail" || are.Email != "a@b.com" {
t.Fatalf("unexpected: %#v", are)
}
}
func TestTokenSourceForAccountScopes_OtherGetError(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
openSecretsStore = func() (secrets.Store, error) {
return &stubStore{err: errNope}, nil
}
_, err := tokenSourceForAccountScopes(context.Background(), "svc", "a@b.com", "default", "id", "secret", []string{"s1"})
if err == nil || !errors.Is(err, errNope) {
t.Fatalf("expected nope, got: %v", err)
}
}
func TestTokenSourceForAccountScopes_HappyPath(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
s := &stubStore{tok: secrets.Token{Email: "a@b.com", RefreshToken: "rt"}}
openSecretsStore = func() (secrets.Store, error) { return s, nil }
ts, err := tokenSourceForAccountScopes(context.Background(), "svc", "A@B.COM", "default", "id", "secret", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ts == nil {
t.Fatalf("expected token source")
}
// Ensure we pass through the email (store normalizes in production).
if s.lastEmail != "A@B.COM" {
t.Fatalf("expected email passed through, got: %q", s.lastEmail)
}
}
func TestTokenSourceForAccount_ReadCredsError(t *testing.T) {
origRead := readClientCredentials
t.Cleanup(func() { readClientCredentials = origRead })
readClientCredentials = func(string) (config.ClientCredentials, error) {
return config.ClientCredentials{}, errMissingCreds
}
_, err := tokenSourceForAccount(context.Background(), googleauth.ServiceGmail, "a@b.com")
if err == nil || !errors.Is(err, errMissingCreds) {
t.Fatalf("expected missing creds, got: %v", err)
}
}
func TestOptionsForAccountScopes_HappyPath(t *testing.T) {
origRead := readClientCredentials
origOpen := openSecretsStore
t.Cleanup(func() {
readClientCredentials = origRead
openSecretsStore = origOpen
})
readClientCredentials = func(string) (config.ClientCredentials, error) {
return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil
}
openSecretsStore = func() (secrets.Store, error) {
return &stubStore{tok: secrets.Token{Email: "a@b.com", RefreshToken: "rt"}}, nil
}
opts, err := optionsForAccountScopes(context.Background(), "svc", "a@b.com", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(opts) == 0 {
t.Fatalf("expected client options")
}
}
func TestOptionsForAccount_HappyPath(t *testing.T) {
origRead := readClientCredentials
origOpen := openSecretsStore
t.Cleanup(func() {
readClientCredentials = origRead
openSecretsStore = origOpen
})
readClientCredentials = func(string) (config.ClientCredentials, error) {
return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil
}
openSecretsStore = func() (secrets.Store, error) {
return &stubStore{tok: secrets.Token{Email: "a@b.com", RefreshToken: "rt"}}, nil
}
opts, err := optionsForAccount(context.Background(), googleauth.ServiceDrive, "a@b.com")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(opts) == 0 {
t.Fatalf("expected client options")
}
}
func TestOptionsForAccountScopes_ServiceAccountPreferred(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
saPath, err := config.ServiceAccountPath("a@b.com")
if err != nil {
t.Fatalf("ServiceAccountPath: %v", err)
}
if _, ensureErr := config.EnsureDir(); ensureErr != nil {
t.Fatalf("EnsureDir: %v", ensureErr)
}
if writeErr := os.WriteFile(saPath, []byte(`{"type":"service_account"}`), 0o600); writeErr != nil {
t.Fatalf("write sa: %v", writeErr)
}
origRead := readClientCredentials
origOpen := openSecretsStore
origSA := newServiceAccountTokenSource
t.Cleanup(func() {
readClientCredentials = origRead
openSecretsStore = origOpen
newServiceAccountTokenSource = origSA
})
readClientCredentials = func(string) (config.ClientCredentials, error) {
t.Fatalf("readClientCredentials should not be called")
return config.ClientCredentials{}, nil
}
openSecretsStore = func() (secrets.Store, error) {
t.Fatalf("openSecretsStore should not be called")
return nil, errBoom
}
called := false
newServiceAccountTokenSource = func(_ context.Context, keyJSON []byte, subject string, scopes []string) (oauth2.TokenSource, error) {
called = true
if subject != "a@b.com" {
t.Fatalf("unexpected subject: %q", subject)
}
if len(scopes) != 1 || scopes[0] != "s1" {
t.Fatalf("unexpected scopes: %#v", scopes)
}
if string(keyJSON) == "" {
t.Fatalf("expected keyJSON")
}
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "t"}), nil
}
opts, err := optionsForAccountScopes(context.Background(), "svc", "a@b.com", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !called {
t.Fatalf("expected service account token source used")
}
if len(opts) == 0 {
t.Fatalf("expected client options")
}
}
func TestNewBaseTransport_RespectsProxyAndTLSMinimum(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
transport := newBaseTransport()
if transport == nil {
t.Fatalf("expected transport")
return
}
if transport.Proxy == nil {
t.Fatalf("expected proxy func")
}
if transport.TLSClientConfig == nil {
t.Fatalf("expected TLS config")
}
if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 {
t.Fatalf("expected TLS min version >= 1.2, got %d", transport.TLSClientConfig.MinVersion)
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://www.googleapis.com", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
proxyURL, err := transport.Proxy(req)
if err != nil {
t.Fatalf("proxy lookup: %v", err)
}
if proxyURL == nil || !strings.Contains(proxyURL.String(), "127.0.0.1:8888") {
t.Fatalf("expected HTTPS proxy to be honored, got: %v", proxyURL)
}
}
func TestNewBaseTransport_SetsResponseHeaderTimeout(t *testing.T) {
transport := newBaseTransport()
if transport.ResponseHeaderTimeout != responseHeaderTimeout {
t.Fatalf("expected ResponseHeaderTimeout=%v, got %v", responseHeaderTimeout, transport.ResponseHeaderTimeout)
}
}
func TestOptionsForAccountScopes_NoClientTimeout(t *testing.T) {
origRead := readClientCredentials
origOpen := openSecretsStore
t.Cleanup(func() {
readClientCredentials = origRead
openSecretsStore = origOpen
})
readClientCredentials = func(string) (config.ClientCredentials, error) {
return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil
}
openSecretsStore = func() (secrets.Store, error) {
return &stubStore{tok: secrets.Token{Email: "a@b.com", RefreshToken: "rt"}}, nil
}
opts, err := optionsForAccountScopes(context.Background(), "svc", "a@b.com", []string{"s1"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(opts) == 0 {
t.Fatalf("expected client options")
}
// The http.Client returned by optionsForAccountScopes must not set a
// hard Timeout so that large file downloads (Drive videos, etc.) are
// not interrupted. Server responsiveness is instead guarded by the
// transport-level ResponseHeaderTimeout.
//
// We cannot easily extract the http.Client from option.ClientOption,
// so we verify the transport layer instead.
transport := newBaseTransport()
if transport.ResponseHeaderTimeout == 0 {
t.Fatalf("expected ResponseHeaderTimeout to be set on transport")
}
}