gogcli/internal/cmd/auth_add_test.go
2026-04-28 08:12:43 +01:00

1269 lines
38 KiB
Go

package cmd
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleauth"
"github.com/steipete/gogcli/internal/secrets"
)
func TestAuthAddCmd_JSON(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"gmail,drive,gmail",
"--manual",
"--force-consent",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !gotOpts.Manual || !gotOpts.ForceConsent {
t.Fatalf("expected options set, got %+v", gotOpts)
}
if len(gotOpts.Services) != 2 {
t.Fatalf("expected deduped services, got %v", gotOpts.Services)
}
var parsed struct {
Stored bool `json:"stored"`
Email string `json:"email"`
Services []string `json:"services"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if !parsed.Stored || parsed.Email != "user@example.com" || len(parsed.Services) != 2 {
t.Fatalf("unexpected response: %#v", parsed)
}
tok, err := store.GetToken(config.DefaultClientName, "user@example.com")
if err != nil {
t.Fatalf("GetToken: %v", err)
}
if tok.RefreshToken != "rt" || !strings.Contains(strings.Join(tok.Services, ","), "gmail") {
t.Fatalf("unexpected token: %#v", tok)
}
}
func TestAuthAddCmd_KeychainError(t *testing.T) {
t.Setenv("GOG_KEYRING_BACKEND", "keychain")
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
// Simulate keychain locked error
ensureKeychainAccess = func() error {
return errors.New("keychain is locked")
}
authCalled := false
authorizeGoogle = func(_ context.Context, _ googleauth.AuthorizeOptions) (string, error) {
authCalled = true
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
t.Fatal("fetchAuthorizedIdentity should not be called when keychain check fails")
return googleauth.Identity{}, nil
}
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
cmd := &AuthAddCmd{Email: "test@example.com", ServicesCSV: "gmail"}
err := cmd.Run(context.Background(), &RootFlags{})
if err == nil {
t.Fatal("expected error when keychain is locked")
}
if !strings.Contains(err.Error(), "keychain") {
t.Errorf("expected error to mention keychain, got: %v", err)
}
if authCalled {
t.Error("authorizeGoogle should not be called when keychain check fails")
}
}
type setTokenErrorStore struct {
*memSecretsStore
err error
}
func (s *setTokenErrorStore) SetToken(string, string, secrets.Token) error {
return s.err
}
func TestAuthAddCmd_StoreFailureReportsOAuthCompleted(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
openSecretsStore = func() (secrets.Store, error) {
return &setTokenErrorStore{
memSecretsStore: newMemSecretsStore(),
err: errors.New("keyring connection timed out after 10s while storing keyring item"),
}, nil
}
err := Execute([]string{"auth", "add", "user@example.com", "--services", "gmail", "--manual"})
if err == nil {
t.Fatal("expected store failure")
}
if !strings.Contains(err.Error(), "OAuth completed, but saving the refresh token failed") {
t.Fatalf("expected post-OAuth store failure context, got: %v", err)
}
if !strings.Contains(err.Error(), "storing keyring item") {
t.Fatalf("expected keyring operation detail, got: %v", err)
}
}
func TestAuthAddCmd_DefaultServices_UserPreset(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "auth", "add", "user@example.com"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
want := googleauth.UserServices()
if len(gotOpts.Services) != len(want) {
t.Fatalf("unexpected services: %v", gotOpts.Services)
}
for _, s := range gotOpts.Services {
if s == googleauth.ServiceKeep {
t.Fatalf("unexpected keep in services: %v", gotOpts.Services)
}
}
}
func TestAuthAddCmd_KeepRejected(t *testing.T) {
origAuth := authorizeGoogle
t.Cleanup(func() { authorizeGoogle = origAuth })
authorizeCalled := false
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
authorizeCalled = true
return "", nil
}
err := Execute([]string{"auth", "add", "user@example.com", "--services", "keep"})
if err == nil {
t.Fatalf("expected error")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected exit code 2, got %T %#v", err, err)
}
if !strings.Contains(err.Error(), "Keep auth") {
t.Fatalf("unexpected error: %v", err)
}
if authorizeCalled {
t.Fatalf("authorizeGoogle should not be called")
}
}
func TestAuthAddCmd_EmailMismatch(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "actual@example.com"}, nil
}
err := Execute([]string{"auth", "add", "expected@example.com"})
if err == nil {
t.Fatalf("expected mismatch error")
}
if !strings.Contains(err.Error(), "authorized as actual@example.com") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAuthAddCmd_ReadonlyScopes(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"gmail,drive,calendar",
"--readonly",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.readonly") {
t.Fatalf("missing gmail.readonly in %v", gotOpts.Scopes)
}
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.readonly") {
t.Fatalf("missing drive.readonly in %v", gotOpts.Scopes)
}
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/calendar.readonly") {
t.Fatalf("missing calendar.readonly in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://mail.google.com/") {
t.Fatalf("unexpected https://mail.google.com/ in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.settings.basic") {
t.Fatalf("unexpected gmail.settings.basic in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.settings.sharing") {
t.Fatalf("unexpected gmail.settings.sharing in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("unexpected drive in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/calendar") {
t.Fatalf("unexpected calendar in %v", gotOpts.Scopes)
}
}
func TestAuthAddCmd_GmailScopeReadonly(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"gmail,drive",
"--gmail-scope",
"readonly",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.readonly") {
t.Fatalf("missing gmail.readonly in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.modify") {
t.Fatalf("unexpected gmail.modify in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.settings.basic") {
t.Fatalf("unexpected gmail.settings.basic in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.settings.sharing") {
t.Fatalf("unexpected gmail.settings.sharing in %v", gotOpts.Scopes)
}
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("missing drive in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.readonly") {
t.Fatalf("unexpected drive.readonly in %v", gotOpts.Scopes)
}
if !gotOpts.DisableIncludeGrantedScopes {
t.Fatalf("expected DisableIncludeGrantedScopes when using --gmail-scope=readonly")
}
}
func TestAuthAddCmd_DriveScopeReadonly(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"drive",
"--drive-scope",
"readonly",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.readonly") {
t.Fatalf("missing drive.readonly in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("unexpected drive in %v", gotOpts.Scopes)
}
if !gotOpts.DisableIncludeGrantedScopes {
t.Fatalf("expected DisableIncludeGrantedScopes when using --drive-scope=readonly")
}
}
func TestAuthAddCmd_DriveScopeFile(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"drive",
"--drive-scope",
"file",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.file") {
t.Fatalf("missing drive.file in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("unexpected drive in %v", gotOpts.Scopes)
}
}
func TestAuthAddCmd_ReadonlyWithDriveScopeFileRejected(t *testing.T) {
err := Execute([]string{"auth", "add", "user@example.com", "--services", "drive", "--readonly", "--drive-scope", "file"})
if err == nil {
t.Fatalf("expected error")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected exit code 2, got %T %#v", err, err)
}
if !strings.Contains(err.Error(), "--drive-scope=file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAuthAddCmd_SheetsReadonlyIncludesDriveReadonly(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"sheets",
"--readonly",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/spreadsheets.readonly") {
t.Fatalf("missing spreadsheets.readonly in %v", gotOpts.Scopes)
}
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.readonly") {
t.Fatalf("missing drive.readonly in %v", gotOpts.Scopes)
}
}
func TestAuthAddCmd_SheetsDriveScopeFile(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"sheets",
"--drive-scope",
"file",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive.file") {
t.Fatalf("missing drive.file in %v", gotOpts.Scopes)
}
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/spreadsheets") {
t.Fatalf("missing spreadsheets in %v", gotOpts.Scopes)
}
if containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("unexpected drive in %v", gotOpts.Scopes)
}
}
func TestAuthAddCmd_RemoteStep1_PrintsAuthURL(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
manualCalled := false
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
manualCalled = true
return googleauth.ManualAuthURLResult{
URL: "https://example.com/auth",
StateReused: true,
}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
var stderr string
out := captureStdout(t, func() {
stderr = captureStderr(t, func() {
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--readonly",
"--remote",
"--step",
"1",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !manualCalled {
t.Fatalf("expected manualAuthURL to be called")
}
if !strings.Contains(out, "auth_url\thttps://example.com/auth") {
t.Fatalf("unexpected output: %q", out)
}
if !strings.Contains(out, "state_reused\ttrue") {
t.Fatalf("expected state_reused output, got: %q", out)
}
if !strings.Contains(stderr, "Run again with the same root flags and --remote --step 2 --auth-url <redirect-url> --services gmail --readonly") {
t.Fatalf("expected step 2 guidance to preserve replay flags, got: %q", stderr)
}
}
func TestAuthAddCmd_RemoteStep1_PreservesAllReplayFlags(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
return googleauth.ManualAuthURLResult{URL: "https://example.com/auth"}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail,drive",
"--remote",
"--step",
"1",
"--drive-scope",
"file",
"--gmail-scope",
"readonly",
"--force-consent",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
want := "Run again with the same root flags and --remote --step 2 --auth-url <redirect-url> --services gmail,drive --drive-scope file --gmail-scope readonly --force-consent"
if !strings.Contains(stderr, want) {
t.Fatalf("expected replay guidance %q, got %q", want, stderr)
}
}
func TestAuthAddCmd_RemoteStep1_OmitsDefaultScopeFlags(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
return googleauth.ManualAuthURLResult{URL: "https://example.com/auth"}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail,drive",
"--remote",
"--step",
"1",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if strings.Contains(stderr, "--drive-scope full") {
t.Fatalf("expected default drive scope to be omitted, got %q", stderr)
}
if strings.Contains(stderr, "--gmail-scope full") {
t.Fatalf("expected default gmail scope to be omitted, got %q", stderr)
}
}
func TestAuthAddCmd_RemoteStep1_PassesRedirectURI(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
var gotOpts googleauth.AuthorizeOptions
manualAuthURL = func(_ context.Context, opts googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
gotOpts = opts
return googleauth.ManualAuthURLResult{
URL: "https://example.com/auth",
}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"1",
"--redirect-uri",
"https://molty2.tail8108.ts.net/oauth2/callback",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if gotOpts.RedirectURI != "https://molty2.tail8108.ts.net/oauth2/callback" {
t.Fatalf("unexpected redirect uri: %q", gotOpts.RedirectURI)
}
}
func TestAuthAddCmd_RemoteStep1_ReplaysRedirectURIInGuidance(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
return googleauth.ManualAuthURLResult{URL: "https://example.com/auth"}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"1",
"--redirect-uri",
"https://molty2.tail8108.ts.net/oauth2/callback",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
want := "--remote --step 2 --auth-url <redirect-url> --redirect-uri https://molty2.tail8108.ts.net/oauth2/callback --services gmail"
if !strings.Contains(stderr, want) {
t.Fatalf("expected replay guidance %q, got %q", want, stderr)
}
}
func TestAuthAddCmd_RemoteStep1_PassesRedirectHostAsRedirectURI(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
var gotOpts googleauth.AuthorizeOptions
manualAuthURL = func(_ context.Context, opts googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
gotOpts = opts
return googleauth.ManualAuthURLResult{URL: "https://example.com/auth"}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
if err := Execute([]string{
"auth", "add", "user@example.com",
"--services", "gmail",
"--remote", "--step", "1",
"--redirect-host", "gog.example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if gotOpts.RedirectURI != "https://gog.example.com/oauth2/callback" {
t.Fatalf("unexpected redirect uri: %q", gotOpts.RedirectURI)
}
}
func TestAuthAddCmd_BrowserFlow_PassesListenAddrAndRedirectHost(t *testing.T) {
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
origStore := openSecretsStore
t.Cleanup(func() {
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
openSecretsStore = origStore
})
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(_ context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "refresh", nil
}
ensureKeychainAccess = func() error { return nil }
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
if err := Execute([]string{
"auth", "add", "user@example.com",
"--services", "gmail",
"--listen-addr", "0.0.0.0:8080",
"--redirect-host", "gog.example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if gotOpts.ListenAddr != "0.0.0.0:8080" {
t.Fatalf("unexpected listen addr: %q", gotOpts.ListenAddr)
}
if gotOpts.RedirectURI != "https://gog.example.com/oauth2/callback" {
t.Fatalf("unexpected redirect uri: %q", gotOpts.RedirectURI)
}
if gotOpts.Manual {
t.Fatalf("redirect-host should not force manual mode")
}
}
func TestAuthAddCmd_RemoteStep1_ReplaysExtraScopesInGuidance(t *testing.T) {
origManualURL := manualAuthURL
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
manualAuthURL = origManualURL
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
return googleauth.ManualAuthURLResult{URL: "https://example.com/auth"}, nil
}
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
t.Fatal("authorizeGoogle should not be called in remote step 1")
return "", nil
}
ensureKeychainAccess = func() error {
t.Fatal("keychain access should not be checked in remote step 1")
return nil
}
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"1",
"--extra-scopes",
"https://www.googleapis.com/auth/gmail.labels, https://www.googleapis.com/auth/gmail.insert",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
want := "--remote --step 2 --auth-url <redirect-url> --services gmail --extra-scopes https://www.googleapis.com/auth/gmail.labels,https://www.googleapis.com/auth/gmail.insert"
if !strings.Contains(stderr, want) {
t.Fatalf("expected replay guidance %q, got %q", want, stderr)
}
}
func TestAuthAddCmd_RemoteStep2_RejectsAuthCode(t *testing.T) {
err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"2",
"--auth-code",
"abc123",
})
if err == nil {
t.Fatalf("expected error")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected exit code 2, got %T %#v", err, err)
}
if !strings.Contains(err.Error(), "--auth-code is not valid with --remote") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAuthAddCmd_RemoteStep2_PassesAuthURL(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"2",
"--auth-url",
"http://127.0.0.1:55555/oauth2/callback?code=abc&state=state123",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if !gotOpts.Manual {
t.Fatalf("expected manual auth in remote step 2")
}
if !gotOpts.RequireState {
t.Fatalf("expected require state in remote step 2")
}
if gotOpts.AuthURL == "" {
t.Fatalf("expected auth URL to be passed through")
}
}
func TestAuthAddCmd_RemoteStep2_PassesRedirectURI(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--remote",
"--step",
"2",
"--redirect-uri",
"https://molty2.tail8108.ts.net/oauth2/callback",
"--auth-url",
"https://molty2.tail8108.ts.net/oauth2/callback?code=abc&state=state123",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if gotOpts.RedirectURI != "https://molty2.tail8108.ts.net/oauth2/callback" {
t.Fatalf("unexpected redirect uri: %q", gotOpts.RedirectURI)
}
}
func TestAuthAddCmd_AuthCode_PassesThrough(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
if err := Execute([]string{
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--auth-code",
"abc123",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
if !gotOpts.Manual {
t.Fatalf("expected manual auth when auth-code is provided")
}
if gotOpts.AuthCode != "abc123" {
t.Fatalf("expected auth-code to be passed through, got %q", gotOpts.AuthCode)
}
}
func TestAuthAddCmd_ExtraScopes(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
origFetch := fetchAuthorizedIdentity
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
fetchAuthorizedIdentity = origFetch
})
ensureKeychainAccess = func() error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
var gotOpts googleauth.AuthorizeOptions
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
gotOpts = opts
gotOpts.Services = append([]googleauth.Service(nil), opts.Services...)
gotOpts.Scopes = append([]string(nil), opts.Scopes...)
return "rt", nil
}
fetchAuthorizedIdentity = func(context.Context, string, string, []string, time.Duration) (googleauth.Identity, error) {
return googleauth.Identity{Email: "user@example.com"}, nil
}
_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"auth",
"add",
"user@example.com",
"--services",
"gmail",
"--gmail-scope",
"readonly",
"--extra-scopes",
"https://www.googleapis.com/auth/gmail.labels,https://www.googleapis.com/auth/gmail.readonly",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// Extra scope should be present
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.labels") {
t.Fatalf("missing gmail.labels in %v", gotOpts.Scopes)
}
// gmail.readonly from --gmail-scope=readonly should still be present
if !containsStringInSlice(gotOpts.Scopes, "https://www.googleapis.com/auth/gmail.readonly") {
t.Fatalf("missing gmail.readonly in %v", gotOpts.Scopes)
}
// Duplicate gmail.readonly (from extra-scopes) should be de-duplicated
count := 0
for _, s := range gotOpts.Scopes {
if s == "https://www.googleapis.com/auth/gmail.readonly" {
count++
}
}
if count != 1 {
t.Fatalf("expected gmail.readonly exactly once, got %d in %v", count, gotOpts.Scopes)
}
}
func containsStringInSlice(items []string, want string) bool {
for _, it := range items {
if it == want {
return true
}
}
return false
}