diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa8eccf0..5658472f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Calendar: add `calendar events --sort=start|end|summary|calendar` and `--order=asc|desc` so `--all` output can be returned chronologically across calendars instead of per-calendar API iteration order. Also documents `now` in the `--from`/`--to` help strings (already accepted by `timeparse`) — the relative form agents need when planning "from now on" — thanks @gado-ships-it. - Calendar: add `calendar events --location` to include event locations in table output. Embedded newlines in the location string are collapsed so multi-line addresses still render on one row — thanks @gado-ships-it. +- Auth: add `gog auth import --client --email` with `--refresh-token-stdin`, `--refresh-token-file`, or `--refresh-token-env` for non-interactive token import without exposing secrets in argv — thanks @jcarnegie. - Drive: add `drive share --notify` for invite targets that require a Drive notification email. - Calendar: keep `calendar appointments` as an explicit diagnostic because the Calendar API still rejects `eventTypes=appointmentSchedule`. (#329) - CLI: add nested `docs tabs ...` and `forms questions ...` aliases for consistent sub-item command patterns while preserving existing flat commands. (#433) diff --git a/docs/commands.generated.md b/docs/commands.generated.md index ac05ce129..1dec71627 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -43,6 +43,7 @@ Generated from `gog schema --json`. - [`gog auth credentials remove []`](commands/gog-auth-credentials-remove.md) - Remove stored OAuth client credentials - [`gog auth credentials set [flags]`](commands/gog-auth-credentials-set.md) - Store OAuth client credentials - [`gog auth doctor [flags]`](commands/gog-auth-doctor.md) - Diagnose auth, keyring, and refresh-token issues + - [`gog auth import --email=STRING [flags]`](commands/gog-auth-import.md) - Import a refresh token non-interactively from stdin, file, or env - [`gog auth keep --key=STRING `](commands/gog-auth-keep.md) - Configure service account for Google Keep (Workspace only) - [`gog auth keyring [ []]`](commands/gog-auth-keyring.md) - Configure keyring backend - [`gog auth list [flags]`](commands/gog-auth-list.md) - List stored accounts diff --git a/docs/commands/README.md b/docs/commands/README.md index 1a8ac32c9..04de36011 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 559. +Generated pages: 560. ## Top-level Commands @@ -93,6 +93,7 @@ Generated pages: 559. - [gog auth credentials remove](gog-auth-credentials-remove.md) - Remove stored OAuth client credentials - [gog auth credentials set](gog-auth-credentials-set.md) - Store OAuth client credentials - [gog auth doctor](gog-auth-doctor.md) - Diagnose auth, keyring, and refresh-token issues + - [gog auth import](gog-auth-import.md) - Import a refresh token non-interactively from stdin, file, or env - [gog auth keep](gog-auth-keep.md) - Configure service account for Google Keep (Workspace only) - [gog auth keyring](gog-auth-keyring.md) - Configure keyring backend - [gog auth list](gog-auth-list.md) - List stored accounts diff --git a/docs/commands/gog-auth-import.md b/docs/commands/gog-auth-import.md new file mode 100644 index 000000000..e6f0cd10b --- /dev/null +++ b/docs/commands/gog-auth-import.md @@ -0,0 +1,48 @@ +# `gog auth import` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Import a refresh token non-interactively from stdin, file, or env + +## Usage + +```bash +gog auth import --email=STRING [flags] +``` + +## Parent + +- [gog auth](gog-auth.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--email` | `string` | | Account email | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--refresh-token-env` | `string` | | Read OAuth refresh token from the named environment variable | +| `--refresh-token-file` | `string` | | Read OAuth refresh token from file | +| `--refresh-token-stdin` | `bool` | | Read OAuth refresh token from stdin | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--services` | `string` | | Comma-separated services to record on the token (informational; does not affect scopes) | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog auth](gog-auth.md) +- [Command index](README.md) diff --git a/docs/commands/gog-auth.md b/docs/commands/gog-auth.md index 05e7e100b..7aedf6035 100644 --- a/docs/commands/gog-auth.md +++ b/docs/commands/gog-auth.md @@ -20,6 +20,7 @@ gog auth [flags] - [gog auth alias](gog-auth-alias.md) - Manage account aliases - [gog auth credentials](gog-auth-credentials.md) - Manage OAuth client credentials - [gog auth doctor](gog-auth-doctor.md) - Diagnose auth, keyring, and refresh-token issues +- [gog auth import](gog-auth-import.md) - Import a refresh token non-interactively from stdin, file, or env - [gog auth keep](gog-auth-keep.md) - Configure service account for Google Keep (Workspace only) - [gog auth keyring](gog-auth-keyring.md) - Configure keyring backend - [gog auth list](gog-auth-list.md) - List stored accounts diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 1881d78ea..bb23621f3 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -42,6 +42,7 @@ const ( type AuthCmd struct { Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Manage OAuth client credentials"` Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"` + Import AuthImportCmd `cmd:"" name:"import" help:"Import a refresh token non-interactively from stdin, file, or env"` Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"` List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"` Doctor AuthDoctorCmd `cmd:"" name:"doctor" help:"Diagnose auth, keyring, and refresh-token issues"` diff --git a/internal/cmd/auth_accounts.go b/internal/cmd/auth_accounts.go index ac881c1fc..a1976861f 100644 --- a/internal/cmd/auth_accounts.go +++ b/internal/cmd/auth_accounts.go @@ -49,7 +49,7 @@ func (c *AuthStatusCmd) Run(ctx context.Context, flags *RootFlags) error { if flags != nil { if a, err := requireAccount(flags); err == nil { account = a - resolvedClient, resolveErr := resolveClientForEmail(account, flags, "") + resolvedClient, resolveErr := resolveClientForEmail(account, flags) if resolveErr != nil { return resolveErr } @@ -222,7 +222,7 @@ func (c *AuthRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - client, err := resolveClientForEmail(email, flags, "") + client, err := resolveClientForEmail(email, flags) if err != nil { return err } diff --git a/internal/cmd/auth_import.go b/internal/cmd/auth_import.go new file mode 100644 index 000000000..2a48518c6 --- /dev/null +++ b/internal/cmd/auth_import.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/99designs/keyring" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/secrets" + "github.com/steipete/gogcli/internal/ui" +) + +var readAuthImportStdin = func() ([]byte, error) { + return io.ReadAll(os.Stdin) +} + +type AuthImportCmd struct { + Email string `name:"email" required:"" help:"Account email"` + RefreshTokenStdin bool `name:"refresh-token-stdin" help:"Read OAuth refresh token from stdin"` + RefreshTokenFile string `name:"refresh-token-file" type:"path" help:"Read OAuth refresh token from file"` + RefreshTokenEnv string `name:"refresh-token-env" help:"Read OAuth refresh token from the named environment variable"` + ServicesCSV string `name:"services" help:"Comma-separated services to record on the token (informational; does not affect scopes)"` +} + +func (c *AuthImportCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + email := normalizeEmail(c.Email) + if email == "" { + return usage("--email is required") + } + + refreshToken, tokenErr := c.resolveRefreshToken() + if tokenErr != nil { + return tokenErr + } + + override := "" + if flags != nil { + override = flags.Client + } + client, clientErr := resolveClientForEmail(email, flags) + if clientErr != nil { + return clientErr + } + + services := splitCommaList(c.ServicesCSV) + force := flags != nil && flags.Force + + if err := dryRunExit(ctx, flags, "auth.import", map[string]any{ + "client": client, + "email": email, + "services": services, + "force": force, + }); err != nil { + return err + } + + if err := ensureKeychainAccessIfNeeded(); err != nil { + return fmt.Errorf("keychain access: %w", err) + } + + store, err := openSecretsStore() + if err != nil { + return err + } + + if !force { + if _, getErr := store.GetToken(client, email); getErr == nil { + return usagef("entry already exists for client=%q email=%q (use --force to overwrite)", client, email) + } else if !errors.Is(getErr, keyring.ErrKeyNotFound) { + return getErr + } + } + + if err := store.SetToken(client, email, secrets.Token{ + Client: client, + Email: email, + Services: services, + RefreshToken: refreshToken, + }); err != nil { + return err + } + if strings.TrimSpace(override) != "" { + cfg, err := config.ReadConfig() + if err != nil { + return err + } + if err := config.SetAccountClient(&cfg, email, client); err != nil { + return err + } + if err := config.WriteConfig(cfg); err != nil { + return err + } + } + + return writeResult(ctx, u, + kv("imported", true), + kv("client", client), + kv("email", email), + ) +} + +func (c *AuthImportCmd) resolveRefreshToken() (string, error) { + sources := 0 + if c.RefreshTokenStdin { + sources++ + } + if strings.TrimSpace(c.RefreshTokenFile) != "" { + sources++ + } + if strings.TrimSpace(c.RefreshTokenEnv) != "" { + sources++ + } + if sources == 0 { + return "", usage("provide refresh token with --refresh-token-stdin, --refresh-token-file, or --refresh-token-env") + } + if sources > 1 { + return "", usage("provide exactly one refresh token source") + } + + var ( + raw []byte + err error + ) + switch { + case c.RefreshTokenStdin: + raw, err = readAuthImportStdin() + if err != nil { + return "", fmt.Errorf("read --refresh-token-stdin: %w", err) + } + case strings.TrimSpace(c.RefreshTokenFile) != "": + path, expandErr := config.ExpandPath(strings.TrimSpace(c.RefreshTokenFile)) + if expandErr != nil { + return "", fmt.Errorf("expand --refresh-token-file: %w", expandErr) + } + raw, err = os.ReadFile(path) //nolint:gosec // user-provided token file path + if err != nil { + return "", fmt.Errorf("read --refresh-token-file: %w", err) + } + case strings.TrimSpace(c.RefreshTokenEnv) != "": + envName := strings.TrimSpace(c.RefreshTokenEnv) + value, ok := os.LookupEnv(envName) + if !ok { + return "", usagef("environment variable %s is not set", envName) + } + raw = []byte(value) + } + + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", usage("refresh token must not be empty") + } + return token, nil +} diff --git a/internal/cmd/auth_import_test.go b/internal/cmd/auth_import_test.go new file mode 100644 index 000000000..269265bf9 --- /dev/null +++ b/internal/cmd/auth_import_test.go @@ -0,0 +1,352 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/secrets" + "github.com/steipete/gogcli/internal/ui" +) + +func newImportTestContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{}) +} + +func withImportOverrides(t *testing.T, store secrets.Store) { + t.Helper() + origOpen := openSecretsStore + origKeychain := ensureKeychainAccess + origStdin := readAuthImportStdin + t.Cleanup(func() { + openSecretsStore = origOpen + ensureKeychainAccess = origKeychain + readAuthImportStdin = origStdin + }) + openSecretsStore = func() (secrets.Store, error) { return store, nil } + ensureKeychainAccess = func() error { return nil } +} + +func authImportCmdWithEnvToken(t *testing.T, email string, token string) *AuthImportCmd { + t.Helper() + t.Setenv("GOG_TEST_REFRESH_TOKEN", token) + return &AuthImportCmd{ + Email: email, + RefreshTokenEnv: "GOG_TEST_REFRESH_TOKEN", + } +} + +func TestAuthImportCmd_RejectsEmptyRefreshToken(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "a@b.com", " ") + cmd.ServicesCSV = "gmail" + err := cmd.Run(newImportTestContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error for empty refresh token") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected ExitError code=2, got %#v", err) + } + if !strings.Contains(err.Error(), "refresh token") { + t.Fatalf("expected refresh token in error, got %q", err.Error()) + } +} + +func TestAuthImportCmd_RejectsMissingRefreshTokenSource(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := &AuthImportCmd{ + Email: "a@b.com", + } + err := cmd.Run(newImportTestContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error for missing refresh token source") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected ExitError code=2, got %#v", err) + } + if !strings.Contains(err.Error(), "--refresh-token-stdin") { + t.Fatalf("expected safe source hint in error, got %q", err.Error()) + } +} + +func TestAuthImportCmd_RejectsMultipleRefreshTokenSources(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt") + cmd.RefreshTokenStdin = true + err := cmd.Run(newImportTestContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error for multiple refresh token sources") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected ExitError code=2, got %#v", err) + } + if !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("expected exactly-one source error, got %q", err.Error()) + } +} + +func TestAuthImportCmd_RejectsEmptyEmail(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, " ", "rt") + err := cmd.Run(newImportTestContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error for empty email") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected ExitError code=2, got %#v", err) + } +} + +func TestAuthImportCmd_DefaultsClientToDefault(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + t.Setenv("GOG_TEST_REFRESH_TOKEN", "rt-1\n") + + cmd := &AuthImportCmd{ + Email: "A@B.com", + RefreshTokenEnv: "GOG_TEST_REFRESH_TOKEN", + ServicesCSV: "gmail, drive", + } + if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + tok, err := store.GetToken(config.DefaultClientName, "a@b.com") + if err != nil { + t.Fatalf("GetToken default client: %v", err) + } + if tok.RefreshToken != "rt-1" { + t.Fatalf("unexpected refresh token: %q", tok.RefreshToken) + } + if tok.Email != "a@b.com" { + t.Fatalf("unexpected email: %q", tok.Email) + } + if strings.Join(tok.Services, ",") != "gmail,drive" { + t.Fatalf("unexpected services: %v", tok.Services) + } +} + +func TestAuthImportCmd_ReadsRefreshTokenFromFile(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + path := t.TempDir() + "/token.txt" + if err := os.WriteFile(path, []byte("rt-file\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + + cmd := &AuthImportCmd{ + Email: "a@b.com", + RefreshTokenFile: path, + } + if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + tok, err := store.GetToken("default", "a@b.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.RefreshToken != "rt-file" { + t.Fatalf("expected file token, got %q", tok.RefreshToken) + } +} + +func TestAuthImportCmd_ExpandsRefreshTokenFilePath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + store := newMemSecretsStore() + withImportOverrides(t, store) + path := filepath.Join(home, "token.txt") + if err := os.WriteFile(path, []byte("rt-home\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + + cmd := &AuthImportCmd{ + Email: "a@b.com", + RefreshTokenFile: "~/token.txt", + } + if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + tok, err := store.GetToken("default", "a@b.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.RefreshToken != "rt-home" { + t.Fatalf("expected expanded file token, got %q", tok.RefreshToken) + } +} + +func TestAuthImportCmd_ReadsRefreshTokenFromStdin(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + readAuthImportStdin = func() ([]byte, error) { + return io.ReadAll(bytes.NewBufferString("rt-stdin\n")) + } + + cmd := &AuthImportCmd{ + Email: "a@b.com", + RefreshTokenStdin: true, + } + if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + tok, err := store.GetToken("default", "a@b.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.RefreshToken != "rt-stdin" { + t.Fatalf("expected stdin token, got %q", tok.RefreshToken) + } +} + +func TestAuthImportCmd_RejectsExistingEntryWithoutForce(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + if err := store.SetToken("default", "a@b.com", secrets.Token{RefreshToken: "rt-old"}); err != nil { + t.Fatalf("seed SetToken: %v", err) + } + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt-new") + err := cmd.Run(newImportTestContext(t), &RootFlags{}) + if err == nil { + t.Fatal("expected error when entry exists without --force") + } + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("expected ExitError code=2, got %#v", err) + } + if !strings.Contains(err.Error(), "--force") { + t.Fatalf("expected --force in error, got %q", err.Error()) + } + tok, _ := store.GetToken("default", "a@b.com") + if tok.RefreshToken != "rt-old" { + t.Fatalf("expected unchanged token, got %q", tok.RefreshToken) + } +} + +func TestAuthImportCmd_ForceOverwritesExistingEntry(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + if err := store.SetToken("default", "a@b.com", secrets.Token{RefreshToken: "rt-old"}); err != nil { + t.Fatalf("seed SetToken: %v", err) + } + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt-new") + if err := cmd.Run(newImportTestContext(t), &RootFlags{Force: true}); err != nil { + t.Fatalf("Run: %v", err) + } + + tok, err := store.GetToken("default", "a@b.com") + if err != nil { + t.Fatalf("GetToken: %v", err) + } + if tok.RefreshToken != "rt-new" { + t.Fatalf("expected overwritten token, got %q", tok.RefreshToken) + } +} + +func TestAuthImportCmd_ForceOverwritesUnreadableEntry(t *testing.T) { + store := &errorTokenStore{err: errors.New("decode token")} + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt-new") + if err := cmd.Run(newImportTestContext(t), &RootFlags{Force: true}); err != nil { + t.Fatalf("Run: %v", err) + } +} + +func TestAuthImportCmd_CustomClientNamespace(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt") + if err := cmd.Run(newImportTestContext(t), &RootFlags{Client: "org"}); err != nil { + t.Fatalf("Run: %v", err) + } + + if _, err := store.GetToken("org", "a@b.com"); err != nil { + t.Fatalf("expected token under custom client: %v", err) + } + if _, err := store.GetToken("default", "a@b.com"); err == nil { + t.Fatalf("expected no token under default client") + } + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("ReadConfig: %v", err) + } + if got := cfg.AccountClients["a@b.com"]; got != "org" { + t.Fatalf("expected account client mapping org, got %q", got) + } +} + +func TestAuthImportCmd_UsesConfiguredClientMapping(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + if err := config.WriteConfig(config.File{ClientDomains: map[string]string{ + "example.com": "work", + }}); err != nil { + t.Fatalf("WriteConfig: %v", err) + } + + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "user@example.com", "rt") + if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil { + t.Fatalf("Run: %v", err) + } + + if _, err := store.GetToken("work", "user@example.com"); err != nil { + t.Fatalf("expected token under mapped client: %v", err) + } + if _, err := store.GetToken("default", "user@example.com"); err == nil { + t.Fatalf("expected no token under default client") + } +} + +func TestAuthImportCmd_DryRunDoesNotWrite(t *testing.T) { + store := newMemSecretsStore() + withImportOverrides(t, store) + + cmd := authImportCmdWithEnvToken(t, "a@b.com", "rt") + err := cmd.Run(newImportTestContext(t), &RootFlags{DryRun: true}) + var ee *ExitError + if !errors.As(err, &ee) || ee.Code != 0 { + t.Fatalf("expected dry-run ExitError code=0, got %#v", err) + } + if _, err := store.GetToken("default", "a@b.com"); err == nil { + t.Fatal("expected no token after dry-run") + } +} diff --git a/internal/cmd/auth_tokens.go b/internal/cmd/auth_tokens.go index b0eadaeff..364dd9e66 100644 --- a/internal/cmd/auth_tokens.go +++ b/internal/cmd/auth_tokens.go @@ -97,7 +97,7 @@ func (c *AuthTokensDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - client, err := resolveClientForEmail(email, flags, "") + client, err := resolveClientForEmail(email, flags) if err != nil { return err } diff --git a/internal/cmd/client_helpers.go b/internal/cmd/client_helpers.go index a722c424b..7c290df00 100644 --- a/internal/cmd/client_helpers.go +++ b/internal/cmd/client_helpers.go @@ -8,18 +8,15 @@ import ( "github.com/steipete/gogcli/internal/config" ) -func resolveClientOverride(flags *RootFlags, cmdClient string) string { - if strings.TrimSpace(cmdClient) != "" { - return cmdClient - } +func resolveClientOverride(flags *RootFlags) string { if flags == nil { return "" } return flags.Client } -func resolveClientForEmail(email string, flags *RootFlags, cmdClient string) (string, error) { - override := resolveClientOverride(flags, cmdClient) +func resolveClientForEmail(email string, flags *RootFlags) (string, error) { + override := resolveClientOverride(flags) return authclient.ResolveClientWithOverride(email, override) } diff --git a/internal/secrets/store_test.go b/internal/secrets/store_test.go index dcfedbdf3..32feea5d4 100644 --- a/internal/secrets/store_test.go +++ b/internal/secrets/store_test.go @@ -292,6 +292,66 @@ func TestOpenKeyring_NoDBus_ForcesFileBackend(t *testing.T) { } } +func TestKeyringStoreSetToken_RoundtripPreservesServices(t *testing.T) { + ring := keyring.NewArrayKeyring(nil) + store := &KeyringStore{ring: ring} + client := config.DefaultClientName + + tok := Token{ + Email: "import@example.com", + Services: []string{"gmail", "drive"}, + RefreshToken: "imported-rt", + } + if err := store.SetToken(client, tok.Email, tok); err != nil { + t.Fatalf("SetToken: %v", err) + } + + got, err := store.GetToken(client, tok.Email) + if err != nil { + t.Fatalf("GetToken: %v", err) + } + + if got.Email != tok.Email { + t.Fatalf("email mismatch: got %q want %q", got.Email, tok.Email) + } + + if got.RefreshToken != tok.RefreshToken { + t.Fatalf("refresh token mismatch: got %q want %q", got.RefreshToken, tok.RefreshToken) + } + + if strings.Join(got.Services, ",") != "gmail,drive" { + t.Fatalf("services mismatch: got %v", got.Services) + } + + if got.CreatedAt.IsZero() { + t.Fatalf("expected CreatedAt to be auto-populated") + } +} + +func TestKeyringStoreSetToken_OverwritesExistingEntry(t *testing.T) { + ring := keyring.NewArrayKeyring(nil) + store := &KeyringStore{ring: ring} + client := config.DefaultClientName + email := "overwrite@example.com" + + if err := store.SetToken(client, email, Token{RefreshToken: "rt-old"}); err != nil { + t.Fatalf("SetToken old: %v", err) + } + + if err := store.SetToken(client, email, Token{RefreshToken: "rt-new"}); err != nil { + t.Fatalf("SetToken new: %v", err) + } + + got, err := store.GetToken(client, email) + if err != nil { + t.Fatalf("GetToken: %v", err) + } + + if got.RefreshToken != "rt-new" { + t.Fatalf("expected overwritten token, got %q", got.RefreshToken) + } +} + func TestOpenKeyring_ExplicitBackend_IgnoresDBusDetection(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home)