From 9efa7248a9334c40fb6a529a99d93d1c7df8cf57 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 20 Oct 2023 12:46:32 +0200 Subject: [PATCH 01/62] Initial multi-account migration work --- cmd/gh/main.go | 8 + internal/config/auth_config_test.go | 220 ++++++++++++++++- internal/config/config.go | 36 ++- internal/config/config_mock.go | 59 ++++- internal/config/config_test.go | 1 + internal/config/migrate.go | 49 ++++ internal/config/migrate_test.go | 228 ++++++++++++++++++ internal/config/migration/multi_account.go | 181 ++++++++++++++ .../config/migration/multi_account_test.go | 185 ++++++++++++++ internal/config/migration_mock.go | 149 ++++++++++++ pkg/cmd/auth/login/login.go | 6 +- pkg/cmd/auth/login/login_test.go | 99 +++++--- pkg/cmd/auth/shared/login_flow.go | 6 +- 13 files changed, 1170 insertions(+), 57 deletions(-) create mode 100644 internal/config/migrate.go create mode 100644 internal/config/migrate_test.go create mode 100644 internal/config/migration/multi_account.go create mode 100644 internal/config/migration/multi_account_test.go create mode 100644 internal/config/migration_mock.go diff --git a/cmd/gh/main.go b/cmd/gh/main.go index bcce3b427..871afbb5f 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -56,6 +56,14 @@ func mainRun() exitCode { ctx := context.Background() + // cfg, err := cmdFactory.Config() + // if err != nil { + // fmt.Fprintf(stderr, "failed to load configuration to attempt migration: %s\n", err) + // } + // if err := cfg.MigrateMultiAccount(); err != nil { + // fmt.Fprintf(stderr, "failed to migrate configuration: %s\n", err) + // } + updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() updateMessageChan := make(chan *update.ReleaseInfo) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index afa47fbfd..c028ef6b3 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/cli/cli/v2/internal/config/migration" ghConfig "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/require" "github.com/zalando/go-keyring" @@ -61,7 +62,7 @@ func TestTokenStoredInConfig(t *testing.T) { // and the source is set to oauth_token but this isn't great: // https://github.com/cli/go-gh/issues/94 require.Equal(t, "test-token", token) - require.Equal(t, "oauth_token", source) + require.Equal(t, oauthTokenKey, source) } func TestTokenStoredInEnv(t *testing.T) { @@ -285,13 +286,13 @@ func TestLoginSetsUserForProvidedHost(t *testing.T) { } func TestLoginSetsGitProtocolForProvidedHost(t *testing.T) { - // Given we are loggedin + // Given we are logged in authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) require.NoError(t, err) // When we get the git protocol - protocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey}) + protocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) require.NoError(t, err) // Then it returns the git protocol we provided on login @@ -311,6 +312,23 @@ func TestLoginAddsHostIfNotAlreadyAdded(t *testing.T) { require.Contains(t, hosts, "github.com") } +// This test mimics the behaviour of logging in with a token and not providing +// a git protocol. +func TestLoginAddsUserToConfigWithoutGitProtocolOrSecureStorage(t *testing.T) { + // Given we are not logged in + authCfg := newTestAuthConfig(t) + + // When we log in without git protocol or secure storage + keyring.MockInit() + _, err := authCfg.Login("github.com", "test-user", "test-token", "", true) + require.NoError(t, err) + + // Then the username is added under the users config + users, err := authCfg.cfg.Keys([]string{hostsKey, "github.com", usersKey}) + require.NoError(t, err) + require.Contains(t, users, "test-user") +} + func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { // Given we are logged into a host keyring.MockInit() @@ -365,3 +383,199 @@ func requireNoKey(t *testing.T, cfg *ghConfig.Config, keys []string) { var keyNotFoundError *ghConfig.KeyNotFoundError require.ErrorAs(t, err, &keyNotFoundError) } + +// Post migration tests + +func TestUserWorksRightAfterMigration(t *testing.T) { + // Given we have logged in before migration + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + // Then we can still get the user correctly + user, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "test-user", user) +} + +func TestGitProtocolWorksRightAfterMigration(t *testing.T) { + // Given we have logged in before migration with a non-default git protocol + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + // Then we can still get the git protocol correctly + gitProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey}) + require.NoError(t, err) + require.Equal(t, "ssh", gitProtocol) +} + +func TestHostsWorksRightAfterMigration(t *testing.T) { + // Given we have logged in before migration + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "ghe.io", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + // Then we can still get the hosts correctly + hosts := authCfg.Hosts() + require.Contains(t, hosts, "ghe.io") +} + +func TestDefaultHostWorksRightAfterMigration(t *testing.T) { + // Given we have logged in before migration to an enterprise host + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "ghe.io", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + // Then the default host is still the enterprise host + defaultHost, source := authCfg.DefaultHost() + require.Equal(t, "ghe.io", defaultHost) + require.Equal(t, hostsKey, source) +} + +func TestTokenWorksRightAfterMigration(t *testing.T) { + // Given we have logged in before migration + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + // Then we can still get the token correctly + token, source := authCfg.Token("github.com") + require.Equal(t, "test-token", token) + require.Equal(t, oauthTokenKey, source) +} + +func TestLogoutRigthAfterMigrationRemovesHost(t *testing.T) { + // Given we have logged in before migration + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we migrate and logout + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + require.NoError(t, authCfg.Logout("github.com")) + + // Then the host is removed from the config + requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com"}) +} + +func TestLoginInsecurePostMigrationUsesConfigForToken(t *testing.T) { + // Given we have not logged in + authCfg := newTestAuthConfig(t) + + // When we migrate and login with insecure storage + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", false) + + // Then it returns success, notes that insecure storage was used, and stores the token in the config + // both under the host and under the user + require.NoError(t, err) + + require.True(t, insecureStorageUsed, "expected to use insecure storage") + requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}, "test-token") + requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", usersKey, "test-user", oauthTokenKey}, "test-token") +} + +func TestLoginPostMigrationSetsGitProtocol(t *testing.T) { + // Given we have logged in after migration + authCfg := newTestAuthConfig(t) + + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we get the git protocol + gitProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) + require.NoError(t, err) + + // Then it returns the git protocol we provided on login + require.Equal(t, "ssh", gitProtocol) +} + +func TestLoginPostMigrationSetsUser(t *testing.T) { + // Given we have logged in after migration + authCfg := newTestAuthConfig(t) + + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) + require.NoError(t, err) + + // When we get the user + user, err := authCfg.User("github.com") + + // Then it returns success and the user we provided on login + require.NoError(t, err) + require.Equal(t, "test-user", user) +} + +func TestLoginSecurePostMigrationRemovesTokenFromConfig(t *testing.T) { + // Given we have logged in insecurely + authCfg := newTestAuthConfig(t) + _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "", false) + require.NoError(t, err) + + // When we migrate and login again with secure storage + var m migration.MultiAccount + require.NoError(t, Migrate(authCfg.cfg, m)) + + keyring.MockInit() + _, err = authCfg.Login("github.com", "test-user", "test-token", "", true) + + // Then it returns success, having removed the old insecure oauth token entry + require.NoError(t, err) + requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}) + requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", usersKey, "test-user", oauthTokenKey}) +} + +// Copied and pasted directly from the trunk branch before doing any work on +// login, plus the addition of AuthConfig as the first arg since it is a method +// receiver in the real implementation. +func preMigrationLogin(c *AuthConfig, hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) { + var setErr error + if secureStorage { + if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil { + // Clean up the previous oauth_token from the config file. + _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + } + } + insecureStorageUsed := false + if !secureStorage || setErr != nil { + c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) + insecureStorageUsed = true + } + + c.cfg.Set([]string{hostsKey, hostname, userKey}, username) + + if gitProtocol != "" { + c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) + } + return insecureStorageUsed, ghConfig.Write(c.cfg) +} diff --git a/internal/config/config.go b/internal/config/config.go index 4e48c8a83..23bff82ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/keyring" ghAuth "github.com/cli/go-gh/v2/pkg/auth" ghConfig "github.com/cli/go-gh/v2/pkg/config" @@ -19,6 +20,9 @@ const ( oauthTokenKey = "oauth_token" pagerKey = "pager" promptKey = "prompt" + userKey = "user" + usersKey = "users" + versionKey = "version" ) // This interface describes interacting with some persistent configuration for gh. @@ -37,6 +41,8 @@ type Config interface { HTTPUnixSocket(string) string Pager(string) string Prompt(string) string + + MigrateMultiAccount() error } func NewConfig() (Config, error) { @@ -126,6 +132,11 @@ func (c *cfg) Prompt(hostname string) string { return val } +func (c *cfg) MigrateMultiAccount() error { + var m migration.MultiAccount + return Migrate(c.cfg, m) +} + func defaultFor(key string) (string, bool) { for _, co := range ConfigOptions() { if co.Key == key { @@ -200,7 +211,7 @@ func (c *AuthConfig) TokenFromKeyring(hostname string) (string, error) { // User will retrieve the username for the logged in user at the given hostname. func (c *AuthConfig) User(hostname string) (string, error) { - return c.cfg.Get([]string{hostsKey, hostname, "user"}) + return c.cfg.Get([]string{hostsKey, hostname, userKey}) } func (c *AuthConfig) Hosts() []string { @@ -240,21 +251,33 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure var setErr error if secureStorage { if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil { - // Clean up the previous oauth_token from the config file. + // Clean up the previous oauth_tokens from the config file. _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}) } } insecureStorageUsed := false if !secureStorage || setErr != nil { + // Set the current active oauth token c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) + // And set the oauth token under the user to support later auth switch + // and logout switch without another migration. + c.cfg.Set([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}, token) insecureStorageUsed = true } - c.cfg.Set([]string{hostsKey, hostname, "user"}, username) + c.cfg.Set([]string{hostsKey, hostname, userKey}, username) if gitProtocol != "" { - c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) + c.cfg.Set([]string{hostsKey, hostname, usersKey, username, gitProtocolKey}, gitProtocol) } + + // Create the username key with an empty value so it will be + // written even when there are no keys set under it. + if _, getErr := c.cfg.Get([]string{hostsKey, hostname, usersKey, username}); getErr != nil { + c.cfg.Set([]string{hostsKey, hostname, usersKey, username}, "") + } + return insecureStorageUsed, ghConfig.Write(c.cfg) } @@ -303,7 +326,12 @@ func fallbackConfig() *ghConfig.Config { return ghConfig.ReadFromString(defaultConfigStr) } +// The schema version in here should match the PostVersion of whatever the +// last migration we decided to run is. Therefore, if we run a new migration, +// this should be bumped. const defaultConfigStr = ` +# The current version of the config schema +version: 1 # What protocol to use when performing git operations. Supported values: ssh, https git_protocol: https # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. diff --git a/internal/config/config_mock.go b/internal/config/config_mock.go index aff4cb9d4..53e17998e 100644 --- a/internal/config/config_mock.go +++ b/internal/config/config_mock.go @@ -38,6 +38,9 @@ var _ Config = &ConfigMock{} // HTTPUnixSocketFunc: func(s string) string { // panic("mock out the HTTPUnixSocket method") // }, +// MigrateMultiAccountFunc: func() error { +// panic("mock out the MigrateMultiAccount method") +// }, // PagerFunc: func(s string) string { // panic("mock out the Pager method") // }, @@ -78,6 +81,9 @@ type ConfigMock struct { // HTTPUnixSocketFunc mocks the HTTPUnixSocket method. HTTPUnixSocketFunc func(s string) string + // MigrateMultiAccountFunc mocks the MigrateMultiAccount method. + MigrateMultiAccountFunc func() error + // PagerFunc mocks the Pager method. PagerFunc func(s string) string @@ -125,6 +131,9 @@ type ConfigMock struct { // S is the s argument value. S string } + // MigrateMultiAccount holds details about calls to the MigrateMultiAccount method. + MigrateMultiAccount []struct { + } // Pager holds details about calls to the Pager method. Pager []struct { // S is the s argument value. @@ -148,17 +157,18 @@ type ConfigMock struct { Write []struct { } } - lockAliases sync.RWMutex - lockAuthentication sync.RWMutex - lockBrowser sync.RWMutex - lockEditor sync.RWMutex - lockGetOrDefault sync.RWMutex - lockGitProtocol sync.RWMutex - lockHTTPUnixSocket sync.RWMutex - lockPager sync.RWMutex - lockPrompt sync.RWMutex - lockSet sync.RWMutex - lockWrite sync.RWMutex + lockAliases sync.RWMutex + lockAuthentication sync.RWMutex + lockBrowser sync.RWMutex + lockEditor sync.RWMutex + lockGetOrDefault sync.RWMutex + lockGitProtocol sync.RWMutex + lockHTTPUnixSocket sync.RWMutex + lockMigrateMultiAccount sync.RWMutex + lockPager sync.RWMutex + lockPrompt sync.RWMutex + lockSet sync.RWMutex + lockWrite sync.RWMutex } // Aliases calls AliasesFunc. @@ -379,6 +389,33 @@ func (mock *ConfigMock) HTTPUnixSocketCalls() []struct { return calls } +// MigrateMultiAccount calls MigrateMultiAccountFunc. +func (mock *ConfigMock) MigrateMultiAccount() error { + if mock.MigrateMultiAccountFunc == nil { + panic("ConfigMock.MigrateMultiAccountFunc: method is nil but Config.MigrateMultiAccount was just called") + } + callInfo := struct { + }{} + mock.lockMigrateMultiAccount.Lock() + mock.calls.MigrateMultiAccount = append(mock.calls.MigrateMultiAccount, callInfo) + mock.lockMigrateMultiAccount.Unlock() + return mock.MigrateMultiAccountFunc() +} + +// MigrateMultiAccountCalls gets all the calls that were made to MigrateMultiAccount. +// Check the length with: +// +// len(mockedConfig.MigrateMultiAccountCalls()) +func (mock *ConfigMock) MigrateMultiAccountCalls() []struct { +} { + var calls []struct { + } + mock.lockMigrateMultiAccount.RLock() + calls = mock.calls.MigrateMultiAccount + mock.lockMigrateMultiAccount.RUnlock() + return calls +} + // Pager calls PagerFunc. func (mock *ConfigMock) Pager(s string) string { if mock.PagerFunc == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6acc32245..3fd082874 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,7 @@ func TestNewConfigProvidesFallback(t *testing.T) { } _, err := NewConfig() require.NoError(t, err) + requireKeyWithValue(t, spiedCfg, []string{versionKey}, "1") requireKeyWithValue(t, spiedCfg, []string{gitProtocolKey}, "https") requireKeyWithValue(t, spiedCfg, []string{editorKey}, "") requireKeyWithValue(t, spiedCfg, []string{promptKey}, "enabled") diff --git a/internal/config/migrate.go b/internal/config/migrate.go new file mode 100644 index 000000000..1556f0a88 --- /dev/null +++ b/internal/config/migrate.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + + ghConfig "github.com/cli/go-gh/v2/pkg/config" +) + +//go:generate moq -rm -out migration_mock.go . Migration + +// Migration is the interace that config migrations must implement. +// +// Migrations will receive a copy of the config, and should modify that copy +// as necessary. After migration has completed, the modified config contents +// will be used. +// +// The calling code is expected to verify that the current version of the config +// matches the PreVersion of the migration before calling Do, and will set the +// config version to the PostVersion after the migration has completed successfully. +type Migration interface { + // PreVersion is the required config version for this to be applied + PreVersion() string + // PostVersion is the config version that must be applied after migration + PostVersion() string + // Do is expected to apply any necessary changes to the config in place + Do(*ghConfig.Config) error +} + +func Migrate(c *ghConfig.Config, m Migration) error { + // It is expected initially that there is no version key because we don't + // have one to begin with, so an error is expected. + version, _ := c.Get([]string{versionKey}) + if m.PreVersion() != version { + return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version) + } + + if err := m.Do(c); err != nil { + return fmt.Errorf("failed to migrate config: %s", err) + } + + c.Set([]string{versionKey}, m.PostVersion()) + + // Then write out our migrated config. + if err := ghConfig.Write(c); err != nil { + return fmt.Errorf("failed to write config after migration: %s", err) + } + + return nil +} diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go new file mode 100644 index 000000000..62f9d6062 --- /dev/null +++ b/internal/config/migrate_test.go @@ -0,0 +1,228 @@ +package config + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + + ghConfig "github.com/cli/go-gh/v2/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestMigrationAppliedSuccessfully(t *testing.T) { + readConfig := StubWriteConfig(t) + + // Given we have a migrator that writes some keys to the top level config + // and hosts key + cfg := ghConfig.ReadFromString(testFullConfig()) + topLevelKey := []string{"toplevelkey"} + newHostKey := []string{hostsKey, "newhost"} + + migration := mockMigration(func(config *ghConfig.Config) error { + config.Set(topLevelKey, "toplevelvalue") + config.Set(newHostKey, "newhostvalue") + return nil + }) + + // When we run the migration + require.NoError(t, Migrate(cfg, migration)) + + // Then our original config is updated with the migration applied + requireKeyWithValue(t, cfg, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, cfg, newHostKey, "newhostvalue") + + // And our config / hosts changes are persisted to their relevant files + // Note that this is real janky. We have writers that represent the + // top level config and the hosts key but we don't merge them back together + // so when we look into the hosts data, we don't nest the key we're + // looking for under the hosts key ¯\_(ツ)_/¯ + var configBuf bytes.Buffer + var hostsBuf bytes.Buffer + readConfig(&configBuf, &hostsBuf) + persistedCfg := ghConfig.ReadFromString(configBuf.String()) + persistedHosts := ghConfig.ReadFromString(hostsBuf.String()) + + requireKeyWithValue(t, persistedCfg, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, persistedHosts, []string{"newhost"}, "newhostvalue") +} + +func TestMigrationAppliedBumpsVersion(t *testing.T) { + readConfig := StubWriteConfig(t) + + // Given we have a migration with a pre version that matches + // the version in the config + cfg := ghConfig.ReadFromString(testFullConfig()) + cfg.Set([]string{versionKey}, "expected-pre-version") + topLevelKey := []string{"toplevelkey"} + + migration := &MigrationMock{ + DoFunc: func(config *ghConfig.Config) error { + config.Set(topLevelKey, "toplevelvalue") + return nil + }, + PreVersionFunc: func() string { + return "expected-pre-version" + }, + PostVersionFunc: func() string { + return "expected-post-version" + }, + } + + // When we migrate + require.NoError(t, Migrate(cfg, migration)) + + // Then our original config is updated with the migration applied + requireKeyWithValue(t, cfg, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, cfg, []string{versionKey}, "expected-post-version") + + // And our config / hosts changes are persisted to their relevant files + var configBuf bytes.Buffer + readConfig(&configBuf, io.Discard) + persistedCfg := ghConfig.ReadFromString(configBuf.String()) + + requireKeyWithValue(t, persistedCfg, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, persistedCfg, []string{versionKey}, "expected-post-version") +} + +func TestMigrationErrorsWhenPreVersionMismatch(t *testing.T) { + StubWriteConfig(t) + + // Given we have a migration with a pre version that does not match + // the version in the config + cfg := ghConfig.ReadFromString(testFullConfig()) + cfg.Set([]string{versionKey}, "not-expected-pre-version") + topLevelKey := []string{"toplevelkey"} + + migration := &MigrationMock{ + DoFunc: func(config *ghConfig.Config) error { + config.Set(topLevelKey, "toplevelvalue") + return nil + }, + PreVersionFunc: func() string { + return "expected-pre-version" + }, + PostVersionFunc: func() string { + return "not-expected" + }, + } + + // When we run Migrate + err := Migrate(cfg, migration) + + // Then there is an error the migration is not applied and the version is not modified + require.ErrorContains(t, err, `failed to migrate as "expected-pre-version" pre migration version did not match config version "not-expected-pre-version"`) + requireNoKey(t, cfg, topLevelKey) + requireKeyWithValue(t, cfg, []string{versionKey}, "not-expected-pre-version") +} + +func TestMigrationErrorWritesNoFiles(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("GH_CONFIG_DIR", tempDir) + + // Given we have a migrator that errors + cfg := ghConfig.ReadFromString(testFullConfig()) + migration := mockMigration(func(config *ghConfig.Config) error { + return errors.New("failed to migrate in test") + }) + + // When we run the migration + err := Migrate(cfg, migration) + + // Then the error is wrapped and bubbled + require.EqualError(t, err, "failed to migrate config: failed to migrate in test") + + // And no files are written to disk + files, err := os.ReadDir(tempDir) + require.NoError(t, err) + require.Len(t, files, 0) +} + +func TestMigrationWriteErrors(t *testing.T) { + tests := []struct { + name string + unwriteableFile string + wantErrContains string + }{ + { + name: "failure to write hosts", + unwriteableFile: "hosts.yml", + wantErrContains: "failed to write config after migration", + }, + { + name: "failure to write config", + unwriteableFile: "config.yml", + wantErrContains: "failed to write config after migration", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("GH_CONFIG_DIR", tempDir) + + // Given we error when writing the files (because we chmod the files as trickery) + makeFileUnwriteable(t, filepath.Join(tempDir, tt.unwriteableFile)) + + cfg := ghConfig.ReadFromString(testFullConfig()) + topLevelKey := []string{"toplevelkey"} + hostsKey := []string{hostsKey, "newhost"} + + migration := mockMigration(func(c *ghConfig.Config) error { + c.Set(topLevelKey, "toplevelvalue") + c.Set(hostsKey, "newhostvalue") + return nil + }) + + // When we run the migration + err := Migrate(cfg, migration) + + // Then the error is wrapped and bubbled + require.ErrorContains(t, err, tt.wantErrContains) + }) + } +} + +func makeFileUnwriteable(t *testing.T, file string) { + t.Helper() + + f, err := os.Create(file) + require.NoError(t, err) + f.Close() + + require.NoError(t, os.Chmod(file, 0000)) +} + +func mockMigration(doFunc func(config *ghConfig.Config) error) *MigrationMock { + return &MigrationMock{ + DoFunc: doFunc, + PreVersionFunc: func() string { + return "" + }, + PostVersionFunc: func() string { + return "not-expected" + }, + } + +} + +func testFullConfig() string { + var data = ` +git_protocol: ssh +editor: +prompt: enabled +pager: less +hosts: + github.com: + user: user1 + oauth_token: xxxxxxxxxxxxxxxxxxxx + git_protocol: ssh + enterprise.com: + user: user2 + oauth_token: yyyyyyyyyyyyyyyyyyyy + git_protocol: https +` + return data +} diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go new file mode 100644 index 000000000..92c00b6a9 --- /dev/null +++ b/internal/config/migration/multi_account.go @@ -0,0 +1,181 @@ +package migration + +import ( + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/v2/internal/keyring" + ghAPI "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/config" +) + +type CowardlyRefusalError struct { + Reason string +} + +func (e CowardlyRefusalError) Error() string { + // Consider whether we should add a call to action here like "open an issue with the contents of your redacted hosts.yml" + return fmt.Sprintf("cowardly refusing to continue with multi account migration: %s", e.Reason) +} + +var hostsKey = []string{"hosts"} + +// This migration exists to take a hosts section of the following structure: +// +// github.com: +// user: williammartin +// git_protocol: https +// editor: vim +// github.localhost: +// user: monalisa +// git_protocol: https +// oauth_token: xyz +// +// We want this to migrate to something like: +// +// github.com: +// user: williammartin +// git_protocol: https +// editor: vim +// users: +// williammartin: +// git_protocol: https +// editor: vim +// +// github.localhost: +// user: monalisa +// git_protocol: https +// oauth_token: xyz +// users: +// monalisa: +// git_protocol: https +// oauth_token: xyz +// +// The reason for this is that we can then add new users under a host. +// Note that we are only copying the config under a new users key, and +// under a specific user. The original config is left alone. This is to +// allow forward compatability for older versions of gh and also to avoid +// breaking existing users of go-gh which looks at a specific location +// in the config for oauth tokens that are stored insecurely. + +type MultiAccount struct { + // Allow injecting a transport layer in tests. + Transport http.RoundTripper +} + +func (m MultiAccount) PreVersion() string { + return "" +} + +func (m MultiAccount) PostVersion() string { + return "1" +} + +func (m MultiAccount) Do(c *config.Config) error { + hostnames, err := c.Keys(hostsKey) + // [github.com, github.localhost] + // We wouldn't expect to have a hosts key when this is the first time anyone + // is logging in with the CLI. + var keyNotFoundError *config.KeyNotFoundError + if errors.As(err, &keyNotFoundError) { + return nil + } + if err != nil { + return CowardlyRefusalError{"couldn't get hosts configuration"} + } + + // If there are no hosts then it doesn't matter whether we migrate or not, + // so lets avoid any confusion and say there's no migration required. + if len(hostnames) == 0 { + return nil + } + + // Otherwise let's get to the business of migrating! + for _, hostname := range hostnames { + configEntryKeys, err := c.Keys(append(hostsKey, hostname)) + // e.g. [user, git_protocol, editor, ouath_token] + if err != nil { + return CowardlyRefusalError{fmt.Sprintf("couldn't get host configuration despite %q existing", hostname)} + } + + // Get the user so that we can nest under it in future + username, err := c.Get(append(hostsKey, hostname, "user")) + if err != nil { + return CowardlyRefusalError{fmt.Sprintf("couldn't get user name for %q", hostname)} + } + + // When anonymous user exists get the user login. + if username == "x-access-token" { + var token string + token, err := c.Get(append(hostsKey, hostname, "oauth_token")) + if err != nil || token == "" { + token, err = keyring.Get(keyringServiceName(hostname), "") + } + if err != nil || token == "" { + return CowardlyRefusalError{fmt.Sprintf("couldn't find oauth token for %q", hostname)} + } + username, err = getUsername(m.Transport, hostname, token) + if err != nil { + return CowardlyRefusalError{fmt.Sprintf("couldn't retrieve logged in user for %q", hostname)} + } + c.Set(append(hostsKey, hostname, "user"), username) + } + + // Create the username key with an empty value so it will be + // written even if there are no keys set under it. + c.Set(append(hostsKey, hostname, "users", username), "") + + for _, configEntryKey := range configEntryKeys { + // Do not re-write the user key. + if configEntryKey == "user" { + continue + } + + // We would expect that these keys map directly to values + // e.g. [williammartin, https, vim, gho_xyz...] but it's possible that a manually + // edited config file might nest further but we don't support that. + // + // We could consider throwing away the nested values, but I suppose + // I'd rather make the user take a destructive action even if we have a backup. + // If they have configuration here, it's probably for a reason. + keys, err := c.Keys(append(hostsKey, hostname, configEntryKey)) + if err == nil && len(keys) > 0 { + return CowardlyRefusalError{"hosts file has entries that are surprisingly deeply nested"} + } + + configEntryValue, err := c.Get(append(hostsKey, hostname, configEntryKey)) + if err != nil { + return CowardlyRefusalError{fmt.Sprintf("couldn't get configuration entry value despite %q / %q existing", hostname, configEntryKey)} + } + + // Set these entries in their new location under the user + c.Set(append(hostsKey, hostname, "users", username, configEntryKey), configEntryValue) + } + } + + return nil +} + +func getUsername(transport http.RoundTripper, hostname, token string) (string, error) { + opts := ghAPI.ClientOptions{ + Host: hostname, + AuthToken: token, + Transport: transport, + } + client, err := ghAPI.NewGraphQLClient(opts) + if err != nil { + return "", err + } + var query struct { + Viewer struct { + Login string + } + } + err = client.Query("CurrentUser", &query, nil) + return query.Viewer.Login, err +} + +func keyringServiceName(hostname string) string { + return "gh:" + hostname +} diff --git a/internal/config/migration/multi_account_test.go b/internal/config/migration/multi_account_test.go new file mode 100644 index 000000000..2d2c027f8 --- /dev/null +++ b/internal/config/migration/multi_account_test.go @@ -0,0 +1,185 @@ +package migration_test + +import ( + "testing" + + "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/go-gh/v2/pkg/config" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" +) + +func TestMigration(t *testing.T) { + cfg := config.ReadFromString(` +hosts: + github.com: + user: user1 + oauth_token: xxxxxxxxxxxxxxxxxxxx + git_protocol: ssh + enterprise.com: + user: user2 + oauth_token: yyyyyyyyyyyyyyyyyyyy + git_protocol: https +`) + + var m migration.MultiAccount + require.NoError(t, m.Do(cfg)) + + // Do some simple checks here for depth and multiple migrations + // but I don't really want to write a full tree traversal matcher. + + // First we'll check that the data has been copied to the new structure + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "user1", "git_protocol"}, "ssh") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "user1", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx") + + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "user2", "git_protocol"}, "https") + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "user2", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy") + + // Then we'll check that the old data has been left alone + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "user1") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "git_protocol"}, "ssh") + + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "user"}, "user2") + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy") + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "git_protocol"}, "https") +} + +func TestPreVersionIsEmptyString(t *testing.T) { + var m migration.MultiAccount + require.Equal(t, "", m.PreVersion()) +} + +func TestPostVersion(t *testing.T) { + var m migration.MultiAccount + require.Equal(t, "1", m.PostVersion()) +} + +func TestMigrationErrorsWithDeeplyNestedEntries(t *testing.T) { + cfg := config.ReadFromString(` +hosts: + github.com: + user: user1 + nested: + too: deep +`) + + var m migration.MultiAccount + err := m.Do(cfg) + + require.ErrorContains(t, err, "hosts file has entries that are surprisingly deeply nested") +} + +func TestMigrationReturnsSuccessfullyWhenNoHostsEntry(t *testing.T) { + cfg := config.ReadFromString(``) + + var m migration.MultiAccount + require.NoError(t, m.Do(cfg)) +} + +func TestMigrationReturnsSuccessfullyWhenEmptyHosts(t *testing.T) { + cfg := config.ReadFromString(` +hosts: +`) + + var m migration.MultiAccount + require.NoError(t, m.Do(cfg)) +} + +func TestMigrationReturnsSuccessfullyWhenAnonymousUserExists(t *testing.T) { + // Simulates config that gets generated when a user logs + // in with a token and git protocol is not specified and + // secure storage is used. + keyring.MockInit() + require.NoError(t, keyring.Set("gh:github.com", "", "test-token")) + + cfg := config.ReadFromString(` +hosts: + github.com: + user: x-access-token +`) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query CurrentUser\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`), + ) + + m := migration.MultiAccount{Transport: reg} + require.NoError(t, m.Do(cfg)) + + require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") + // monalisa key gets created with no value + users, err := cfg.Keys([]string{"hosts", "github.com", "users"}) + require.NoError(t, err) + require.Equal(t, []string{"monalisa"}, users) +} + +func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndGitProtocol(t *testing.T) { + // Simulates config that gets generated when a user logs + // in with a token and git protocol is specified and + // secure storage is used. + keyring.MockInit() + require.NoError(t, keyring.Set("gh:github.com", "", "test-token")) + + cfg := config.ReadFromString(` +hosts: + github.com: + user: x-access-token + git_protocol: ssh +`) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query CurrentUser\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`), + ) + + m := migration.MultiAccount{Transport: reg} + require.NoError(t, m.Do(cfg)) + + require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") +} + +func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndInsecureStorage(t *testing.T) { + // Simulates config that gets generated when a user logs + // in with a token and git protocol is specified and + // secure storage is not used. + cfg := config.ReadFromString(` +hosts: + github.com: + user: x-access-token + oauth_token: test-token + git_protocol: ssh +`) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query CurrentUser\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`), + ) + + m := migration.MultiAccount{Transport: reg} + require.NoError(t, m.Do(cfg)) + + require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "oauth_token"}, "test-token") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") +} + +func requireKeyWithValue(t *testing.T, cfg *config.Config, keys []string, value string) { + t.Helper() + + actual, err := cfg.Get(keys) + require.NoError(t, err) + + require.Equal(t, value, actual) +} diff --git a/internal/config/migration_mock.go b/internal/config/migration_mock.go new file mode 100644 index 000000000..bf8133fb4 --- /dev/null +++ b/internal/config/migration_mock.go @@ -0,0 +1,149 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package config + +import ( + ghConfig "github.com/cli/go-gh/v2/pkg/config" + "sync" +) + +// Ensure, that MigrationMock does implement Migration. +// If this is not the case, regenerate this file with moq. +var _ Migration = &MigrationMock{} + +// MigrationMock is a mock implementation of Migration. +// +// func TestSomethingThatUsesMigration(t *testing.T) { +// +// // make and configure a mocked Migration +// mockedMigration := &MigrationMock{ +// DoFunc: func(config *ghConfig.Config) error { +// panic("mock out the Do method") +// }, +// PostVersionFunc: func() string { +// panic("mock out the PostVersion method") +// }, +// PreVersionFunc: func() string { +// panic("mock out the PreVersion method") +// }, +// } +// +// // use mockedMigration in code that requires Migration +// // and then make assertions. +// +// } +type MigrationMock struct { + // DoFunc mocks the Do method. + DoFunc func(config *ghConfig.Config) error + + // PostVersionFunc mocks the PostVersion method. + PostVersionFunc func() string + + // PreVersionFunc mocks the PreVersion method. + PreVersionFunc func() string + + // calls tracks calls to the methods. + calls struct { + // Do holds details about calls to the Do method. + Do []struct { + // Config is the config argument value. + Config *ghConfig.Config + } + // PostVersion holds details about calls to the PostVersion method. + PostVersion []struct { + } + // PreVersion holds details about calls to the PreVersion method. + PreVersion []struct { + } + } + lockDo sync.RWMutex + lockPostVersion sync.RWMutex + lockPreVersion sync.RWMutex +} + +// Do calls DoFunc. +func (mock *MigrationMock) Do(config *ghConfig.Config) error { + if mock.DoFunc == nil { + panic("MigrationMock.DoFunc: method is nil but Migration.Do was just called") + } + callInfo := struct { + Config *ghConfig.Config + }{ + Config: config, + } + mock.lockDo.Lock() + mock.calls.Do = append(mock.calls.Do, callInfo) + mock.lockDo.Unlock() + return mock.DoFunc(config) +} + +// DoCalls gets all the calls that were made to Do. +// Check the length with: +// +// len(mockedMigration.DoCalls()) +func (mock *MigrationMock) DoCalls() []struct { + Config *ghConfig.Config +} { + var calls []struct { + Config *ghConfig.Config + } + mock.lockDo.RLock() + calls = mock.calls.Do + mock.lockDo.RUnlock() + return calls +} + +// PostVersion calls PostVersionFunc. +func (mock *MigrationMock) PostVersion() string { + if mock.PostVersionFunc == nil { + panic("MigrationMock.PostVersionFunc: method is nil but Migration.PostVersion was just called") + } + callInfo := struct { + }{} + mock.lockPostVersion.Lock() + mock.calls.PostVersion = append(mock.calls.PostVersion, callInfo) + mock.lockPostVersion.Unlock() + return mock.PostVersionFunc() +} + +// PostVersionCalls gets all the calls that were made to PostVersion. +// Check the length with: +// +// len(mockedMigration.PostVersionCalls()) +func (mock *MigrationMock) PostVersionCalls() []struct { +} { + var calls []struct { + } + mock.lockPostVersion.RLock() + calls = mock.calls.PostVersion + mock.lockPostVersion.RUnlock() + return calls +} + +// PreVersion calls PreVersionFunc. +func (mock *MigrationMock) PreVersion() string { + if mock.PreVersionFunc == nil { + panic("MigrationMock.PreVersionFunc: method is nil but Migration.PreVersion was just called") + } + callInfo := struct { + }{} + mock.lockPreVersion.Lock() + mock.calls.PreVersion = append(mock.calls.PreVersion, callInfo) + mock.lockPreVersion.Unlock() + return mock.PreVersionFunc() +} + +// PreVersionCalls gets all the calls that were made to PreVersion. +// Check the length with: +// +// len(mockedMigration.PreVersionCalls()) +func (mock *MigrationMock) PreVersionCalls() []struct { +} { + var calls []struct { + } + mock.lockPreVersion.RLock() + calls = mock.calls.PreVersion + mock.lockPreVersion.RUnlock() + return calls +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index ae793ae85..fdfdfc244 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -173,8 +173,12 @@ func loginRun(opts *LoginOptions) error { if err := shared.HasMinimumScopes(httpClient, hostname, opts.Token); err != nil { return fmt.Errorf("error validating token: %w", err) } + username, err := shared.GetCurrentLogin(httpClient, hostname, opts.Token) + if err != nil { + return fmt.Errorf("error retrieving current user: %w", err) + } // Adding a user key ensures that a nonempty host section gets written to the config file. - _, loginErr := authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, !opts.InsecureStorage) + _, loginErr := authCfg.Login(hostname, username, opts.Token, opts.GitProtocol, !opts.InsecureStorage) return loginErr } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 72d796a39..c41f9ff1f 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -274,8 +274,11 @@ func Test_loginRun_nontty(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc123\n user: x-access-token\n", + wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n user: monalisa\n", }, { name: "insecure with token and https git-protocol", @@ -287,8 +290,11 @@ func Test_loginRun_nontty(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc123\n user: x-access-token\n git_protocol: https\n", + wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n", }, { name: "with token and non-default host", @@ -299,8 +305,11 @@ func Test_loginRun_nontty(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "albert.wesker:\n oauth_token: abc123\n user: x-access-token\n", + wantHosts: "albert.wesker:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n user: monalisa\n", }, { name: "missing repo scope", @@ -333,8 +342,11 @@ func Test_loginRun_nontty(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc456\n user: x-access-token\n", + wantHosts: "github.com:\n oauth_token: abc456\n users:\n monalisa:\n oauth_token: abc456\n user: monalisa\n", }, { name: "github.com token from environment", @@ -351,9 +363,9 @@ func Test_loginRun_nontty(t *testing.T) { }, wantErr: "SilentError", wantStderr: heredoc.Doc(` - The value of the GH_TOKEN environment variable is being used for authentication. - To have GitHub CLI store credentials instead, first clear the value from the environment. - `), + The value of the GH_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), }, { name: "GHE token from environment", @@ -370,9 +382,9 @@ func Test_loginRun_nontty(t *testing.T) { }, wantErr: "SilentError", wantStderr: heredoc.Doc(` - The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication. - To have GitHub CLI store credentials instead, first clear the value from the environment. - `), + The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), }, { name: "with token and secure storage", @@ -382,8 +394,11 @@ func Test_loginRun_nontty(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n user: x-access-token\n", + wantHosts: "github.com:\n user: monalisa\n users:\n monalisa:\n", wantSecureToken: "abc123", }, } @@ -485,11 +500,14 @@ func Test_loginRun_Survey(t *testing.T) { InsecureStorage: true, }, wantHosts: heredoc.Doc(` - rebecca.chambers: - oauth_token: def456 - user: jillv - git_protocol: https - `), + rebecca.chambers: + oauth_token: def456 + users: + jillv: + oauth_token: def456 + git_protocol: https + user: jillv + `), prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { @@ -516,11 +534,14 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose enterprise", wantHosts: heredoc.Doc(` - brad.vickers: - oauth_token: def456 - user: jillv - git_protocol: https - `), + brad.vickers: + oauth_token: def456 + users: + jillv: + oauth_token: def456 + git_protocol: https + user: jillv + `), opts: &LoginOptions{ Interactive: true, InsecureStorage: true, @@ -556,11 +577,14 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose github.com", wantHosts: heredoc.Doc(` - github.com: - oauth_token: def456 - user: jillv - git_protocol: https - `), + github.com: + oauth_token: def456 + users: + jillv: + oauth_token: def456 + git_protocol: https + user: jillv + `), opts: &LoginOptions{ Interactive: true, InsecureStorage: true, @@ -587,11 +611,14 @@ func Test_loginRun_Survey(t *testing.T) { { name: "sets git_protocol", wantHosts: heredoc.Doc(` - github.com: - oauth_token: def456 - user: jillv - git_protocol: ssh - `), + github.com: + oauth_token: def456 + users: + jillv: + oauth_token: def456 + git_protocol: ssh + user: jillv + `), opts: &LoginOptions{ Interactive: true, InsecureStorage: true, @@ -633,10 +660,12 @@ func Test_loginRun_Survey(t *testing.T) { rs.Register(`git config credential\.helper`, 1, "") }, wantHosts: heredoc.Doc(` - github.com: - user: jillv - git_protocol: https - `), + github.com: + user: jillv + users: + jillv: + git_protocol: https + `), wantErrOut: regexp.MustCompile("Logged in as jillv"), wantSecureToken: "def456", }, diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 5f8a524cb..38ee46bde 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -177,9 +177,9 @@ func Login(opts *LoginOptions) error { if username == "" { var err error - username, err = getCurrentLogin(httpClient, hostname, authToken) + username, err = GetCurrentLogin(httpClient, hostname, authToken) if err != nil { - return fmt.Errorf("error using api: %w", err) + return fmt.Errorf("error retrieving current user: %w", err) } } @@ -238,7 +238,7 @@ func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title strin return add.SSHKeyUpload(httpClient, hostname, f, title) } -func getCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) { +func GetCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) { query := `query UserCurrent{viewer{login}}` reqBody, err := json.Marshal(map[string]interface{}{"query": query}) if err != nil { From e0ebbc9e881d92273b92417954acade391a069c1 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 31 Oct 2023 16:28:10 +0100 Subject: [PATCH 02/62] Add auth token migration to multi account migration --- internal/config/migration/multi_account.go | 137 +++++++++++------- .../config/migration/multi_account_test.go | 87 ++++++++++- 2 files changed, 165 insertions(+), 59 deletions(-) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index 92c00b6a9..f950e6fc5 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -11,12 +11,12 @@ import ( ) type CowardlyRefusalError struct { - Reason string + err error } func (e CowardlyRefusalError) Error() string { // Consider whether we should add a call to action here like "open an issue with the contents of your redacted hosts.yml" - return fmt.Sprintf("cowardly refusing to continue with multi account migration: %s", e.Reason) + return fmt.Sprintf("cowardly refusing to continue with multi account migration: %s", e.err.Error()) } var hostsKey = []string{"hosts"} @@ -82,7 +82,7 @@ func (m MultiAccount) Do(c *config.Config) error { return nil } if err != nil { - return CowardlyRefusalError{"couldn't get hosts configuration"} + return CowardlyRefusalError{errors.New("couldn't get hosts configuration")} } // If there are no hosts then it doesn't matter whether we migrate or not, @@ -93,71 +93,47 @@ func (m MultiAccount) Do(c *config.Config) error { // Otherwise let's get to the business of migrating! for _, hostname := range hostnames { - configEntryKeys, err := c.Keys(append(hostsKey, hostname)) - // e.g. [user, git_protocol, editor, ouath_token] + token, inKeyring, err := getToken(c, hostname) if err != nil { - return CowardlyRefusalError{fmt.Sprintf("couldn't get host configuration despite %q existing", hostname)} + return CowardlyRefusalError{fmt.Errorf("couldn't find oauth token for %q: %w", hostname, err)} } - // Get the user so that we can nest under it in future - username, err := c.Get(append(hostsKey, hostname, "user")) + username, err := getUsername(c, hostname, token, m.Transport) if err != nil { - return CowardlyRefusalError{fmt.Sprintf("couldn't get user name for %q", hostname)} + return CowardlyRefusalError{fmt.Errorf("couldn't get user name for %q: %w", hostname, err)} } - // When anonymous user exists get the user login. - if username == "x-access-token" { - var token string - token, err := c.Get(append(hostsKey, hostname, "oauth_token")) - if err != nil || token == "" { - token, err = keyring.Get(keyringServiceName(hostname), "") - } - if err != nil || token == "" { - return CowardlyRefusalError{fmt.Sprintf("couldn't find oauth token for %q", hostname)} - } - username, err = getUsername(m.Transport, hostname, token) - if err != nil { - return CowardlyRefusalError{fmt.Sprintf("couldn't retrieve logged in user for %q", hostname)} - } - c.Set(append(hostsKey, hostname, "user"), username) + if err := migrateToken(hostname, username, token, inKeyring); err != nil { + return CowardlyRefusalError{fmt.Errorf("couldn't not migrate oauth token for %q: %w", hostname, err)} } - // Create the username key with an empty value so it will be - // written even if there are no keys set under it. - c.Set(append(hostsKey, hostname, "users", username), "") - - for _, configEntryKey := range configEntryKeys { - // Do not re-write the user key. - if configEntryKey == "user" { - continue - } - - // We would expect that these keys map directly to values - // e.g. [williammartin, https, vim, gho_xyz...] but it's possible that a manually - // edited config file might nest further but we don't support that. - // - // We could consider throwing away the nested values, but I suppose - // I'd rather make the user take a destructive action even if we have a backup. - // If they have configuration here, it's probably for a reason. - keys, err := c.Keys(append(hostsKey, hostname, configEntryKey)) - if err == nil && len(keys) > 0 { - return CowardlyRefusalError{"hosts file has entries that are surprisingly deeply nested"} - } - - configEntryValue, err := c.Get(append(hostsKey, hostname, configEntryKey)) - if err != nil { - return CowardlyRefusalError{fmt.Sprintf("couldn't get configuration entry value despite %q / %q existing", hostname, configEntryKey)} - } - - // Set these entries in their new location under the user - c.Set(append(hostsKey, hostname, "users", username, configEntryKey), configEntryValue) + if err := migrateConfig(c, hostname, username); err != nil { + return CowardlyRefusalError{fmt.Errorf("couldn't not migrate config for %q: %w", hostname, err)} } } return nil } -func getUsername(transport http.RoundTripper, hostname, token string) (string, error) { +func getToken(c *config.Config, hostname string) (string, bool, error) { + if token, _ := c.Get(append(hostsKey, hostname, "oauth_token")); token != "" { + return token, false, nil + } + token, err := keyring.Get(keyringServiceName(hostname), "") + if err != nil { + return "", false, err + } + if token == "" { + return "", false, errors.New("token not found in config or keyring") + } + return token, true, nil +} + +func getUsername(c *config.Config, hostname, token string, transport http.RoundTripper) (string, error) { + username, _ := c.Get(append(hostsKey, hostname, "user")) + if username != "" && username != "x-access-token" { + return username, nil + } opts := ghAPI.ClientOptions{ Host: hostname, AuthToken: token, @@ -176,6 +152,59 @@ func getUsername(transport http.RoundTripper, hostname, token string) (string, e return query.Viewer.Login, err } +func migrateToken(hostname, username, token string, inKeyring bool) error { + // If token is not currently stored in the keyring do not migrate it, + // as it is being stored in the config and is being handled when + // when migrating the config. + if !inKeyring { + return nil + } + return keyring.Set(keyringServiceName(hostname), username, token) +} + +func migrateConfig(c *config.Config, hostname, username string) error { + // Set the user key incase it was previously an anonymous user. + c.Set(append(hostsKey, hostname, "user"), username) + // Create the username key with an empty value so it will be + // written even if there are no keys set under it. + c.Set(append(hostsKey, hostname, "users", username), "") + + // e.g. [user, git_protocol, editor, ouath_token] + configEntryKeys, err := c.Keys(append(hostsKey, hostname)) + if err != nil { + return fmt.Errorf("couldn't get host configuration despite %q existing", hostname) + } + + for _, configEntryKey := range configEntryKeys { + // Do not re-write process the user and users keys. + if configEntryKey == "user" || configEntryKey == "users" { + continue + } + + // We would expect that these keys map directly to values + // e.g. [williammartin, https, vim, gho_xyz...] but it's possible that a manually + // edited config file might nest further but we don't support that. + // + // We could consider throwing away the nested values, but I suppose + // I'd rather make the user take a destructive action even if we have a backup. + // If they have configuration here, it's probably for a reason. + keys, err := c.Keys(append(hostsKey, hostname, configEntryKey)) + if err == nil && len(keys) > 0 { + return errors.New("hosts file has entries that are surprisingly deeply nested") + } + + configEntryValue, err := c.Get(append(hostsKey, hostname, configEntryKey)) + if err != nil { + return fmt.Errorf("couldn't get configuration entry value despite %q / %q existing", hostname, configEntryKey) + } + + // Set these entries in their new location under the user + c.Set(append(hostsKey, hostname, "users", username, configEntryKey), configEntryValue) + } + + return nil +} + func keyringServiceName(hostname string) string { return "gh:" + hostname } diff --git a/internal/config/migration/multi_account_test.go b/internal/config/migration/multi_account_test.go index 2d2c027f8..bcdbcc343 100644 --- a/internal/config/migration/multi_account_test.go +++ b/internal/config/migration/multi_account_test.go @@ -1,13 +1,14 @@ package migration_test import ( + "fmt" "testing" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" ) func TestMigration(t *testing.T) { @@ -46,6 +47,60 @@ hosts: requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "git_protocol"}, "https") } +func TestMigrationSecureStorage(t *testing.T) { + cfg := config.ReadFromString(` +hosts: + github.com: + user: userOne + git_protocol: ssh + enterprise.com: + user: userTwo + git_protocol: https +`) + + userOneToken := "userOne-token" + userTwoToken := "userTwo-token" + + keyring.MockInit() + require.NoError(t, keyring.Set("gh:github.com", "", userOneToken)) + require.NoError(t, keyring.Set("gh:enterprise.com", "", userTwoToken)) + + var m migration.MultiAccount + require.NoError(t, m.Do(cfg)) + + // Verify token gets stored with host and username + gotUserOneToken, err := keyring.Get("gh:github.com", "userOne") + require.NoError(t, err) + require.Equal(t, userOneToken, gotUserOneToken) + + // Verify token still exists with only host + gotUserOneToken, err = keyring.Get("gh:github.com", "") + require.NoError(t, err) + require.Equal(t, userOneToken, gotUserOneToken) + + // Verify token gets stored with host and username + gotUserTwoToken, err := keyring.Get("gh:enterprise.com", "userTwo") + require.NoError(t, err) + require.Equal(t, userTwoToken, gotUserTwoToken) + + // Verify token still exists with only host + gotUserTwoToken, err = keyring.Get("gh:enterprise.com", "") + require.NoError(t, err) + require.Equal(t, userTwoToken, gotUserTwoToken) + + // First we'll check that the data has been copied to the new structure + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "userOne", "git_protocol"}, "ssh") + + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "userTwo", "git_protocol"}, "https") + + // Then we'll check that the old data has been left alone + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "userOne") + requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "git_protocol"}, "ssh") + + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "user"}, "userTwo") + requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "git_protocol"}, "https") +} + func TestPreVersionIsEmptyString(t *testing.T) { var m migration.MultiAccount require.Equal(t, "", m.PreVersion()) @@ -91,8 +146,9 @@ func TestMigrationReturnsSuccessfullyWhenAnonymousUserExists(t *testing.T) { // Simulates config that gets generated when a user logs // in with a token and git protocol is not specified and // secure storage is used. + token := "test-token" keyring.MockInit() - require.NoError(t, keyring.Set("gh:github.com", "", "test-token")) + require.NoError(t, keyring.Set("gh:github.com", "", token)) cfg := config.ReadFromString(` hosts: @@ -110,20 +166,31 @@ hosts: m := migration.MultiAccount{Transport: reg} require.NoError(t, m.Do(cfg)) - require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) + require.Equal(t, fmt.Sprintf("token %s", token), reg.Requests[0].Header.Get("Authorization")) requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") // monalisa key gets created with no value users, err := cfg.Keys([]string{"hosts", "github.com", "users"}) require.NoError(t, err) require.Equal(t, []string{"monalisa"}, users) + + // Verify token gets stored with host and username + gotToken, err := keyring.Get("gh:github.com", "monalisa") + require.NoError(t, err) + require.Equal(t, token, gotToken) + + // Verify token still exists with only host + gotToken, err = keyring.Get("gh:github.com", "") + require.NoError(t, err) + require.Equal(t, token, gotToken) } func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndGitProtocol(t *testing.T) { // Simulates config that gets generated when a user logs // in with a token and git protocol is specified and // secure storage is used. + token := "test-token" keyring.MockInit() - require.NoError(t, keyring.Set("gh:github.com", "", "test-token")) + require.NoError(t, keyring.Set("gh:github.com", "", token)) cfg := config.ReadFromString(` hosts: @@ -142,9 +209,19 @@ hosts: m := migration.MultiAccount{Transport: reg} require.NoError(t, m.Do(cfg)) - require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) + require.Equal(t, fmt.Sprintf("token %s", token), reg.Requests[0].Header.Get("Authorization")) requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") + + // Verify token gets stored with host and username + gotToken, err := keyring.Get("gh:github.com", "monalisa") + require.NoError(t, err) + require.Equal(t, token, gotToken) + + // Verify token still exists with only host + gotToken, err = keyring.Get("gh:github.com", "") + require.NoError(t, err) + require.Equal(t, token, gotToken) } func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndInsecureStorage(t *testing.T) { From d8084f5f6d2828cacb498f7b6248d90f42b152bf Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 31 Oct 2023 16:29:04 +0100 Subject: [PATCH 03/62] Hide all keyring package implementation inside internal keyring package --- internal/config/auth_config_test.go | 6 +++--- internal/keyring/keyring.go | 8 ++++++++ pkg/cmd/auth/login/login_test.go | 2 +- pkg/cmd/auth/logout/logout_test.go | 2 +- pkg/cmd/auth/token/token_test.go | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index c028ef6b3..ab35f6cfb 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -5,9 +5,9 @@ import ( "testing" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/keyring" ghConfig "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" ) func newTestAuthConfig(t *testing.T) *AuthConfig { @@ -104,7 +104,7 @@ func TestTokenFromKeyringNonExistent(t *testing.T) { _, err := authCfg.TokenFromKeyring("github.com") // Then it returns failure bubbling the ErrNotFound - require.ErrorIs(t, err, keyring.ErrNotFound) + require.ErrorContains(t, err, "secret not found in keyring") } func TestHasEnvTokenWithoutAnyEnvToken(t *testing.T) { @@ -344,7 +344,7 @@ func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com"}) _, err = keyring.Get(keyringServiceName("github.com"), "") - require.ErrorIs(t, err, keyring.ErrNotFound) + require.ErrorContains(t, err, "secret not found in keyring") } // Note that I'm not sure this test enforces particularly desirable behaviour diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index afb5025e6..b6ee990fc 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -66,3 +66,11 @@ func Delete(service, user string) error { return &TimeoutError{"timeout while trying to delete secret from keyring"} } } + +func MockInit() { + keyring.MockInit() +} + +func MockInitWithError(err error) { + keyring.MockInitWithError(err) +} diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index c41f9ff1f..fae3b526b 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -17,7 +18,6 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" ) func stubHomeDir(t *testing.T, dir string) { diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 75c215aaa..41448cba8 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -8,13 +8,13 @@ import ( "testing" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" ) func Test_NewCmdLogout(t *testing.T) { diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index 03324c0b2..5baca706b 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -5,11 +5,11 @@ import ( "testing" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" - "github.com/zalando/go-keyring" ) func TestNewCmdToken(t *testing.T) { From 2ca8b1ea94d061578d19ace8e56b1179b641dc7a Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 1 Nov 2023 11:33:28 +0100 Subject: [PATCH 04/62] Login sets token in keyring using username --- internal/config/auth_config_test.go | 13 ++++++++++--- internal/config/config.go | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index ab35f6cfb..85e5c8d23 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -214,17 +214,24 @@ func TestLoginSecureStorageUsesKeyring(t *testing.T) { // Given a usable keyring keyring.MockInit() authCfg := newTestAuthConfig(t) + host := "github.com" + user := "test-user" + token := "test-token" // When we login with secure storage - insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", true) + insecureStorageUsed, err := authCfg.Login(host, user, token, "", true) // Then it returns success, notes that insecure storage was not used, and stores the token in the keyring require.NoError(t, err) require.False(t, insecureStorageUsed, "expected to use secure storage") - token, err := keyring.Get(keyringServiceName("github.com"), "") + gotToken, err := keyring.Get(keyringServiceName(host), "") require.NoError(t, err) - require.Equal(t, "test-token", token) + require.Equal(t, token, gotToken) + + gotToken, err = keyring.Get(keyringServiceName(host), user) + require.NoError(t, err) + require.Equal(t, token, gotToken) } func TestLoginSecureStorageRemovesOldInsecureConfigToken(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 23bff82ce..123a20a91 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -251,6 +251,9 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure var setErr error if secureStorage { if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil { + setErr = keyring.Set(keyringServiceName(hostname), username, token) + } + if setErr == nil { // Clean up the previous oauth_tokens from the config file. _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}) From 68e30beac45269adefa506794ae4057b4fc50783 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 1 Nov 2023 12:21:31 +0100 Subject: [PATCH 05/62] Logout removes token from keyring using username --- internal/config/auth_config_test.go | 24 +++++++++++++++++------- internal/config/config.go | 3 ++- pkg/cmd/auth/logout/logout.go | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 85e5c8d23..3cbf71311 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -340,17 +340,23 @@ func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { // Given we are logged into a host keyring.MockInit() authCfg := newTestAuthConfig(t) - _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", true) + host := "github.com" + user := "test-user" + token := "test-token" + + _, err := authCfg.Login(host, user, token, "ssh", true) require.NoError(t, err) // When we logout - err = authCfg.Logout("github.com") + err = authCfg.Logout(host, user) // Then we return success, and the host and token are removed from the config and keyring require.NoError(t, err) - requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com"}) - _, err = keyring.Get(keyringServiceName("github.com"), "") + requireNoKey(t, authCfg.cfg, []string{hostsKey, host}) + _, err = keyring.Get(keyringServiceName(host), "") + require.ErrorContains(t, err, "secret not found in keyring") + _, err = keyring.Get(keyringServiceName(host), user) require.ErrorContains(t, err, "secret not found in keyring") } @@ -368,7 +374,7 @@ func TestLogoutIgnoresErrorsFromConfigAndKeyring(t *testing.T) { authCfg := newTestAuthConfig(t) // When we logout - err := authCfg.Logout("github.com") + err := authCfg.Logout("github.com", "test-user") // Then it returns success anyway, suppressing the errors require.NoError(t, err) @@ -475,14 +481,18 @@ func TestTokenWorksRightAfterMigration(t *testing.T) { func TestLogoutRigthAfterMigrationRemovesHost(t *testing.T) { // Given we have logged in before migration authCfg := newTestAuthConfig(t) - _, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false) + host := "github.com" + user := "test-user" + token := "test-token" + + _, err := preMigrationLogin(authCfg, host, user, token, "ssh", false) require.NoError(t, err) // When we migrate and logout var m migration.MultiAccount require.NoError(t, Migrate(authCfg.cfg, m)) - require.NoError(t, authCfg.Logout("github.com")) + require.NoError(t, authCfg.Logout(host, user)) // Then the host is removed from the config requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com"}) diff --git a/internal/config/config.go b/internal/config/config.go index 123a20a91..45437885d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -286,9 +286,10 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure // Logout will remove user, git protocol, and auth token for the given hostname. // It will remove the auth token from the encrypted storage if it exists there. -func (c *AuthConfig) Logout(hostname string) error { +func (c *AuthConfig) Logout(hostname, username string) error { _ = c.cfg.Remove([]string{hostsKey, hostname}) _ = keyring.Delete(keyringServiceName(hostname), "") + _ = keyring.Delete(keyringServiceName(hostname), username) return ghConfig.Write(c.cfg) } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index b2afc9a6c..e3be3e678 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -125,7 +125,7 @@ func logoutRun(opts *LogoutOptions) error { usernameStr = fmt.Sprintf(" account '%s'", username) } - if err := authCfg.Logout(hostname); err != nil { + if err := authCfg.Logout(hostname, username); err != nil { return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) } From d2ff55737cf43800e6d6a6fef8ef4845d1333bbe Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 1 Nov 2023 12:41:07 +0100 Subject: [PATCH 06/62] Enable multi-account migration --- cmd/gh/main.go | 13 ++++++------- internal/config/migrate.go | 7 +++++++ internal/config/migration/multi_account.go | 8 ++++---- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 871afbb5f..4fe0f46d7 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -56,13 +56,12 @@ func mainRun() exitCode { ctx := context.Background() - // cfg, err := cmdFactory.Config() - // if err != nil { - // fmt.Fprintf(stderr, "failed to load configuration to attempt migration: %s\n", err) - // } - // if err := cfg.MigrateMultiAccount(); err != nil { - // fmt.Fprintf(stderr, "failed to migrate configuration: %s\n", err) - // } + if cfg, err := cmdFactory.Config(); err == nil { + if err := cfg.MigrateMultiAccount(); err != nil { + fmt.Fprintf(stderr, "failed to migrate configuration: %s\n", err) + return exitError + } + } updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() diff --git a/internal/config/migrate.go b/internal/config/migrate.go index 1556f0a88..06a38027f 100644 --- a/internal/config/migrate.go +++ b/internal/config/migrate.go @@ -30,6 +30,13 @@ func Migrate(c *ghConfig.Config, m Migration) error { // It is expected initially that there is no version key because we don't // have one to begin with, so an error is expected. version, _ := c.Get([]string{versionKey}) + + // If migration has already occured then do not attempt to migrate again. + if m.PostVersion() == version { + return nil + } + + // If migration is incompatible with current version then return an error. if m.PreVersion() != version { return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version) } diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index f950e6fc5..d8a6ed142 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -103,13 +103,13 @@ func (m MultiAccount) Do(c *config.Config) error { return CowardlyRefusalError{fmt.Errorf("couldn't get user name for %q: %w", hostname, err)} } - if err := migrateToken(hostname, username, token, inKeyring); err != nil { - return CowardlyRefusalError{fmt.Errorf("couldn't not migrate oauth token for %q: %w", hostname, err)} - } - if err := migrateConfig(c, hostname, username); err != nil { return CowardlyRefusalError{fmt.Errorf("couldn't not migrate config for %q: %w", hostname, err)} } + + if err := migrateToken(hostname, username, token, inKeyring); err != nil { + return CowardlyRefusalError{fmt.Errorf("couldn't not migrate oauth token for %q: %w", hostname, err)} + } } return nil From eb771aecc9eaf88e0dfdc302b516a7801714f6f8 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 2 Nov 2023 11:17:55 +0100 Subject: [PATCH 07/62] Address PR comments --- cmd/gh/main.go | 4 +- internal/config/auth_config_test.go | 38 ++++--- internal/config/config.go | 60 ++++++++++- internal/config/config_mock.go | 110 ++++++++++++++------- internal/config/migrate.go | 56 ----------- internal/config/migrate_test.go | 75 ++++++++++---- internal/config/migration/multi_account.go | 7 +- internal/config/stub.go | 7 ++ 8 files changed, 226 insertions(+), 131 deletions(-) delete mode 100644 internal/config/migrate.go diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 4fe0f46d7..46b5490b7 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" @@ -57,7 +58,8 @@ func mainRun() exitCode { ctx := context.Background() if cfg, err := cmdFactory.Config(); err == nil { - if err := cfg.MigrateMultiAccount(); err != nil { + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { fmt.Fprintf(stderr, "failed to migrate configuration: %s\n", err) return exitError } diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 3cbf71311..5fe67e877 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -319,13 +319,13 @@ func TestLoginAddsHostIfNotAlreadyAdded(t *testing.T) { require.Contains(t, hosts, "github.com") } -// This test mimics the behaviour of logging in with a token and not providing -// a git protocol. -func TestLoginAddsUserToConfigWithoutGitProtocolOrSecureStorage(t *testing.T) { +// This test mimics the behaviour of logging in with a token, not providing +// a git protocol, and using secure storage. +func TestLoginAddsUserToConfigWithoutGitProtocolAndWithSecureStorage(t *testing.T) { // Given we are not logged in authCfg := newTestAuthConfig(t) - // When we log in without git protocol or secure storage + // When we log in without git protocol and with secure storage keyring.MockInit() _, err := authCfg.Login("github.com", "test-user", "test-token", "", true) require.NoError(t, err) @@ -407,7 +407,8 @@ func TestUserWorksRightAfterMigration(t *testing.T) { // When we migrate var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) // Then we can still get the user correctly user, err := authCfg.User("github.com") @@ -423,7 +424,8 @@ func TestGitProtocolWorksRightAfterMigration(t *testing.T) { // When we migrate var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) // Then we can still get the git protocol correctly gitProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey}) @@ -439,7 +441,8 @@ func TestHostsWorksRightAfterMigration(t *testing.T) { // When we migrate var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) // Then we can still get the hosts correctly hosts := authCfg.Hosts() @@ -454,7 +457,8 @@ func TestDefaultHostWorksRightAfterMigration(t *testing.T) { // When we migrate var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) // Then the default host is still the enterprise host defaultHost, source := authCfg.DefaultHost() @@ -470,7 +474,8 @@ func TestTokenWorksRightAfterMigration(t *testing.T) { // When we migrate var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) // Then we can still get the token correctly token, source := authCfg.Token("github.com") @@ -490,7 +495,8 @@ func TestLogoutRigthAfterMigrationRemovesHost(t *testing.T) { // When we migrate and logout var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) require.NoError(t, authCfg.Logout(host, user)) @@ -504,7 +510,8 @@ func TestLoginInsecurePostMigrationUsesConfigForToken(t *testing.T) { // When we migrate and login with insecure storage var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", false) @@ -522,7 +529,8 @@ func TestLoginPostMigrationSetsGitProtocol(t *testing.T) { authCfg := newTestAuthConfig(t) var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) require.NoError(t, err) @@ -540,7 +548,8 @@ func TestLoginPostMigrationSetsUser(t *testing.T) { authCfg := newTestAuthConfig(t) var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) require.NoError(t, err) @@ -561,7 +570,8 @@ func TestLoginSecurePostMigrationRemovesTokenFromConfig(t *testing.T) { // When we migrate and login again with secure storage var m migration.MultiAccount - require.NoError(t, Migrate(authCfg.cfg, m)) + c := cfg{authCfg.cfg} + require.NoError(t, c.Migrate(m)) keyring.MockInit() _, err = authCfg.Login("github.com", "test-user", "test-token", "", true) diff --git a/internal/config/config.go b/internal/config/config.go index 45437885d..172e4a903 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,10 @@ package config import ( + "fmt" "os" "path/filepath" - "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/keyring" ghAuth "github.com/cli/go-gh/v2/pkg/auth" ghConfig "github.com/cli/go-gh/v2/pkg/config" @@ -32,6 +32,7 @@ type Config interface { GetOrDefault(string, string) (string, error) Set(string, string, string) Write() error + Migrate(Migration) error Aliases() *AliasConfig Authentication() *AuthConfig @@ -41,8 +42,27 @@ type Config interface { HTTPUnixSocket(string) string Pager(string) string Prompt(string) string + Version() string +} - MigrateMultiAccount() error +// Migration is the interace that config migrations must implement. +// +// Migrations will receive a copy of the config, and should modify that copy +// as necessary. After migration has completed, the modified config contents +// will be used. +// +// The calling code is expected to verify that the current version of the config +// matches the PreVersion of the migration before calling Do, and will set the +// config version to the PostVersion after the migration has completed successfully. +// +//go:generate moq -rm -out migration_mock.go . Migration +type Migration interface { + // PreVersion is the required config version for this to be applied + PreVersion() string + // PostVersion is the config version that must be applied after migration + PostVersion() string + // Do is expected to apply any necessary changes to the config in place + Do(*ghConfig.Config) error } func NewConfig() (Config, error) { @@ -132,9 +152,36 @@ func (c *cfg) Prompt(hostname string) string { return val } -func (c *cfg) MigrateMultiAccount() error { - var m migration.MultiAccount - return Migrate(c.cfg, m) +func (c *cfg) Version() string { + val, _ := c.GetOrDefault("", versionKey) + return val +} + +func (c *cfg) Migrate(m Migration) error { + version := c.Version() + + // If migration has already occured then do not attempt to migrate again. + if m.PostVersion() == version { + return nil + } + + // If migration is incompatible with current version then return an error. + if m.PreVersion() != version { + return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version) + } + + if err := m.Do(c.cfg); err != nil { + return fmt.Errorf("failed to migrate config: %s", err) + } + + c.Set("", versionKey, m.PostVersion()) + + // Then write out our migrated config. + if err := c.Write(); err != nil { + return fmt.Errorf("failed to write config after migration: %s", err) + } + + return nil } func defaultFor(key string) (string, bool) { @@ -250,7 +297,10 @@ func (c *AuthConfig) SetDefaultHost(host, source string) { func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) { var setErr error if secureStorage { + // Set the current active oauth token if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil { + // And set the oauth token under the user to support later auth switch + // and logout switch without another migration. setErr = keyring.Set(keyringServiceName(hostname), username, token) } if setErr == nil { diff --git a/internal/config/config_mock.go b/internal/config/config_mock.go index 53e17998e..586078a2c 100644 --- a/internal/config/config_mock.go +++ b/internal/config/config_mock.go @@ -38,8 +38,8 @@ var _ Config = &ConfigMock{} // HTTPUnixSocketFunc: func(s string) string { // panic("mock out the HTTPUnixSocket method") // }, -// MigrateMultiAccountFunc: func() error { -// panic("mock out the MigrateMultiAccount method") +// MigrateFunc: func(migration Migration) error { +// panic("mock out the Migrate method") // }, // PagerFunc: func(s string) string { // panic("mock out the Pager method") @@ -50,6 +50,9 @@ var _ Config = &ConfigMock{} // SetFunc: func(s1 string, s2 string, s3 string) { // panic("mock out the Set method") // }, +// VersionFunc: func() string { +// panic("mock out the Version method") +// }, // WriteFunc: func() error { // panic("mock out the Write method") // }, @@ -81,8 +84,8 @@ type ConfigMock struct { // HTTPUnixSocketFunc mocks the HTTPUnixSocket method. HTTPUnixSocketFunc func(s string) string - // MigrateMultiAccountFunc mocks the MigrateMultiAccount method. - MigrateMultiAccountFunc func() error + // MigrateFunc mocks the Migrate method. + MigrateFunc func(migration Migration) error // PagerFunc mocks the Pager method. PagerFunc func(s string) string @@ -93,6 +96,9 @@ type ConfigMock struct { // SetFunc mocks the Set method. SetFunc func(s1 string, s2 string, s3 string) + // VersionFunc mocks the Version method. + VersionFunc func() string + // WriteFunc mocks the Write method. WriteFunc func() error @@ -131,8 +137,10 @@ type ConfigMock struct { // S is the s argument value. S string } - // MigrateMultiAccount holds details about calls to the MigrateMultiAccount method. - MigrateMultiAccount []struct { + // Migrate holds details about calls to the Migrate method. + Migrate []struct { + // Migration is the migration argument value. + Migration Migration } // Pager holds details about calls to the Pager method. Pager []struct { @@ -153,22 +161,26 @@ type ConfigMock struct { // S3 is the s3 argument value. S3 string } + // Version holds details about calls to the Version method. + Version []struct { + } // Write holds details about calls to the Write method. Write []struct { } } - lockAliases sync.RWMutex - lockAuthentication sync.RWMutex - lockBrowser sync.RWMutex - lockEditor sync.RWMutex - lockGetOrDefault sync.RWMutex - lockGitProtocol sync.RWMutex - lockHTTPUnixSocket sync.RWMutex - lockMigrateMultiAccount sync.RWMutex - lockPager sync.RWMutex - lockPrompt sync.RWMutex - lockSet sync.RWMutex - lockWrite sync.RWMutex + lockAliases sync.RWMutex + lockAuthentication sync.RWMutex + lockBrowser sync.RWMutex + lockEditor sync.RWMutex + lockGetOrDefault sync.RWMutex + lockGitProtocol sync.RWMutex + lockHTTPUnixSocket sync.RWMutex + lockMigrate sync.RWMutex + lockPager sync.RWMutex + lockPrompt sync.RWMutex + lockSet sync.RWMutex + lockVersion sync.RWMutex + lockWrite sync.RWMutex } // Aliases calls AliasesFunc. @@ -389,30 +401,35 @@ func (mock *ConfigMock) HTTPUnixSocketCalls() []struct { return calls } -// MigrateMultiAccount calls MigrateMultiAccountFunc. -func (mock *ConfigMock) MigrateMultiAccount() error { - if mock.MigrateMultiAccountFunc == nil { - panic("ConfigMock.MigrateMultiAccountFunc: method is nil but Config.MigrateMultiAccount was just called") +// Migrate calls MigrateFunc. +func (mock *ConfigMock) Migrate(migration Migration) error { + if mock.MigrateFunc == nil { + panic("ConfigMock.MigrateFunc: method is nil but Config.Migrate was just called") } callInfo := struct { - }{} - mock.lockMigrateMultiAccount.Lock() - mock.calls.MigrateMultiAccount = append(mock.calls.MigrateMultiAccount, callInfo) - mock.lockMigrateMultiAccount.Unlock() - return mock.MigrateMultiAccountFunc() + Migration Migration + }{ + Migration: migration, + } + mock.lockMigrate.Lock() + mock.calls.Migrate = append(mock.calls.Migrate, callInfo) + mock.lockMigrate.Unlock() + return mock.MigrateFunc(migration) } -// MigrateMultiAccountCalls gets all the calls that were made to MigrateMultiAccount. +// MigrateCalls gets all the calls that were made to Migrate. // Check the length with: // -// len(mockedConfig.MigrateMultiAccountCalls()) -func (mock *ConfigMock) MigrateMultiAccountCalls() []struct { +// len(mockedConfig.MigrateCalls()) +func (mock *ConfigMock) MigrateCalls() []struct { + Migration Migration } { var calls []struct { + Migration Migration } - mock.lockMigrateMultiAccount.RLock() - calls = mock.calls.MigrateMultiAccount - mock.lockMigrateMultiAccount.RUnlock() + mock.lockMigrate.RLock() + calls = mock.calls.Migrate + mock.lockMigrate.RUnlock() return calls } @@ -520,6 +537,33 @@ func (mock *ConfigMock) SetCalls() []struct { return calls } +// Version calls VersionFunc. +func (mock *ConfigMock) Version() string { + if mock.VersionFunc == nil { + panic("ConfigMock.VersionFunc: method is nil but Config.Version was just called") + } + callInfo := struct { + }{} + mock.lockVersion.Lock() + mock.calls.Version = append(mock.calls.Version, callInfo) + mock.lockVersion.Unlock() + return mock.VersionFunc() +} + +// VersionCalls gets all the calls that were made to Version. +// Check the length with: +// +// len(mockedConfig.VersionCalls()) +func (mock *ConfigMock) VersionCalls() []struct { +} { + var calls []struct { + } + mock.lockVersion.RLock() + calls = mock.calls.Version + mock.lockVersion.RUnlock() + return calls +} + // Write calls WriteFunc. func (mock *ConfigMock) Write() error { if mock.WriteFunc == nil { diff --git a/internal/config/migrate.go b/internal/config/migrate.go deleted file mode 100644 index 06a38027f..000000000 --- a/internal/config/migrate.go +++ /dev/null @@ -1,56 +0,0 @@ -package config - -import ( - "fmt" - - ghConfig "github.com/cli/go-gh/v2/pkg/config" -) - -//go:generate moq -rm -out migration_mock.go . Migration - -// Migration is the interace that config migrations must implement. -// -// Migrations will receive a copy of the config, and should modify that copy -// as necessary. After migration has completed, the modified config contents -// will be used. -// -// The calling code is expected to verify that the current version of the config -// matches the PreVersion of the migration before calling Do, and will set the -// config version to the PostVersion after the migration has completed successfully. -type Migration interface { - // PreVersion is the required config version for this to be applied - PreVersion() string - // PostVersion is the config version that must be applied after migration - PostVersion() string - // Do is expected to apply any necessary changes to the config in place - Do(*ghConfig.Config) error -} - -func Migrate(c *ghConfig.Config, m Migration) error { - // It is expected initially that there is no version key because we don't - // have one to begin with, so an error is expected. - version, _ := c.Get([]string{versionKey}) - - // If migration has already occured then do not attempt to migrate again. - if m.PostVersion() == version { - return nil - } - - // If migration is incompatible with current version then return an error. - if m.PreVersion() != version { - return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version) - } - - if err := m.Do(c); err != nil { - return fmt.Errorf("failed to migrate config: %s", err) - } - - c.Set([]string{versionKey}, m.PostVersion()) - - // Then write out our migrated config. - if err := ghConfig.Write(c); err != nil { - return fmt.Errorf("failed to write config after migration: %s", err) - } - - return nil -} diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 62f9d6062..193848558 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -17,7 +17,8 @@ func TestMigrationAppliedSuccessfully(t *testing.T) { // Given we have a migrator that writes some keys to the top level config // and hosts key - cfg := ghConfig.ReadFromString(testFullConfig()) + c := ghConfig.ReadFromString(testFullConfig()) + topLevelKey := []string{"toplevelkey"} newHostKey := []string{hostsKey, "newhost"} @@ -28,11 +29,12 @@ func TestMigrationAppliedSuccessfully(t *testing.T) { }) // When we run the migration - require.NoError(t, Migrate(cfg, migration)) + conf := cfg{c} + require.NoError(t, conf.Migrate(migration)) // Then our original config is updated with the migration applied - requireKeyWithValue(t, cfg, topLevelKey, "toplevelvalue") - requireKeyWithValue(t, cfg, newHostKey, "newhostvalue") + requireKeyWithValue(t, c, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, c, newHostKey, "newhostvalue") // And our config / hosts changes are persisted to their relevant files // Note that this is real janky. We have writers that represent the @@ -54,8 +56,8 @@ func TestMigrationAppliedBumpsVersion(t *testing.T) { // Given we have a migration with a pre version that matches // the version in the config - cfg := ghConfig.ReadFromString(testFullConfig()) - cfg.Set([]string{versionKey}, "expected-pre-version") + c := ghConfig.ReadFromString(testFullConfig()) + c.Set([]string{versionKey}, "expected-pre-version") topLevelKey := []string{"toplevelkey"} migration := &MigrationMock{ @@ -72,11 +74,12 @@ func TestMigrationAppliedBumpsVersion(t *testing.T) { } // When we migrate - require.NoError(t, Migrate(cfg, migration)) + conf := cfg{c} + require.NoError(t, conf.Migrate(migration)) // Then our original config is updated with the migration applied - requireKeyWithValue(t, cfg, topLevelKey, "toplevelvalue") - requireKeyWithValue(t, cfg, []string{versionKey}, "expected-post-version") + requireKeyWithValue(t, c, topLevelKey, "toplevelvalue") + requireKeyWithValue(t, c, []string{versionKey}, "expected-post-version") // And our config / hosts changes are persisted to their relevant files var configBuf bytes.Buffer @@ -87,13 +90,40 @@ func TestMigrationAppliedBumpsVersion(t *testing.T) { requireKeyWithValue(t, persistedCfg, []string{versionKey}, "expected-post-version") } +func TestMigrationIsNoopWhenAlreadyApplied(t *testing.T) { + // Given we have a migration with a post version that matches + // the version in the config + c := ghConfig.ReadFromString(testFullConfig()) + c.Set([]string{versionKey}, "expected-post-version") + + migration := &MigrationMock{ + DoFunc: func(config *ghConfig.Config) error { + return errors.New("is not called") + }, + PreVersionFunc: func() string { + return "is not called" + }, + PostVersionFunc: func() string { + return "expected-post-version" + }, + } + + // When we run Migrate + conf := cfg{c} + err := conf.Migrate(migration) + + // Then there is nothing done and the config is not modified + require.NoError(t, err) + requireKeyWithValue(t, c, []string{versionKey}, "expected-post-version") +} + func TestMigrationErrorsWhenPreVersionMismatch(t *testing.T) { StubWriteConfig(t) // Given we have a migration with a pre version that does not match // the version in the config - cfg := ghConfig.ReadFromString(testFullConfig()) - cfg.Set([]string{versionKey}, "not-expected-pre-version") + c := ghConfig.ReadFromString(testFullConfig()) + c.Set([]string{versionKey}, "not-expected-pre-version") topLevelKey := []string{"toplevelkey"} migration := &MigrationMock{ @@ -110,12 +140,13 @@ func TestMigrationErrorsWhenPreVersionMismatch(t *testing.T) { } // When we run Migrate - err := Migrate(cfg, migration) + conf := cfg{c} + err := conf.Migrate(migration) // Then there is an error the migration is not applied and the version is not modified require.ErrorContains(t, err, `failed to migrate as "expected-pre-version" pre migration version did not match config version "not-expected-pre-version"`) - requireNoKey(t, cfg, topLevelKey) - requireKeyWithValue(t, cfg, []string{versionKey}, "not-expected-pre-version") + requireNoKey(t, c, topLevelKey) + requireKeyWithValue(t, c, []string{versionKey}, "not-expected-pre-version") } func TestMigrationErrorWritesNoFiles(t *testing.T) { @@ -123,13 +154,14 @@ func TestMigrationErrorWritesNoFiles(t *testing.T) { t.Setenv("GH_CONFIG_DIR", tempDir) // Given we have a migrator that errors - cfg := ghConfig.ReadFromString(testFullConfig()) + c := ghConfig.ReadFromString(testFullConfig()) migration := mockMigration(func(config *ghConfig.Config) error { return errors.New("failed to migrate in test") }) // When we run the migration - err := Migrate(cfg, migration) + conf := cfg{c} + err := conf.Migrate(migration) // Then the error is wrapped and bubbled require.EqualError(t, err, "failed to migrate config: failed to migrate in test") @@ -166,18 +198,19 @@ func TestMigrationWriteErrors(t *testing.T) { // Given we error when writing the files (because we chmod the files as trickery) makeFileUnwriteable(t, filepath.Join(tempDir, tt.unwriteableFile)) - cfg := ghConfig.ReadFromString(testFullConfig()) + c := ghConfig.ReadFromString(testFullConfig()) topLevelKey := []string{"toplevelkey"} hostsKey := []string{hostsKey, "newhost"} - migration := mockMigration(func(c *ghConfig.Config) error { - c.Set(topLevelKey, "toplevelvalue") - c.Set(hostsKey, "newhostvalue") + migration := mockMigration(func(someCfg *ghConfig.Config) error { + someCfg.Set(topLevelKey, "toplevelvalue") + someCfg.Set(hostsKey, "newhostvalue") return nil }) // When we run the migration - err := Migrate(cfg, migration) + conf := cfg{c} + err := conf.Migrate(migration) // Then the error is wrapped and bubbled require.ErrorContains(t, err, tt.wantErrContains) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index d8a6ed142..92a84d131 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -65,6 +65,8 @@ type MultiAccount struct { } func (m MultiAccount) PreVersion() string { + // It is expected that there is no version key since this migration + // introduces it. return "" } @@ -149,7 +151,10 @@ func getUsername(c *config.Config, hostname, token string, transport http.RoundT } } err = client.Query("CurrentUser", &query, nil) - return query.Viewer.Login, err + if err != nil { + return "", err + } + return query.Viewer.Login, nil } func migrateToken(hostname, username, token string, inKeyring bool) error { diff --git a/internal/config/stub.go b/internal/config/stub.go index 143c9b870..10d409804 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -26,6 +26,9 @@ func NewFromString(cfgStr string) *ConfigMock { mock.WriteFunc = func() error { return cfg.Write() } + mock.MigrateFunc = func(m Migration) error { + return cfg.Migrate(m) + } mock.AliasesFunc = func() *AliasConfig { return &AliasConfig{cfg: c} } @@ -69,6 +72,10 @@ func NewFromString(cfgStr string) *ConfigMock { val, _ := cfg.GetOrDefault(hostname, promptKey) return val } + mock.VersionFunc = func() string { + val, _ := cfg.GetOrDefault("", versionKey) + return val + } return mock } From 38b73e3f85c885cc54335fd0bb8a7bc72c7764e8 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 2 Nov 2023 13:21:03 +0100 Subject: [PATCH 08/62] Set host level git_protocol on login --- internal/config/auth_config_test.go | 26 ++++++++++++++++++++------ internal/config/config.go | 4 ++++ pkg/cmd/auth/login/login_test.go | 7 ++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 5fe67e877..78d6072c8 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -298,12 +298,19 @@ func TestLoginSetsGitProtocolForProvidedHost(t *testing.T) { _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) require.NoError(t, err) - // When we get the git protocol - protocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) + // When we get the host git protocol + hostProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey}) require.NoError(t, err) // Then it returns the git protocol we provided on login - require.Equal(t, "ssh", protocol) + require.Equal(t, "ssh", hostProtocol) + + // When we get the users git protocol + userProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) + require.NoError(t, err) + + // Then it returns the git protocol we provided on login + require.Equal(t, "ssh", userProtocol) } func TestLoginAddsHostIfNotAlreadyAdded(t *testing.T) { @@ -535,12 +542,19 @@ func TestLoginPostMigrationSetsGitProtocol(t *testing.T) { _, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false) require.NoError(t, err) - // When we get the git protocol - gitProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) + // When we get the host git protocol + hostProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey}) require.NoError(t, err) // Then it returns the git protocol we provided on login - require.Equal(t, "ssh", gitProtocol) + require.Equal(t, "ssh", hostProtocol) + + // When we get the user git protocol + userProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) + require.NoError(t, err) + + // Then it returns the git protocol we provided on login + require.Equal(t, "ssh", userProtocol) } func TestLoginPostMigrationSetsUser(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 172e4a903..3f9e9d4f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -322,6 +322,10 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure c.cfg.Set([]string{hostsKey, hostname, userKey}, username) if gitProtocol != "" { + // Set the git protocol + c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) + // And set the git protocol under the user to support later auth switch + // and logout switch without another migration. c.cfg.Set([]string{hostsKey, hostname, usersKey, username, gitProtocolKey}, gitProtocol) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index fae3b526b..8fa5df58e 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -294,7 +294,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n", + wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n git_protocol: https\n", }, { name: "with token and non-default host", @@ -507,6 +507,7 @@ func Test_loginRun_Survey(t *testing.T) { oauth_token: def456 git_protocol: https user: jillv + git_protocol: https `), prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -541,6 +542,7 @@ func Test_loginRun_Survey(t *testing.T) { oauth_token: def456 git_protocol: https user: jillv + git_protocol: https `), opts: &LoginOptions{ Interactive: true, @@ -584,6 +586,7 @@ func Test_loginRun_Survey(t *testing.T) { oauth_token: def456 git_protocol: https user: jillv + git_protocol: https `), opts: &LoginOptions{ Interactive: true, @@ -618,6 +621,7 @@ func Test_loginRun_Survey(t *testing.T) { oauth_token: def456 git_protocol: ssh user: jillv + git_protocol: ssh `), opts: &LoginOptions{ Interactive: true, @@ -662,6 +666,7 @@ func Test_loginRun_Survey(t *testing.T) { wantHosts: heredoc.Doc(` github.com: user: jillv + git_protocol: https users: jillv: git_protocol: https From 4f33d88c5fb7532b8347dee63798829fbc41f314 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 2 Nov 2023 14:31:59 +0100 Subject: [PATCH 09/62] Set user level config values automatically when setting host level config values --- internal/config/config.go | 5 +++++ internal/config/config_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 3f9e9d4f8..ed82df99f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -107,7 +107,12 @@ func (c *cfg) Set(hostname, key, value string) { c.cfg.Set([]string{key}, value) return } + c.cfg.Set([]string{hostsKey, hostname, key}, value) + + if user, _ := c.cfg.Get([]string{hostsKey, hostname, userKey}); user != "" { + c.cfg.Set([]string{hostsKey, hostname, usersKey, user, key}, value) + } } func (c *cfg) Write() error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3fd082874..f6832f4a4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,3 +163,44 @@ func TestFallbackConfig(t *testing.T) { requireKeyWithValue(t, cfg, []string{browserKey}, "") requireNoKey(t, cfg, []string{"unknown"}) } + +func TestSetTopLevelKey(t *testing.T) { + c := newTestConfig() + host := "" + key := "top-level-key" + val := "top-level-value" + c.Set(host, key, val) + requireKeyWithValue(t, c.cfg, []string{key}, val) +} + +func TestSetHostSpecificKey(t *testing.T) { + c := newTestConfig() + host := "github.com" + key := "host-level-key" + val := "host-level-value" + c.Set(host, key, val) + requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) +} + +func TestSetUserSpecificKey(t *testing.T) { + c := newTestConfig() + host := "github.com" + user := "test-user" + c.cfg.Set([]string{hostsKey, host, userKey}, user) + + key := "host-level-key" + val := "host-level-value" + c.Set(host, key, val) + requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) + requireKeyWithValue(t, c.cfg, []string{hostsKey, host, usersKey, user, key}, val) +} + +func TestSetUserSpecificKeyNoUserPresent(t *testing.T) { + c := newTestConfig() + host := "github.com" + key := "host-level-key" + val := "host-level-value" + c.Set(host, key, val) + requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) + requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey}) +} From 06c36a74c2d94083111c3a585dc7b7323102602b Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Nov 2023 12:58:46 +0100 Subject: [PATCH 10/62] Remove host entries without valid tokens during migration --- internal/config/migration/multi_account.go | 51 ++++++++++++++----- .../config/migration/multi_account_test.go | 43 ++++++++++++++++ internal/keyring/keyring.go | 6 +++ 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index 92a84d131..d7d28b1c6 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -10,6 +10,8 @@ import ( "github.com/cli/go-gh/v2/pkg/config" ) +var noTokenError = errors.New("no token found") + type CowardlyRefusalError struct { err error } @@ -21,6 +23,11 @@ func (e CowardlyRefusalError) Error() string { var hostsKey = []string{"hosts"} +type tokenSource struct { + token string + inKeyring bool +} + // This migration exists to take a hosts section of the following structure: // // github.com: @@ -95,12 +102,21 @@ func (m MultiAccount) Do(c *config.Config) error { // Otherwise let's get to the business of migrating! for _, hostname := range hostnames { - token, inKeyring, err := getToken(c, hostname) + tokenSource, err := getToken(c, hostname) + // If no token existed for this host we'll remove the entry from the hosts file + // by deleting it and moving on to the next one. + if errors.Is(err, noTokenError) { + // The only error that can be returned here is the key not existing, which + // we know can't be true. + _ = c.Remove(append(hostsKey, hostname)) + continue + } + // For any other error we'll error out if err != nil { return CowardlyRefusalError{fmt.Errorf("couldn't find oauth token for %q: %w", hostname, err)} } - username, err := getUsername(c, hostname, token, m.Transport) + username, err := getUsername(c, hostname, tokenSource.token, m.Transport) if err != nil { return CowardlyRefusalError{fmt.Errorf("couldn't get user name for %q: %w", hostname, err)} } @@ -109,7 +125,7 @@ func (m MultiAccount) Do(c *config.Config) error { return CowardlyRefusalError{fmt.Errorf("couldn't not migrate config for %q: %w", hostname, err)} } - if err := migrateToken(hostname, username, token, inKeyring); err != nil { + if err := migrateToken(hostname, username, tokenSource); err != nil { return CowardlyRefusalError{fmt.Errorf("couldn't not migrate oauth token for %q: %w", hostname, err)} } } @@ -117,18 +133,27 @@ func (m MultiAccount) Do(c *config.Config) error { return nil } -func getToken(c *config.Config, hostname string) (string, bool, error) { +func getToken(c *config.Config, hostname string) (tokenSource, error) { if token, _ := c.Get(append(hostsKey, hostname, "oauth_token")); token != "" { - return token, false, nil + return tokenSource{token: token, inKeyring: false}, nil } token, err := keyring.Get(keyringServiceName(hostname), "") - if err != nil { - return "", false, err + + // If we have an error and it's not relating to there being no token + // then we'll return the error cause that's really unexpected. + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return tokenSource{}, err } - if token == "" { - return "", false, errors.New("token not found in config or keyring") + + // Otherwise we'll return a sentinel error + if err != nil || token == "" { + return tokenSource{}, noTokenError } - return token, true, nil + + return tokenSource{ + token: token, + inKeyring: true, + }, nil } func getUsername(c *config.Config, hostname, token string, transport http.RoundTripper) (string, error) { @@ -157,14 +182,14 @@ func getUsername(c *config.Config, hostname, token string, transport http.RoundT return query.Viewer.Login, nil } -func migrateToken(hostname, username, token string, inKeyring bool) error { +func migrateToken(hostname, username string, tokenSource tokenSource) error { // If token is not currently stored in the keyring do not migrate it, // as it is being stored in the config and is being handled when // when migrating the config. - if !inKeyring { + if !tokenSource.inKeyring { return nil } - return keyring.Set(keyringServiceName(hostname), username, token) + return keyring.Set(keyringServiceName(hostname), username, tokenSource.token) } func migrateConfig(c *config.Config, hostname, username string) error { diff --git a/internal/config/migration/multi_account_test.go b/internal/config/migration/multi_account_test.go index bcdbcc343..f70839881 100644 --- a/internal/config/migration/multi_account_test.go +++ b/internal/config/migration/multi_account_test.go @@ -1,6 +1,7 @@ package migration_test import ( + "errors" "fmt" "testing" @@ -252,6 +253,40 @@ hosts: requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") } +func TestMigrationRemovesHostsWithInvalidTokens(t *testing.T) { + // Simulates config when user is logged in securely + // but no token entry is in the keyring. + keyring.MockInit() + cfg := config.ReadFromString(` +hosts: + github.com: + user: user1 + git_protocol: ssh +`) + + m := migration.MultiAccount{} + require.NoError(t, m.Do(cfg)) + + requireNoKey(t, cfg, []string{"hosts", "github.com"}) +} + +func TestMigrationErrorsWhenUnableToGetExpectedSecureToken(t *testing.T) { + // Simulates config when user is logged in securely + // but no token entry is in the keyring. + keyring.MockInitWithError(errors.New("keyring test error")) + cfg := config.ReadFromString(` +hosts: + github.com: + user: user1 + git_protocol: ssh +`) + + m := migration.MultiAccount{} + err := m.Do(cfg) + + require.ErrorContains(t, err, `couldn't find oauth token for "github.com": keyring test error`) +} + func requireKeyWithValue(t *testing.T, cfg *config.Config, keys []string, value string) { t.Helper() @@ -260,3 +295,11 @@ func requireKeyWithValue(t *testing.T, cfg *config.Config, keys []string, value require.Equal(t, value, actual) } + +func requireNoKey(t *testing.T, cfg *config.Config, keys []string) { + t.Helper() + + _, err := cfg.Get(keys) + var keyNotFoundError *config.KeyNotFoundError + require.ErrorAs(t, err, &keyNotFoundError) +} diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index b6ee990fc..f873c6436 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -2,11 +2,14 @@ package keyring import ( + "errors" "time" "github.com/zalando/go-keyring" ) +var ErrNotFound = errors.New("secret not found in keyring") + type TimeoutError struct { message string } @@ -46,6 +49,9 @@ func Get(service, user string) (string, error) { }() select { case res := <-ch: + if errors.Is(res.err, keyring.ErrNotFound) { + return "", ErrNotFound + } return res.val, res.err case <-time.After(3 * time.Second): return "", &TimeoutError{"timeout while trying to get secret from keyring"} From a00294eff903abca39c3b6576f3b48a65beacc7e Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Nov 2023 13:02:08 +0100 Subject: [PATCH 11/62] Fix double negatives in migration errors --- internal/config/migration/multi_account.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index d7d28b1c6..642ffe3ca 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -122,11 +122,11 @@ func (m MultiAccount) Do(c *config.Config) error { } if err := migrateConfig(c, hostname, username); err != nil { - return CowardlyRefusalError{fmt.Errorf("couldn't not migrate config for %q: %w", hostname, err)} + return CowardlyRefusalError{fmt.Errorf("couldn't migrate config for %q: %w", hostname, err)} } if err := migrateToken(hostname, username, tokenSource); err != nil { - return CowardlyRefusalError{fmt.Errorf("couldn't not migrate oauth token for %q: %w", hostname, err)} + return CowardlyRefusalError{fmt.Errorf("couldn't migrate oauth token for %q: %w", hostname, err)} } } From 209aed30b44f9604258fb9d03d0303a224238270 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Nov 2023 13:04:12 +0100 Subject: [PATCH 12/62] Fix typo on migration comment --- internal/config/migration/multi_account.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index 642ffe3ca..6163856b7 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -184,8 +184,7 @@ func getUsername(c *config.Config, hostname, token string, transport http.RoundT func migrateToken(hostname, username string, tokenSource tokenSource) error { // If token is not currently stored in the keyring do not migrate it, - // as it is being stored in the config and is being handled when - // when migrating the config. + // as it is being stored in the config and is being handled when migrating the config. if !tokenSource.inKeyring { return nil } From c4fcf9ba1a18a882004b68b381abef690f49f235 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Nov 2023 15:53:39 +0100 Subject: [PATCH 13/62] Add test to ensure login command allows multiple users --- pkg/cmd/auth/login/login.go | 13 -------- pkg/cmd/auth/login/login_test.go | 55 +++++++++++++++++--------------- 2 files changed, 29 insertions(+), 39 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index fdfdfc244..16d0581a1 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -182,19 +182,6 @@ func loginRun(opts *LoginOptions) error { return loginErr } - existingToken, _ := authCfg.Token(hostname) - if existingToken != "" && opts.Interactive { - if err := shared.HasMinimumScopes(httpClient, hostname, existingToken); err == nil { - keepGoing, err := opts.Prompter.Confirm(fmt.Sprintf("You're already logged into %s. Do you want to re-authenticate?", hostname), false) - if err != nil { - return err - } - if !keepGoing { - return nil - } - } - } - return shared.Login(&shared.LoginOptions{ IO: opts.IO, Config: authCfg, diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 8fa5df58e..9a909426c 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -18,6 +18,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func stubHomeDir(t *testing.T, dir string) { @@ -401,6 +402,34 @@ func Test_loginRun_nontty(t *testing.T) { wantHosts: "github.com:\n user: monalisa\n users:\n monalisa:\n", wantSecureToken: "abc123", }, + { + name: "given we are already logged in, a new user is added to the config", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "newUserToken", + }, + cfgStubs: func(c *config.ConfigMock) { + _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) + require.NoError(t, err) + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"newUser"}}}`)) + }, + wantHosts: heredoc.Doc(` + github.com: + users: + monalisa: + oauth_token: abc123 + git_protocol: https + newUser: + user: newUser + git_protocol: https + `), + wantSecureToken: "newUserToken", + }, } for _, tt := range tests { @@ -466,32 +495,6 @@ func Test_loginRun_Survey(t *testing.T) { wantErrOut *regexp.Regexp wantSecureToken string }{ - { - name: "already authenticated", - opts: &LoginOptions{ - Interactive: true, - }, - cfgStubs: func(c *config.ConfigMock) { - authCfg := c.Authentication() - authCfg.SetToken("ghi789", "oauth_token") - c.AuthenticationFunc = func() *config.AuthConfig { - return authCfg - } - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) - }, - prompterStubs: func(pm *prompter.PrompterMock) { - pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { - if prompt == "What account do you want to log into?" { - return prompter.IndexFor(opts, "GitHub.com") - } - return -1, prompter.NoSuchPromptErr(prompt) - } - }, - wantHosts: "", - wantErrOut: nil, - }, { name: "hostname set", opts: &LoginOptions{ From a9acece7dd05304b699d74749a7c1c299560613a Mon Sep 17 00:00:00 2001 From: William Martin Date: Sun, 26 Nov 2023 14:49:09 +0100 Subject: [PATCH 14/62] Split Login into adding a user and switching It makes clear the steps that should be needed to "switch" which should be shared between Login (add user and switch to it), Logout (remove user and switch to another), and Switch (no modification and switch to a user) --- internal/config/config.go | 55 ++++++++++++++++++++++---------- pkg/cmd/auth/login/login_test.go | 32 +++++++++---------- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index ed82df99f..b779fca61 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -300,35 +300,24 @@ func (c *AuthConfig) SetDefaultHost(host, source string) { // If the encrypt option is specified it will first try to store the auth token // in encrypted storage and will fall back to the plain text config file. func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) { + // In this section we set up the users config var setErr error if secureStorage { - // Set the current active oauth token - if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil { - // And set the oauth token under the user to support later auth switch - // and logout switch without another migration. - setErr = keyring.Set(keyringServiceName(hostname), username, token) - } + // Try to set the token for this user in the encrypted storage for later switching + setErr = keyring.Set(keyringServiceName(hostname), username, token) if setErr == nil { - // Clean up the previous oauth_tokens from the config file. - _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + // Clean up the previous oauth_token from the config file, if there were one _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}) } } insecureStorageUsed := false if !secureStorage || setErr != nil { - // Set the current active oauth token - c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) - // And set the oauth token under the user to support later auth switch - // and logout switch without another migration. + // And set the oauth token under the user for later switching c.cfg.Set([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}, token) insecureStorageUsed = true } - c.cfg.Set([]string{hostsKey, hostname, userKey}, username) - if gitProtocol != "" { - // Set the git protocol - c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) // And set the git protocol under the user to support later auth switch // and logout switch without another migration. c.cfg.Set([]string{hostsKey, hostname, usersKey, username, gitProtocolKey}, gitProtocol) @@ -340,7 +329,39 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure c.cfg.Set([]string{hostsKey, hostname, usersKey, username}, "") } - return insecureStorageUsed, ghConfig.Write(c.cfg) + // Then we perform a switch to the new user + return insecureStorageUsed, c.switchUser(hostname, username) +} + +func (c *AuthConfig) switchUser(hostname, user string) error { + // We first need to idempotently clear out any set tokens for the host + _ = keyring.Delete(keyringServiceName(hostname), "") + _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + + // Then we'll move the keyring token or insecure token as necessary, only one of the + // following branches should be true. + + // If there is a token in the secure keyring for the user, move it to the active slot + if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { + if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil { + return fmt.Errorf("failed to move active token in keyring: %v", err) + } + } + + // If there is a token in the insecure config for the user, move it to the active field + if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { + c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) + } + + // Then we'll ensure the git protocol is moved as well + if gitProtocol, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, gitProtocolKey}); err == nil { + c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) + } + + // Then we'll update the active user for the host + c.cfg.Set([]string{hostsKey, hostname, userKey}, user) + + return ghConfig.Write(c.cfg) } // Logout will remove user, git protocol, and auth token for the given hostname. diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 9a909426c..ec98138b4 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -279,7 +279,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n user: monalisa\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n oauth_token: abc123\n user: monalisa\n", }, { name: "insecure with token and https git-protocol", @@ -295,7 +295,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n git_protocol: https\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n", }, { name: "with token and non-default host", @@ -310,7 +310,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "albert.wesker:\n oauth_token: abc123\n users:\n monalisa:\n oauth_token: abc123\n user: monalisa\n", + wantHosts: "albert.wesker:\n users:\n monalisa:\n oauth_token: abc123\n oauth_token: abc123\n user: monalisa\n", }, { name: "missing repo scope", @@ -347,7 +347,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n oauth_token: abc456\n users:\n monalisa:\n oauth_token: abc456\n user: monalisa\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc456\n oauth_token: abc456\n user: monalisa\n", }, { name: "github.com token from environment", @@ -399,7 +399,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n user: monalisa\n users:\n monalisa:\n", + wantHosts: "github.com:\n users:\n monalisa:\n user: monalisa\n", wantSecureToken: "abc123", }, { @@ -425,8 +425,8 @@ func Test_loginRun_nontty(t *testing.T) { oauth_token: abc123 git_protocol: https newUser: - user: newUser git_protocol: https + user: newUser `), wantSecureToken: "newUserToken", }, @@ -504,13 +504,13 @@ func Test_loginRun_Survey(t *testing.T) { }, wantHosts: heredoc.Doc(` rebecca.chambers: - oauth_token: def456 users: jillv: oauth_token: def456 git_protocol: https - user: jillv + oauth_token: def456 git_protocol: https + user: jillv `), prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { @@ -539,13 +539,13 @@ func Test_loginRun_Survey(t *testing.T) { name: "choose enterprise", wantHosts: heredoc.Doc(` brad.vickers: - oauth_token: def456 users: jillv: oauth_token: def456 git_protocol: https - user: jillv + oauth_token: def456 git_protocol: https + user: jillv `), opts: &LoginOptions{ Interactive: true, @@ -583,13 +583,13 @@ func Test_loginRun_Survey(t *testing.T) { name: "choose github.com", wantHosts: heredoc.Doc(` github.com: - oauth_token: def456 users: jillv: oauth_token: def456 git_protocol: https - user: jillv + oauth_token: def456 git_protocol: https + user: jillv `), opts: &LoginOptions{ Interactive: true, @@ -618,13 +618,13 @@ func Test_loginRun_Survey(t *testing.T) { name: "sets git_protocol", wantHosts: heredoc.Doc(` github.com: - oauth_token: def456 users: jillv: oauth_token: def456 git_protocol: ssh - user: jillv + oauth_token: def456 git_protocol: ssh + user: jillv `), opts: &LoginOptions{ Interactive: true, @@ -668,11 +668,11 @@ func Test_loginRun_Survey(t *testing.T) { }, wantHosts: heredoc.Doc(` github.com: - user: jillv - git_protocol: https users: jillv: git_protocol: https + git_protocol: https + user: jillv `), wantErrOut: regexp.MustCompile("Logged in as jillv"), wantSecureToken: "def456", From 1bf6023164391b8aa285cdeafabd881dfedcb58f Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 28 Nov 2023 13:25:45 +0100 Subject: [PATCH 15/62] Display message when logging into existing account --- internal/config/auth_config_test.go | 27 +++++++++++++++ internal/config/config.go | 9 +++++ pkg/cmd/auth/login/login.go | 1 + pkg/cmd/auth/login/login_test.go | 46 +++++++++++++++++++++++++- pkg/cmd/auth/shared/login_flow.go | 15 +++++++++ pkg/cmd/auth/shared/login_flow_test.go | 4 +++ 6 files changed, 101 insertions(+), 1 deletion(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 78d6072c8..8d4accf0b 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -387,6 +387,33 @@ func TestLogoutIgnoresErrorsFromConfigAndKeyring(t *testing.T) { require.NoError(t, err) } +func TestUsersForHostNoHost(t *testing.T) { + // Given we have a config with no hosts + authCfg := newTestAuthConfig(t) + + // When we get the users for a host that doesn't exist + _, err := authCfg.UsersForHost("github.com") + + // Then it returns an error + require.EqualError(t, err, "unknown host: github.com") +} + +func TestUsersForHostWithUsers(t *testing.T) { + // Given we have a config with a host and users + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token", "ssh", false) + require.NoError(t, err) + + // When we get the users for that host + users, err := authCfg.UsersForHost("github.com") + + // Then it succeeds and returns the users + require.NoError(t, err) + require.Equal(t, []string{"test-user-1", "test-user-2"}, users) +} + func requireKeyWithValue(t *testing.T, cfg *ghConfig.Config, keys []string, value string) { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index b779fca61..92b9bdac8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -373,6 +373,15 @@ func (c *AuthConfig) Logout(hostname, username string) error { return ghConfig.Write(c.cfg) } +func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { + users, err := c.cfg.Keys([]string{hostsKey, hostname, usersKey}) + if err != nil { + return nil, fmt.Errorf("unknown host: %s", hostname) + } + + return users, nil +} + func keyringServiceName(hostname string) string { return "gh:" + hostname } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 16d0581a1..d3184dbd0 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -177,6 +177,7 @@ func loginRun(opts *LoginOptions) error { if err != nil { return fmt.Errorf("error retrieving current user: %w", err) } + // Adding a user key ensures that a nonempty host section gets written to the config file. _, loginErr := authCfg.Login(hostname, username, opts.Token, opts.GitProtocol, !opts.InsecureStorage) return loginErr diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index ec98138b4..0e8ea0e25 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -403,7 +403,7 @@ func Test_loginRun_nontty(t *testing.T) { wantSecureToken: "abc123", }, { - name: "given we are already logged in, a new user is added to the config", + name: "given we are already logged in, and log in as a new user, it is added to the config", opts: &LoginOptions{ Hostname: "github.com", Token: "newUserToken", @@ -677,6 +677,50 @@ func Test_loginRun_Survey(t *testing.T) { wantErrOut: regexp.MustCompile("Logged in as jillv"), wantSecureToken: "def456", }, + { + name: "given we log in as a user that is already in the config, we get an informational message", + opts: &LoginOptions{ + Hostname: "github.com", + Interactive: true, + InsecureStorage: true, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { + switch prompt { + case "What is your preferred protocol for Git operations?": + return prompter.IndexFor(opts, "HTTPS") + case "How would you like to authenticate GitHub CLI?": + return prompter.IndexFor(opts, "Paste an authentication token") + } + return -1, prompter.NoSuchPromptErr(prompt) + } + }, + cfgStubs: func(c *config.ConfigMock) { + _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) + require.NoError(t, err) + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(`git config credential\.https:/`, 1, "") + rs.Register(`git config credential\.helper`, 1, "") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) + }, + wantHosts: heredoc.Doc(` + github.com: + users: + monalisa: + oauth_token: def456 + git_protocol: https + git_protocol: https + user: monalisa + oauth_token: def456 + `), + wantErrOut: regexp.MustCompile(`! You were already logged in to this account`), + }, } for _, tt := range tests { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 38ee46bde..74a8ca7ad 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "slices" "strings" "github.com/MakeNowJust/heredoc" @@ -23,6 +24,7 @@ const defaultSSHKeyTitle = "GitHub CLI" type iconfig interface { Login(string, string, string, string, bool) (bool, error) + UsersForHost(string) ([]string, error) } type LoginOptions struct { @@ -183,6 +185,15 @@ func Login(opts *LoginOptions) error { } } + // Get these users before adding the new one, so that we can + // check whether the user was already logged in later. + // + // In this case we ignore the error if the host doesn't exist + // because that can occur when the user is logging into a host + // for the first time. + usersForHost, _ := cfg.UsersForHost(hostname) + userWasAlreadyLoggedIn := slices.Contains(usersForHost, username) + if gitProtocol != "" { fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon()) @@ -217,6 +228,10 @@ func Login(opts *LoginOptions) error { } fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username)) + if userWasAlreadyLoggedIn { + fmt.Fprintf(opts.IO.ErrOut, "%s You were already logged in to this account\n", cs.WarningIcon()) + } + return nil } diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 5dbbc46c1..626c21290 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -25,6 +25,10 @@ func (c tinyConfig) Login(host, username, token, gitProtocol string, encrypt boo return false, nil } +func (c tinyConfig) UsersForHost(hostname string) ([]string, error) { + return nil, nil +} + func TestLogin_ssh(t *testing.T) { dir := t.TempDir() ios, _, stdout, stderr := iostreams.Test() From 72d5550407582b509ab5a05a3d926a7057a18f5c Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 28 Nov 2023 17:37:23 +0100 Subject: [PATCH 16/62] Implemented auth logout for multiaccount --- internal/config/auth_config_test.go | 53 ++++- internal/config/config.go | 36 +++- internal/prompter/test.go | 6 +- pkg/cmd/auth/logout/logout.go | 118 ++++++----- pkg/cmd/auth/logout/logout_test.go | 300 ++++++++++++++++++++-------- 5 files changed, 373 insertions(+), 140 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 8d4accf0b..6f8583b2b 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -367,6 +367,57 @@ func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { require.ErrorContains(t, err, "secret not found in keyring") } +func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) { + // Given we have two accounts logged into a host + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "inactive-user", "test-token-1", "ssh", true) + require.NoError(t, err) + + _, err = authCfg.Login("github.com", "active-user", "test-token-2", "https", true) + require.NoError(t, err) + + // When we logout of the active user + err = authCfg.Logout("github.com", "active-user") + + // Then we return success and the inactive user is now active + require.NoError(t, err) + activeUser, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "inactive-user", activeUser) + + token, err := authCfg.TokenFromKeyring("github.com") + require.NoError(t, err) + require.Equal(t, "test-token-1", token) + + usersForHost, err := authCfg.UsersForHost("github.com") + require.NoError(t, err) + require.NotContains(t, "active-user", usersForHost) +} + +func TestLogoutOfInactiveUserDoesNotSwitchUser(t *testing.T) { + // Given we have two accounts logged into a host + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "inactive-user-1", "test-token-1.1", "ssh", true) + require.NoError(t, err) + + _, err = authCfg.Login("github.com", "inactive-user-2", "test-token-1.2", "ssh", true) + require.NoError(t, err) + + _, err = authCfg.Login("github.com", "active-user", "test-token-2", "https", true) + require.NoError(t, err) + + // When we logout of an inactive user + err = authCfg.Logout("github.com", "inactive-user-1") + + // Then we return success and the active user is still active + require.NoError(t, err) + activeUser, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "active-user", activeUser) +} + // Note that I'm not sure this test enforces particularly desirable behaviour // since it leads users to believe a token has been removed when really // that might have failed for some reason. @@ -517,7 +568,7 @@ func TestTokenWorksRightAfterMigration(t *testing.T) { require.Equal(t, oauthTokenKey, source) } -func TestLogoutRigthAfterMigrationRemovesHost(t *testing.T) { +func TestLogoutRightAfterMigrationRemovesHost(t *testing.T) { // Given we have logged in before migration authCfg := newTestAuthConfig(t) host := "github.com" diff --git a/internal/config/config.go b/internal/config/config.go index 92b9bdac8..e739151d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "github.com/cli/cli/v2/internal/keyring" ghAuth "github.com/cli/go-gh/v2/pkg/auth" @@ -333,6 +334,7 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure return insecureStorageUsed, c.switchUser(hostname, username) } +// TODO: Verify that git protocol switching works as expected func (c *AuthConfig) switchUser(hostname, user string) error { // We first need to idempotently clear out any set tokens for the host _ = keyring.Delete(keyringServiceName(hostname), "") @@ -367,10 +369,36 @@ func (c *AuthConfig) switchUser(hostname, user string) error { // Logout will remove user, git protocol, and auth token for the given hostname. // It will remove the auth token from the encrypted storage if it exists there. func (c *AuthConfig) Logout(hostname, username string) error { - _ = c.cfg.Remove([]string{hostsKey, hostname}) - _ = keyring.Delete(keyringServiceName(hostname), "") - _ = keyring.Delete(keyringServiceName(hostname), username) - return ghConfig.Write(c.cfg) + // This error is ignorable because if there is no host then no logout is required + users, _ := c.UsersForHost(hostname) + + // If there is only one (or zero) users, then we remove the host + // and unset the keyring tokens. + if len(users) < 2 { + _ = c.cfg.Remove([]string{hostsKey, hostname}) + _ = keyring.Delete(keyringServiceName(hostname), "") + _ = keyring.Delete(keyringServiceName(hostname), username) + return ghConfig.Write(c.cfg) + } + + // Otherwise, we remove the user from this host + _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username}) + + // This error is ignorable because we already know there is an active user for the host + activeUser, _ := c.User(hostname) + + // If the user we're removing isn't active, then we just write the config + if activeUser != username { + return ghConfig.Write(c.cfg) + } + + // Otherwise we get the first user in the slice that isn't the user we're removing + switchUserIdx := slices.IndexFunc(users, func(n string) bool { + return n != username + }) + + // And switch to them + return c.switchUser(hostname, users[switchUserIdx]) } func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { diff --git a/internal/prompter/test.go b/internal/prompter/test.go index af55c084d..04375ce76 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -138,13 +138,13 @@ func IndexFor(options []string, answer string) (int, error) { return ix, nil } } - return -1, NoSuchAnswerErr(answer) + return -1, NoSuchAnswerErr(answer, options) } func NoSuchPromptErr(prompt string) error { return fmt.Errorf("no such prompt '%s'", prompt) } -func NoSuchAnswerErr(answer string) error { - return fmt.Errorf("no such answer '%s'", answer) +func NoSuchAnswerErr(answer string, options []string) error { + return fmt.Errorf("no such answer '%s' in [%s]", answer, strings.Join(options, ", ")) } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index e3be3e678..1c137515a 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -2,10 +2,9 @@ package logout import ( "fmt" - "net/http" + "slices" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -14,19 +13,17 @@ import ( ) type LogoutOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - Config func() (config.Config, error) - Prompter shared.Prompt - Hostname string + IO *iostreams.IOStreams + Config func() (config.Config, error) + Prompter shared.Prompt + Hostname string } func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { opts := &LogoutOptions{ - HttpClient: f.HttpClient, - IO: f.IOStreams, - Config: f.Config, - Prompter: f.Prompter, + IO: f.IOStreams, + Config: f.Config, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -64,6 +61,8 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co func logoutRun(opts *LogoutOptions) error { hostname := opts.Hostname + // TODO: opts.Username likeley needed in the future + username := "" cfg, err := opts.Config() if err != nil { @@ -71,34 +70,56 @@ func logoutRun(opts *LogoutOptions) error { } authCfg := cfg.Authentication() - candidates := authCfg.Hosts() - if len(candidates) == 0 { + knownHosts := authCfg.Hosts() + if len(knownHosts) == 0 { return fmt.Errorf("not logged in to any hosts") } - if hostname == "" { - if len(candidates) == 1 { - hostname = candidates[0] - } else { - selected, err := opts.Prompter.Select( - "What account do you want to log out of?", "", candidates) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - hostname = candidates[selected] - } - } else { - var found bool - for _, c := range candidates { - if c == hostname { - found = true - break - } + if hostname != "" { + if !slices.Contains(knownHosts, hostname) { + return fmt.Errorf("not logged in to %s", hostname) } + } - if !found { - return fmt.Errorf("not logged into %s", hostname) + type hostUser struct { + host string + user string + } + var candidates []hostUser + + for _, host := range knownHosts { + if hostname != "" && host != hostname { + continue } + knownUsers, err := cfg.Authentication().UsersForHost(host) + if err != nil { + return err + } + for _, user := range knownUsers { + candidates = append(candidates, hostUser{host: host, user: user}) + } + } + + // We can ignore the error here because a host must always have an active user + preLogoutActiveUser, _ := authCfg.User(hostname) + + if len(candidates) == 1 { + hostname = candidates[0].host + username = candidates[0].user + } else if !opts.IO.CanPrompt() { + username = preLogoutActiveUser + } else { + prompts := make([]string, len(candidates)) + for i, c := range candidates { + prompts[i] = fmt.Sprintf("%s (%s)", c.user, c.host) + } + selected, err := opts.Prompter.Select( + "What account do you want to log out of?", "", prompts) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + hostname = candidates[selected].host + username = candidates[selected].user } if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { @@ -107,34 +128,25 @@ func logoutRun(opts *LogoutOptions) error { return cmdutil.SilentError } - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - apiClient := api.NewClientFromHTTP(httpClient) - - username, err := api.CurrentLoginName(apiClient, hostname) - if err != nil { - // suppressing; the user is trying to delete this token and it might be bad. - // we'll see if the username is in the config and fall back to that. - username, _ = authCfg.User(hostname) - } - - usernameStr := "" - if username != "" { - usernameStr = fmt.Sprintf(" account '%s'", username) - } - if err := authCfg.Logout(hostname, username); err != nil { return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) } + postLogoutActiveUser, _ := authCfg.User(hostname) + hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && + postLogoutActiveUser != "" + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() if isTTY { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n", - cs.SuccessIcon(), cs.Bold(hostname), usernameStr) + fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s account '%s'\n", + cs.SuccessIcon(), cs.Bold(hostname), username) + + if hasSwitchedToNewUser { + fmt.Fprintf(opts.IO.ErrOut, "%s Switched account to '%s'\n", + cs.SuccessIcon(), cs.Bold(postLogoutActiveUser)) + } } return nil diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 41448cba8..1bd4a6b93 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -2,19 +2,18 @@ package logout import ( "bytes" - "fmt" - "net/http" + "io" "regexp" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_NewCmdLogout(t *testing.T) { @@ -41,16 +40,16 @@ func Test_NewCmdLogout(t *testing.T) { { name: "tty with hostname", tty: true, - cli: "--hostname harry.mason", + cli: "--hostname github.com", wants: LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", }, }, { name: "nontty with hostname", - cli: "--hostname harry.mason", + cli: "--hostname github.com", wants: LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", }, }, } @@ -64,7 +63,7 @@ func Test_NewCmdLogout(t *testing.T) { ios.SetStdoutTTY(tt.tty) argv, err := shlex.Split(tt.cli) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *LogoutOptions cmd := NewCmdLogout(f, func(opts *LogoutOptions) error { @@ -81,46 +80,88 @@ func Test_NewCmdLogout(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) + require.NoError(t, err) - assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + require.Equal(t, tt.wants.Hostname, gotOpts.Hostname) }) } } +type host string +type user string + +type hostUsers struct { + host host + users []user +} + func Test_logoutRun_tty(t *testing.T) { tests := []struct { name string opts *LogoutOptions prompterStubs func(*prompter.PrompterMock) - cfgHosts []string + cfgHosts []hostUsers secureStorage bool wantHosts string wantErrOut *regexp.Regexp wantErr string }{ { - name: "no arguments, multiple hosts", - opts: &LogoutOptions{}, - cfgHosts: []string{"cheryl.mason", "github.com"}, - wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + name: "no arguments, multiple hosts with one user each", + opts: &LogoutOptions{}, + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe"}}, + {"github.com", []user{"monalisa"}}, + }, + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(_, _ string, opts []string) (int, error) { - return prompter.IndexFor(opts, "github.com") + return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { - name: "no arguments, one host", - opts: &LogoutOptions{}, - cfgHosts: []string{"github.com"}, + name: "no arguments, multiple hosts with multiple users each", + opts: &LogoutOptions{}, + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe", "monalisa-ghe2"}}, + {"github.com", []user{"monalisa", "monalisa2"}}, + }, + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n monalisa-ghe2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa-ghe2\n oauth_token: abc123\ngithub.com:\n users:\n monalisa2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: abc123\n", + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "monalisa (github.com)") + } + }, + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + }, + { + name: "no arguments, one host, one user", + opts: &LogoutOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + }, wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + }, + { + name: "no arguments, one host, multiple users", + opts: &LogoutOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa", "monalisa2"}}, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "monalisa (github.com)") + } + }, + wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: abc123\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { name: "no arguments, no hosts", @@ -130,11 +171,25 @@ func Test_logoutRun_tty(t *testing.T) { { name: "hostname", opts: &LogoutOptions{ - Hostname: "cheryl.mason", + Hostname: "ghe.io", }, - cfgHosts: []string{"cheryl.mason", "github.com"}, - wantHosts: "github.com:\n oauth_token: abc123\n", - wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`), + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe"}}, + {"github.com", []user{"monalisa"}}, + }, + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", + wantErrOut: regexp.MustCompile(`Logged out of ghe.io account 'monalisa-ghe'`), + }, + { + name: "hostname but not logged in to it", + opts: &LogoutOptions{ + Hostname: "ghe.io", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + }, + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", + wantErr: "not logged in to ghe.io", }, { name: "secure storage", @@ -142,9 +197,11 @@ func Test_logoutRun_tty(t *testing.T) { opts: &LogoutOptions{ Hostname: "github.com", }, - cfgHosts: []string{"github.com"}, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + }, wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, } @@ -153,15 +210,16 @@ func Test_logoutRun_tty(t *testing.T) { keyring.MockInit() readConfigs := config.StubWriteConfig(t) cfg := config.NewFromString("") - for _, hostname := range tt.cfgHosts { - if tt.secureStorage { - cfg.Set(hostname, "user", "monalisa") - _ = keyring.Set(fmt.Sprintf("gh:%s", hostname), "", "abc123") - cfg.Authentication().SetToken("abc123", "keyring") - } else { - cfg.Set(hostname, "oauth_token", "abc123") + for _, hostUsers := range tt.cfgHosts { + for _, user := range hostUsers.users { + cfg.Authentication().Login( + string(hostUsers.host), + string(user), + "abc123", "ssh", tt.secureStorage, + ) } } + tt.opts.Config = func() (config.Config, error) { return cfg, nil } @@ -171,15 +229,6 @@ func Test_logoutRun_tty(t *testing.T) { ios.SetStdoutTTY(true) tt.opts.IO = ios - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`), - ) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { tt.prompterStubs(pm) @@ -188,16 +237,16 @@ func Test_logoutRun_tty(t *testing.T) { err := logoutRun(tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) return } else { - assert.NoError(t, err) + require.NoError(t, err) } if tt.wantErrOut == nil { - assert.Equal(t, "", stderr.String()) + require.Equal(t, "", stderr.String()) } else { - assert.True(t, tt.wantErrOut.MatchString(stderr.String())) + require.True(t, tt.wantErrOut.MatchString(stderr.String()), stderr.String()) } mainBuf := bytes.Buffer{} @@ -205,9 +254,8 @@ func Test_logoutRun_tty(t *testing.T) { readConfigs(&mainBuf, &hostsBuf) secureToken, _ := cfg.Authentication().TokenFromKeyring(tt.opts.Hostname) - assert.Equal(t, tt.wantHosts, hostsBuf.String()) - assert.Equal(t, "", secureToken) - reg.Verify(t) + require.Equal(t, tt.wantHosts, hostsBuf.String()) + require.Equal(t, "", secureToken) }) } } @@ -216,7 +264,7 @@ func Test_logoutRun_nontty(t *testing.T) { tests := []struct { name string opts *LogoutOptions - cfgHosts []string + cfgHosts []hostUsers secureStorage bool ghtoken string wantHosts string @@ -225,23 +273,28 @@ func Test_logoutRun_nontty(t *testing.T) { { name: "hostname, one host", opts: &LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, }, - cfgHosts: []string{"harry.mason"}, wantHosts: "{}\n", }, { name: "hostname, multiple hosts", opts: &LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", }, - cfgHosts: []string{"harry.mason", "cheryl.mason"}, - wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + {"ghe.io", []user{"monalisa-ghe"}}, + }, + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", }, { name: "hostname, no hosts", opts: &LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", }, wantErr: `not logged in to any hosts`, }, @@ -249,9 +302,11 @@ func Test_logoutRun_nontty(t *testing.T) { name: "secure storage", secureStorage: true, opts: &LogoutOptions{ - Hostname: "harry.mason", + Hostname: "github.com", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, }, - cfgHosts: []string{"harry.mason"}, wantHosts: "{}\n", }, } @@ -261,13 +316,13 @@ func Test_logoutRun_nontty(t *testing.T) { keyring.MockInit() readConfigs := config.StubWriteConfig(t) cfg := config.NewFromString("") - for _, hostname := range tt.cfgHosts { - if tt.secureStorage { - cfg.Set(hostname, "user", "monalisa") - _ = keyring.Set(fmt.Sprintf("gh:%s", hostname), "", "abc123") - cfg.Authentication().SetToken("abc123", "keyring") - } else { - cfg.Set(hostname, "oauth_token", "abc123") + for _, hostUsers := range tt.cfgHosts { + for _, user := range hostUsers.users { + cfg.Authentication().Login( + string(hostUsers.host), + string(user), + "abc123", "ssh", tt.secureStorage, + ) } } tt.opts.Config = func() (config.Config, error) { @@ -279,28 +334,115 @@ func Test_logoutRun_nontty(t *testing.T) { ios.SetStdoutTTY(false) tt.opts.IO = ios - reg := &httpmock.Registry{} - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - err := logoutRun(tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) } else { - assert.NoError(t, err) + require.NoError(t, err) } - assert.Equal(t, "", stderr.String()) + require.Equal(t, "", stderr.String()) mainBuf := bytes.Buffer{} hostsBuf := bytes.Buffer{} readConfigs(&mainBuf, &hostsBuf) secureToken, _ := cfg.Authentication().TokenFromKeyring(tt.opts.Hostname) - assert.Equal(t, tt.wantHosts, hostsBuf.String()) - assert.Equal(t, "", secureToken) - reg.Verify(t) + require.Equal(t, tt.wantHosts, hostsBuf.String()) + require.Equal(t, "", secureToken) }) } } + +func TestLogoutSwitchesUserNonTTY(t *testing.T) { + keyring.MockInit() + + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(false) + ios.SetStdoutTTY(false) + + readConfigs := config.StubWriteConfig(t) + cfg := config.NewFromString("") + _, err := cfg.Authentication().Login("github.com", "test-user-1", "test-token-1", "https", true) + require.NoError(t, err) + + _, err = cfg.Authentication().Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + opts := LogoutOptions{ + IO: ios, + Config: func() (config.Config, error) { + return cfg, nil + }, + Hostname: "github.com", + } + + require.NoError(t, logoutRun(&opts)) + + hostsBuf := bytes.Buffer{} + readConfigs(io.Discard, &hostsBuf) + + secureToken, _ := cfg.Authentication().TokenFromKeyring("github.com") + require.Equal(t, "test-token-1", secureToken) + + expectedHosts := heredoc.Doc(` + github.com: + users: + test-user-1: + git_protocol: https + git_protocol: https + user: test-user-1 + `) + + require.Equal(t, expectedHosts, hostsBuf.String()) +} + +func TestLogoutSwitchesUserTTY(t *testing.T) { + keyring.MockInit() + + ios, _, _, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + + readConfigs := config.StubWriteConfig(t) + cfg := config.NewFromString("") + _, err := cfg.Authentication().Login("github.com", "test-user-1", "test-token-1", "https", true) + require.NoError(t, err) + + _, err = cfg.Authentication().Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + pm := &prompter.PrompterMock{} + pm.SelectFunc = func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "test-user-2 (github.com)") + } + + opts := LogoutOptions{ + IO: ios, + Config: func() (config.Config, error) { + return cfg, nil + }, + Prompter: pm, + } + + require.NoError(t, logoutRun(&opts)) + + hostsBuf := bytes.Buffer{} + readConfigs(io.Discard, &hostsBuf) + + secureToken, _ := cfg.Authentication().TokenFromKeyring("github.com") + require.Equal(t, "test-token-1", secureToken) + + expectedHosts := heredoc.Doc(` + github.com: + users: + test-user-1: + git_protocol: https + git_protocol: https + user: test-user-1 + `) + + require.Equal(t, expectedHosts, hostsBuf.String()) + + require.Contains(t, stderr.String(), "✓ Switched account to 'test-user-1'") +} From 2fc6dbd851a435dbeb219609f09a924381e0620e Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 28 Nov 2023 21:03:52 +0000 Subject: [PATCH 17/62] Add user flag to auth logout command --- pkg/cmd/auth/logout/logout.go | 53 +++++--- pkg/cmd/auth/logout/logout_test.go | 193 +++++++++++++++++++++-------- 2 files changed, 173 insertions(+), 73 deletions(-) diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 1c137515a..a20c8a77b 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -17,6 +17,7 @@ type LogoutOptions struct { Config func() (config.Config, error) Prompter shared.Prompt Hostname string + Username string } func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { @@ -29,23 +30,25 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "logout", Args: cobra.ExactArgs(0), - Short: "Log out of a GitHub host", - Long: heredoc.Docf(`Remove authentication for a GitHub host. + Short: "Log out of a GitHub user account", + Long: heredoc.Docf(` + Remove authentication for a GitHub user account. - This command removes the authentication configuration for a host either specified - interactively or via %[1]s--hostname%[1]s. + This command removes the authentication configuration for a user account + either specified interactively or via the %[1]s--hostname%[1]s and %[1]s--user%[1]s flags. `, "`"), Example: heredoc.Doc(` + # Select what host and user account to log out of via a prompt $ gh auth logout - # => select what host to log out of via a prompt - $ gh auth logout --hostname enterprise.internal - # => log out of specified host + # Log out of specified user account on specified host + $ gh auth logout --hostname enterprise.internal --user monalisa `), RunE: func(cmd *cobra.Command, args []string) error { - if opts.Hostname == "" && !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("--hostname required when not running interactively") - } + // if (opts.Hostname == "" || opts.Username == "") && !opts.IO.CanPrompt() { + // return cmdutil.FlagErrorf("--hostname and --user required when not running interactively") + // } + if runF != nil { return runF(opts) } @@ -55,14 +58,14 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to log out of") + cmd.Flags().StringVarP(&opts.Username, "user", "u", "", "The user account to log out of") return cmd } func logoutRun(opts *LogoutOptions) error { hostname := opts.Hostname - // TODO: opts.Username likeley needed in the future - username := "" + username := opts.Username cfg, err := opts.Config() if err != nil { @@ -79,6 +82,13 @@ func logoutRun(opts *LogoutOptions) error { if !slices.Contains(knownHosts, hostname) { return fmt.Errorf("not logged in to %s", hostname) } + + if username != "" { + knownUsers, _ := cfg.Authentication().UsersForHost(hostname) + if !slices.Contains(knownUsers, username) { + return fmt.Errorf("not logged in as %s on %s", username, hostname) + } + } } type hostUser struct { @@ -96,18 +106,18 @@ func logoutRun(opts *LogoutOptions) error { return err } for _, user := range knownUsers { + if username != "" && user != username { + continue + } candidates = append(candidates, hostUser{host: host, user: user}) } } - // We can ignore the error here because a host must always have an active user - preLogoutActiveUser, _ := authCfg.User(hostname) - if len(candidates) == 1 { hostname = candidates[0].host username = candidates[0].user } else if !opts.IO.CanPrompt() { - username = preLogoutActiveUser + return fmt.Errorf("unable to determine which user account to log out of, please specify %[1]s--hostname%[1]s and %[1]s--user%[1]s", "`") } else { prompts := make([]string, len(candidates)) for i, c := range candidates { @@ -128,17 +138,20 @@ func logoutRun(opts *LogoutOptions) error { return cmdutil.SilentError } + // We can ignore the error here because a host must always have an active user + preLogoutActiveUser, _ := authCfg.User(hostname) + if err := authCfg.Logout(hostname, username); err != nil { return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) } - postLogoutActiveUser, _ := authCfg.User(hostname) - hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && - postLogoutActiveUser != "" - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() if isTTY { + postLogoutActiveUser, _ := authCfg.User(hostname) + hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && + postLogoutActiveUser != "" + cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s account '%s'\n", cs.SuccessIcon(), cs.Bold(hostname), username) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 1bd4a6b93..57f40a59c 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -18,24 +18,21 @@ import ( func Test_NewCmdLogout(t *testing.T) { tests := []struct { - name string - cli string - wants LogoutOptions - wantsErr bool - tty bool + name string + cli string + wants LogoutOptions + tty bool }{ { - name: "nontty no arguments", - cli: "", - wantsErr: true, + name: "nontty no arguments", + cli: "", + wants: LogoutOptions{}, }, { - name: "tty no arguments", - tty: true, - cli: "", - wants: LogoutOptions{ - Hostname: "", - }, + name: "tty no arguments", + tty: true, + cli: "", + wants: LogoutOptions{}, }, { name: "tty with hostname", @@ -52,6 +49,38 @@ func Test_NewCmdLogout(t *testing.T) { Hostname: "github.com", }, }, + { + name: "tty with user", + tty: true, + cli: "--user monalisa", + wants: LogoutOptions{ + Username: "github.com", + }, + }, + { + name: "nontty with user", + cli: "--user monalisa", + wants: LogoutOptions{ + Username: "github.com", + }, + }, + { + name: "tty with hostname and user", + tty: true, + cli: "--hostname github.com --user monalisa", + wants: LogoutOptions{ + Hostname: "github.com", + Username: "monalisa", + }, + }, + { + name: "nontty with hostname and user", + cli: "--hostname github.com --user monalisa", + wants: LogoutOptions{ + Hostname: "github.com", + Username: "monalisa", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -79,15 +108,10 @@ func Test_NewCmdLogout(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() - if tt.wantsErr { - require.Error(t, err) - return - } require.NoError(t, err) require.Equal(t, tt.wants.Hostname, gotOpts.Hostname) }) - } } @@ -111,7 +135,7 @@ func Test_logoutRun_tty(t *testing.T) { wantErr string }{ { - name: "no arguments, multiple hosts with one user each", + name: "logs out prompted user when multiple known hosts with one user each", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ {"ghe.io", []user{"monalisa-ghe"}}, @@ -126,7 +150,7 @@ func Test_logoutRun_tty(t *testing.T) { wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { - name: "no arguments, multiple hosts with multiple users each", + name: "logs out prompted user when multiple known hosts with multiple users each", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ {"ghe.io", []user{"monalisa-ghe", "monalisa-ghe2"}}, @@ -141,7 +165,7 @@ func Test_logoutRun_tty(t *testing.T) { wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { - name: "no arguments, one host, one user", + name: "logs out only logged in user", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, @@ -150,7 +174,7 @@ func Test_logoutRun_tty(t *testing.T) { wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { - name: "no arguments, one host, multiple users", + name: "logs out prompted user when one known host with multiple users", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa", "monalisa2"}}, @@ -164,14 +188,10 @@ func Test_logoutRun_tty(t *testing.T) { wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, { - name: "no arguments, no hosts", - opts: &LogoutOptions{}, - wantErr: `not logged in to any hosts`, - }, - { - name: "hostname", + name: "logs out specified user when multiple known hosts with one user each", opts: &LogoutOptions{ Hostname: "ghe.io", + Username: "monalisa-ghe", }, cfgHosts: []hostUsers{ {"ghe.io", []user{"monalisa-ghe"}}, @@ -181,21 +201,11 @@ func Test_logoutRun_tty(t *testing.T) { wantErrOut: regexp.MustCompile(`Logged out of ghe.io account 'monalisa-ghe'`), }, { - name: "hostname but not logged in to it", - opts: &LogoutOptions{ - Hostname: "ghe.io", - }, - cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, - }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", - wantErr: "not logged in to ghe.io", - }, - { - name: "secure storage", + name: "logs out specified user that is using secure storage", secureStorage: true, opts: &LogoutOptions{ Hostname: "github.com", + Username: "monalisa", }, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, @@ -203,6 +213,33 @@ func Test_logoutRun_tty(t *testing.T) { wantHosts: "{}\n", wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), }, + { + name: "errors when no known hosts", + opts: &LogoutOptions{}, + wantErr: `not logged in to any hosts`, + }, + { + name: "errors when specified host is not a known host", + opts: &LogoutOptions{ + Hostname: "ghe.io", + Username: "monalisa-ghe", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + }, + wantErr: "not logged in to ghe.io", + }, + { + name: "errors when specified user is not logged in on specified host", + opts: &LogoutOptions{ + Hostname: "ghe.io", + Username: "unknown-user", + }, + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe"}}, + }, + wantErr: "not logged in as unknown-user on ghe.io", + }, } for _, tt := range tests { @@ -212,7 +249,7 @@ func Test_logoutRun_tty(t *testing.T) { cfg := config.NewFromString("") for _, hostUsers := range tt.cfgHosts { for _, user := range hostUsers.users { - cfg.Authentication().Login( + _, _ = cfg.Authentication().Login( string(hostUsers.host), string(user), "abc123", "ssh", tt.secureStorage, @@ -271,9 +308,10 @@ func Test_logoutRun_nontty(t *testing.T) { wantErr string }{ { - name: "hostname, one host", + name: "logs out specified user when one known host", opts: &LogoutOptions{ Hostname: "github.com", + Username: "monalisa", }, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, @@ -281,9 +319,10 @@ func Test_logoutRun_nontty(t *testing.T) { wantHosts: "{}\n", }, { - name: "hostname, multiple hosts", + name: "logs out specified user when multiple known hosts", opts: &LogoutOptions{ Hostname: "github.com", + Username: "monalisa", }, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, @@ -292,23 +331,69 @@ func Test_logoutRun_nontty(t *testing.T) { wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", }, { - name: "hostname, no hosts", - opts: &LogoutOptions{ - Hostname: "github.com", - }, - wantErr: `not logged in to any hosts`, - }, - { - name: "secure storage", + name: "logs out specified user that is using secure storage", secureStorage: true, opts: &LogoutOptions{ Hostname: "github.com", + Username: "monalisa", }, cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, }, wantHosts: "{}\n", }, + { + name: "errors when no known hosts", + opts: &LogoutOptions{ + Hostname: "github.com", + Username: "monalisa", + }, + wantErr: `not logged in to any hosts`, + }, + { + name: "errors when specified host is not a known host", + opts: &LogoutOptions{ + Hostname: "ghe.io", + Username: "monalisa-ghe", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + }, + wantErr: "not logged in to ghe.io", + }, + { + name: "errors when specified user is not logged in on specified host", + opts: &LogoutOptions{ + Hostname: "ghe.io", + Username: "unknown-user", + }, + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe"}}, + }, + wantErr: "not logged in as unknown-user on ghe.io", + }, + { + name: "errors when host is specified but user is ambiguous", + opts: &LogoutOptions{ + Hostname: "ghe.io", + }, + cfgHosts: []hostUsers{ + {"ghe.io", []user{"monalisa-ghe"}}, + {"ghe.io", []user{"monalisa-ghe-2"}}, + }, + wantErr: "unable to determine which user account to log out of, please specify `--hostname` and `--user`", + }, + { + name: "errors when user is specified but host is ambiguous", + opts: &LogoutOptions{ + Username: "monalisa", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + {"ghe.io", []user{"monalisa"}}, + }, + wantErr: "unable to determine which user account to log out of, please specify `--hostname` and `--user`", + }, } for _, tt := range tests { @@ -318,7 +403,7 @@ func Test_logoutRun_nontty(t *testing.T) { cfg := config.NewFromString("") for _, hostUsers := range tt.cfgHosts { for _, user := range hostUsers.users { - cfg.Authentication().Login( + _, _ = cfg.Authentication().Login( string(hostUsers.host), string(user), "abc123", "ssh", tt.secureStorage, @@ -337,6 +422,7 @@ func Test_logoutRun_nontty(t *testing.T) { err := logoutRun(tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) + return } else { require.NoError(t, err) } @@ -375,6 +461,7 @@ func TestLogoutSwitchesUserNonTTY(t *testing.T) { return cfg, nil }, Hostname: "github.com", + Username: "test-user-2", } require.NoError(t, logoutRun(&opts)) From df274d4f3a0086abb042c53c4a0b4a26bad8356d Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 29 Nov 2023 17:03:09 +0100 Subject: [PATCH 18/62] Implement auth switch --- internal/config/config.go | 6 +- internal/config/stub.go | 24 ++ pkg/cmd/auth/auth.go | 2 + pkg/cmd/auth/logout/logout.go | 7 +- pkg/cmd/auth/switch/switch.go | 165 ++++++++++++++ pkg/cmd/auth/switch/switch_test.go | 353 +++++++++++++++++++++++++++++ 6 files changed, 549 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/auth/switch/switch.go create mode 100644 pkg/cmd/auth/switch/switch_test.go diff --git a/internal/config/config.go b/internal/config/config.go index e739151d8..264fe64d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -331,11 +331,11 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure } // Then we perform a switch to the new user - return insecureStorageUsed, c.switchUser(hostname, username) + return insecureStorageUsed, c.SwitchUser(hostname, username) } // TODO: Verify that git protocol switching works as expected -func (c *AuthConfig) switchUser(hostname, user string) error { +func (c *AuthConfig) SwitchUser(hostname, user string) error { // We first need to idempotently clear out any set tokens for the host _ = keyring.Delete(keyringServiceName(hostname), "") _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) @@ -398,7 +398,7 @@ func (c *AuthConfig) Logout(hostname, username string) error { }) // And switch to them - return c.switchUser(hostname, users[switchUserIdx]) + return c.SwitchUser(hostname, users[switchUserIdx]) } func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { diff --git a/internal/config/stub.go b/internal/config/stub.go index 10d409804..1673d47a2 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -79,6 +79,30 @@ func NewFromString(cfgStr string) *ConfigMock { return mock } +func NewIsolatedTestConfig(t *testing.T) (Config, func(io.Writer, io.Writer)) { + c := ghConfig.ReadFromString("") + cfg := cfg{c} + + // The real implementation of config.Read uses a sync.Once + // to read config files and initialise package level variables + // that are used from then on. + // + // This means that tests can't be isolated from each other, so + // we swap out the function here to return a new config each time. + ghConfig.Read = func(_ *ghConfig.Config) (*ghConfig.Config, error) { + return c, nil + } + + // The config.Write method isn't defined in the same way as Read to allow + // the function to be swapped out and it does try to write to disk. + // + // We should consider whether it makes sense to change that but in the meantime + // we can use GH_CONFIG_DIR env var to ensure the tests remain isolated. + readConfigs := StubWriteConfig(t) + + return &cfg, readConfigs +} + // StubWriteConfig stubs out the filesystem where config file are written. // It then returns a function that will read in the config files into io.Writers. // It automatically cleans up environment variables and written files. diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index c003049eb..70e01653f 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -7,6 +7,7 @@ import ( authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh" authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit" authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status" + authSwitchCmd "github.com/cli/cli/v2/pkg/cmd/auth/switch" authTokenCmd "github.com/cli/cli/v2/pkg/cmd/auth/token" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -28,6 +29,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil)) cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) + cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil)) return cmd } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index a20c8a77b..5077cdae1 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -1,6 +1,7 @@ package logout import ( + "errors" "fmt" "slices" @@ -45,10 +46,6 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co $ gh auth logout --hostname enterprise.internal --user monalisa `), RunE: func(cmd *cobra.Command, args []string) error { - // if (opts.Hostname == "" || opts.Username == "") && !opts.IO.CanPrompt() { - // return cmdutil.FlagErrorf("--hostname and --user required when not running interactively") - // } - if runF != nil { return runF(opts) } @@ -117,7 +114,7 @@ func logoutRun(opts *LogoutOptions) error { hostname = candidates[0].host username = candidates[0].user } else if !opts.IO.CanPrompt() { - return fmt.Errorf("unable to determine which user account to log out of, please specify %[1]s--hostname%[1]s and %[1]s--user%[1]s", "`") + return errors.New("unable to determine which user account to log out of, please specify `--hostname` and `--user`") } else { prompts := make([]string, len(candidates)) for i, c := range candidates { diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go new file mode 100644 index 000000000..0fde876d6 --- /dev/null +++ b/pkg/cmd/auth/switch/switch.go @@ -0,0 +1,165 @@ +package authswitch + +import ( + "errors" + "fmt" + "slices" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type SwitchOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + Prompter shared.Prompt + Hostname string + User string +} + +func NewCmdSwitch(f *cmdutil.Factory, runF func(*SwitchOptions) error) *cobra.Command { + opts := SwitchOptions{ + IO: f.IOStreams, + Config: f.Config, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "switch", + Args: cobra.ExactArgs(0), + Short: "Switch to another GitHub account", + Long: heredoc.Doc(""), + Example: heredoc.Doc(""), + RunE: func(c *cobra.Command, args []string) error { + if runF != nil { + return runF(&opts) + } + + return switchRun(&opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to switch account on") + cmd.Flags().StringVarP(&opts.User, "user", "u", "", "The user to switch to") + + return cmd +} + +type hostUser struct { + host string + user string + active bool +} + +type candidates []hostUser + +func (c candidates) inactiveOptions() []hostUser { + var inactive []hostUser + for _, candidate := range c { + if !candidate.active { + inactive = append(inactive, candidate) + } + } + return inactive +} + +func switchRun(opts *SwitchOptions) error { + hostname := opts.Hostname + username := opts.User + + cfg, err := opts.Config() + if err != nil { + return err + } + authCfg := cfg.Authentication() + + knownHosts := authCfg.Hosts() + if len(knownHosts) == 0 { + return fmt.Errorf("not logged in to any hosts") + } + + if hostname != "" { + if !slices.Contains(knownHosts, hostname) { + return fmt.Errorf("not logged in to %s", hostname) + } + + if username != "" { + knownUsers, _ := cfg.Authentication().UsersForHost(hostname) + if !slices.Contains(knownUsers, username) { + return fmt.Errorf("not logged in as %s on %s", username, hostname) + } + } + } + + var candidates candidates + + for _, host := range knownHosts { + if hostname != "" && host != hostname { + continue + } + hostActiveUser, err := authCfg.User(host) + if err != nil { + return err + } + knownUsers, err := cfg.Authentication().UsersForHost(host) + if err != nil { + return err + } + for _, user := range knownUsers { + if username != "" && user != username { + continue + } + candidates = append(candidates, hostUser{host: host, user: user, active: user == hostActiveUser}) + } + } + + inactiveCandidates := candidates.inactiveOptions() + if len(candidates) == 0 { + return errors.New("no user accounts matched that criteria") + } else if len(candidates) == 1 { + hostname = candidates[0].host + username = candidates[0].user + } else if len(inactiveCandidates) == 1 { + hostname = inactiveCandidates[0].host + username = inactiveCandidates[0].user + } else if !opts.IO.CanPrompt() { + return errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`") + } else { + prompts := make([]string, len(candidates)) + for i, c := range candidates { + prompt := fmt.Sprintf("%s (%s)", c.user, c.host) + if c.active { + prompt += " - active" + } + prompts[i] = prompt + } + selected, err := opts.Prompter.Select( + "What account do you want to switch to?", "", prompts) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + hostname = candidates[selected].host + username = candidates[selected].user + } + + if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src) + fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI manage credentials instead, first clear the value from the environment.\n") + return cmdutil.SilentError + } + + err = authCfg.SwitchUser(hostname, username) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Switched active account on %s to '%s'\n", + cs.SuccessIcon(), hostname, cs.Bold(username)) + + return nil +} diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go new file mode 100644 index 000000000..c4858afd4 --- /dev/null +++ b/pkg/cmd/auth/switch/switch_test.go @@ -0,0 +1,353 @@ +package authswitch + +import ( + "bytes" + "errors" + "fmt" + "io" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/require" +) + +func TestNewCmdSwitch(t *testing.T) { + tests := []struct { + name string + input string + expectedOpts SwitchOptions + expectedErrMsg string + }{ + { + name: "no flags", + input: "", + expectedOpts: SwitchOptions{}, + }, + { + name: "hostname flag", + input: "--hostname github.com", + expectedOpts: SwitchOptions{ + Hostname: "github.com", + }, + }, + { + name: "user flag", + input: "--user monalisa", + expectedOpts: SwitchOptions{ + User: "monalisa", + }, + }, + { + name: "positional args is an error", + input: "some-positional-arg", + expectedErrMsg: "accepts 0 arg(s), received 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + argv, err := shlex.Split(tt.input) + require.NoError(t, err) + + var gotOpts *SwitchOptions + cmd := NewCmdSwitch(f, func(opts *SwitchOptions) error { + gotOpts = opts + return nil + }) + // Override the help flag as happens in production to allow -h flag + // to be used for hostname. + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.expectedErrMsg != "" { + require.ErrorContains(t, err, tt.expectedErrMsg) + return + } + + require.NoError(t, err) + require.Equal(t, &tt.expectedOpts, gotOpts) + }) + } + +} + +func TestSwitchRun(t *testing.T) { + type host string + type user struct { + name string + token string + } + + type hostUsers struct { + host host + users []user + } + + tests := []struct { + name string + opts SwitchOptions + cfgHosts []hostUsers + env map[string]string + expectedHostToSwitch string + expectedActiveUser string + expectedActiveToken string + expectedHosts string + expectedStderr string + expectedErr error + + prompterStubs func(*prompter.PrompterMock) + }{ + { + name: "given one host with two users, switches to the other user", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedHostToSwitch: "github.com", + expectedActiveUser: "inactive-user", + expectedActiveToken: "inactive-user-token", + expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + expectedStderr: "✓ Switched active account on github.com to 'inactive-user'", + }, + { + name: "given one host, with three users, switches to the specified user", + opts: SwitchOptions{ + User: "inactive-user-2", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user-1", "inactive-user-1-token"}, + {"inactive-user-2", "inactive-user-2-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedHostToSwitch: "github.com", + expectedActiveUser: "inactive-user-2", + expectedActiveToken: "inactive-user-2-token", + expectedHosts: "github.com:\n users:\n inactive-user-1:\n git_protocol: ssh\n inactive-user-2:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user-2\n", + expectedStderr: "✓ Switched active account on github.com to 'inactive-user-2'", + }, + { + name: "given multiple hosts, with multiple users, switches to the specific user on the host", + opts: SwitchOptions{ + Hostname: "ghe.io", + User: "inactive-user", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + {"ghe.io", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedHostToSwitch: "ghe.io", + expectedActiveUser: "inactive-user", + expectedActiveToken: "inactive-user-token", + expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + expectedStderr: "✓ Switched active account on ghe.io to 'inactive-user'", + }, + { + name: "given we're not logged into any hosts, provide an informative error", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{}, + expectedErr: fmt.Errorf("not logged in to any hosts"), + }, + { + name: "given we can't disambiguate users across hosts", + opts: SwitchOptions{ + User: "inactive-user", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + {"ghe.io", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedErr: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + }, + { + name: "given we can't disambiguate user on a single host", + opts: SwitchOptions{ + Hostname: "github.com", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user-1", "inactive-user-1-token"}, + {"inactive-user-2", "inactive-user-2-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedErr: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + }, + { + name: "given the auth token isn't writeable (e.g. a token env var is set)", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + env: map[string]string{"GH_TOKEN": "unimportant-test-value"}, + expectedErr: cmdutil.SilentError, + expectedStderr: "The value of the GH_TOKEN environment variable is being used for authentication.", + }, + { + name: "specified hostname doesn't exist", + opts: SwitchOptions{ + Hostname: "ghe.io", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedErr: errors.New("not logged in to ghe.io"), + }, + { + name: "specified user doesn't exist on host", + opts: SwitchOptions{ + Hostname: "github.com", + User: "non-existent-user", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedErr: errors.New("not logged in as non-existent-user on github.com"), + }, + { + name: "specified user doesn't exist on any host", + opts: SwitchOptions{ + User: "non-existent-user", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"active-user", "active-user-token"}, + }}, + {"ghe.io", []user{ + {"active-user", "active-user-token"}, + }}, + }, + expectedErr: errors.New("no user accounts matched that criteria"), + }, + { + name: "when options need to be disambiguated, the user is prompted with matrix of options including active users (if possible)", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + {"ghe.io", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { + require.Equal(t, "What account do you want to switch to?", prompt) + require.Equal(t, []string{ + "inactive-user (github.com)", + "active-user (github.com) - active", + "inactive-user (ghe.io)", + "active-user (ghe.io) - active", + }, opts) + + return prompter.IndexFor(opts, "inactive-user (ghe.io)") + } + }, + expectedHostToSwitch: "ghe.io", + expectedActiveUser: "inactive-user", + expectedActiveToken: "inactive-user-token", + expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + expectedStderr: "✓ Switched active account on ghe.io to 'inactive-user'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyring.MockInit() + + cfg, readConfigs := config.NewIsolatedTestConfig(t) + + for k, v := range tt.env { + t.Setenv(k, v) + } + + isInteractive := tt.prompterStubs != nil + if isInteractive { + pm := &prompter.PrompterMock{} + tt.prompterStubs(pm) + tt.opts.Prompter = pm + } + + for _, hostUsers := range tt.cfgHosts { + for _, user := range hostUsers.users { + _, err := cfg.Authentication().Login( + string(hostUsers.host), + user.name, + user.token, "ssh", true, + ) + require.NoError(t, err) + } + } + + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + ios, _, _, stderr := iostreams.Test() + ios.SetStdinTTY(isInteractive) + ios.SetStdoutTTY(isInteractive) + tt.opts.IO = ios + + err := switchRun(&tt.opts) + if tt.expectedErr != nil { + require.Equal(t, tt.expectedErr, err) + require.Contains(t, stderr.String(), tt.expectedStderr) + return + } + + require.NoError(t, err) + + activeUser, err := cfg.Authentication().User(tt.expectedHostToSwitch) + require.NoError(t, err) + require.Equal(t, tt.expectedActiveUser, activeUser) + + activeToken, _ := cfg.Authentication().TokenFromKeyring(tt.expectedHostToSwitch) + require.Equal(t, tt.expectedActiveToken, activeToken) + + hostsBuf := bytes.Buffer{} + readConfigs(io.Discard, &hostsBuf) + + require.Equal(t, tt.expectedHosts, hostsBuf.String()) + + require.Contains(t, stderr.String(), tt.expectedStderr) + }) + } +} From 7667fbdb5a71df954d5b5783359b51db5a59360b Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 29 Nov 2023 17:06:37 +0100 Subject: [PATCH 19/62] Handle logout having no candidates --- pkg/cmd/auth/logout/logout.go | 4 +++- pkg/cmd/auth/logout/logout_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 5077cdae1..a400dfc93 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -110,7 +110,9 @@ func logoutRun(opts *LogoutOptions) error { } } - if len(candidates) == 1 { + if len(candidates) == 0 { + return errors.New("no user accounts matched that criteria") + } else if len(candidates) == 1 { hostname = candidates[0].host username = candidates[0].user } else if !opts.IO.CanPrompt() { diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 57f40a59c..d3bfa6ca2 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -240,6 +240,17 @@ func Test_logoutRun_tty(t *testing.T) { }, wantErr: "not logged in as unknown-user on ghe.io", }, + { + name: "errors when user is specified but doesn't exist on any host", + opts: &LogoutOptions{ + Username: "unknown-user", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{"monalisa"}}, + {"ghe.io", []user{"monalisa"}}, + }, + wantErr: "no user accounts matched that criteria", + }, } for _, tt := range tests { From 98381e63c9bfd6a17c1d1e6bb6050399005ee04b Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 29 Nov 2023 17:20:16 +0100 Subject: [PATCH 20/62] Refactor switch test to be more structural --- pkg/cmd/auth/switch/switch_test.go | 140 +++++++++++++++++------------ 1 file changed, 85 insertions(+), 55 deletions(-) diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index c4858afd4..0d1552478 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -3,7 +3,6 @@ package authswitch import ( "bytes" "errors" - "fmt" "io" "testing" @@ -83,28 +82,37 @@ func TestNewCmdSwitch(t *testing.T) { } func TestSwitchRun(t *testing.T) { - type host string type user struct { name string token string } type hostUsers struct { - host host + host string users []user } + type successfulExpectation struct { + switchedHost string + activeUser string + activeToken string + hostsCfg string + stderr string + } + + type failedExpectation struct { + err error + stderr string + } + tests := []struct { - name string - opts SwitchOptions - cfgHosts []hostUsers - env map[string]string - expectedHostToSwitch string - expectedActiveUser string - expectedActiveToken string - expectedHosts string - expectedStderr string - expectedErr error + name string + opts SwitchOptions + cfgHosts []hostUsers + env map[string]string + + expectedSuccess successfulExpectation + expectedFailure failedExpectation prompterStubs func(*prompter.PrompterMock) }{ @@ -117,11 +125,13 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedHostToSwitch: "github.com", - expectedActiveUser: "inactive-user", - expectedActiveToken: "inactive-user-token", - expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - expectedStderr: "✓ Switched active account on github.com to 'inactive-user'", + expectedSuccess: successfulExpectation{ + switchedHost: "github.com", + activeUser: "inactive-user", + activeToken: "inactive-user-token", + hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + stderr: "✓ Switched active account on github.com to 'inactive-user'", + }, }, { name: "given one host, with three users, switches to the specified user", @@ -135,11 +145,13 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedHostToSwitch: "github.com", - expectedActiveUser: "inactive-user-2", - expectedActiveToken: "inactive-user-2-token", - expectedHosts: "github.com:\n users:\n inactive-user-1:\n git_protocol: ssh\n inactive-user-2:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user-2\n", - expectedStderr: "✓ Switched active account on github.com to 'inactive-user-2'", + expectedSuccess: successfulExpectation{ + switchedHost: "github.com", + activeUser: "inactive-user-2", + activeToken: "inactive-user-2-token", + hostsCfg: "github.com:\n users:\n inactive-user-1:\n git_protocol: ssh\n inactive-user-2:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user-2\n", + stderr: "✓ Switched active account on github.com to 'inactive-user-2'", + }, }, { name: "given multiple hosts, with multiple users, switches to the specific user on the host", @@ -157,17 +169,21 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedHostToSwitch: "ghe.io", - expectedActiveUser: "inactive-user", - expectedActiveToken: "inactive-user-token", - expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - expectedStderr: "✓ Switched active account on ghe.io to 'inactive-user'", + expectedSuccess: successfulExpectation{ + switchedHost: "ghe.io", + activeUser: "inactive-user", + activeToken: "inactive-user-token", + hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + stderr: "✓ Switched active account on ghe.io to 'inactive-user'", + }, }, { - name: "given we're not logged into any hosts, provide an informative error", - opts: SwitchOptions{}, - cfgHosts: []hostUsers{}, - expectedErr: fmt.Errorf("not logged in to any hosts"), + name: "given we're not logged into any hosts, provide an informative error", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{}, + expectedFailure: failedExpectation{ + err: errors.New("not logged in to any hosts"), + }, }, { name: "given we can't disambiguate users across hosts", @@ -184,7 +200,9 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedErr: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + expectedFailure: failedExpectation{ + err: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + }, }, { name: "given we can't disambiguate user on a single host", @@ -198,7 +216,9 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedErr: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + expectedFailure: failedExpectation{ + err: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + }, }, { name: "given the auth token isn't writeable (e.g. a token env var is set)", @@ -209,9 +229,11 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - env: map[string]string{"GH_TOKEN": "unimportant-test-value"}, - expectedErr: cmdutil.SilentError, - expectedStderr: "The value of the GH_TOKEN environment variable is being used for authentication.", + env: map[string]string{"GH_TOKEN": "unimportant-test-value"}, + expectedFailure: failedExpectation{ + err: cmdutil.SilentError, + stderr: "The value of the GH_TOKEN environment variable is being used for authentication.", + }, }, { name: "specified hostname doesn't exist", @@ -224,7 +246,9 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedErr: errors.New("not logged in to ghe.io"), + expectedFailure: failedExpectation{ + err: errors.New("not logged in to ghe.io"), + }, }, { name: "specified user doesn't exist on host", @@ -238,7 +262,9 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedErr: errors.New("not logged in as non-existent-user on github.com"), + expectedFailure: failedExpectation{ + err: errors.New("not logged in as non-existent-user on github.com"), + }, }, { name: "specified user doesn't exist on any host", @@ -253,7 +279,9 @@ func TestSwitchRun(t *testing.T) { {"active-user", "active-user-token"}, }}, }, - expectedErr: errors.New("no user accounts matched that criteria"), + expectedFailure: failedExpectation{ + err: errors.New("no user accounts matched that criteria"), + }, }, { name: "when options need to be disambiguated, the user is prompted with matrix of options including active users (if possible)", @@ -281,11 +309,13 @@ func TestSwitchRun(t *testing.T) { return prompter.IndexFor(opts, "inactive-user (ghe.io)") } }, - expectedHostToSwitch: "ghe.io", - expectedActiveUser: "inactive-user", - expectedActiveToken: "inactive-user-token", - expectedHosts: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - expectedStderr: "✓ Switched active account on ghe.io to 'inactive-user'", + expectedSuccess: successfulExpectation{ + switchedHost: "ghe.io", + activeUser: "inactive-user", + activeToken: "inactive-user-token", + hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + stderr: "✓ Switched active account on ghe.io to 'inactive-user'", + }, }, } @@ -309,7 +339,7 @@ func TestSwitchRun(t *testing.T) { for _, hostUsers := range tt.cfgHosts { for _, user := range hostUsers.users { _, err := cfg.Authentication().Login( - string(hostUsers.host), + hostUsers.host, user.name, user.token, "ssh", true, ) @@ -327,27 +357,27 @@ func TestSwitchRun(t *testing.T) { tt.opts.IO = ios err := switchRun(&tt.opts) - if tt.expectedErr != nil { - require.Equal(t, tt.expectedErr, err) - require.Contains(t, stderr.String(), tt.expectedStderr) + if tt.expectedFailure.err != nil { + require.Equal(t, tt.expectedFailure.err, err) + require.Contains(t, stderr.String(), tt.expectedFailure.stderr) return } require.NoError(t, err) - activeUser, err := cfg.Authentication().User(tt.expectedHostToSwitch) + activeUser, err := cfg.Authentication().User(tt.expectedSuccess.switchedHost) require.NoError(t, err) - require.Equal(t, tt.expectedActiveUser, activeUser) + require.Equal(t, tt.expectedSuccess.activeUser, activeUser) - activeToken, _ := cfg.Authentication().TokenFromKeyring(tt.expectedHostToSwitch) - require.Equal(t, tt.expectedActiveToken, activeToken) + activeToken, _ := cfg.Authentication().TokenFromKeyring(tt.expectedSuccess.switchedHost) + require.Equal(t, tt.expectedSuccess.activeToken, activeToken) hostsBuf := bytes.Buffer{} readConfigs(io.Discard, &hostsBuf) - require.Equal(t, tt.expectedHosts, hostsBuf.String()) + require.Equal(t, tt.expectedSuccess.hostsCfg, hostsBuf.String()) - require.Contains(t, stderr.String(), tt.expectedStderr) + require.Contains(t, stderr.String(), tt.expectedSuccess.stderr) }) } } From eca5f72328e9507f7f8431d4615e55f86d1d82d0 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 29 Nov 2023 21:07:21 +0000 Subject: [PATCH 21/62] UX polish and standardization of language --- internal/config/config.go | 3 +- pkg/cmd/auth/gitcredential/helper.go | 1 + pkg/cmd/auth/login/login.go | 13 +++---- pkg/cmd/auth/logout/logout.go | 51 ++++++++++++++-------------- pkg/cmd/auth/logout/logout_test.go | 44 ++++++++++++++---------- pkg/cmd/auth/refresh/refresh.go | 5 +-- pkg/cmd/auth/setupgit/setupgit.go | 1 + pkg/cmd/auth/switch/switch.go | 40 ++++++++++++++-------- pkg/cmd/auth/switch/switch_test.go | 28 +++++++-------- pkg/cmd/auth/token/token.go | 10 ++++-- 10 files changed, 111 insertions(+), 85 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 264fe64d1..562d528a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -334,7 +334,8 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure return insecureStorageUsed, c.SwitchUser(hostname, username) } -// TODO: Verify that git protocol switching works as expected +// TODO: Write tests +// TODO: How should git protocol be handled? Do we need to set it at the user level since it could have been changed? func (c *AuthConfig) SwitchUser(hostname, user string) error { // We first need to idempotently clear out any set tokens for the host _ = keyring.Delete(keyringServiceName(hostname), "") diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index fcceaf186..a40043e7a 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -55,6 +55,7 @@ func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) * return cmd } +// TODO: In multi-account we should use active user token only if the username is not passed in. func helperRun(opts *CredentialOptions) error { if opts.Operation == "store" { // We pretend to implement the "store" operation, but do nothing since we already have a cached token. diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index d3184dbd0..ac377c4cc 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -53,14 +53,15 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "login", Args: cobra.ExactArgs(0), - Short: "Authenticate with a GitHub host", + Short: "Log in to a GitHub account", Long: heredoc.Docf(` Authenticate with a GitHub host. The default authentication mode is a web-based browser flow. After completion, an authentication token will be stored securely in the system credential store. - If a credential store is not found or there is an issue using it gh will fallback to writing the token to a plain text file. - See %[1]sgh auth status%[1]s for its stored location. + If a credential store is not found or there is an issue using it gh will fallback + to writing the token to a plain text file. See %[1]sgh auth status%[1]s for its + stored location. Alternatively, use %[1]s--with-token%[1]s to pass in a token on standard input. The minimum required scopes for the token are: %[1]srepo%[1]s, %[1]sread:org%[1]s, and %[1]sgist%[1]s. @@ -72,13 +73,13 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm To use gh in GitHub Actions, add %[1]sGH_TOKEN: ${{ github.token }}%[1]s to %[1]senv%[1]s. `, "`"), Example: heredoc.Doc(` - # start interactive setup + # Start interactive setup $ gh auth login - # authenticate against github.com by reading the token from a file + # Authenticate against github.com by reading the token from a file $ gh auth login --with-token < mytoken.txt - # authenticate with a specific GitHub instance + # Authenticate with specific host $ gh auth login --hostname enterprise.internal `), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index a400dfc93..a47435dad 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -31,18 +31,21 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "logout", Args: cobra.ExactArgs(0), - Short: "Log out of a GitHub user account", - Long: heredoc.Docf(` - Remove authentication for a GitHub user account. + Short: "Log out of a GitHub account", + Long: heredoc.Doc(` + Remove authentication for a GitHub account. - This command removes the authentication configuration for a user account - either specified interactively or via the %[1]s--hostname%[1]s and %[1]s--user%[1]s flags. - `, "`"), + This command removes the stored authentication configuration + for an account. The authentication configuration is only + removed locally. + + This command does not invalidate authentication tokens. + `), Example: heredoc.Doc(` - # Select what host and user account to log out of via a prompt + # Select what host and account to log out of via a prompt $ gh auth logout - # Log out of specified user account on specified host + # Log out of a specific host and specific account $ gh auth logout --hostname enterprise.internal --user monalisa `), RunE: func(cmd *cobra.Command, args []string) error { @@ -55,7 +58,7 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to log out of") - cmd.Flags().StringVarP(&opts.Username, "user", "u", "", "The user account to log out of") + cmd.Flags().StringVarP(&opts.Username, "user", "u", "", "The account to log out of") return cmd } @@ -83,7 +86,7 @@ func logoutRun(opts *LogoutOptions) error { if username != "" { knownUsers, _ := cfg.Authentication().UsersForHost(hostname) if !slices.Contains(knownUsers, username) { - return fmt.Errorf("not logged in as %s on %s", username, hostname) + return fmt.Errorf("not logged in to %s account %s", hostname, username) } } } @@ -111,12 +114,12 @@ func logoutRun(opts *LogoutOptions) error { } if len(candidates) == 0 { - return errors.New("no user accounts matched that criteria") + return errors.New("no accounts matched that criteria") } else if len(candidates) == 1 { hostname = candidates[0].host username = candidates[0].user } else if !opts.IO.CanPrompt() { - return errors.New("unable to determine which user account to log out of, please specify `--hostname` and `--user`") + return errors.New("unable to determine which account to log out of, please specify `--hostname` and `--user`") } else { prompts := make([]string, len(candidates)) for i, c := range candidates { @@ -141,24 +144,20 @@ func logoutRun(opts *LogoutOptions) error { preLogoutActiveUser, _ := authCfg.User(hostname) if err := authCfg.Logout(hostname, username); err != nil { - return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) + return err } - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + postLogoutActiveUser, _ := authCfg.User(hostname) + hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && + postLogoutActiveUser != "" - if isTTY { - postLogoutActiveUser, _ := authCfg.User(hostname) - hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && - postLogoutActiveUser != "" + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s account %s\n", + cs.SuccessIcon(), hostname, cs.Bold(username)) - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s account '%s'\n", - cs.SuccessIcon(), cs.Bold(hostname), username) - - if hasSwitchedToNewUser { - fmt.Fprintf(opts.IO.ErrOut, "%s Switched account to '%s'\n", - cs.SuccessIcon(), cs.Bold(postLogoutActiveUser)) - } + if hasSwitchedToNewUser { + fmt.Fprintf(opts.IO.ErrOut, "%s Switched active account for %s to %s\n", + cs.SuccessIcon(), hostname, cs.Bold(postLogoutActiveUser)) } return nil diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index d3bfa6ca2..97f8bba93 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -147,7 +147,7 @@ func Test_logoutRun_tty(t *testing.T) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out prompted user when multiple known hosts with multiple users each", @@ -162,7 +162,7 @@ func Test_logoutRun_tty(t *testing.T) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out only logged in user", @@ -171,7 +171,7 @@ func Test_logoutRun_tty(t *testing.T) { {"github.com", []user{"monalisa"}}, }, wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out prompted user when one known host with multiple users", @@ -185,7 +185,7 @@ func Test_logoutRun_tty(t *testing.T) { } }, wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: abc123\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user when multiple known hosts with one user each", @@ -198,7 +198,7 @@ func Test_logoutRun_tty(t *testing.T) { {"github.com", []user{"monalisa"}}, }, wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", - wantErrOut: regexp.MustCompile(`Logged out of ghe.io account 'monalisa-ghe'`), + wantErrOut: regexp.MustCompile(`Logged out of ghe.io account monalisa-ghe`), }, { name: "logs out specified user that is using secure storage", @@ -211,7 +211,7 @@ func Test_logoutRun_tty(t *testing.T) { {"github.com", []user{"monalisa"}}, }, wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account 'monalisa'`), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "errors when no known hosts", @@ -238,7 +238,7 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts: []hostUsers{ {"ghe.io", []user{"monalisa-ghe"}}, }, - wantErr: "not logged in as unknown-user on ghe.io", + wantErr: "not logged in to ghe.io account unknown-user", }, { name: "errors when user is specified but doesn't exist on any host", @@ -249,7 +249,7 @@ func Test_logoutRun_tty(t *testing.T) { {"github.com", []user{"monalisa"}}, {"ghe.io", []user{"monalisa"}}, }, - wantErr: "no user accounts matched that criteria", + wantErr: "no accounts matched that criteria", }, } @@ -316,6 +316,7 @@ func Test_logoutRun_nontty(t *testing.T) { secureStorage bool ghtoken string wantHosts string + wantErrOut *regexp.Regexp wantErr string }{ { @@ -327,7 +328,8 @@ func Test_logoutRun_nontty(t *testing.T) { cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, }, - wantHosts: "{}\n", + wantHosts: "{}\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user when multiple known hosts", @@ -339,7 +341,8 @@ func Test_logoutRun_nontty(t *testing.T) { {"github.com", []user{"monalisa"}}, {"ghe.io", []user{"monalisa-ghe"}}, }, - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user that is using secure storage", @@ -351,7 +354,8 @@ func Test_logoutRun_nontty(t *testing.T) { cfgHosts: []hostUsers{ {"github.com", []user{"monalisa"}}, }, - wantHosts: "{}\n", + wantHosts: "{}\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "errors when no known hosts", @@ -381,7 +385,7 @@ func Test_logoutRun_nontty(t *testing.T) { cfgHosts: []hostUsers{ {"ghe.io", []user{"monalisa-ghe"}}, }, - wantErr: "not logged in as unknown-user on ghe.io", + wantErr: "not logged in to ghe.io account unknown-user", }, { name: "errors when host is specified but user is ambiguous", @@ -392,7 +396,7 @@ func Test_logoutRun_nontty(t *testing.T) { {"ghe.io", []user{"monalisa-ghe"}}, {"ghe.io", []user{"monalisa-ghe-2"}}, }, - wantErr: "unable to determine which user account to log out of, please specify `--hostname` and `--user`", + wantErr: "unable to determine which account to log out of, please specify `--hostname` and `--user`", }, { name: "errors when user is specified but host is ambiguous", @@ -403,7 +407,7 @@ func Test_logoutRun_nontty(t *testing.T) { {"github.com", []user{"monalisa"}}, {"ghe.io", []user{"monalisa"}}, }, - wantErr: "unable to determine which user account to log out of, please specify `--hostname` and `--user`", + wantErr: "unable to determine which account to log out of, please specify `--hostname` and `--user`", }, } @@ -438,7 +442,11 @@ func Test_logoutRun_nontty(t *testing.T) { require.NoError(t, err) } - require.Equal(t, "", stderr.String()) + if tt.wantErrOut == nil { + require.Equal(t, "", stderr.String()) + } else { + require.True(t, tt.wantErrOut.MatchString(stderr.String()), stderr.String()) + } mainBuf := bytes.Buffer{} hostsBuf := bytes.Buffer{} @@ -454,7 +462,7 @@ func Test_logoutRun_nontty(t *testing.T) { func TestLogoutSwitchesUserNonTTY(t *testing.T) { keyring.MockInit() - ios, _, _, _ := iostreams.Test() + ios, _, _, stderr := iostreams.Test() ios.SetStdinTTY(false) ios.SetStdoutTTY(false) @@ -493,6 +501,8 @@ func TestLogoutSwitchesUserNonTTY(t *testing.T) { `) require.Equal(t, expectedHosts, hostsBuf.String()) + + require.Contains(t, stderr.String(), "✓ Switched active account for github.com to test-user-1") } func TestLogoutSwitchesUserTTY(t *testing.T) { @@ -542,5 +552,5 @@ func TestLogoutSwitchesUserTTY(t *testing.T) { require.Equal(t, expectedHosts, hostsBuf.String()) - require.Contains(t, stderr.String(), "✓ Switched account to 'test-user-1'") + require.Contains(t, stderr.String(), "✓ Switched active account for github.com to test-user-1") } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 2335a9952..cae48de4a 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -35,15 +35,12 @@ type RefreshOptions struct { InsecureStorage bool } +// TODO: Determine if this is super wonky in multi-account world. Do we need a --user flag? func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { opts := &RefreshOptions{ IO: f.IOStreams, Config: f.Config, AuthFlow: func(authCfg *config.AuthConfig, io *iostreams.IOStreams, hostname string, scopes []string, interactive, secureStorage bool) error { - if secureStorage { - cs := io.ColorScheme() - fmt.Fprintf(io.ErrOut, "%s Using secure storage could break installed extensions", cs.WarningIcon()) - } token, username, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) if err != nil { return err diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index 8ba35a78c..730e36bf1 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -100,6 +100,7 @@ func setupGitRun(opts *SetupGitOptions) error { } for _, hostname := range hostnamesToSetup { + //TODO: Does Setup need to take a list of all logged in users and tokens on a host? if err := opts.gitConfigure.Setup(hostname, "", ""); err != nil { return fmt.Errorf("failed to set up git credential helper: %w", err) } diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go index 0fde876d6..1106b7832 100644 --- a/pkg/cmd/auth/switch/switch.go +++ b/pkg/cmd/auth/switch/switch.go @@ -18,7 +18,7 @@ type SwitchOptions struct { Config func() (config.Config, error) Prompter shared.Prompt Hostname string - User string + Username string } func NewCmdSwitch(f *cmdutil.Factory, runF func(*SwitchOptions) error) *cobra.Command { @@ -29,11 +29,22 @@ func NewCmdSwitch(f *cmdutil.Factory, runF func(*SwitchOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "switch", - Args: cobra.ExactArgs(0), - Short: "Switch to another GitHub account", - Long: heredoc.Doc(""), - Example: heredoc.Doc(""), + Use: "switch", + Args: cobra.ExactArgs(0), + Short: "Switch active GitHub account", + Long: heredoc.Doc(` + Switch the active account for a GitHub host. + + This command changes the authentication configuration that will + be used when running commands targeting the specified GitHub host. + `), + Example: heredoc.Doc(` + # Select what host and account to switch to via a prompt + $ gh auth switch + + # Switch to a specific host and specific account + $ gh auth logout --hostname enterprise.internal --user monalisa + `), RunE: func(c *cobra.Command, args []string) error { if runF != nil { return runF(&opts) @@ -43,8 +54,8 @@ func NewCmdSwitch(f *cmdutil.Factory, runF func(*SwitchOptions) error) *cobra.Co }, } - cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to switch account on") - cmd.Flags().StringVarP(&opts.User, "user", "u", "", "The user to switch to") + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to switch account for") + cmd.Flags().StringVarP(&opts.Username, "user", "u", "", "The account to switch to") return cmd } @@ -69,7 +80,7 @@ func (c candidates) inactiveOptions() []hostUser { func switchRun(opts *SwitchOptions) error { hostname := opts.Hostname - username := opts.User + username := opts.Username cfg, err := opts.Config() if err != nil { @@ -90,7 +101,7 @@ func switchRun(opts *SwitchOptions) error { if username != "" { knownUsers, _ := cfg.Authentication().UsersForHost(hostname) if !slices.Contains(knownUsers, username) { - return fmt.Errorf("not logged in as %s on %s", username, hostname) + return fmt.Errorf("not logged in to %s account %s", hostname, username) } } } @@ -119,7 +130,7 @@ func switchRun(opts *SwitchOptions) error { inactiveCandidates := candidates.inactiveOptions() if len(candidates) == 0 { - return errors.New("no user accounts matched that criteria") + return errors.New("no accounts matched that criteria") } else if len(candidates) == 1 { hostname = candidates[0].host username = candidates[0].user @@ -127,7 +138,7 @@ func switchRun(opts *SwitchOptions) error { hostname = inactiveCandidates[0].host username = inactiveCandidates[0].user } else if !opts.IO.CanPrompt() { - return errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`") + return errors.New("unable to determine which account to switch to, please specify `--hostname` and `--user`") } else { prompts := make([]string, len(candidates)) for i, c := range candidates { @@ -152,13 +163,12 @@ func switchRun(opts *SwitchOptions) error { return cmdutil.SilentError } - err = authCfg.SwitchUser(hostname, username) - if err != nil { + if err := authCfg.SwitchUser(hostname, username); err != nil { return err } cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Switched active account on %s to '%s'\n", + fmt.Fprintf(opts.IO.ErrOut, "%s Switched active account for %s to %s\n", cs.SuccessIcon(), hostname, cs.Bold(username)) return nil diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index 0d1552478..237f37172 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -38,7 +38,7 @@ func TestNewCmdSwitch(t *testing.T) { name: "user flag", input: "--user monalisa", expectedOpts: SwitchOptions{ - User: "monalisa", + Username: "monalisa", }, }, { @@ -130,13 +130,13 @@ func TestSwitchRun(t *testing.T) { activeUser: "inactive-user", activeToken: "inactive-user-token", hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - stderr: "✓ Switched active account on github.com to 'inactive-user'", + stderr: "✓ Switched active account for github.com to inactive-user", }, }, { name: "given one host, with three users, switches to the specified user", opts: SwitchOptions{ - User: "inactive-user-2", + Username: "inactive-user-2", }, cfgHosts: []hostUsers{ {"github.com", []user{ @@ -150,14 +150,14 @@ func TestSwitchRun(t *testing.T) { activeUser: "inactive-user-2", activeToken: "inactive-user-2-token", hostsCfg: "github.com:\n users:\n inactive-user-1:\n git_protocol: ssh\n inactive-user-2:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user-2\n", - stderr: "✓ Switched active account on github.com to 'inactive-user-2'", + stderr: "✓ Switched active account for github.com to inactive-user-2", }, }, { name: "given multiple hosts, with multiple users, switches to the specific user on the host", opts: SwitchOptions{ Hostname: "ghe.io", - User: "inactive-user", + Username: "inactive-user", }, cfgHosts: []hostUsers{ {"github.com", []user{ @@ -174,7 +174,7 @@ func TestSwitchRun(t *testing.T) { activeUser: "inactive-user", activeToken: "inactive-user-token", hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - stderr: "✓ Switched active account on ghe.io to 'inactive-user'", + stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, { @@ -188,7 +188,7 @@ func TestSwitchRun(t *testing.T) { { name: "given we can't disambiguate users across hosts", opts: SwitchOptions{ - User: "inactive-user", + Username: "inactive-user", }, cfgHosts: []hostUsers{ {"github.com", []user{ @@ -201,7 +201,7 @@ func TestSwitchRun(t *testing.T) { }}, }, expectedFailure: failedExpectation{ - err: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + err: errors.New("unable to determine which account to switch to, please specify `--hostname` and `--user`"), }, }, { @@ -217,7 +217,7 @@ func TestSwitchRun(t *testing.T) { }}, }, expectedFailure: failedExpectation{ - err: errors.New("unable to determine which user account to switch to, please specify `--hostname` and `--user`"), + err: errors.New("unable to determine which account to switch to, please specify `--hostname` and `--user`"), }, }, { @@ -254,7 +254,7 @@ func TestSwitchRun(t *testing.T) { name: "specified user doesn't exist on host", opts: SwitchOptions{ Hostname: "github.com", - User: "non-existent-user", + Username: "non-existent-user", }, cfgHosts: []hostUsers{ {"github.com", []user{ @@ -263,13 +263,13 @@ func TestSwitchRun(t *testing.T) { }}, }, expectedFailure: failedExpectation{ - err: errors.New("not logged in as non-existent-user on github.com"), + err: errors.New("not logged in to github.com account non-existent-user"), }, }, { name: "specified user doesn't exist on any host", opts: SwitchOptions{ - User: "non-existent-user", + Username: "non-existent-user", }, cfgHosts: []hostUsers{ {"github.com", []user{ @@ -280,7 +280,7 @@ func TestSwitchRun(t *testing.T) { }}, }, expectedFailure: failedExpectation{ - err: errors.New("no user accounts matched that criteria"), + err: errors.New("no accounts matched that criteria"), }, }, { @@ -314,7 +314,7 @@ func TestSwitchRun(t *testing.T) { activeUser: "inactive-user", activeToken: "inactive-user-token", hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", - stderr: "✓ Switched active account on ghe.io to 'inactive-user'", + stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, } diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index fee8dc638..bf4bd81cc 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -3,6 +3,7 @@ package token import ( "fmt" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -17,6 +18,7 @@ type TokenOptions struct { SecureStorage bool } +// TODO: Detmerine if this is wonky in multi-account world. Do we need a --user flag? func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Command { opts := &TokenOptions{ IO: f.IOStreams, @@ -25,8 +27,12 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "token", - Short: "Print the auth token gh is configured to use", - Args: cobra.ExactArgs(0), + Short: "Print the authentication token gh is configured to use", + Long: heredoc.Doc(` + This command outputs the authentication token for the active + account on a given GitHub host. + `), + Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) From 08c7bd1df2ef0697bc081eb0e309017912b39bc0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 30 Nov 2023 11:59:13 +0100 Subject: [PATCH 22/62] Use real config and login in status tests --- pkg/cmd/auth/status/status_test.go | 189 +++++++++++++---------------- 1 file changed, 81 insertions(+), 108 deletions(-) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index dfebddda3..eb14ae42e 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -10,11 +10,13 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_NewCmdStatus(t *testing.T) { @@ -74,13 +76,11 @@ func Test_NewCmdStatus(t *testing.T) { } func Test_statusRun(t *testing.T) { - readConfigs := config.StubWriteConfig(t) - tests := []struct { name string - opts *StatusOptions + opts StatusOptions httpStubs func(*httpmock.Registry) - cfgStubs func(*config.ConfigMock) + cfgStubs func(config.Config) wantErr string wantOut string wantErrOut string @@ -107,97 +107,72 @@ func Test_statusRun(t *testing.T) { }, { name: "hostname set", - opts: &StatusOptions{ - Hostname: "joel.miller", + opts: StatusOptions{ + Hostname: "ghe.io", }, - cfgStubs: func(c *config.ConfigMock) { - c.Set("joel.miller", "oauth_token", "abc123") - c.Set("github.com", "oauth_token", "abc123") + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) // mock for CurrentLoginName + // TODO: Remove in favour of asking the config for the user reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` - joel.miller - ✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for joel.miller configured to use https protocol. + ghe.io + ✓ Logged in to ghe.io as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use https protocol. ✓ Token: ****** ✓ Token scopes: repo,read:org `), }, { name: "missing scope", - opts: &StatusOptions{}, - cfgStubs: func(c *config.ConfigMock) { - c.Set("joel.miller", "oauth_token", "abc123") - c.Set("github.com", "oauth_token", "abc123") + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo")) - // mocks for HeaderHasMinimumScopes api requests to github.com host - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) - // mock for CurrentLoginName - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) }, wantErr: "SilentError", wantErrOut: heredoc.Doc(` - joel.miller - X joel.miller: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org' - - To request missing scopes, run: gh auth refresh -h joel.miller - - github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org + ghe.io + X ghe.io: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org' + - To request missing scopes, run: gh auth refresh -h ghe.io `), }, { name: "bad token", - opts: &StatusOptions{}, - cfgStubs: func(c *config.ConfigMock) { - c.Set("joel.miller", "oauth_token", "abc123") - c.Set("github.com", "oauth_token", "abc123") + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mock for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) - // mock for HeaderHasMinimumScopes api requests to github.com - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) - // mock for CurrentLoginName - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) }, wantErr: "SilentError", wantErrOut: heredoc.Doc(` - joel.miller - X joel.miller: authentication failed - - The joel.miller token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h joel.miller - - To forget about this host, run: gh auth logout -h joel.miller - - github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org + ghe.io + X ghe.io: authentication failed + - The ghe.io token in GH_CONFIG_DIR/hosts.yml is invalid. + - To re-authenticate, run: gh auth login -h ghe.io + - To forget about this host, run: gh auth logout -h ghe.io `), }, { name: "all good", - opts: &StatusOptions{}, - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "gho_abc123") - c.Set("joel.miller", "oauth_token", "gho_abc123") + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "gho_abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "ssh") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to github.com @@ -209,32 +184,33 @@ func Test_statusRun(t *testing.T) { httpmock.REST("GET", "api/v3/"), httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "")) // mock for CurrentLoginName, one for each host + // TODO: remove for user config reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa-ghe"}}}`)) }, wantOut: heredoc.Doc(` github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for github.com configured to use https protocol. ✓ Token: gho_****** ✓ Token scopes: repo, read:org - - joel.miller - ✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for joel.miller configured to use https protocol. + + ghe.io + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use ssh protocol. ✓ Token: gho_****** X Token scopes: none `), }, { name: "server-to-server token", - opts: &StatusOptions{}, - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "ghs_xxx") + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "ghs_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to github.com @@ -244,20 +220,20 @@ func Test_statusRun(t *testing.T) { // mock for CurrentLoginName reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ghs_*** + ✓ Token: ghs_****** `), }, { name: "PAT V2 token", - opts: &StatusOptions{}, - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "github_pat_xxx") + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "github_pat_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to github.com @@ -267,23 +243,23 @@ func Test_statusRun(t *testing.T) { // mock for CurrentLoginName reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for github.com configured to use https protocol. - ✓ Token: github_pat_*** + ✓ Token: github_pat_****** `), }, { name: "show token", - opts: &StatusOptions{ + opts: StatusOptions{ ShowToken: true, }, - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "xyz456") - c.Set("joel.miller", "oauth_token", "abc123") + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "xyz456", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes on a non-github.com host @@ -293,32 +269,32 @@ func Test_statusRun(t *testing.T) { // mock for CurrentLoginName, one for each host reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa-ghe"}}}`)) }, wantOut: heredoc.Doc(` github.com - ✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml) + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for github.com configured to use https protocol. - ✓ Token: xyz456 - ✓ Token scopes: repo,read:org - - joel.miller - ✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for joel.miller configured to use https protocol. ✓ Token: abc123 ✓ Token scopes: repo,read:org + + ghe.io + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use https protocol. + ✓ Token: xyz456 + ✓ Token scopes: repo,read:org `), }, { name: "missing hostname", - opts: &StatusOptions{ + opts: StatusOptions{ Hostname: "github.example.com", }, - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "abc123") + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) {}, wantErr: "SilentError", @@ -328,9 +304,7 @@ func Test_statusRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.opts == nil { - tt.opts = &StatusOptions{} - } + keyring.MockInit() ios, _, stdout, stderr := iostreams.Test() @@ -338,7 +312,7 @@ func Test_statusRun(t *testing.T) { ios.SetStderrTTY(true) ios.SetStdoutTTY(true) tt.opts.IO = ios - cfg := config.NewFromString("") + cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) } @@ -355,24 +329,23 @@ func Test_statusRun(t *testing.T) { tt.httpStubs(reg) } - err := statusRun(tt.opts) + err := statusRun(&tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) } else { - assert.NoError(t, err) + require.NoError(t, err) } output := strings.ReplaceAll(stdout.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/") errorOutput := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/") - assert.Equal(t, tt.wantErrOut, errorOutput) - assert.Equal(t, tt.wantOut, output) - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - readConfigs(&mainBuf, &hostsBuf) - - assert.Equal(t, "", mainBuf.String()) - assert.Equal(t, "", hostsBuf.String()) + require.Equal(t, tt.wantErrOut, errorOutput) + require.Equal(t, tt.wantOut, output) }) } } + +func login(t *testing.T, c config.Config, hostname, username, protocol, token string) { + t.Helper() + _, err := c.Authentication().Login(hostname, username, protocol, token, false) + require.NoError(t, err) +} From 5d10beb60a2af2032d536aaf8f1ace7c10fc3f78 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 30 Nov 2023 12:04:14 +0100 Subject: [PATCH 23/62] Use config to look up user for status --- pkg/cmd/auth/status/status.go | 7 ++----- pkg/cmd/auth/status/status_test.go | 30 +----------------------------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 831dfc503..2f684597c 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -137,11 +136,9 @@ func statusRun(opts *StatusOptions) error { } failed = true } else { - apiClient := api.NewClientFromHTTP(httpClient) - username, err := api.CurrentLoginName(apiClient, hostname) + username, err := authCfg.User(hostname) if err != nil { - addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err) - failed = true + return err } addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index eb14ae42e..7017e80c9 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -117,15 +117,10 @@ func Test_statusRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) - // mock for CurrentLoginName - // TODO: Remove in favour of asking the config for the user - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` ghe.io - ✓ Logged in to ghe.io as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for ghe.io configured to use https protocol. ✓ Token: ****** ✓ Token scopes: repo,read:org @@ -183,14 +178,6 @@ func Test_statusRun(t *testing.T) { reg.Register( httpmock.REST("GET", "api/v3/"), httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "")) - // mock for CurrentLoginName, one for each host - // TODO: remove for user config - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa-ghe"}}}`)) }, wantOut: heredoc.Doc(` github.com @@ -217,10 +204,6 @@ func Test_statusRun(t *testing.T) { reg.Register( httpmock.REST("GET", ""), httpmock.ScopesResponder("")) - // mock for CurrentLoginName - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` github.com @@ -240,10 +223,6 @@ func Test_statusRun(t *testing.T) { reg.Register( httpmock.REST("GET", ""), httpmock.ScopesResponder("")) - // mock for CurrentLoginName - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, wantOut: heredoc.Doc(` github.com @@ -266,13 +245,6 @@ func Test_statusRun(t *testing.T) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) // mocks for HeaderHasMinimumScopes on github.com reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) - // mock for CurrentLoginName, one for each host - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa-ghe"}}}`)) }, wantOut: heredoc.Doc(` github.com From e4ed4041cdee26a22d344228ef1264578b54001b Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 30 Nov 2023 12:44:59 +0100 Subject: [PATCH 24/62] Use auth config and only print stdout in status --- internal/config/config.go | 13 +++ pkg/cmd/auth/status/status.go | 124 ++++++++++++--------------- pkg/cmd/auth/status/status_test.go | 129 ++++++++++++++--------------- 3 files changed, 130 insertions(+), 136 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 562d528a8..393588b71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -411,6 +411,19 @@ func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { return users, nil } +func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) { + if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { + return token, "keyring", nil + } + + // If there is a token in the insecure config for the user, move it to the active field + if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { + return token, "oauth_token", nil + } + + return "", "default", fmt.Errorf("no token found for: %s", user) +} + func keyringServiceName(hostname string) string { return "gh:" + hostname } diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 2f684597c..b83269067 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -83,7 +83,6 @@ func statusRun(opts *StatusOptions) error { return err } - var failed bool var isHostnameFound bool for _, hostname := range hostnames { @@ -92,67 +91,64 @@ func statusRun(opts *StatusOptions) error { } isHostnameFound = true - token, tokenSource := authCfg.Token(hostname) - if tokenSource == "oauth_token" { - // The go-gh function TokenForHost returns this value as source for tokens read from the - // config file, but we want the file path instead. This attempts to reconstruct it. - tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") - } - _, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname) - statusInfo[hostname] = []string{} - addMsg := func(x string, ys ...interface{}) { - statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...)) - } - scopesHeader, err := shared.GetScopes(httpClient, hostname, token) - if err != nil { - var networkError net.Error - if errors.As(err, &networkError) && networkError.Timeout() { - addMsg("%s %s: timeout trying to connect to host", cs.Red("X"), hostname) - } else { - addMsg("%s %s: authentication failed", cs.Red("X"), hostname) - addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource) - if tokenIsWriteable { - addMsg("- To re-authenticate, run: %s %s", - cs.Bold("gh auth login -h"), cs.Bold(hostname)) - addMsg("- To forget about this host, run: %s %s", - cs.Bold("gh auth logout -h"), cs.Bold(hostname)) - } + users, _ := authCfg.UsersForHost(hostname) + for _, username := range users { + token, tokenSource, _ := authCfg.TokenForUser(hostname, username) + if tokenSource == "oauth_token" { + // The go-gh function TokenForHost returns this value as source for tokens read from the + // config file, but we want the file path instead. This attempts to reconstruct it. + tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") } - failed = true - continue - } + _, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname) - if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { - var missingScopes *shared.MissingScopesError - if errors.As(err, &missingScopes) { - addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err) - if tokenIsWriteable { - addMsg("- To request missing scopes, run: %s %s", - cs.Bold("gh auth refresh -h"), - cs.Bold(hostname)) - } + addMsg := func(x string, ys ...interface{}) { + statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...)) } - failed = true - } else { - username, err := authCfg.User(hostname) + + scopesHeader, err := shared.GetScopes(httpClient, hostname, token) if err != nil { - return err + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + addMsg("%s %s: timeout trying to connect to host", cs.Red("X"), hostname) + } else { + addMsg("%s %s: authentication failed", cs.Red("X"), hostname) + addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource) + if tokenIsWriteable { + addMsg("- To re-authenticate, run: %s %s", + cs.Bold("gh auth login -h"), cs.Bold(hostname)) + addMsg("- To forget about this host, run: %s %s", + cs.Bold("gh auth logout -h"), cs.Bold(hostname)) + } + } + continue } - addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) - proto := cfg.GitProtocol(hostname) - if proto != "" { - addMsg("%s Git operations for %s configured to use %s protocol.", - cs.SuccessIcon(), hostname, cs.Bold(proto)) - } - addMsg("%s Token: %s", cs.SuccessIcon(), displayToken(token, opts.ShowToken)) + if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { + var missingScopes *shared.MissingScopesError + if errors.As(err, &missingScopes) { + addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err) + if tokenIsWriteable { + addMsg("- To request missing scopes, run: %s %s", + cs.Bold("gh auth refresh -h"), + cs.Bold(hostname)) + } + } + } else { + addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) + proto := cfg.GitProtocol(hostname) + if proto != "" { + addMsg("%s Git operations for %s configured to use %s protocol.", + cs.SuccessIcon(), hostname, cs.Bold(proto)) + } + addMsg("%s Token: %s", cs.SuccessIcon(), displayToken(token, opts.ShowToken)) - if scopesHeader != "" { - addMsg("%s Token scopes: %s", cs.SuccessIcon(), scopesHeader) - } else if expectScopes(token) { - addMsg("%s Token scopes: none", cs.Red("X")) + if scopesHeader != "" { + addMsg("%s Token scopes: %s", cs.SuccessIcon(), scopesHeader) + } else if expectScopes(token) { + addMsg("%s Token scopes: none", cs.Red("X")) + } } } } @@ -169,29 +165,17 @@ func statusRun(opts *StatusOptions) error { if !ok { continue } - if prevEntry && failed { - fmt.Fprint(stderr, "\n") - } else if prevEntry && !failed { + + if prevEntry { fmt.Fprint(stdout, "\n") } prevEntry = true - if failed { - fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname)) - for _, line := range lines { - fmt.Fprintf(stderr, " %s\n", line) - } - } else { - fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname)) - for _, line := range lines { - fmt.Fprintf(stdout, " %s\n", line) - } + fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname)) + for _, line := range lines { + fmt.Fprintf(stdout, " %s\n", line) } } - if failed { - return cmdutil.SilentError - } - return nil } diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 7017e80c9..307742c56 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -81,29 +81,28 @@ func Test_statusRun(t *testing.T) { opts StatusOptions httpStubs func(*httpmock.Registry) cfgStubs func(config.Config) - wantErr string + wantErr error wantOut string wantErrOut string }{ { name: "timeout error", - opts: &StatusOptions{ - Hostname: "joel.miller", + opts: StatusOptions{ + Hostname: "github.com", }, - cfgStubs: func(c *config.ConfigMock) { - c.Set("joel.miller", "oauth_token", "abc123") + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), func(req *http.Request) (*http.Response, error) { + reg.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) { // timeout error return nil, context.DeadlineExceeded }) }, - wantErr: "SilentError", - wantErrOut: heredoc.Doc(` - joel.miller - X joel.miller: timeout trying to connect to host - `), + wantOut: heredoc.Doc(` + github.com + X github.com: timeout trying to connect to host + `), }, { name: "hostname set", @@ -119,12 +118,12 @@ func Test_statusRun(t *testing.T) { reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) }, wantOut: heredoc.Doc(` - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org - `), + ghe.io + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use https protocol. + ✓ Token: ****** + ✓ Token scopes: repo,read:org + `), }, { name: "missing scope", @@ -136,12 +135,11 @@ func Test_statusRun(t *testing.T) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo")) }, - wantErr: "SilentError", - wantErrOut: heredoc.Doc(` - ghe.io - X ghe.io: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org' - - To request missing scopes, run: gh auth refresh -h ghe.io - `), + wantOut: heredoc.Doc(` + ghe.io + X ghe.io: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org' + - To request missing scopes, run: gh auth refresh -h ghe.io + `), }, { name: "bad token", @@ -153,14 +151,13 @@ func Test_statusRun(t *testing.T) { // mock for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) }, - wantErr: "SilentError", - wantErrOut: heredoc.Doc(` - ghe.io - X ghe.io: authentication failed - - The ghe.io token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io - - To forget about this host, run: gh auth logout -h ghe.io - `), + wantOut: heredoc.Doc(` + ghe.io + X ghe.io: authentication failed + - The ghe.io token in GH_CONFIG_DIR/hosts.yml is invalid. + - To re-authenticate, run: gh auth login -h ghe.io + - To forget about this host, run: gh auth logout -h ghe.io + `), }, { name: "all good", @@ -180,18 +177,18 @@ func Test_statusRun(t *testing.T) { httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: gho_****** - ✓ Token scopes: repo, read:org + github.com + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: gho_****** + ✓ Token scopes: repo, read:org - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use ssh protocol. - ✓ Token: gho_****** - X Token scopes: none - `), + ghe.io + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use ssh protocol. + ✓ Token: gho_****** + X Token scopes: none + `), }, { name: "server-to-server token", @@ -206,11 +203,11 @@ func Test_statusRun(t *testing.T) { httpmock.ScopesResponder("")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ghs_****** - `), + github.com + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: ghs_****** + `), }, { name: "PAT V2 token", @@ -225,11 +222,11 @@ func Test_statusRun(t *testing.T) { httpmock.ScopesResponder("")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: github_pat_****** - `), + github.com + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: github_pat_****** + `), }, { name: "show token", @@ -247,18 +244,18 @@ func Test_statusRun(t *testing.T) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: abc123 - ✓ Token scopes: repo,read:org + github.com + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: abc123 + ✓ Token scopes: repo,read:org - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use https protocol. - ✓ Token: xyz456 - ✓ Token scopes: repo,read:org - `), + ghe.io + ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for ghe.io configured to use https protocol. + ✓ Token: xyz456 + ✓ Token scopes: repo,read:org + `), }, { name: "missing hostname", @@ -269,7 +266,7 @@ func Test_statusRun(t *testing.T) { login(t, c, "github.com", "monalisa", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) {}, - wantErr: "SilentError", + wantErr: cmdutil.SilentError, wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n", }, } @@ -302,8 +299,8 @@ func Test_statusRun(t *testing.T) { } err := statusRun(&tt.opts) - if tt.wantErr != "" { - require.EqualError(t, err, tt.wantErr) + if tt.wantErr != nil { + require.Equal(t, err, tt.wantErr) } else { require.NoError(t, err) } From b2997cc7bdb5eae93afc96bc3ca71bae0720f2ec Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 30 Nov 2023 13:17:23 +0100 Subject: [PATCH 25/62] Handle multi account golden path in auth status --- pkg/cmd/auth/status/status.go | 178 ++++++++++++++++++++++------- pkg/cmd/auth/status/status_test.go | 24 ++++ 2 files changed, 163 insertions(+), 39 deletions(-) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index b83269067..36203bd6e 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -16,6 +16,120 @@ import ( "github.com/spf13/cobra" ) +type validEntry struct { + host string + user string + token string + tokenSource string + gitProtocol string + scopes string +} + +func (e validEntry) String(cs *iostreams.ColorScheme) string { + var sb strings.Builder + + sb.WriteString( + fmt.Sprintf(" %s Logged in to %s as %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource), + ) + if e.gitProtocol != "" { + sb.WriteString(fmt.Sprintf(" %s Git operations for %s configured to use %s protocol.\n", + cs.SuccessIcon(), e.host, cs.Bold(e.gitProtocol))) + } + sb.WriteString(fmt.Sprintf(" %s Token: %s\n", cs.SuccessIcon(), e.token)) + + if e.scopes != "" { + sb.WriteString(fmt.Sprintf(" %s Token scopes: %s\n", cs.SuccessIcon(), e.scopes)) + } else if expectScopes(e.token) { + sb.WriteString(fmt.Sprintf(" %s Token scopes: none\n", cs.Red("X"))) + } + + return sb.String() +} + +type missingScopes []string + +func (ms missingScopes) String() string { + var missing []string + for _, s := range ms { + missing = append(missing, fmt.Sprintf("'%s'", s)) + } + scopes := strings.Join(missing, ", ") + + if len(ms) == 1 { + return "missing required scope " + scopes + } + return "missing required scopes " + scopes +} + +type missingScopesEntry struct { + host string + tokenSource string + missingScopes missingScopes + tokenIsWriteable bool +} + +func (e missingScopesEntry) String(cs *iostreams.ColorScheme) string { + var sb strings.Builder + + sb.WriteString( + fmt.Sprintf(" %s %s: the token in %s is %s\n", cs.Red("X"), e.host, e.tokenSource, e.missingScopes), + ) + if e.tokenIsWriteable { + sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s %s\n", + cs.Bold("gh auth refresh -h"), + cs.Bold(e.host))) + } + + return sb.String() +} + +type invalidTokenEntry struct { + host string + tokenSource string + tokenIsWriteable bool +} + +func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf(" %s %s: authentication failed\n", cs.Red("X"), e.host)) + sb.WriteString(fmt.Sprintf(" - The %s token in %s is invalid.\n", cs.Bold(e.host), e.tokenSource)) + if e.tokenIsWriteable { + sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s %s\n", + cs.Bold("gh auth login -h"), cs.Bold(e.host))) + sb.WriteString(fmt.Sprintf(" - To forget about this host, run: %s %s\n", + cs.Bold("gh auth logout -h"), cs.Bold(e.host))) + } + + return sb.String() +} + +type timeoutErrorEntry struct { + host string +} + +func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf(" %s %s: timeout trying to connect to host\n", cs.Red("X"), e.host)) + + return sb.String() +} + +type Entry interface { + String(cs *iostreams.ColorScheme) string +} + +type Entries []Entry + +func (e Entries) Strings(cs *iostreams.ColorScheme) []string { + var out []string + for _, entry := range e { + out = append(out, entry.String(cs)) + } + return out +} + type StatusOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -69,7 +183,7 @@ func statusRun(opts *StatusOptions) error { stdout := opts.IO.Out cs := opts.IO.ColorScheme() - statusInfo := map[string][]string{} + statuses := make(map[string]Entries) hostnames := authCfg.Hosts() if len(hostnames) == 0 { @@ -91,8 +205,6 @@ func statusRun(opts *StatusOptions) error { } isHostnameFound = true - statusInfo[hostname] = []string{} - users, _ := authCfg.UsersForHost(hostname) for _, username := range users { token, tokenSource, _ := authCfg.TokenForUser(hostname, username) @@ -103,52 +215,42 @@ func statusRun(opts *StatusOptions) error { } _, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname) - addMsg := func(x string, ys ...interface{}) { - statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...)) - } - scopesHeader, err := shared.GetScopes(httpClient, hostname, token) if err != nil { var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { - addMsg("%s %s: timeout trying to connect to host", cs.Red("X"), hostname) + statuses[hostname] = append(statuses[hostname], timeoutErrorEntry{ + host: hostname, + }) } else { - addMsg("%s %s: authentication failed", cs.Red("X"), hostname) - addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource) - if tokenIsWriteable { - addMsg("- To re-authenticate, run: %s %s", - cs.Bold("gh auth login -h"), cs.Bold(hostname)) - addMsg("- To forget about this host, run: %s %s", - cs.Bold("gh auth logout -h"), cs.Bold(hostname)) - } + statuses[hostname] = append(statuses[hostname], invalidTokenEntry{ + host: hostname, + tokenSource: tokenSource, + tokenIsWriteable: tokenIsWriteable, + }) } + continue } if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { var missingScopes *shared.MissingScopesError if errors.As(err, &missingScopes) { - addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err) - if tokenIsWriteable { - addMsg("- To request missing scopes, run: %s %s", - cs.Bold("gh auth refresh -h"), - cs.Bold(hostname)) - } + statuses[hostname] = append(statuses[hostname], missingScopesEntry{ + host: hostname, + tokenSource: tokenSource, + missingScopes: missingScopes.MissingScopes, + tokenIsWriteable: tokenIsWriteable, + }) } } else { - addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) - proto := cfg.GitProtocol(hostname) - if proto != "" { - addMsg("%s Git operations for %s configured to use %s protocol.", - cs.SuccessIcon(), hostname, cs.Bold(proto)) - } - addMsg("%s Token: %s", cs.SuccessIcon(), displayToken(token, opts.ShowToken)) - - if scopesHeader != "" { - addMsg("%s Token scopes: %s", cs.SuccessIcon(), scopesHeader) - } else if expectScopes(token) { - addMsg("%s Token scopes: none", cs.Red("X")) - } + statuses[hostname] = append(statuses[hostname], validEntry{ + host: hostname, + user: username, + token: displayToken(token, opts.ShowToken), + tokenSource: tokenSource, + gitProtocol: cfg.GitProtocol(hostname), + scopes: scopesHeader}) } } } @@ -161,7 +263,7 @@ func statusRun(opts *StatusOptions) error { prevEntry := false for _, hostname := range hostnames { - lines, ok := statusInfo[hostname] + entries, ok := statuses[hostname] if !ok { continue } @@ -171,9 +273,7 @@ func statusRun(opts *StatusOptions) error { } prevEntry = true fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname)) - for _, line := range lines { - fmt.Fprintf(stdout, " %s\n", line) - } + fmt.Fprintf(stdout, "%s", strings.Join(entries.Strings(cs), "\n")) } return nil diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 307742c56..253158121 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -269,6 +269,30 @@ func Test_statusRun(t *testing.T) { wantErr: cmdutil.SilentError, wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n", }, + { + name: "multiple accounts on a host", + opts: StatusOptions{}, + cfgStubs: func(c config.Config) { + login(t, c, "github.com", "monalisa", "abc123", "https") + login(t, c, "github.com", "monalisa-2", "abc123", "https") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,project:read")) + }, + wantOut: heredoc.Doc(` + github.com + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: ****** + ✓ Token scopes: repo,read:org + + ✓ Logged in to github.com as monalisa-2 (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: ****** + ✓ Token scopes: repo,read:org,project:read + `), + }, } for _, tt := range tests { From 760dc91faa243e9fd8b7bd60fdf5263465084fc6 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 30 Nov 2023 16:00:20 +0000 Subject: [PATCH 26/62] Include environment variable users in auth status output --- internal/config/config.go | 1 + pkg/cmd/auth/status/status.go | 164 +++++++++++++++++++++-------- pkg/cmd/auth/status/status_test.go | 22 ++-- 3 files changed, 130 insertions(+), 57 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 393588b71..c30f4e294 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -411,6 +411,7 @@ func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { return users, nil } +// TODO: Write tests and explore implementation and return value more func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) { if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { return token, "keyring", nil diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 36203bd6e..fe6fc1077 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -17,6 +18,7 @@ import ( ) type validEntry struct { + active bool host string user string token string @@ -40,7 +42,7 @@ func (e validEntry) String(cs *iostreams.ColorScheme) string { if e.scopes != "" { sb.WriteString(fmt.Sprintf(" %s Token scopes: %s\n", cs.SuccessIcon(), e.scopes)) } else if expectScopes(e.token) { - sb.WriteString(fmt.Sprintf(" %s Token scopes: none\n", cs.Red("X"))) + sb.WriteString(fmt.Sprintf(" %s Token scopes: none\n", cs.WarningIcon())) } return sb.String() @@ -62,6 +64,7 @@ func (ms missingScopes) String() string { } type missingScopesEntry struct { + active bool host string tokenSource string missingScopes missingScopes @@ -84,6 +87,7 @@ func (e missingScopesEntry) String(cs *iostreams.ColorScheme) string { } type invalidTokenEntry struct { + active bool host string tokenSource string tokenIsWriteable bool @@ -177,8 +181,6 @@ func statusRun(opts *StatusOptions) error { } authCfg := cfg.Authentication() - // TODO check tty - stderr := opts.IO.ErrOut stdout := opts.IO.Out cs := opts.IO.ColorScheme() @@ -205,53 +207,39 @@ func statusRun(opts *StatusOptions) error { } isHostnameFound = true + var activeUser string + gitProtocol := cfg.GitProtocol(hostname) + activeUserToken, activeUserTokenSource := authCfg.Token(hostname) + if authTokenWriteable(activeUserTokenSource) { + activeUser, _ = authCfg.User(hostname) + } + entry := buildEntry(httpClient, buildEntryOptions{ + active: true, + gitProtocol: gitProtocol, + hostname: hostname, + showToken: opts.ShowToken, + token: activeUserToken, + tokenSource: activeUserTokenSource, + username: activeUser, + }) + statuses[hostname] = append(statuses[hostname], entry) + users, _ := authCfg.UsersForHost(hostname) for _, username := range users { - token, tokenSource, _ := authCfg.TokenForUser(hostname, username) - if tokenSource == "oauth_token" { - // The go-gh function TokenForHost returns this value as source for tokens read from the - // config file, but we want the file path instead. This attempts to reconstruct it. - tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") - } - _, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname) - - scopesHeader, err := shared.GetScopes(httpClient, hostname, token) - if err != nil { - var networkError net.Error - if errors.As(err, &networkError) && networkError.Timeout() { - statuses[hostname] = append(statuses[hostname], timeoutErrorEntry{ - host: hostname, - }) - } else { - statuses[hostname] = append(statuses[hostname], invalidTokenEntry{ - host: hostname, - tokenSource: tokenSource, - tokenIsWriteable: tokenIsWriteable, - }) - } - + if username == activeUser { continue } - - if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { - var missingScopes *shared.MissingScopesError - if errors.As(err, &missingScopes) { - statuses[hostname] = append(statuses[hostname], missingScopesEntry{ - host: hostname, - tokenSource: tokenSource, - missingScopes: missingScopes.MissingScopes, - tokenIsWriteable: tokenIsWriteable, - }) - } - } else { - statuses[hostname] = append(statuses[hostname], validEntry{ - host: hostname, - user: username, - token: displayToken(token, opts.ShowToken), - tokenSource: tokenSource, - gitProtocol: cfg.GitProtocol(hostname), - scopes: scopesHeader}) - } + token, tokenSource, _ := authCfg.TokenForUser(hostname, username) + entry := buildEntry(httpClient, buildEntryOptions{ + active: false, + gitProtocol: gitProtocol, + hostname: hostname, + showToken: opts.ShowToken, + token: token, + tokenSource: tokenSource, + username: username, + }) + statuses[hostname] = append(statuses[hostname], entry) } } @@ -295,3 +283,87 @@ func displayToken(token string, printRaw bool) string { func expectScopes(token string) bool { return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_") } + +type buildEntryOptions struct { + active bool + gitProtocol string + hostname string + showToken bool + token string + tokenSource string + username string +} + +func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry { + tokenIsWriteable := authTokenWriteable(opts.tokenSource) + + if opts.tokenSource == "oauth_token" { + // The go-gh function TokenForHost returns this value as source for tokens read from the + // config file, but we want the file path instead. This attempts to reconstruct it. + opts.tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") + } + + // If token is not writeable, then it came from an environment variable and + // we need to fetch the username as it won't be stored in the config. + if !tokenIsWriteable { + // The httpClient will automatically use the correct token here as + // the token from the environment variable take highest precedence. + apiClient := api.NewClientFromHTTP(httpClient) + var err error + opts.username, err = api.CurrentLoginName(apiClient, opts.hostname) + if err != nil { + return invalidTokenEntry{ + active: opts.active, + host: opts.hostname, + tokenIsWriteable: tokenIsWriteable, + tokenSource: opts.tokenSource, + } + } + } + + // Get scopes for token. + scopesHeader, err := shared.GetScopes(httpClient, opts.hostname, opts.token) + if err != nil { + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return timeoutErrorEntry{ + host: opts.hostname, + } + } + + return invalidTokenEntry{ + active: opts.active, + host: opts.hostname, + tokenIsWriteable: tokenIsWriteable, + tokenSource: opts.tokenSource, + } + } + + // Check if token has minimum set of scopes. + if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { + var missingScopes *shared.MissingScopesError + if errors.As(err, &missingScopes) { + return missingScopesEntry{ + active: opts.active, + host: opts.hostname, + missingScopes: missingScopes.MissingScopes, + tokenIsWriteable: tokenIsWriteable, + tokenSource: opts.tokenSource, + } + } + } + + return validEntry{ + active: opts.active, + gitProtocol: opts.gitProtocol, + host: opts.hostname, + scopes: scopesHeader, + token: displayToken(opts.token, opts.showToken), + tokenSource: opts.tokenSource, + user: opts.username, + } +} + +func authTokenWriteable(src string) bool { + return !strings.HasSuffix(src, "_TOKEN") +} diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 253158121..9bf793e06 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -187,7 +187,7 @@ func Test_statusRun(t *testing.T) { ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) ✓ Git operations for ghe.io configured to use ssh protocol. ✓ Token: gho_****** - X Token scopes: none + ! Token scopes: none `), }, { @@ -281,17 +281,17 @@ func Test_statusRun(t *testing.T) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,project:read")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org + github.com + ✓ Logged in to github.com as monalisa-2 (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: ****** + ✓ Token scopes: repo,read:org - ✓ Logged in to github.com as monalisa-2 (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org,project:read - `), + ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) + ✓ Git operations for github.com configured to use https protocol. + ✓ Token: ****** + ✓ Token scopes: repo,read:org,project:read + `), }, } From a0e5e4c709e3648956ee0a31d04d674cc302e83b Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 11:11:01 +0100 Subject: [PATCH 27/62] Use NewIsolatedTestConfig in AuthConfig tests --- internal/config/auth_config_test.go | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 6f8583b2b..d43a14e51 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -11,28 +11,8 @@ import ( ) func newTestAuthConfig(t *testing.T) *AuthConfig { - authCfg := AuthConfig{ - cfg: ghConfig.ReadFromString(""), - } - - // The real implementation of config.Read uses a sync.Once - // to read config files and initialise package level variables - // that are used from then on. - // - // This means that tests can't be isolated from each other, so - // we swap out the function here to return a new config each time. - ghConfig.Read = func(_ *ghConfig.Config) (*ghConfig.Config, error) { - return authCfg.cfg, nil - } - - // The config.Write method isn't defined in the same way as Read to allow - // the function to be swapped out and it does try to write to disk. - // - // We should consider whether it makes sense to change that but in the meantime - // we can use GH_CONFIG_DIR env var to ensure the tests remain isolated. - StubWriteConfig(t) - - return &authCfg + cfg, _ := NewIsolatedTestConfig(t) + return cfg.Authentication() } func TestTokenFromKeyring(t *testing.T) { From 8e89af96e8cfb5efb8558b8ab370e7b627ab8cb6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 11:31:22 +0100 Subject: [PATCH 28/62] Write tests for SwitchUser --- internal/config/auth_config_test.go | 122 ++++++++++++++++++++++++++++ internal/config/config.go | 8 +- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index d43a14e51..349713da1 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -418,6 +418,128 @@ func TestLogoutIgnoresErrorsFromConfigAndKeyring(t *testing.T) { require.NoError(t, err) } +func TestSwitchUserMakesSecureTokenActive(t *testing.T) { + // Given we have a user with a secure token + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + // When we switch to that user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Their secure token is now active + token, err := authCfg.TokenFromKeyring("github.com") + require.NoError(t, err) + require.Equal(t, "test-token-1", token) +} + +func TestSwitchUserMakesInsecureTokenActive(t *testing.T) { + // Given we have a user with an insecure token + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false) + require.NoError(t, err) + + // When we switch to that user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Their insecure token is now active + token, source := authCfg.Token("github.com") + require.Equal(t, "test-token-1", token) + require.Equal(t, oauthTokenKey, source) +} + +func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { + // Given we have two users logged into a host + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false) + require.NoError(t, err) + + // When we switch to the other user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Then the active user is updated + activeUser, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "test-user-1", activeUser) +} + +// TODO: This might be removed +func TestSwitchUserUpdatesTheHostLevelGitProtocol(t *testing.T) { + // Given we have two users logged into a host + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "https", false) + require.NoError(t, err) + + // When we switch to the other user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Then the host level git protocol is updated + requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", gitProtocolKey}, "ssh") +} + +func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { + // Given we have a user but no token can be found (because we deleted them, simulating an error case) + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + keyring.Delete(keyringServiceName("github.com"), "test-user-1") + + // When we switch to the user + err = authCfg.SwitchUser("github.com", "test-user-1") + + // Then it returns an error + require.EqualError(t, err, "no token found for 'test-user-1'") +} + +func TestSwitchClearsActiveSecureTokenWhenSwitchingToInsecureUser(t *testing.T) { + // Given we have an active secure token + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + // When we switch to an insecure user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Then the active secure token is cleared + _, err = authCfg.TokenFromKeyring("github.com") + require.Error(t, err) +} + +func TestSwitchClearsActiveInsecureTokenWhenSwitchingToSecureUser(t *testing.T) { + // Given we have an active insecure token + keyring.MockInit() + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false) + require.NoError(t, err) + + // When we switch to a secure user + require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) + + // Then the active insecure token is cleared + requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}) +} + func TestUsersForHostNoHost(t *testing.T) { // Given we have a config with no hosts authCfg := newTestAuthConfig(t) diff --git a/internal/config/config.go b/internal/config/config.go index c30f4e294..7d30623be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -334,7 +334,6 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure return insecureStorageUsed, c.SwitchUser(hostname, username) } -// TODO: Write tests // TODO: How should git protocol be handled? Do we need to set it at the user level since it could have been changed? func (c *AuthConfig) SwitchUser(hostname, user string) error { // We first need to idempotently clear out any set tokens for the host @@ -345,15 +344,22 @@ func (c *AuthConfig) SwitchUser(hostname, user string) error { // following branches should be true. // If there is a token in the secure keyring for the user, move it to the active slot + var tokenSwitched bool if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil { return fmt.Errorf("failed to move active token in keyring: %v", err) } + tokenSwitched = true } // If there is a token in the insecure config for the user, move it to the active field if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) + tokenSwitched = true + } + + if !tokenSwitched { + return fmt.Errorf("no token found for '%s'", user) } // Then we'll ensure the git protocol is moved as well From e806664ef736a1cba373155842112831d094b91f Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 1 Dec 2023 16:00:58 +0000 Subject: [PATCH 29/62] New UX for auth status --- pkg/cmd/auth/status/status.go | 130 ++++++++++--------------- pkg/cmd/auth/status/status_test.go | 149 ++++++++++++++++------------- 2 files changed, 134 insertions(+), 145 deletions(-) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index fe6fc1077..2caef6700 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "path/filepath" + "slices" "strings" "github.com/MakeNowJust/heredoc" @@ -31,56 +32,26 @@ func (e validEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder sb.WriteString( - fmt.Sprintf(" %s Logged in to %s as %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource), + fmt.Sprintf(" %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource), ) - if e.gitProtocol != "" { - sb.WriteString(fmt.Sprintf(" %s Git operations for %s configured to use %s protocol.\n", - cs.SuccessIcon(), e.host, cs.Bold(e.gitProtocol))) - } - sb.WriteString(fmt.Sprintf(" %s Token: %s\n", cs.SuccessIcon(), e.token)) + activeStr := fmt.Sprintf("%v", e.active) + sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) + sb.WriteString(fmt.Sprintf(" - Git operations protocol: %s\n", cs.Bold(e.gitProtocol))) + sb.WriteString(fmt.Sprintf(" - Token: %s\n", cs.Bold(e.token))) - if e.scopes != "" { - sb.WriteString(fmt.Sprintf(" %s Token scopes: %s\n", cs.SuccessIcon(), e.scopes)) - } else if expectScopes(e.token) { - sb.WriteString(fmt.Sprintf(" %s Token scopes: none\n", cs.WarningIcon())) - } - - return sb.String() -} - -type missingScopes []string - -func (ms missingScopes) String() string { - var missing []string - for _, s := range ms { - missing = append(missing, fmt.Sprintf("'%s'", s)) - } - scopes := strings.Join(missing, ", ") - - if len(ms) == 1 { - return "missing required scope " + scopes - } - return "missing required scopes " + scopes -} - -type missingScopesEntry struct { - active bool - host string - tokenSource string - missingScopes missingScopes - tokenIsWriteable bool -} - -func (e missingScopesEntry) String(cs *iostreams.ColorScheme) string { - var sb strings.Builder - - sb.WriteString( - fmt.Sprintf(" %s %s: the token in %s is %s\n", cs.Red("X"), e.host, e.tokenSource, e.missingScopes), - ) - if e.tokenIsWriteable { - sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s %s\n", - cs.Bold("gh auth refresh -h"), - cs.Bold(e.host))) + if expectScopes(e.token) { + sb.WriteString(fmt.Sprintf(" - Token scopes: %s\n", cs.Bold(displayScopes(e.scopes)))) + if err := shared.HeaderHasMinimumScopes(e.scopes); err != nil { + var missingScopesError *shared.MissingScopesError + if errors.As(err, &missingScopesError) { + missingScopes := strings.Join(missingScopesError.MissingScopes, ",") + sb.WriteString(fmt.Sprintf(" %s Missing required token scopes: %s\n", + cs.WarningIcon(), + cs.Bold(displayScopes(missingScopes)))) + refreshInstructions := fmt.Sprintf("gh auth refresh -h %s", e.host) + sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s\n", cs.Bold(refreshInstructions))) + } + } } return sb.String() @@ -89,6 +60,7 @@ func (e missingScopesEntry) String(cs *iostreams.ColorScheme) string { type invalidTokenEntry struct { active bool host string + user string tokenSource string tokenIsWriteable bool } @@ -96,13 +68,19 @@ type invalidTokenEntry struct { func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder - sb.WriteString(fmt.Sprintf(" %s %s: authentication failed\n", cs.Red("X"), e.host)) - sb.WriteString(fmt.Sprintf(" - The %s token in %s is invalid.\n", cs.Bold(e.host), e.tokenSource)) + if e.user != "" { + sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource)) + } else { + sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource)) + } + activeStr := fmt.Sprintf("%v", e.active) + sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) + sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.tokenSource)) if e.tokenIsWriteable { - sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s %s\n", - cs.Bold("gh auth login -h"), cs.Bold(e.host))) - sb.WriteString(fmt.Sprintf(" - To forget about this host, run: %s %s\n", - cs.Bold("gh auth logout -h"), cs.Bold(e.host))) + loginInstructions := fmt.Sprintf("gh auth login -h %s", e.host) + logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.host, e.user) + sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions))) + sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions))) } return sb.String() @@ -190,7 +168,13 @@ func statusRun(opts *StatusOptions) error { hostnames := authCfg.Hosts() if len(hostnames) == 0 { fmt.Fprintf(stderr, - "You are not logged into any GitHub hosts. Run %s to authenticate.\n", cs.Bold("gh auth login")) + "You are not logged into any GitHub hosts. To log in, run: %s\n", cs.Bold("gh auth login")) + return cmdutil.SilentError + } + + if opts.Hostname != "" && !slices.Contains(hostnames, opts.Hostname) { + fmt.Fprintf(stderr, + "You are not logged into any accounts on %s\n", opts.Hostname) return cmdutil.SilentError } @@ -199,13 +183,10 @@ func statusRun(opts *StatusOptions) error { return err } - var isHostnameFound bool - for _, hostname := range hostnames { if opts.Hostname != "" && opts.Hostname != hostname { continue } - isHostnameFound = true var activeUser string gitProtocol := cfg.GitProtocol(hostname) @@ -243,12 +224,6 @@ func statusRun(opts *StatusOptions) error { } } - if !isHostnameFound { - fmt.Fprintf(stderr, - "Hostname %q not found among authenticated GitHub hosts\n", opts.Hostname) - return cmdutil.SilentError - } - prevEntry := false for _, hostname := range hostnames { entries, ok := statuses[hostname] @@ -280,6 +255,17 @@ func displayToken(token string, printRaw bool) string { return strings.Repeat("*", len(token)) } +func displayScopes(scopes string) string { + if scopes == "" { + return "none" + } + list := strings.Split(scopes, ",") + for i, s := range list { + list[i] = fmt.Sprintf("'%s'", strings.TrimSpace(s)) + } + return strings.Join(list, ", ") +} + func expectScopes(token string) bool { return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_") } @@ -315,6 +301,7 @@ func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry { return invalidTokenEntry{ active: opts.active, host: opts.hostname, + user: opts.username, tokenIsWriteable: tokenIsWriteable, tokenSource: opts.tokenSource, } @@ -334,25 +321,12 @@ func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry { return invalidTokenEntry{ active: opts.active, host: opts.hostname, + user: opts.username, tokenIsWriteable: tokenIsWriteable, tokenSource: opts.tokenSource, } } - // Check if token has minimum set of scopes. - if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil { - var missingScopes *shared.MissingScopesError - if errors.As(err, &missingScopes) { - return missingScopesEntry{ - active: opts.active, - host: opts.hostname, - missingScopes: missingScopes.MissingScopes, - tokenIsWriteable: tokenIsWriteable, - tokenSource: opts.tokenSource, - } - } - } - return validEntry{ active: opts.active, gitProtocol: opts.gitProtocol, diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 9bf793e06..695267a71 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -110,54 +110,61 @@ func Test_statusRun(t *testing.T) { Hostname: "ghe.io", }, cfgStubs: func(c config.Config) { - login(t, c, "github.com", "monalisa", "abc123", "https") - login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") + login(t, c, "github.com", "monalisa", "gho_abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) }, wantOut: heredoc.Doc(` - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org - `), + ghe.io + ✓ Logged in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo', 'read:org' + `), }, { name: "missing scope", opts: StatusOptions{}, cfgStubs: func(c config.Config) { - login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo")) }, wantOut: heredoc.Doc(` - ghe.io - X ghe.io: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org' - - To request missing scopes, run: gh auth refresh -h ghe.io - `), + ghe.io + ✓ Logged in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo' + ! Missing required token scopes: 'read:org' + - To request missing scopes, run: gh auth refresh -h ghe.io + `), }, { name: "bad token", opts: StatusOptions{}, cfgStubs: func(c config.Config) { - login(t, c, "ghe.io", "monalisa-ghe", "abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { // mock for HeaderHasMinimumScopes api requests to a non-github.com host reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno")) }, wantOut: heredoc.Doc(` - ghe.io - X ghe.io: authentication failed - - The ghe.io token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io - - To forget about this host, run: gh auth logout -h ghe.io - `), + ghe.io + X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - The token in GH_CONFIG_DIR/hosts.yml is invalid. + - To re-authenticate, run: gh auth login -h ghe.io + - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe + `), }, { name: "all good", @@ -177,18 +184,20 @@ func Test_statusRun(t *testing.T) { httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: gho_****** - ✓ Token scopes: repo, read:org + github.com + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo', 'read:org' - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use ssh protocol. - ✓ Token: gho_****** - ! Token scopes: none - `), + ghe.io + ✓ Logged in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: ssh + - Token: gho_****** + - Token scopes: none + `), }, { name: "server-to-server token", @@ -203,11 +212,12 @@ func Test_statusRun(t *testing.T) { httpmock.ScopesResponder("")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ghs_****** - `), + github.com + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: ghs_****** + `), }, { name: "PAT V2 token", @@ -222,11 +232,12 @@ func Test_statusRun(t *testing.T) { httpmock.ScopesResponder("")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: github_pat_****** - `), + github.com + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: github_pat_****** + `), }, { name: "show token", @@ -234,8 +245,8 @@ func Test_statusRun(t *testing.T) { ShowToken: true, }, cfgStubs: func(c config.Config) { - login(t, c, "github.com", "monalisa", "abc123", "https") - login(t, c, "ghe.io", "monalisa-ghe", "xyz456", "https") + login(t, c, "github.com", "monalisa", "gho_abc123", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_xyz456", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes on a non-github.com host @@ -244,18 +255,20 @@ func Test_statusRun(t *testing.T) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: abc123 - ✓ Token scopes: repo,read:org + github.com + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_abc123 + - Token scopes: 'repo', 'read:org' - ghe.io - ✓ Logged in to ghe.io as monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for ghe.io configured to use https protocol. - ✓ Token: xyz456 - ✓ Token scopes: repo,read:org - `), + ghe.io + ✓ Logged in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_xyz456 + - Token scopes: 'repo', 'read:org' + `), }, { name: "missing hostname", @@ -267,31 +280,33 @@ func Test_statusRun(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) {}, wantErr: cmdutil.SilentError, - wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n", + wantErrOut: "You are not logged into any accounts on github.example.com\n", }, { name: "multiple accounts on a host", opts: StatusOptions{}, cfgStubs: func(c config.Config) { - login(t, c, "github.com", "monalisa", "abc123", "https") - login(t, c, "github.com", "monalisa-2", "abc123", "https") + login(t, c, "github.com", "monalisa", "gho_abc123", "https") + login(t, c, "github.com", "monalisa-2", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,project:read")) }, wantOut: heredoc.Doc(` - github.com - ✓ Logged in to github.com as monalisa-2 (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org + github.com + ✓ Logged in to github.com account monalisa-2 (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo', 'read:org' - ✓ Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml) - ✓ Git operations for github.com configured to use https protocol. - ✓ Token: ****** - ✓ Token scopes: repo,read:org,project:read - `), + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: false + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo', 'read:org', 'project:read' + `), }, } From 5b3e7290bea10cb5a19a5d2ea15ad669422afea8 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 15:16:53 +0100 Subject: [PATCH 30/62] Use real config and env in login cmd tests --- pkg/cmd/auth/login/login_test.go | 36 +++++++++++-------------------- pkg/cmd/auth/setupgit/setupgit.go | 1 - 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 0e8ea0e25..ec66fab24 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -259,8 +259,9 @@ func Test_loginRun_nontty(t *testing.T) { tests := []struct { name string opts *LoginOptions + env map[string]string httpStubs func(*httpmock.Registry) - cfgStubs func(*config.ConfigMock) + cfgStubs func(config.Config) wantHosts string wantErr string wantStderr string @@ -355,13 +356,7 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "github.com", Token: "abc456", }, - cfgStubs: func(c *config.ConfigMock) { - authCfg := c.Authentication() - authCfg.SetToken("value_from_env", "GH_TOKEN") - c.AuthenticationFunc = func() *config.AuthConfig { - return authCfg - } - }, + env: map[string]string{"GH_TOKEN": "value_from_env"}, wantErr: "SilentError", wantStderr: heredoc.Doc(` The value of the GH_TOKEN environment variable is being used for authentication. @@ -374,13 +369,7 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "ghe.io", Token: "abc456", }, - cfgStubs: func(c *config.ConfigMock) { - authCfg := c.Authentication() - authCfg.SetToken("value_from_env", "GH_ENTERPRISE_TOKEN") - c.AuthenticationFunc = func() *config.AuthConfig { - return authCfg - } - }, + env: map[string]string{"GH_ENTERPRISE_TOKEN": "value_from_env"}, wantErr: "SilentError", wantStderr: heredoc.Doc(` The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication. @@ -408,7 +397,7 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "github.com", Token: "newUserToken", }, - cfgStubs: func(c *config.ConfigMock) { + cfgStubs: func(c config.Config) { _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) require.NoError(t, err) }, @@ -440,8 +429,7 @@ func Test_loginRun_nontty(t *testing.T) { tt.opts.IO = ios keyring.MockInit() - readConfigs := config.StubWriteConfig(t) - cfg := config.NewBlankConfig() + cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) } @@ -458,6 +446,10 @@ func Test_loginRun_nontty(t *testing.T) { tt.httpStubs(reg) } + for k, v := range tt.env { + t.Setenv(k, v) + } + _, restoreRun := run.Stub() defer restoreRun(t) @@ -490,7 +482,7 @@ func Test_loginRun_Survey(t *testing.T) { httpStubs func(*httpmock.Registry) prompterStubs func(*prompter.PrompterMock) runStubs func(*run.CommandStubber) - cfgStubs func(*config.ConfigMock) + cfgStubs func(config.Config) wantHosts string wantErrOut *regexp.Regexp wantSecureToken string @@ -695,7 +687,7 @@ func Test_loginRun_Survey(t *testing.T) { return -1, prompter.NoSuchPromptErr(prompt) } }, - cfgStubs: func(c *config.ConfigMock) { + cfgStubs: func(c config.Config) { _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) require.NoError(t, err) }, @@ -737,9 +729,7 @@ func Test_loginRun_Survey(t *testing.T) { tt.opts.IO = ios keyring.MockInit() - readConfigs := config.StubWriteConfig(t) - - cfg := config.NewBlankConfig() + cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index 730e36bf1..8ba35a78c 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -100,7 +100,6 @@ func setupGitRun(opts *SetupGitOptions) error { } for _, hostname := range hostnamesToSetup { - //TODO: Does Setup need to take a list of all logged in users and tokens on a host? if err := opts.gitConfigure.Setup(hostname, "", ""); err != nil { return fmt.Errorf("failed to set up git credential helper: %w", err) } From dc0f6d55e26709ccb75f3718c4a611ca64a34280 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 15:18:51 +0100 Subject: [PATCH 31/62] Use real config in fork cmd tests --- pkg/cmd/repo/fork/fork_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index c0aa32bbd..e85558597 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -211,7 +211,7 @@ func TestRepoFork(t *testing.T) { httpStubs func(*httpmock.Registry) execStubs func(*run.CommandStubber) promptStubs func(*prompter.MockPrompter) - cfgStubs func(*config.ConfigMock) + cfgStubs func(config.Config) remotes []*context.Remote wantOut string wantErrOut string @@ -254,7 +254,7 @@ func TestRepoFork(t *testing.T) { Repo: ghrepo.New("OWNER", "REPO"), }, }, - cfgStubs: func(c *config.ConfigMock) { + cfgStubs: func(c config.Config) { c.Set("", "git_protocol", "") }, httpStubs: forkPost, @@ -731,7 +731,7 @@ func TestRepoFork(t *testing.T) { return &http.Client{Transport: reg}, nil } - cfg := config.NewBlankConfig() + cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) } From 239f983ad4024fc05f6878f12cc08242d1443474 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 15:21:32 +0100 Subject: [PATCH 32/62] Use real config in auth check tests --- pkg/cmdutil/auth_check_test.go | 42 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/cmdutil/auth_check_test.go b/pkg/cmdutil/auth_check_test.go index fa8bd80e2..273946e82 100644 --- a/pkg/cmdutil/auth_check_test.go +++ b/pkg/cmdutil/auth_check_test.go @@ -4,53 +4,53 @@ import ( "testing" "github.com/cli/cli/v2/internal/config" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_CheckAuth(t *testing.T) { tests := []struct { name string - cfgStubs func(*config.ConfigMock) + env map[string]string + cfgStubs func(config.Config) expected bool }{ { name: "no known hosts, no env auth token", - cfgStubs: func(c *config.ConfigMock) {}, + cfgStubs: func(_ config.Config) {}, expected: false, }, { - name: "no known hosts, env auth token", - cfgStubs: func(c *config.ConfigMock) { - c.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetToken("token", "GITHUB_TOKEN") - return authCfg - } - }, + name: "no known hosts, env auth token", + env: map[string]string{"GITHUB_TOKEN": "token"}, expected: true, }, { name: "known host", - cfgStubs: func(c *config.ConfigMock) { - c.Set("github.com", "oauth_token", "token") + cfgStubs: func(c config.Config) { + _, err := c.Authentication().Login("github.com", "test-user", "test-token", "https", false) + require.NoError(t, err) }, expected: true, }, { - name: "enterprise token", - cfgStubs: func(c *config.ConfigMock) { - t.Setenv("GH_ENTERPRISE_TOKEN", "token") - }, + name: "enterprise token", + env: map[string]string{"GH_ENTERPRISE_TOKEN": "token"}, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := config.NewBlankConfig() - tt.cfgStubs(cfg) - result := CheckAuth(cfg) - assert.Equal(t, tt.expected, result) + cfg, _ := config.NewIsolatedTestConfig(t) + if tt.cfgStubs != nil { + tt.cfgStubs(cfg) + } + + for k, v := range tt.env { + t.Setenv(k, v) + } + + require.Equal(t, tt.expected, CheckAuth(cfg)) }) } } From 748d59ec64a0a4f1e5b28d25742fe0e5852f6d50 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 15:33:09 +0100 Subject: [PATCH 33/62] Use real config in auth refresh tests --- pkg/cmd/auth/refresh/refresh_test.go | 38 +++++++++++----------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index c87c2bcb8..2841615cf 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -13,7 +13,7 @@ import ( "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_NewCmdRefresh(t *testing.T) { @@ -142,7 +142,7 @@ func Test_NewCmdRefresh(t *testing.T) { ios.SetNeverPrompt(tt.neverPrompt) argv, err := shlex.Split(tt.cli) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *RefreshOptions cmd := NewCmdRefresh(f, func(opts *RefreshOptions) error { @@ -159,12 +159,12 @@ func Test_NewCmdRefresh(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) - assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) - assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes) + require.NoError(t, err) + require.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + require.Equal(t, tt.wants.Scopes, gotOpts.Scopes) }) } } @@ -182,7 +182,6 @@ func Test_refreshRun(t *testing.T) { opts *RefreshOptions prompterStubs func(*prompter.PrompterMock) cfgHosts []string - config config.Config oldScopes string wantErr string nontty bool @@ -416,14 +415,9 @@ func Test_refreshRun(t *testing.T) { return nil } - var cfg config.Config - if tt.config != nil { - cfg = tt.config - } else { - cfg = config.NewFromString("") - for _, hostname := range tt.cfgHosts { - cfg.Set(hostname, "oauth_token", "abc123") - } + cfg, _ := config.NewIsolatedTestConfig(t) + for _, hostname := range tt.cfgHosts { + cfg.Authentication().Login(hostname, "test-user", "abc123", "https", false) } tt.opts.Config = func() (config.Config, error) { return cfg, nil @@ -462,17 +456,15 @@ func Test_refreshRun(t *testing.T) { err := refreshRun(tt.opts) if tt.wantErr != "" { - if assert.Error(t, err) { - assert.Contains(t, err.Error(), tt.wantErr) - } + require.Contains(t, err.Error(), tt.wantErr) } else { - assert.NoError(t, err) + require.NoError(t, err) } - assert.Equal(t, tt.wantAuthArgs.hostname, aa.hostname) - assert.Equal(t, tt.wantAuthArgs.scopes, aa.scopes) - assert.Equal(t, tt.wantAuthArgs.interactive, aa.interactive) - assert.Equal(t, tt.wantAuthArgs.secureStorage, aa.secureStorage) + require.Equal(t, tt.wantAuthArgs.hostname, aa.hostname) + require.Equal(t, tt.wantAuthArgs.scopes, aa.scopes) + require.Equal(t, tt.wantAuthArgs.interactive, aa.interactive) + require.Equal(t, tt.wantAuthArgs.secureStorage, aa.secureStorage) }) } } From 20dd95b88db8c4a70b7cf0fe181476f10cb12fa3 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 15:48:35 +0100 Subject: [PATCH 34/62] Use real config in token cmd tests --- pkg/cmd/auth/token/token_test.go | 133 ++++++++++++++++--------------- 1 file changed, 70 insertions(+), 63 deletions(-) diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index 5baca706b..c13d5fd41 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdToken(t *testing.T) { @@ -53,7 +53,7 @@ func TestNewCmdToken(t *testing.T) { }, } argv, err := shlex.Split(tt.input) - assert.NoError(t, err) + require.NoError(t, err) var cmdOpts *TokenOptions cmd := NewCmdToken(f, func(opts *TokenOptions) error { @@ -70,14 +70,14 @@ func TestNewCmdToken(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, tt.wantErrMsg) + require.Error(t, err) + require.EqualError(t, err, tt.wantErrMsg) return } - assert.NoError(t, err) - assert.Equal(t, tt.output.Hostname, cmdOpts.Hostname) - assert.Equal(t, tt.output.SecureStorage, cmdOpts.SecureStorage) + require.NoError(t, err) + require.Equal(t, tt.output.Hostname, cmdOpts.Hostname) + require.Equal(t, tt.output.SecureStorage, cmdOpts.SecureStorage) }) } } @@ -86,59 +86,45 @@ func TestTokenRun(t *testing.T) { tests := []struct { name string opts TokenOptions + env map[string]string + cfgStubs func(config.Config) wantStdout string wantErr bool wantErrMsg string }{ { name: "token", - opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - cfg.Set("github.com", "oauth_token", "gho_ABCDEFG") - return cfg, nil - }, + opts: TokenOptions{}, + cfgStubs: func(cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) }, wantStdout: "gho_ABCDEFG\n", }, { name: "token by hostname", opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - cfg.Set("github.com", "oauth_token", "gho_ABCDEFG") - cfg.Set("github.mycompany.com", "oauth_token", "gho_1234567") - return cfg, nil - }, Hostname: "github.mycompany.com", }, + cfgStubs: func(cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) + login(t, cfg, "github.mycompany.com", "test-user", "gho_1234567", "https", false) + }, wantStdout: "gho_1234567\n", }, { - name: "no token", - opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - return cfg, nil - }, - }, + name: "no token", + opts: TokenOptions{}, wantErr: true, wantErrMsg: "no oauth token", }, { name: "uses default host when one is not provided", - opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST") - authCfg.SetToken("gho_1234567", "default") - return authCfg - } - return cfg, nil - }, + opts: TokenOptions{}, + cfgStubs: func(cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) + login(t, cfg, "github.mycompany.com", "test-user", "gho_1234567", "https", false) }, + env: map[string]string{"GH_HOST": "github.mycompany.com"}, wantStdout: "gho_1234567\n", }, } @@ -147,14 +133,28 @@ func TestTokenRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ios, _, stdout, _ := iostreams.Test() tt.opts.IO = ios + + for k, v := range tt.env { + t.Setenv(k, v) + } + + cfg, _ := config.NewIsolatedTestConfig(t) + if tt.cfgStubs != nil { + tt.cfgStubs(cfg) + } + + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + err := tokenRun(&tt.opts) if tt.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, tt.wantErrMsg) + require.Error(t, err) + require.EqualError(t, err, tt.wantErrMsg) return } - assert.NoError(t, err) - assert.Equal(t, tt.wantStdout, stdout.String()) + require.NoError(t, err) + require.Equal(t, tt.wantStdout, stdout.String()) }) } } @@ -163,41 +163,32 @@ func TestTokenRunSecureStorage(t *testing.T) { tests := []struct { name string opts TokenOptions + cfgStubs func(config.Config) wantStdout string wantErr bool wantErrMsg string }{ { name: "token", - opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - _ = keyring.Set("gh:github.com", "", "gho_ABCDEFG") - return cfg, nil - }, + opts: TokenOptions{}, + cfgStubs: func(cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", true) }, wantStdout: "gho_ABCDEFG\n", }, { name: "token by hostname", opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - _ = keyring.Set("gh:mycompany.com", "", "gho_1234567") - return cfg, nil - }, Hostname: "mycompany.com", }, + cfgStubs: func(cfg config.Config) { + login(t, cfg, "mycompany.com", "test-user", "gho_1234567", "https", true) + }, wantStdout: "gho_1234567\n", }, { - name: "no token", - opts: TokenOptions{ - Config: func() (config.Config, error) { - cfg := config.NewBlankConfig() - return cfg, nil - }, - }, + name: "no token", + opts: TokenOptions{}, wantErr: true, wantErrMsg: "no oauth token", }, @@ -209,14 +200,30 @@ func TestTokenRunSecureStorage(t *testing.T) { ios, _, stdout, _ := iostreams.Test() tt.opts.IO = ios tt.opts.SecureStorage = true + + cfg, _ := config.NewIsolatedTestConfig(t) + if tt.cfgStubs != nil { + tt.cfgStubs(cfg) + } + + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + err := tokenRun(&tt.opts) if tt.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, tt.wantErrMsg) + require.Error(t, err) + require.EqualError(t, err, tt.wantErrMsg) return } - assert.NoError(t, err) - assert.Equal(t, tt.wantStdout, stdout.String()) + require.NoError(t, err) + require.Equal(t, tt.wantStdout, stdout.String()) }) } } + +func login(t *testing.T, c config.Config, hostname, username, token, gitProtocol string, secureStorage bool) { + t.Helper() + _, err := c.Authentication().Login(hostname, username, token, "https", secureStorage) + require.NoError(t, err) +} From 0a7871c6d3dd133987274d06ed9e07c469ee5896 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 16:35:05 +0100 Subject: [PATCH 35/62] Use real config in setupgit cmd tests --- pkg/cmd/auth/setupgit/setupgit_test.go | 138 ++++++++++++------------- 1 file changed, 64 insertions(+), 74 deletions(-) diff --git a/pkg/cmd/auth/setupgit/setupgit_test.go b/pkg/cmd/auth/setupgit/setupgit_test.go index 635cece8c..2206378e2 100644 --- a/pkg/cmd/auth/setupgit/setupgit_test.go +++ b/pkg/cmd/auth/setupgit/setupgit_test.go @@ -6,23 +6,32 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type mockGitConfigurer struct { + hosts []string setupErr error } +func (gf *mockGitConfigurer) SetupFor(hostname string) []string { + return gf.hosts +} + func (gf *mockGitConfigurer) Setup(hostname, username, authToken string) error { + gf.hosts = append(gf.hosts, hostname) return gf.setupErr } func Test_setupGitRun(t *testing.T) { tests := []struct { - name string - opts *SetupGitOptions - expectedErr string - expectedErrOut string + name string + opts *SetupGitOptions + setupErr error + cfgStubs func(config.Config) + expectedHostsSetup []string + expectedErr string + expectedErrOut string }{ { name: "opts.Config returns an error", @@ -34,18 +43,8 @@ func Test_setupGitRun(t *testing.T) { expectedErr: "oops", }, { - name: "no authenticated hostnames", - opts: &SetupGitOptions{ - Config: func() (config.Config, error) { - cfg := &config.ConfigMock{} - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetHosts([]string{}) - return authCfg - } - return cfg, nil - }, - }, + name: "no authenticated hostnames", + opts: &SetupGitOptions{}, expectedErr: "SilentError", expectedErrOut: "You are not logged into any GitHub hosts. Run gh auth login to authenticate.\n", }, @@ -53,79 +52,46 @@ func Test_setupGitRun(t *testing.T) { name: "not authenticated with the hostname given as flag", opts: &SetupGitOptions{ Hostname: "foo", - Config: func() (config.Config, error) { - cfg := &config.ConfigMock{} - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetHosts([]string{"bar"}) - return authCfg - } - return cfg, nil - }, + }, + cfgStubs: func(cfg config.Config) { + cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) }, expectedErr: "You are not logged into the GitHub host \"foo\"\n", expectedErrOut: "", }, { - name: "error setting up git for hostname", - opts: &SetupGitOptions{ - gitConfigure: &mockGitConfigurer{ - setupErr: fmt.Errorf("broken"), - }, - Config: func() (config.Config, error) { - cfg := &config.ConfigMock{} - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetHosts([]string{"bar"}) - return authCfg - } - return cfg, nil - }, + name: "error setting up git for hostname", + opts: &SetupGitOptions{}, + setupErr: fmt.Errorf("broken"), + cfgStubs: func(cfg config.Config) { + cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) }, expectedErr: "failed to set up git credential helper: broken", expectedErrOut: "", }, { name: "no hostname option given. Setup git for each hostname in config", - opts: &SetupGitOptions{ - gitConfigure: &mockGitConfigurer{}, - Config: func() (config.Config, error) { - cfg := &config.ConfigMock{} - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetHosts([]string{"bar"}) - return authCfg - } - return cfg, nil - }, + opts: &SetupGitOptions{}, + cfgStubs: func(cfg config.Config) { + cfg.Authentication().Login("ghe.io", "test-user", "gho_ABCDEFG", "https", false) + cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) }, + expectedHostsSetup: []string{"github.com", "ghe.io"}, }, { name: "setup git for the hostname given via options", opts: &SetupGitOptions{ - Hostname: "yes", - gitConfigure: &mockGitConfigurer{}, - Config: func() (config.Config, error) { - cfg := &config.ConfigMock{} - cfg.AuthenticationFunc = func() *config.AuthConfig { - authCfg := &config.AuthConfig{} - authCfg.SetHosts([]string{"bar", "yes"}) - return authCfg - } - return cfg, nil - }, + Hostname: "ghe.io", }, + cfgStubs: func(cfg config.Config) { + cfg.Authentication().Login("ghe.io", "test-user", "gho_ABCDEFG", "https", false) + }, + expectedHostsSetup: []string{"ghe.io"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.opts.Config == nil { - tt.opts.Config = func() (config.Config, error) { - return &config.ConfigMock{}, nil - } - } - ios, _, _, stderr := iostreams.Test() ios.SetStdinTTY(true) @@ -133,14 +99,38 @@ func Test_setupGitRun(t *testing.T) { ios.SetStdoutTTY(true) tt.opts.IO = ios - err := setupGitRun(tt.opts) - if tt.expectedErr != "" { - assert.EqualError(t, err, tt.expectedErr) - } else { - assert.NoError(t, err) + cfg, _ := config.NewIsolatedTestConfig(t) + if tt.cfgStubs != nil { + tt.cfgStubs(cfg) } - assert.Equal(t, tt.expectedErrOut, stderr.String()) + if tt.opts.Config == nil { + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + } + + gcSpy := &mockGitConfigurer{setupErr: tt.setupErr} + tt.opts.gitConfigure = gcSpy + + err := setupGitRun(tt.opts) + if tt.expectedErr != "" { + require.EqualError(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + } + + if tt.expectedHostsSetup != nil { + require.Equal(t, tt.expectedHostsSetup, gcSpy.hosts) + } + + require.Equal(t, tt.expectedErrOut, stderr.String()) }) } } + +func login(t *testing.T, c config.Config, hostname, username, token, gitProtocol string, secureStorage bool) { + t.Helper() + _, err := c.Authentication().Login(hostname, username, token, "https", secureStorage) + require.NoError(t, err) +} From c9a6b7cc00d040c31d1523ba2e8b6afdaed770a1 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 16:43:20 +0100 Subject: [PATCH 36/62] Always mock the keyring in NewIsolatedTestConfig --- internal/config/auth_config_test.go | 25 +++++-------------------- internal/config/stub.go | 8 ++++++++ pkg/cmd/auth/login/login_test.go | 3 --- pkg/cmd/auth/status/status_test.go | 3 --- pkg/cmd/auth/switch/switch_test.go | 3 --- pkg/cmd/auth/token/token_test.go | 2 -- 6 files changed, 13 insertions(+), 31 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 349713da1..cb673f27d 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" ) +// Note that NewIsolatedTestConfig sets up a Mock keyring as well func newTestAuthConfig(t *testing.T) *AuthConfig { cfg, _ := NewIsolatedTestConfig(t) return cfg.Authentication() @@ -17,11 +18,10 @@ func newTestAuthConfig(t *testing.T) *AuthConfig { func TestTokenFromKeyring(t *testing.T) { // Given a keyring that contains a token for a host - keyring.MockInit() + authCfg := newTestAuthConfig(t) require.NoError(t, keyring.Set(keyringServiceName("github.com"), "", "test-token")) // When we get the token from the auth config - authCfg := newTestAuthConfig(t) token, err := authCfg.TokenFromKeyring("github.com") // Then it returns successfully with the correct token @@ -61,7 +61,6 @@ func TestTokenStoredInEnv(t *testing.T) { func TestTokenStoredInKeyring(t *testing.T) { // When the user has logged in securely - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user", "test-token", "", true) require.NoError(t, err) @@ -77,10 +76,9 @@ func TestTokenStoredInKeyring(t *testing.T) { func TestTokenFromKeyringNonExistent(t *testing.T) { // Given a keyring that doesn't contain any tokens - keyring.MockInit() + authCfg := newTestAuthConfig(t) // When we try to get a token from the auth config - authCfg := newTestAuthConfig(t) _, err := authCfg.TokenFromKeyring("github.com") // Then it returns failure bubbling the ErrNotFound @@ -192,7 +190,6 @@ func TestDefaultHostLoggedInToOnlyOneHost(t *testing.T) { func TestLoginSecureStorageUsesKeyring(t *testing.T) { // Given a usable keyring - keyring.MockInit() authCfg := newTestAuthConfig(t) host := "github.com" user := "test-user" @@ -216,7 +213,6 @@ func TestLoginSecureStorageUsesKeyring(t *testing.T) { func TestLoginSecureStorageRemovesOldInsecureConfigToken(t *testing.T) { // Given a usable keyring and an oauth token in the config - keyring.MockInit() authCfg := newTestAuthConfig(t) authCfg.cfg.Set([]string{hostsKey, "github.com", oauthTokenKey}, "old-token") @@ -230,8 +226,8 @@ func TestLoginSecureStorageRemovesOldInsecureConfigToken(t *testing.T) { func TestLoginSecureStorageWithErrorFallsbackAndReports(t *testing.T) { // Given a keyring that errors - keyring.MockInitWithError(errors.New("test-explosion")) authCfg := newTestAuthConfig(t) + keyring.MockInitWithError(errors.New("test-explosion")) // When we login with secure storage insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", true) @@ -313,7 +309,6 @@ func TestLoginAddsUserToConfigWithoutGitProtocolAndWithSecureStorage(t *testing. authCfg := newTestAuthConfig(t) // When we log in without git protocol and with secure storage - keyring.MockInit() _, err := authCfg.Login("github.com", "test-user", "test-token", "", true) require.NoError(t, err) @@ -325,7 +320,6 @@ func TestLoginAddsUserToConfigWithoutGitProtocolAndWithSecureStorage(t *testing. func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { // Given we are logged into a host - keyring.MockInit() authCfg := newTestAuthConfig(t) host := "github.com" user := "test-user" @@ -349,7 +343,6 @@ func TestLogoutRemovesHostAndKeyringToken(t *testing.T) { func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) { // Given we have two accounts logged into a host - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "inactive-user", "test-token-1", "ssh", true) require.NoError(t, err) @@ -377,7 +370,6 @@ func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) { func TestLogoutOfInactiveUserDoesNotSwitchUser(t *testing.T) { // Given we have two accounts logged into a host - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "inactive-user-1", "test-token-1.1", "ssh", true) require.NoError(t, err) @@ -420,7 +412,6 @@ func TestLogoutIgnoresErrorsFromConfigAndKeyring(t *testing.T) { func TestSwitchUserMakesSecureTokenActive(t *testing.T) { // Given we have a user with a secure token - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) require.NoError(t, err) @@ -438,7 +429,6 @@ func TestSwitchUserMakesSecureTokenActive(t *testing.T) { func TestSwitchUserMakesInsecureTokenActive(t *testing.T) { // Given we have a user with an insecure token - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) require.NoError(t, err) @@ -456,7 +446,6 @@ func TestSwitchUserMakesInsecureTokenActive(t *testing.T) { func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { // Given we have two users logged into a host - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) require.NoError(t, err) @@ -475,7 +464,6 @@ func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { // TODO: This might be removed func TestSwitchUserUpdatesTheHostLevelGitProtocol(t *testing.T) { // Given we have two users logged into a host - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) require.NoError(t, err) @@ -491,7 +479,6 @@ func TestSwitchUserUpdatesTheHostLevelGitProtocol(t *testing.T) { func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { // Given we have a user but no token can be found (because we deleted them, simulating an error case) - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) require.NoError(t, err) @@ -504,12 +491,12 @@ func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { err = authCfg.SwitchUser("github.com", "test-user-1") // Then it returns an error + // But also restores the previous require.EqualError(t, err, "no token found for 'test-user-1'") } func TestSwitchClearsActiveSecureTokenWhenSwitchingToInsecureUser(t *testing.T) { // Given we have an active secure token - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) require.NoError(t, err) @@ -526,7 +513,6 @@ func TestSwitchClearsActiveSecureTokenWhenSwitchingToInsecureUser(t *testing.T) func TestSwitchClearsActiveInsecureTokenWhenSwitchingToSecureUser(t *testing.T) { // Given we have an active insecure token - keyring.MockInit() authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) require.NoError(t, err) @@ -767,7 +753,6 @@ func TestLoginSecurePostMigrationRemovesTokenFromConfig(t *testing.T) { c := cfg{authCfg.cfg} require.NoError(t, c.Migrate(m)) - keyring.MockInit() _, err = authCfg.Login("github.com", "test-user", "test-token", "", true) // Then it returns success, having removed the old insecure oauth token entry diff --git a/internal/config/stub.go b/internal/config/stub.go index 1673d47a2..98c1ceaba 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/cli/cli/v2/internal/keyring" ghConfig "github.com/cli/go-gh/v2/pkg/config" ) @@ -79,7 +80,14 @@ func NewFromString(cfgStr string) *ConfigMock { return mock } +// NewIsolatedTestConfig sets up a Mock keyring, creates a blank config +// overwrites the ghConfig.Read function that returns a singleton config +// in the real implementation, sets the GH_CONFIG_DIR env var so that +// any call to Write goes to a different location on disk, and then returns +// the blank config and a function that reads any data written to disk. func NewIsolatedTestConfig(t *testing.T) (Config, func(io.Writer, io.Writer)) { + keyring.MockInit() + c := ghConfig.ReadFromString("") cfg := cfg{c} diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index ec66fab24..48361fde6 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -10,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -428,7 +427,6 @@ func Test_loginRun_nontty(t *testing.T) { ios.SetStdoutTTY(false) tt.opts.IO = ios - keyring.MockInit() cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) @@ -728,7 +726,6 @@ func Test_loginRun_Survey(t *testing.T) { tt.opts.IO = ios - keyring.MockInit() cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { tt.cfgStubs(cfg) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 695267a71..172e0f1e6 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -10,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -312,8 +311,6 @@ func Test_statusRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() - ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(true) diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index 237f37172..43301b2dc 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -321,8 +320,6 @@ func TestSwitchRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() - cfg, readConfigs := config.NewIsolatedTestConfig(t) for k, v := range tt.env { diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index c13d5fd41..9259e93f8 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" @@ -196,7 +195,6 @@ func TestTokenRunSecureStorage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() ios, _, stdout, _ := iostreams.Test() tt.opts.IO = ios tt.opts.SecureStorage = true From c165d5ccc0960db1e46499c01bb8870f595009af Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 17:17:53 +0100 Subject: [PATCH 37/62] Use isolated config in logout cmd tests --- pkg/cmd/auth/logout/logout_test.go | 320 +++++++++++++++-------------- 1 file changed, 167 insertions(+), 153 deletions(-) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 97f8bba93..5e0048bce 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -6,9 +6,7 @@ import ( "regexp" "testing" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -82,6 +80,7 @@ func Test_NewCmdLogout(t *testing.T) { }, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -115,14 +114,18 @@ func Test_NewCmdLogout(t *testing.T) { } } -type host string -type user string +type user struct { + name string + token string +} type hostUsers struct { - host host + host string users []user } +type tokenAssertion func(t *testing.T, cfg config.Config) + func Test_logoutRun_tty(t *testing.T) { tests := []struct { name string @@ -131,6 +134,7 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts []hostUsers secureStorage bool wantHosts string + assertToken tokenAssertion wantErrOut *regexp.Regexp wantErr string }{ @@ -138,54 +142,73 @@ func Test_logoutRun_tty(t *testing.T) { name: "logs out prompted user when multiple known hosts with one user each", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe"}}, - {"github.com", []user{"monalisa"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(_, _ string, opts []string) (int, error) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + assertToken: hasNoToken("github.com"), + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out prompted user when multiple known hosts with multiple users each", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe", "monalisa-ghe2"}}, - {"github.com", []user{"monalisa", "monalisa2"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + {"monalisa-ghe2", "abc123"}, + }}, + {"github.com", []user{ + {"monalisa", "monalisa-token"}, + {"monalisa2", "monalisa2-token"}, + }}, }, - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n monalisa-ghe2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa-ghe2\n oauth_token: abc123\ngithub.com:\n users:\n monalisa2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: abc123\n", prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(_, _ string, opts []string) (int, error) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + assertToken: hasActiveToken("github.com", "monalisa2-token"), + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n monalisa-ghe2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa-ghe2\n oauth_token: abc123\ngithub.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out only logged in user", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "{}\n", + assertToken: hasNoToken("github.com"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out prompted user when one known host with multiple users", opts: &LogoutOptions{}, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa", "monalisa2"}}, + {"github.com", []user{ + {"monalisa", "monalisa-token"}, + {"monalisa2", "monalisa2-token"}, + }}, }, prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(_, _ string, opts []string) (int, error) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: abc123\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", + assertToken: hasActiveToken("github.com", "monalisa2-token"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user when multiple known hosts with one user each", @@ -194,11 +217,16 @@ func Test_logoutRun_tty(t *testing.T) { Username: "monalisa-ghe", }, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe"}}, - {"github.com", []user{"monalisa"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", - wantErrOut: regexp.MustCompile(`Logged out of ghe.io account monalisa-ghe`), + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", + assertToken: hasNoToken("ghe.io"), + wantErrOut: regexp.MustCompile(`Logged out of ghe.io account monalisa-ghe`), }, { name: "logs out specified user that is using secure storage", @@ -208,10 +236,13 @@ func Test_logoutRun_tty(t *testing.T) { Username: "monalisa", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "{}\n", + assertToken: hasNoToken("github.com"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "errors when no known hosts", @@ -225,7 +256,9 @@ func Test_logoutRun_tty(t *testing.T) { Username: "monalisa-ghe", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, wantErr: "not logged in to ghe.io", }, @@ -236,7 +269,9 @@ func Test_logoutRun_tty(t *testing.T) { Username: "unknown-user", }, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, }, wantErr: "not logged in to ghe.io account unknown-user", }, @@ -246,24 +281,43 @@ func Test_logoutRun_tty(t *testing.T) { Username: "unknown-user", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, - {"ghe.io", []user{"monalisa"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, wantErr: "no accounts matched that criteria", }, + { + name: "switches user if there is another one available", + opts: &LogoutOptions{ + Hostname: "github.com", + Username: "monalisa2", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"monalisa", "monalisa-token"}, + {"monalisa2", "monalisa2-token"}, + }}, + }, + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", + assertToken: hasActiveToken("github.com", "monalisa-token"), + wantErrOut: regexp.MustCompile("✓ Switched active account for github.com to monalisa"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() - readConfigs := config.StubWriteConfig(t) - cfg := config.NewFromString("") + cfg, readConfigs := config.NewIsolatedTestConfig(t) + for _, hostUsers := range tt.cfgHosts { for _, user := range hostUsers.users { _, _ = cfg.Authentication().Login( string(hostUsers.host), - string(user), - "abc123", "ssh", tt.secureStorage, + user.name, + user.token, "ssh", tt.secureStorage, ) } } @@ -297,13 +351,14 @@ func Test_logoutRun_tty(t *testing.T) { require.True(t, tt.wantErrOut.MatchString(stderr.String()), stderr.String()) } - mainBuf := bytes.Buffer{} hostsBuf := bytes.Buffer{} - readConfigs(&mainBuf, &hostsBuf) - secureToken, _ := cfg.Authentication().TokenFromKeyring(tt.opts.Hostname) + readConfigs(io.Discard, &hostsBuf) require.Equal(t, tt.wantHosts, hostsBuf.String()) - require.Equal(t, "", secureToken) + + if tt.assertToken != nil { + tt.assertToken(t, cfg) + } }) } } @@ -314,8 +369,8 @@ func Test_logoutRun_nontty(t *testing.T) { opts *LogoutOptions cfgHosts []hostUsers secureStorage bool - ghtoken string wantHosts string + assertToken tokenAssertion wantErrOut *regexp.Regexp wantErr string }{ @@ -326,10 +381,13 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "monalisa", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "{}\n", + assertToken: hasNoToken("github.com"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user when multiple known hosts", @@ -338,11 +396,16 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "monalisa", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, - {"ghe.io", []user{"monalisa-ghe"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, }, - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + assertToken: hasNoToken("github.com"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "logs out specified user that is using secure storage", @@ -352,10 +415,13 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "monalisa", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, - wantHosts: "{}\n", - wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), + wantHosts: "{}\n", + assertToken: hasNoToken("github.com"), + wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { name: "errors when no known hosts", @@ -372,7 +438,9 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "monalisa-ghe", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, }, wantErr: "not logged in to ghe.io", }, @@ -383,7 +451,9 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "unknown-user", }, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + }}, }, wantErr: "not logged in to ghe.io account unknown-user", }, @@ -393,8 +463,10 @@ func Test_logoutRun_nontty(t *testing.T) { Hostname: "ghe.io", }, cfgHosts: []hostUsers{ - {"ghe.io", []user{"monalisa-ghe"}}, - {"ghe.io", []user{"monalisa-ghe-2"}}, + {"ghe.io", []user{ + {"monalisa-ghe", "abc123"}, + {"monalisa-ghe2", "abc123"}, + }}, }, wantErr: "unable to determine which account to log out of, please specify `--hostname` and `--user`", }, @@ -404,24 +476,43 @@ func Test_logoutRun_nontty(t *testing.T) { Username: "monalisa", }, cfgHosts: []hostUsers{ - {"github.com", []user{"monalisa"}}, - {"ghe.io", []user{"monalisa"}}, + {"github.com", []user{ + {"monalisa", "abc123"}, + }}, + {"ghe.io", []user{ + {"monalisa", "abc123"}, + }}, }, wantErr: "unable to determine which account to log out of, please specify `--hostname` and `--user`", }, + { + name: "switches user if there is another one available", + opts: &LogoutOptions{ + Hostname: "github.com", + Username: "monalisa2", + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"monalisa", "monalisa-token"}, + {"monalisa2", "monalisa2-token"}, + }}, + }, + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", + assertToken: hasActiveToken("github.com", "monalisa-token"), + wantErrOut: regexp.MustCompile("✓ Switched active account for github.com to monalisa"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() - readConfigs := config.StubWriteConfig(t) - cfg := config.NewFromString("") + cfg, readConfigs := config.NewIsolatedTestConfig(t) + for _, hostUsers := range tt.cfgHosts { for _, user := range hostUsers.users { _, _ = cfg.Authentication().Login( string(hostUsers.host), - string(user), - "abc123", "ssh", tt.secureStorage, + user.name, + user.token, "ssh", tt.secureStorage, ) } } @@ -448,109 +539,32 @@ func Test_logoutRun_nontty(t *testing.T) { require.True(t, tt.wantErrOut.MatchString(stderr.String()), stderr.String()) } - mainBuf := bytes.Buffer{} hostsBuf := bytes.Buffer{} - readConfigs(&mainBuf, &hostsBuf) - secureToken, _ := cfg.Authentication().TokenFromKeyring(tt.opts.Hostname) + readConfigs(io.Discard, &hostsBuf) require.Equal(t, tt.wantHosts, hostsBuf.String()) - require.Equal(t, "", secureToken) + + if tt.assertToken != nil { + tt.assertToken(t, cfg) + } }) } } -func TestLogoutSwitchesUserNonTTY(t *testing.T) { - keyring.MockInit() +func hasNoToken(hostname string) tokenAssertion { + return func(t *testing.T, cfg config.Config) { + t.Helper() - ios, _, _, stderr := iostreams.Test() - ios.SetStdinTTY(false) - ios.SetStdoutTTY(false) - - readConfigs := config.StubWriteConfig(t) - cfg := config.NewFromString("") - _, err := cfg.Authentication().Login("github.com", "test-user-1", "test-token-1", "https", true) - require.NoError(t, err) - - _, err = cfg.Authentication().Login("github.com", "test-user-2", "test-token-2", "ssh", true) - require.NoError(t, err) - - opts := LogoutOptions{ - IO: ios, - Config: func() (config.Config, error) { - return cfg, nil - }, - Hostname: "github.com", - Username: "test-user-2", + token, _ := cfg.Authentication().Token(hostname) + require.Empty(t, token) } - - require.NoError(t, logoutRun(&opts)) - - hostsBuf := bytes.Buffer{} - readConfigs(io.Discard, &hostsBuf) - - secureToken, _ := cfg.Authentication().TokenFromKeyring("github.com") - require.Equal(t, "test-token-1", secureToken) - - expectedHosts := heredoc.Doc(` - github.com: - users: - test-user-1: - git_protocol: https - git_protocol: https - user: test-user-1 - `) - - require.Equal(t, expectedHosts, hostsBuf.String()) - - require.Contains(t, stderr.String(), "✓ Switched active account for github.com to test-user-1") } -func TestLogoutSwitchesUserTTY(t *testing.T) { - keyring.MockInit() +func hasActiveToken(hostname string, expectedToken string) tokenAssertion { + return func(t *testing.T, cfg config.Config) { + t.Helper() - ios, _, _, stderr := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - - readConfigs := config.StubWriteConfig(t) - cfg := config.NewFromString("") - _, err := cfg.Authentication().Login("github.com", "test-user-1", "test-token-1", "https", true) - require.NoError(t, err) - - _, err = cfg.Authentication().Login("github.com", "test-user-2", "test-token-2", "ssh", true) - require.NoError(t, err) - - pm := &prompter.PrompterMock{} - pm.SelectFunc = func(_, _ string, opts []string) (int, error) { - return prompter.IndexFor(opts, "test-user-2 (github.com)") + token, _ := cfg.Authentication().Token(hostname) + require.Equal(t, expectedToken, token) } - - opts := LogoutOptions{ - IO: ios, - Config: func() (config.Config, error) { - return cfg, nil - }, - Prompter: pm, - } - - require.NoError(t, logoutRun(&opts)) - - hostsBuf := bytes.Buffer{} - readConfigs(io.Discard, &hostsBuf) - - secureToken, _ := cfg.Authentication().TokenFromKeyring("github.com") - require.Equal(t, "test-token-1", secureToken) - - expectedHosts := heredoc.Doc(` - github.com: - users: - test-user-1: - git_protocol: https - git_protocol: https - user: test-user-1 - `) - - require.Equal(t, expectedHosts, hostsBuf.String()) - - require.Contains(t, stderr.String(), "✓ Switched active account for github.com to test-user-1") } From 15ba536317cd2522f6cb47ca2018c819b76aaa66 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 Dec 2023 17:30:03 +0100 Subject: [PATCH 38/62] Ensure subtests use the right t during setup --- pkg/cmd/auth/login/login_test.go | 12 ++++++------ pkg/cmd/auth/setupgit/setupgit_test.go | 22 +++++++++++----------- pkg/cmd/auth/status/status_test.go | 24 ++++++++++++------------ pkg/cmd/auth/token/token_test.go | 18 +++++++++--------- pkg/cmd/repo/fork/fork_test.go | 6 +++--- pkg/cmdutil/auth_check_test.go | 7 +++---- 6 files changed, 44 insertions(+), 45 deletions(-) diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 48361fde6..9cfeb871e 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -260,7 +260,7 @@ func Test_loginRun_nontty(t *testing.T) { opts *LoginOptions env map[string]string httpStubs func(*httpmock.Registry) - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) wantHosts string wantErr string wantStderr string @@ -396,7 +396,7 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "github.com", Token: "newUserToken", }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) require.NoError(t, err) }, @@ -429,7 +429,7 @@ func Test_loginRun_nontty(t *testing.T) { cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { return cfg, nil @@ -480,7 +480,7 @@ func Test_loginRun_Survey(t *testing.T) { httpStubs func(*httpmock.Registry) prompterStubs func(*prompter.PrompterMock) runStubs func(*run.CommandStubber) - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) wantHosts string wantErrOut *regexp.Regexp wantSecureToken string @@ -685,7 +685,7 @@ func Test_loginRun_Survey(t *testing.T) { return -1, prompter.NoSuchPromptErr(prompt) } }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { _, err := c.Authentication().Login("github.com", "monalisa", "abc123", "https", false) require.NoError(t, err) }, @@ -728,7 +728,7 @@ func Test_loginRun_Survey(t *testing.T) { cfg, readConfigs := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { return cfg, nil diff --git a/pkg/cmd/auth/setupgit/setupgit_test.go b/pkg/cmd/auth/setupgit/setupgit_test.go index 2206378e2..dd8b2a4d6 100644 --- a/pkg/cmd/auth/setupgit/setupgit_test.go +++ b/pkg/cmd/auth/setupgit/setupgit_test.go @@ -28,7 +28,7 @@ func Test_setupGitRun(t *testing.T) { name string opts *SetupGitOptions setupErr error - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) expectedHostsSetup []string expectedErr string expectedErrOut string @@ -53,8 +53,8 @@ func Test_setupGitRun(t *testing.T) { opts: &SetupGitOptions{ Hostname: "foo", }, - cfgStubs: func(cfg config.Config) { - cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) }, expectedErr: "You are not logged into the GitHub host \"foo\"\n", expectedErrOut: "", @@ -63,8 +63,8 @@ func Test_setupGitRun(t *testing.T) { name: "error setting up git for hostname", opts: &SetupGitOptions{}, setupErr: fmt.Errorf("broken"), - cfgStubs: func(cfg config.Config) { - cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) }, expectedErr: "failed to set up git credential helper: broken", expectedErrOut: "", @@ -72,9 +72,9 @@ func Test_setupGitRun(t *testing.T) { { name: "no hostname option given. Setup git for each hostname in config", opts: &SetupGitOptions{}, - cfgStubs: func(cfg config.Config) { - cfg.Authentication().Login("ghe.io", "test-user", "gho_ABCDEFG", "https", false) - cfg.Authentication().Login("github.com", "test-user", "gho_ABCDEFG", "https", false) + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "ghe.io", "test-user", "gho_ABCDEFG", "https", false) + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) }, expectedHostsSetup: []string{"github.com", "ghe.io"}, }, @@ -83,8 +83,8 @@ func Test_setupGitRun(t *testing.T) { opts: &SetupGitOptions{ Hostname: "ghe.io", }, - cfgStubs: func(cfg config.Config) { - cfg.Authentication().Login("ghe.io", "test-user", "gho_ABCDEFG", "https", false) + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "ghe.io", "test-user", "gho_ABCDEFG", "https", false) }, expectedHostsSetup: []string{"ghe.io"}, }, @@ -101,7 +101,7 @@ func Test_setupGitRun(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } if tt.opts.Config == nil { diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 172e0f1e6..ea5cf52cd 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -79,7 +79,7 @@ func Test_statusRun(t *testing.T) { name string opts StatusOptions httpStubs func(*httpmock.Registry) - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) wantErr error wantOut string wantErrOut string @@ -89,7 +89,7 @@ func Test_statusRun(t *testing.T) { opts: StatusOptions{ Hostname: "github.com", }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { @@ -108,7 +108,7 @@ func Test_statusRun(t *testing.T) { opts: StatusOptions{ Hostname: "ghe.io", }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "gho_abc123", "https") login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, @@ -128,7 +128,7 @@ func Test_statusRun(t *testing.T) { { name: "missing scope", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { @@ -149,7 +149,7 @@ func Test_statusRun(t *testing.T) { { name: "bad token", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { @@ -168,7 +168,7 @@ func Test_statusRun(t *testing.T) { { name: "all good", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "gho_abc123", "https") login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "ssh") }, @@ -201,7 +201,7 @@ func Test_statusRun(t *testing.T) { { name: "server-to-server token", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "ghs_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { @@ -221,7 +221,7 @@ func Test_statusRun(t *testing.T) { { name: "PAT V2 token", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "github_pat_abc123", "https") }, httpStubs: func(reg *httpmock.Registry) { @@ -243,7 +243,7 @@ func Test_statusRun(t *testing.T) { opts: StatusOptions{ ShowToken: true, }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "gho_abc123", "https") login(t, c, "ghe.io", "monalisa-ghe", "gho_xyz456", "https") }, @@ -274,7 +274,7 @@ func Test_statusRun(t *testing.T) { opts: StatusOptions{ Hostname: "github.example.com", }, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "abc123", "https") }, httpStubs: func(reg *httpmock.Registry) {}, @@ -284,7 +284,7 @@ func Test_statusRun(t *testing.T) { { name: "multiple accounts on a host", opts: StatusOptions{}, - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { login(t, c, "github.com", "monalisa", "gho_abc123", "https") login(t, c, "github.com", "monalisa-2", "gho_abc123", "https") }, @@ -319,7 +319,7 @@ func Test_statusRun(t *testing.T) { tt.opts.IO = ios cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { return cfg, nil diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index 9259e93f8..a175542ac 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -86,7 +86,7 @@ func TestTokenRun(t *testing.T) { name string opts TokenOptions env map[string]string - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) wantStdout string wantErr bool wantErrMsg string @@ -94,7 +94,7 @@ func TestTokenRun(t *testing.T) { { name: "token", opts: TokenOptions{}, - cfgStubs: func(cfg config.Config) { + cfgStubs: func(t *testing.T, cfg config.Config) { login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) }, wantStdout: "gho_ABCDEFG\n", @@ -104,7 +104,7 @@ func TestTokenRun(t *testing.T) { opts: TokenOptions{ Hostname: "github.mycompany.com", }, - cfgStubs: func(cfg config.Config) { + cfgStubs: func(t *testing.T, cfg config.Config) { login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) login(t, cfg, "github.mycompany.com", "test-user", "gho_1234567", "https", false) }, @@ -119,7 +119,7 @@ func TestTokenRun(t *testing.T) { { name: "uses default host when one is not provided", opts: TokenOptions{}, - cfgStubs: func(cfg config.Config) { + cfgStubs: func(t *testing.T, cfg config.Config) { login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) login(t, cfg, "github.mycompany.com", "test-user", "gho_1234567", "https", false) }, @@ -139,7 +139,7 @@ func TestTokenRun(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { @@ -162,7 +162,7 @@ func TestTokenRunSecureStorage(t *testing.T) { tests := []struct { name string opts TokenOptions - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) wantStdout string wantErr bool wantErrMsg string @@ -170,7 +170,7 @@ func TestTokenRunSecureStorage(t *testing.T) { { name: "token", opts: TokenOptions{}, - cfgStubs: func(cfg config.Config) { + cfgStubs: func(t *testing.T, cfg config.Config) { login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", true) }, wantStdout: "gho_ABCDEFG\n", @@ -180,7 +180,7 @@ func TestTokenRunSecureStorage(t *testing.T) { opts: TokenOptions{ Hostname: "mycompany.com", }, - cfgStubs: func(cfg config.Config) { + cfgStubs: func(t *testing.T, cfg config.Config) { login(t, cfg, "mycompany.com", "test-user", "gho_1234567", "https", true) }, wantStdout: "gho_1234567\n", @@ -201,7 +201,7 @@ func TestTokenRunSecureStorage(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index e85558597..7f37c0ae9 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -211,7 +211,7 @@ func TestRepoFork(t *testing.T) { httpStubs func(*httpmock.Registry) execStubs func(*run.CommandStubber) promptStubs func(*prompter.MockPrompter) - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) remotes []*context.Remote wantOut string wantErrOut string @@ -254,7 +254,7 @@ func TestRepoFork(t *testing.T) { Repo: ghrepo.New("OWNER", "REPO"), }, }, - cfgStubs: func(c config.Config) { + cfgStubs: func(_ *testing.T, c config.Config) { c.Set("", "git_protocol", "") }, httpStubs: forkPost, @@ -733,7 +733,7 @@ func TestRepoFork(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } tt.opts.Config = func() (config.Config, error) { return cfg, nil diff --git a/pkg/cmdutil/auth_check_test.go b/pkg/cmdutil/auth_check_test.go index 273946e82..25cbae567 100644 --- a/pkg/cmdutil/auth_check_test.go +++ b/pkg/cmdutil/auth_check_test.go @@ -11,12 +11,11 @@ func Test_CheckAuth(t *testing.T) { tests := []struct { name string env map[string]string - cfgStubs func(config.Config) + cfgStubs func(*testing.T, config.Config) expected bool }{ { name: "no known hosts, no env auth token", - cfgStubs: func(_ config.Config) {}, expected: false, }, { @@ -26,7 +25,7 @@ func Test_CheckAuth(t *testing.T) { }, { name: "known host", - cfgStubs: func(c config.Config) { + cfgStubs: func(t *testing.T, c config.Config) { _, err := c.Authentication().Login("github.com", "test-user", "test-token", "https", false) require.NoError(t, err) }, @@ -43,7 +42,7 @@ func Test_CheckAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { - tt.cfgStubs(cfg) + tt.cfgStubs(t, cfg) } for k, v := range tt.env { From 587007a56292c0cad144986a68359013699483ea Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Sat, 2 Dec 2023 09:52:36 +0000 Subject: [PATCH 39/62] Additional auth status tests --- pkg/cmd/auth/status/status_test.go | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index ea5cf52cd..a17644f1b 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -78,6 +78,7 @@ func Test_statusRun(t *testing.T) { tests := []struct { name string opts StatusOptions + env map[string]string httpStubs func(*httpmock.Registry) cfgStubs func(*testing.T, config.Config) wantErr error @@ -198,6 +199,28 @@ func Test_statusRun(t *testing.T) { - Token scopes: none `), }, + { + name: "token from env", + opts: StatusOptions{}, + env: map[string]string{"GH_TOKEN": "gho_abc123"}, + cfgStubs: func(t *testing.T, c config.Config) {}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", ""), + httpmock.ScopesResponder("")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) + }, + wantOut: heredoc.Doc(` + github.com + ✓ Logged in to github.com account monalisa (GH_TOKEN) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: none + `), + }, { name: "server-to-server token", opts: StatusOptions{}, @@ -307,6 +330,59 @@ func Test_statusRun(t *testing.T) { - Token scopes: 'repo', 'read:org', 'project:read' `), }, + { + name: "multiple hosts with multiple accounts with environment tokens and with errors", + opts: StatusOptions{}, + env: map[string]string{"GH_ENTERPRISE_TOKEN": "gho_abc123"}, + cfgStubs: func(t *testing.T, c config.Config) { + login(t, c, "github.com", "monalisa", "gho_def456", "https") + login(t, c, "github.com", "monalisa-2", "gho_ghi789", "https") + login(t, c, "ghe.io", "monalisa-ghe", "gho_xyz123", "ssh") + }, + httpStubs: func(reg *httpmock.Registry) { + // Get scopes for monalia-2 + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + // Get scopes for monalisa + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo")) + // Get scopes for monalisa-ghe-2 + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) + // Error getting scopes for monalisa-ghe + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(404, "{}")) + // Get username for monalisa-ghe-2 + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa-ghe-2"}}}`)) + }, + wantOut: heredoc.Doc(` + github.com + ✓ Logged in to github.com account monalisa-2 (GH_CONFIG_DIR/hosts.yml) + - Active account: true + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo', 'read:org' + + ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: false + - Git operations protocol: https + - Token: gho_****** + - Token scopes: 'repo' + ! Missing required token scopes: 'read:org' + - To request missing scopes, run: gh auth refresh -h github.com + + ghe.io + ✓ Logged in to ghe.io account monalisa-ghe-2 (GH_ENTERPRISE_TOKEN) + - Active account: true + - Git operations protocol: ssh + - Token: gho_****** + - Token scopes: 'repo', 'read:org' + + X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) + - Active account: false + - The token in GH_CONFIG_DIR/hosts.yml is invalid. + - To re-authenticate, run: gh auth login -h ghe.io + - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe + `), + }, } for _, tt := range tests { @@ -334,6 +410,10 @@ func Test_statusRun(t *testing.T) { tt.httpStubs(reg) } + for k, v := range tt.env { + t.Setenv(k, v) + } + err := statusRun(&tt.opts) if tt.wantErr != nil { require.Equal(t, err, tt.wantErr) From 553b89f30c84d5f96fd4a63b7654827cb86d70db Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 12:12:22 +0100 Subject: [PATCH 40/62] Add tests for AuthConfig TokenForUser --- internal/config/auth_config_test.go | 41 +++++++++++++++++++++++++++++ internal/config/config.go | 3 +-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index cb673f27d..a6595e112 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -553,6 +553,47 @@ func TestUsersForHostWithUsers(t *testing.T) { require.Equal(t, []string{"test-user-1", "test-user-2"}, users) } +func TestTokenForUserSecureLogin(t *testing.T) { + // Given a user has logged in securely + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", true) + require.NoError(t, err) + + // When we get the token + token, source, err := authCfg.TokenForUser("github.com", "test-user-1") + + // Then it returns the token and the source as keyring + require.NoError(t, err) + require.Equal(t, "test-token", token) + require.Equal(t, "keyring", source) +} + +func TestTokenForUserInsecureLogin(t *testing.T) { + // Given a user has logged in insecurely + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", false) + require.NoError(t, err) + + // When we get the token + token, source, err := authCfg.TokenForUser("github.com", "test-user-1") + + // Then it returns the token and the source as oauth_token + require.NoError(t, err) + require.Equal(t, "test-token", token) + require.Equal(t, "oauth_token", source) +} + +func TestTokenForUserNotFoundErrors(t *testing.T) { + // Given a user has not logged in + authCfg := newTestAuthConfig(t) + + // When we get the token + _, _, err := authCfg.TokenForUser("github.com", "test-user-1") + + // Then it returns an error + require.EqualError(t, err, "no token found for 'test-user-1'") +} + func requireKeyWithValue(t *testing.T, cfg *ghConfig.Config, keys []string, value string) { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index 7d30623be..eefc76868 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -417,7 +417,6 @@ func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { return users, nil } -// TODO: Write tests and explore implementation and return value more func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) { if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { return token, "keyring", nil @@ -428,7 +427,7 @@ func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) return token, "oauth_token", nil } - return "", "default", fmt.Errorf("no token found for: %s", user) + return "", "default", fmt.Errorf("no token found for '%s'", user) } func keyringServiceName(hostname string) string { From 08c659bdf834599d82df3de8f45663f07095cced Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 12:41:11 +0100 Subject: [PATCH 41/62] Document that git protocol during login is host level --- pkg/cmd/auth/login/login.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index ac377c4cc..9018a9d0b 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -71,6 +71,10 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm %[1]sgh help environment%[1]s for more info. To use gh in GitHub Actions, add %[1]sGH_TOKEN: ${{ github.token }}%[1]s to %[1]senv%[1]s. + + The git protocol to use for git operations on this host can be set with %[1]s--git-protocol%[1]s, + or during the interactive prompting. Although login is for a single account on a host, setting + the git protocol will take affect for all users on the host. `, "`"), Example: heredoc.Doc(` # Start interactive setup @@ -126,7 +130,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes to request") cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate") - cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations") + cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations on this host") // secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility var secureStorage bool From ab5103f06173fb4fd38f5d786b1ef51ce4fcde0b Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 12:46:11 +0100 Subject: [PATCH 42/62] Don't set user level git protocol and don't switch --- internal/config/auth_config_test.go | 30 ----------------------------- internal/config/config.go | 13 +++++-------- pkg/cmd/auth/login/login_test.go | 19 ++++++------------ pkg/cmd/auth/logout/logout_test.go | 14 +++++++------- pkg/cmd/auth/switch/switch_test.go | 8 ++++---- 5 files changed, 22 insertions(+), 62 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index a6595e112..df565a958 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -280,13 +280,6 @@ func TestLoginSetsGitProtocolForProvidedHost(t *testing.T) { // Then it returns the git protocol we provided on login require.Equal(t, "ssh", hostProtocol) - - // When we get the users git protocol - userProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) - require.NoError(t, err) - - // Then it returns the git protocol we provided on login - require.Equal(t, "ssh", userProtocol) } func TestLoginAddsHostIfNotAlreadyAdded(t *testing.T) { @@ -461,22 +454,6 @@ func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { require.Equal(t, "test-user-1", activeUser) } -// TODO: This might be removed -func TestSwitchUserUpdatesTheHostLevelGitProtocol(t *testing.T) { - // Given we have two users logged into a host - authCfg := newTestAuthConfig(t) - _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) - require.NoError(t, err) - _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "https", false) - require.NoError(t, err) - - // When we switch to the other user - require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) - - // Then the host level git protocol is updated - requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", gitProtocolKey}, "ssh") -} - func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { // Given we have a user but no token can be found (because we deleted them, simulating an error case) authCfg := newTestAuthConfig(t) @@ -755,13 +732,6 @@ func TestLoginPostMigrationSetsGitProtocol(t *testing.T) { // Then it returns the git protocol we provided on login require.Equal(t, "ssh", hostProtocol) - - // When we get the user git protocol - userProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", usersKey, "test-user", gitProtocolKey}) - require.NoError(t, err) - - // Then it returns the git protocol we provided on login - require.Equal(t, "ssh", userProtocol) } func TestLoginPostMigrationSetsUser(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index eefc76868..81e80e706 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -319,9 +319,11 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure } if gitProtocol != "" { - // And set the git protocol under the user to support later auth switch - // and logout switch without another migration. - c.cfg.Set([]string{hostsKey, hostname, usersKey, username, gitProtocolKey}, gitProtocol) + // Set the host level git protocol + // Although it might be expected that this is handled by switch, git protocol + // is currently a host level config and not a user level config, so any change + // will overwrite the protocol for all users on the host. + c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) } // Create the username key with an empty value so it will be @@ -362,11 +364,6 @@ func (c *AuthConfig) SwitchUser(hostname, user string) error { return fmt.Errorf("no token found for '%s'", user) } - // Then we'll ensure the git protocol is moved as well - if gitProtocol, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, gitProtocolKey}); err == nil { - c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) - } - // Then we'll update the active user for the host c.cfg.Set([]string{hostsKey, hostname, userKey}, user) diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 9cfeb871e..e1bcfdbf6 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -295,7 +295,7 @@ func Test_loginRun_nontty(t *testing.T) { httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`)) }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n oauth_token: abc123\n git_protocol: https\n user: monalisa\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: https\n oauth_token: abc123\n user: monalisa\n", }, { name: "with token and non-default host", @@ -411,7 +411,6 @@ func Test_loginRun_nontty(t *testing.T) { users: monalisa: oauth_token: abc123 - git_protocol: https newUser: git_protocol: https user: newUser @@ -497,9 +496,8 @@ func Test_loginRun_Survey(t *testing.T) { users: jillv: oauth_token: def456 - git_protocol: https - oauth_token: def456 git_protocol: https + oauth_token: def456 user: jillv `), prompterStubs: func(pm *prompter.PrompterMock) { @@ -532,9 +530,8 @@ func Test_loginRun_Survey(t *testing.T) { users: jillv: oauth_token: def456 - git_protocol: https - oauth_token: def456 git_protocol: https + oauth_token: def456 user: jillv `), opts: &LoginOptions{ @@ -576,9 +573,8 @@ func Test_loginRun_Survey(t *testing.T) { users: jillv: oauth_token: def456 - git_protocol: https - oauth_token: def456 git_protocol: https + oauth_token: def456 user: jillv `), opts: &LoginOptions{ @@ -611,9 +607,8 @@ func Test_loginRun_Survey(t *testing.T) { users: jillv: oauth_token: def456 - git_protocol: ssh - oauth_token: def456 git_protocol: ssh + oauth_token: def456 user: jillv `), opts: &LoginOptions{ @@ -658,10 +653,9 @@ func Test_loginRun_Survey(t *testing.T) { }, wantHosts: heredoc.Doc(` github.com: + git_protocol: https users: jillv: - git_protocol: https - git_protocol: https user: jillv `), wantErrOut: regexp.MustCompile("Logged in as jillv"), @@ -704,7 +698,6 @@ func Test_loginRun_Survey(t *testing.T) { users: monalisa: oauth_token: def456 - git_protocol: https git_protocol: https user: monalisa oauth_token: def456 diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 5e0048bce..28c4ed36f 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -155,7 +155,7 @@ func Test_logoutRun_tty(t *testing.T) { } }, assertToken: hasNoToken("github.com"), - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n user: monalisa-ghe\n", wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { @@ -177,7 +177,7 @@ func Test_logoutRun_tty(t *testing.T) { } }, assertToken: hasActiveToken("github.com", "monalisa2-token"), - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n monalisa-ghe2:\n oauth_token: abc123\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa-ghe2\n oauth_token: abc123\ngithub.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n monalisa-ghe2:\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe2\n oauth_token: abc123\ngithub.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, { @@ -206,7 +206,7 @@ func Test_logoutRun_tty(t *testing.T) { return prompter.IndexFor(opts, "monalisa (github.com)") } }, - wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", + wantHosts: "github.com:\n users:\n monalisa2:\n oauth_token: monalisa2-token\n git_protocol: ssh\n user: monalisa2\n oauth_token: monalisa2-token\n", assertToken: hasActiveToken("github.com", "monalisa2-token"), wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, @@ -224,7 +224,7 @@ func Test_logoutRun_tty(t *testing.T) { {"monalisa", "abc123"}, }}, }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n user: monalisa\n", assertToken: hasNoToken("ghe.io"), wantErrOut: regexp.MustCompile(`Logged out of ghe.io account monalisa-ghe`), }, @@ -302,7 +302,7 @@ func Test_logoutRun_tty(t *testing.T) { {"monalisa2", "monalisa2-token"}, }}, }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", assertToken: hasActiveToken("github.com", "monalisa-token"), wantErrOut: regexp.MustCompile("✓ Switched active account for github.com to monalisa"), }, @@ -403,7 +403,7 @@ func Test_logoutRun_nontty(t *testing.T) { {"monalisa-ghe", "abc123"}, }}, }, - wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n git_protocol: ssh\n user: monalisa-ghe\n", + wantHosts: "ghe.io:\n users:\n monalisa-ghe:\n oauth_token: abc123\n git_protocol: ssh\n oauth_token: abc123\n user: monalisa-ghe\n", assertToken: hasNoToken("github.com"), wantErrOut: regexp.MustCompile(`Logged out of github.com account monalisa`), }, @@ -497,7 +497,7 @@ func Test_logoutRun_nontty(t *testing.T) { {"monalisa2", "monalisa2-token"}, }}, }, - wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", + wantHosts: "github.com:\n users:\n monalisa:\n oauth_token: monalisa-token\n git_protocol: ssh\n user: monalisa\n oauth_token: monalisa-token\n", assertToken: hasActiveToken("github.com", "monalisa-token"), wantErrOut: regexp.MustCompile("✓ Switched active account for github.com to monalisa"), }, diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index 43301b2dc..f16d41317 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -128,7 +128,7 @@ func TestSwitchRun(t *testing.T) { switchedHost: "github.com", activeUser: "inactive-user", activeToken: "inactive-user-token", - hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + hostsCfg: "github.com:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: inactive-user\n", stderr: "✓ Switched active account for github.com to inactive-user", }, }, @@ -148,7 +148,7 @@ func TestSwitchRun(t *testing.T) { switchedHost: "github.com", activeUser: "inactive-user-2", activeToken: "inactive-user-2-token", - hostsCfg: "github.com:\n users:\n inactive-user-1:\n git_protocol: ssh\n inactive-user-2:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user-2\n", + hostsCfg: "github.com:\n git_protocol: ssh\n users:\n inactive-user-1:\n inactive-user-2:\n active-user:\n user: inactive-user-2\n", stderr: "✓ Switched active account for github.com to inactive-user-2", }, }, @@ -172,7 +172,7 @@ func TestSwitchRun(t *testing.T) { switchedHost: "ghe.io", activeUser: "inactive-user", activeToken: "inactive-user-token", - hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + hostsCfg: "github.com:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: active-user\nghe.io:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: inactive-user\n", stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, @@ -312,7 +312,7 @@ func TestSwitchRun(t *testing.T) { switchedHost: "ghe.io", activeUser: "inactive-user", activeToken: "inactive-user-token", - hostsCfg: "github.com:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: active-user\nghe.io:\n users:\n inactive-user:\n git_protocol: ssh\n active-user:\n git_protocol: ssh\n git_protocol: ssh\n user: inactive-user\n", + hostsCfg: "github.com:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: active-user\nghe.io:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: inactive-user\n", stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, From 8d53c9e55eca95d73143511128500cda032bf2e2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 13:14:24 +0100 Subject: [PATCH 43/62] Only migrate oauth token under new user --- internal/config/migration/multi_account.go | 50 ++++-------- .../config/migration/multi_account_test.go | 78 +++---------------- 2 files changed, 28 insertions(+), 100 deletions(-) diff --git a/internal/config/migration/multi_account.go b/internal/config/migration/multi_account.go index 6163856b7..d4e1e7a67 100644 --- a/internal/config/migration/multi_account.go +++ b/internal/config/migration/multi_account.go @@ -47,8 +47,6 @@ type tokenSource struct { // editor: vim // users: // williammartin: -// git_protocol: https -// editor: vim // // github.localhost: // user: monalisa @@ -56,9 +54,13 @@ type tokenSource struct { // oauth_token: xyz // users: // monalisa: -// git_protocol: https // oauth_token: xyz // +// For each hosts, we will create a new key `users` with the value of the host level +// `user` key as a list entry. If there is a host level `oauth_token` we will +// put that under the new user entry, otherwise there will be no value for the +// new user key. No other host level configuration will be copied to the new user. +// // The reason for this is that we can then add new users under a host. // Note that we are only copying the config under a new users key, and // under a specific user. The original config is left alone. This is to @@ -198,39 +200,21 @@ func migrateConfig(c *config.Config, hostname, username string) error { // written even if there are no keys set under it. c.Set(append(hostsKey, hostname, "users", username), "") - // e.g. [user, git_protocol, editor, ouath_token] - configEntryKeys, err := c.Keys(append(hostsKey, hostname)) + insecureToken, err := c.Get(append(hostsKey, hostname, "oauth_token")) + var keyNotFoundError *config.KeyNotFoundError + // If there is no token then we're done here + if errors.As(err, &keyNotFoundError) { + return nil + } + + // If there's another error (current implementation doesn't have any other error but we'll be defensive) + // then bubble something up. if err != nil { - return fmt.Errorf("couldn't get host configuration despite %q existing", hostname) - } - - for _, configEntryKey := range configEntryKeys { - // Do not re-write process the user and users keys. - if configEntryKey == "user" || configEntryKey == "users" { - continue - } - - // We would expect that these keys map directly to values - // e.g. [williammartin, https, vim, gho_xyz...] but it's possible that a manually - // edited config file might nest further but we don't support that. - // - // We could consider throwing away the nested values, but I suppose - // I'd rather make the user take a destructive action even if we have a backup. - // If they have configuration here, it's probably for a reason. - keys, err := c.Keys(append(hostsKey, hostname, configEntryKey)) - if err == nil && len(keys) > 0 { - return errors.New("hosts file has entries that are surprisingly deeply nested") - } - - configEntryValue, err := c.Get(append(hostsKey, hostname, configEntryKey)) - if err != nil { - return fmt.Errorf("couldn't get configuration entry value despite %q / %q existing", hostname, configEntryKey) - } - - // Set these entries in their new location under the user - c.Set(append(hostsKey, hostname, "users", username, configEntryKey), configEntryValue) + return fmt.Errorf("couldn't get oauth token for %s: %s", hostname, err) } + // Otherwise we'll set the token under the new key + c.Set(append(hostsKey, hostname, "users", username, "oauth_token"), insecureToken) return nil } diff --git a/internal/config/migration/multi_account_test.go b/internal/config/migration/multi_account_test.go index f70839881..8feb853bd 100644 --- a/internal/config/migration/multi_account_test.go +++ b/internal/config/migration/multi_account_test.go @@ -28,14 +28,8 @@ hosts: var m migration.MultiAccount require.NoError(t, m.Do(cfg)) - // Do some simple checks here for depth and multiple migrations - // but I don't really want to write a full tree traversal matcher. - - // First we'll check that the data has been copied to the new structure - requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "user1", "git_protocol"}, "ssh") + // First we'll check that the oauth tokens have been moved to their new locations requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "user1", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx") - - requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "user2", "git_protocol"}, "https") requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "user2", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy") // Then we'll check that the old data has been left alone @@ -89,10 +83,9 @@ hosts: require.NoError(t, err) require.Equal(t, userTwoToken, gotUserTwoToken) - // First we'll check that the data has been copied to the new structure - requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "userOne", "git_protocol"}, "ssh") - - requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "userTwo", "git_protocol"}, "https") + // First we'll check that the users have been created with no config underneath them + requireKeyExists(t, cfg, []string{"hosts", "github.com", "users", "userOne"}) + requireKeyExists(t, cfg, []string{"hosts", "enterprise.com", "users", "userTwo"}) // Then we'll check that the old data has been left alone requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "userOne") @@ -112,21 +105,6 @@ func TestPostVersion(t *testing.T) { require.Equal(t, "1", m.PostVersion()) } -func TestMigrationErrorsWithDeeplyNestedEntries(t *testing.T) { - cfg := config.ReadFromString(` -hosts: - github.com: - user: user1 - nested: - too: deep -`) - - var m migration.MultiAccount - err := m.Do(cfg) - - require.ErrorContains(t, err, "hosts file has entries that are surprisingly deeply nested") -} - func TestMigrationReturnsSuccessfullyWhenNoHostsEntry(t *testing.T) { cfg := config.ReadFromString(``) @@ -185,46 +163,6 @@ hosts: require.Equal(t, token, gotToken) } -func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndGitProtocol(t *testing.T) { - // Simulates config that gets generated when a user logs - // in with a token and git protocol is specified and - // secure storage is used. - token := "test-token" - keyring.MockInit() - require.NoError(t, keyring.Set("gh:github.com", "", token)) - - cfg := config.ReadFromString(` -hosts: - github.com: - user: x-access-token - git_protocol: ssh -`) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.GraphQL(`query CurrentUser\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`), - ) - - m := migration.MultiAccount{Transport: reg} - require.NoError(t, m.Do(cfg)) - - require.Equal(t, fmt.Sprintf("token %s", token), reg.Requests[0].Header.Get("Authorization")) - requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") - requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") - - // Verify token gets stored with host and username - gotToken, err := keyring.Get("gh:github.com", "monalisa") - require.NoError(t, err) - require.Equal(t, token, gotToken) - - // Verify token still exists with only host - gotToken, err = keyring.Get("gh:github.com", "") - require.NoError(t, err) - require.Equal(t, token, gotToken) -} - func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndInsecureStorage(t *testing.T) { // Simulates config that gets generated when a user logs // in with a token and git protocol is specified and @@ -250,7 +188,6 @@ hosts: require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization")) requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa") requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "oauth_token"}, "test-token") - requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "git_protocol"}, "ssh") } func TestMigrationRemovesHostsWithInvalidTokens(t *testing.T) { @@ -287,6 +224,13 @@ hosts: require.ErrorContains(t, err, `couldn't find oauth token for "github.com": keyring test error`) } +func requireKeyExists(t *testing.T, cfg *config.Config, keys []string) { + t.Helper() + + _, err := cfg.Get(keys) + require.NoError(t, err) +} + func requireKeyWithValue(t *testing.T, cfg *config.Config, keys []string, value string) { t.Helper() From ecfb226d5d10bb26f5f230f4c90f11179d18ca25 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 13:32:34 +0100 Subject: [PATCH 44/62] Fix linting errors --- internal/config/auth_config_test.go | 2 +- pkg/cmd/auth/refresh/refresh_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index df565a958..97373394c 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -462,7 +462,7 @@ func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) require.NoError(t, err) - keyring.Delete(keyringServiceName("github.com"), "test-user-1") + require.NoError(t, keyring.Delete(keyringServiceName("github.com"), "test-user-1")) // When we switch to the user err = authCfg.SwitchUser("github.com", "test-user-1") diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 2841615cf..97c3bff11 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -417,7 +417,8 @@ func Test_refreshRun(t *testing.T) { cfg, _ := config.NewIsolatedTestConfig(t) for _, hostname := range tt.cfgHosts { - cfg.Authentication().Login(hostname, "test-user", "abc123", "https", false) + _, err := cfg.Authentication().Login(hostname, "test-user", "abc123", "https", false) + require.NoError(t, err) } tt.opts.Config = func() (config.Config, error) { return cfg, nil From 7106129f650cd512d165d7a0179db08bff553a57 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 13:49:28 +0100 Subject: [PATCH 45/62] Restore previous happy state on Switch failure --- internal/config/auth_config_test.go | 56 +++++++++++++++++- internal/config/config.go | 92 +++++++++++++++++++---------- 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 97373394c..4a95e00dd 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -454,12 +454,28 @@ func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { require.Equal(t, "test-user-1", activeUser) } -func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { +func TestSwitchUserErrorsImmediatelyIfTheActiveTokenComesFromEnvironment(t *testing.T) { + // Given we have a token in the env + authCfg := newTestAuthConfig(t) + t.Setenv("GH_TOKEN", "unimportant-test-value") + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + // When we switch to a user + err = authCfg.SwitchUser("github.com", "test-user-1") + + // Then it errors immediately with an informative message + require.ErrorContains(t, err, "currently active token for github.com is from GH_TOKEN") +} + +func TestSwitchUserErrorsAndRestoresUserAndInsecureConfigUnderFailure(t *testing.T) { // Given we have a user but no token can be found (because we deleted them, simulating an error case) authCfg := newTestAuthConfig(t) _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true) require.NoError(t, err) - _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false) require.NoError(t, err) require.NoError(t, keyring.Delete(keyringServiceName("github.com"), "test-user-1")) @@ -468,8 +484,42 @@ func TestSwitchUserErrorsIfNoTokenMadeActive(t *testing.T) { err = authCfg.SwitchUser("github.com", "test-user-1") // Then it returns an error - // But also restores the previous require.EqualError(t, err, "no token found for 'test-user-1'") + + // And restores the previous state + activeUser, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "test-user-2", activeUser) + + token, source := authCfg.Token("github.com") + require.Equal(t, "test-token-2", token) + require.Equal(t, "oauth_token", source) +} + +func TestSwitchUserErrorsAndRestoresUserAndKeyringUnderFailure(t *testing.T) { + // Given we have a user but no token can be found (because we deleted them, simulating an error case) + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true) + require.NoError(t, err) + + require.NoError(t, authCfg.cfg.Remove([]string{hostsKey, "github.com", usersKey, "test-user-1", oauthTokenKey})) + + // When we switch to the user + err = authCfg.SwitchUser("github.com", "test-user-1") + + // Then it returns an error + require.EqualError(t, err, "no token found for 'test-user-1'") + + // And restores the previous state + activeUser, err := authCfg.User("github.com") + require.NoError(t, err) + require.Equal(t, "test-user-2", activeUser) + + token, source := authCfg.Token("github.com") + require.Equal(t, "test-token-2", token) + require.Equal(t, "keyring", source) } func TestSwitchClearsActiveSecureTokenWhenSwitchingToInsecureUser(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 81e80e706..708a142bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -332,42 +333,40 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure c.cfg.Set([]string{hostsKey, hostname, usersKey, username}, "") } - // Then we perform a switch to the new user - return insecureStorageUsed, c.SwitchUser(hostname, username) + // Then we activate the new user + return insecureStorageUsed, c.activateUser(hostname, username) } -// TODO: How should git protocol be handled? Do we need to set it at the user level since it could have been changed? func (c *AuthConfig) SwitchUser(hostname, user string) error { - // We first need to idempotently clear out any set tokens for the host - _ = keyring.Delete(keyringServiceName(hostname), "") - _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + previouslyActiveUser, err := c.User(hostname) + if err != nil { + return fmt.Errorf("failed to get active user: %s", err) + } - // Then we'll move the keyring token or insecure token as necessary, only one of the - // following branches should be true. + previouslyActiveToken, previousSource := c.Token(hostname) + if previousSource != "keyring" && previousSource != "oauth_token" { + return fmt.Errorf("currently active token for %s is from %s", hostname, previousSource) + } - // If there is a token in the secure keyring for the user, move it to the active slot - var tokenSwitched bool - if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { - if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil { - return fmt.Errorf("failed to move active token in keyring: %v", err) + err = c.activateUser(hostname, user) + if err != nil { + // Given that activateUser can only fail before the config is written, or when writing the config + // we know for sure that the config has not been written. However, we still should restore it back + // to its previous clean state just in case something else tries to make use of the config, or tries + // to write it again. + if previousSource == "keyring" { + err = errors.Join(err, keyring.Set(keyringServiceName(hostname), "", previouslyActiveToken)) } - tokenSwitched = true + + if previousSource == "oauth_token" { + c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, previouslyActiveToken) + } + c.cfg.Set([]string{hostsKey, hostname, userKey}, previouslyActiveUser) + + return err } - // If there is a token in the insecure config for the user, move it to the active field - if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { - c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) - tokenSwitched = true - } - - if !tokenSwitched { - return fmt.Errorf("no token found for '%s'", user) - } - - // Then we'll update the active user for the host - c.cfg.Set([]string{hostsKey, hostname, userKey}, user) - - return ghConfig.Write(c.cfg) + return nil } // Logout will remove user, git protocol, and auth token for the given hostname. @@ -401,8 +400,41 @@ func (c *AuthConfig) Logout(hostname, username string) error { return n != username }) - // And switch to them - return c.SwitchUser(hostname, users[switchUserIdx]) + // And activate them + return c.activateUser(hostname, users[switchUserIdx]) +} + +func (c *AuthConfig) activateUser(hostname, user string) error { + // We first need to idempotently clear out any set tokens for the host + _ = keyring.Delete(keyringServiceName(hostname), "") + _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) + + // Then we'll move the keyring token or insecure token as necessary, only one of the + // following branches should be true. + + // If there is a token in the secure keyring for the user, move it to the active slot + var tokenSwitched bool + if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { + if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil { + return fmt.Errorf("failed to move active token in keyring: %v", err) + } + tokenSwitched = true + } + + // If there is a token in the insecure config for the user, move it to the active field + if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { + c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) + tokenSwitched = true + } + + if !tokenSwitched { + return fmt.Errorf("no token found for '%s'", user) + } + + // Then we'll update the active user for the host + c.cfg.Set([]string{hostsKey, hostname, userKey}, user) + + return ghConfig.Write(c.cfg) } func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { From 4e04b98f6f79b8e4bcd29155119d015897d6f9b0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 14:12:40 +0100 Subject: [PATCH 46/62] Add user flag to auth token command --- internal/config/auth_config_test.go | 23 +++++++++++++++++++ internal/config/config.go | 15 ++++++++++++- pkg/cmd/auth/token/token.go | 27 +++++++++++++++++------ pkg/cmd/auth/token/token_test.go | 34 +++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 4a95e00dd..eb35159a8 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -29,6 +29,29 @@ func TestTokenFromKeyring(t *testing.T) { require.Equal(t, "test-token", token) } +func TestTokenFromKeyringForUser(t *testing.T) { + // Given a keyring that contains a token for a host with a specific user + authCfg := newTestAuthConfig(t) + require.NoError(t, keyring.Set(keyringServiceName("github.com"), "test-user", "test-token")) + + // When we get the token from the auth config + token, err := authCfg.TokenFromKeyringForUser("github.com", "test-user") + + // Then it returns successfully with the correct token + require.NoError(t, err) + require.Equal(t, "test-token", token) +} + +func TestTokenFromKeyringForUserErrorsIfUsernameIsBlank(t *testing.T) { + authCfg := newTestAuthConfig(t) + + // When we get the token from the keyring for an empty username + _, err := authCfg.TokenFromKeyringForUser("github.com", "") + + // Then it returns an error + require.ErrorContains(t, err, "username cannot be blank") +} + func TestTokenStoredInConfig(t *testing.T) { // When the user has logged in insecurely authCfg := newTestAuthConfig(t) diff --git a/internal/config/config.go b/internal/config/config.go index 708a142bd..5f9b34ca8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -263,6 +263,20 @@ func (c *AuthConfig) TokenFromKeyring(hostname string) (string, error) { return keyring.Get(keyringServiceName(hostname), "") } +// TokenFromKeyringForUser will retrieve the auth token for the given hostname +// and username, only searching in encrypted storage. +// +// An empty username will return an error because the potential to return +// the currently active token under surprising cases is just too high to risk +// compared to the utility of having the function being smart. +func (c *AuthConfig) TokenFromKeyringForUser(hostname, username string) (string, error) { + if username == "" { + return "", errors.New("username cannot be blank") + } + + return keyring.Get(keyringServiceName(hostname), username) +} + // User will retrieve the username for the logged in user at the given hostname. func (c *AuthConfig) User(hostname string) (string, error) { return c.cfg.Get([]string{hostsKey, hostname, userKey}) @@ -451,7 +465,6 @@ func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) return token, "keyring", nil } - // If there is a token in the insecure config for the user, move it to the active field if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { return token, "oauth_token", nil } diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index bf4bd81cc..5f6f4d915 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -15,10 +15,10 @@ type TokenOptions struct { Config func() (config.Config, error) Hostname string + Username string SecureStorage bool } -// TODO: Detmerine if this is wonky in multi-account world. Do we need a --user flag? func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Command { opts := &TokenOptions{ IO: f.IOStreams, @@ -28,10 +28,11 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "token", Short: "Print the authentication token gh is configured to use", - Long: heredoc.Doc(` - This command outputs the authentication token for the active - account on a given GitHub host. - `), + Long: heredoc.Docf(` + This command outputs the authentication token for an account on a given GitHub host. + + Without the %[1]s--user%[1]s flag, the token for the currently active user is printed. + `, "`"), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { @@ -43,6 +44,7 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance authenticated with") + cmd.Flags().StringVarP(&opts.Username, "user", "u", "", "The account to log out of") cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Search only secure credential store for authentication token") _ = cmd.Flags().MarkHidden("secure-storage") @@ -62,10 +64,21 @@ func tokenRun(opts *TokenOptions) error { } var val string + // If this conditional logic ends up being duplicated anywhere, + // we should consider making a factory function that returns the correct + // behavior. For now, keeping it all inline is simplest. if opts.SecureStorage { - val, _ = authCfg.TokenFromKeyring(hostname) + if opts.Username == "" { + val, _ = authCfg.TokenFromKeyring(hostname) + } else { + val, _ = authCfg.TokenFromKeyringForUser(hostname, opts.Username) + } } else { - val, _ = authCfg.Token(hostname) + if opts.Username == "" { + val, _ = authCfg.Token(hostname) + } else { + val, _, _ = authCfg.TokenForUser(hostname, opts.Username) + } } if val == "" { return fmt.Errorf("no oauth token") diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index a175542ac..dc9968b31 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -29,6 +29,16 @@ func TestNewCmdToken(t *testing.T) { input: "--hostname github.mycompany.com", output: TokenOptions{Hostname: "github.mycompany.com"}, }, + { + name: "with user", + input: "--user test-user", + output: TokenOptions{Username: "test-user"}, + }, + { + name: "with shorthand user", + input: "-u test-user", + output: TokenOptions{Username: "test-user"}, + }, { name: "with shorthand hostname", input: "-h github.mycompany.com", @@ -126,6 +136,18 @@ func TestTokenRun(t *testing.T) { env: map[string]string{"GH_HOST": "github.mycompany.com"}, wantStdout: "gho_1234567\n", }, + { + name: "token for user", + opts: TokenOptions{ + Hostname: "github.com", + Username: "test-user", + }, + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", false) + login(t, cfg, "github.com", "test-user-2", "gho_1234567", "https", false) + }, + wantStdout: "gho_ABCDEFG\n", + }, } for _, tt := range tests { @@ -191,6 +213,18 @@ func TestTokenRunSecureStorage(t *testing.T) { wantErr: true, wantErrMsg: "no oauth token", }, + { + name: "token for user", + opts: TokenOptions{ + Hostname: "github.com", + Username: "test-user", + }, + cfgStubs: func(t *testing.T, cfg config.Config) { + login(t, cfg, "github.com", "test-user", "gho_ABCDEFG", "https", true) + login(t, cfg, "github.com", "test-user-2", "gho_1234567", "https", true) + }, + wantStdout: "gho_ABCDEFG\n", + }, } for _, tt := range tests { From 2c72647cf774e782eb4ea88049f575074836a2d6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 15:51:30 +0100 Subject: [PATCH 47/62] Update git protocol login prompt to indicate it is host level --- pkg/cmd/auth/login/login_test.go | 12 ++++++------ pkg/cmd/auth/shared/login_flow.go | 2 +- pkg/cmd/auth/shared/login_flow_test.go | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index e1bcfdbf6..60c6f6f24 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -503,7 +503,7 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") @@ -543,7 +543,7 @@ func Test_loginRun_Survey(t *testing.T) { switch prompt { case "What account do you want to log into?": return prompter.IndexFor(opts, "GitHub Enterprise Server") - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") @@ -586,7 +586,7 @@ func Test_loginRun_Survey(t *testing.T) { switch prompt { case "What account do you want to log into?": return prompter.IndexFor(opts, "GitHub.com") - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") @@ -620,7 +620,7 @@ func Test_loginRun_Survey(t *testing.T) { switch prompt { case "What account do you want to log into?": return prompter.IndexFor(opts, "GitHub.com") - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "SSH") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") @@ -639,7 +639,7 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") @@ -671,7 +671,7 @@ func Test_loginRun_Survey(t *testing.T) { prompterStubs: func(pm *prompter.PrompterMock) { pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "HTTPS") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 74a8ca7ad..9e3f369af 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -58,7 +58,7 @@ func Login(opts *LoginOptions) error { "SSH", } result, err := opts.Prompter.Select( - "What is your preferred protocol for Git operations?", + "What is your preferred protocol for Git operations on this host?", options[0], options) if err != nil { diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 626c21290..b14d2228c 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -52,7 +52,7 @@ func TestLogin_ssh(t *testing.T) { pm := &prompter.PrompterMock{} pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { switch prompt { - case "What is your preferred protocol for Git operations?": + case "What is your preferred protocol for Git operations on this host?": return prompter.IndexFor(opts, "SSH") case "How would you like to authenticate GitHub CLI?": return prompter.IndexFor(opts, "Paste an authentication token") From dbff555835c758e27c2e4cfd8e58b0b7c3961e48 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 15:56:15 +0100 Subject: [PATCH 48/62] Update auth token short and long --- pkg/cmd/auth/token/token.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index 5f6f4d915..2c57771b6 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -27,11 +27,13 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "token", - Short: "Print the authentication token gh is configured to use", + Short: "Print the authentication token gh uses for a hostname and account", Long: heredoc.Docf(` This command outputs the authentication token for an account on a given GitHub host. - Without the %[1]s--user%[1]s flag, the token for the currently active user is printed. + Without the %[1]s--hostname%[1]s flag, the default host is chosen. + + Without the %[1]s--user%[1]s flag, the currently active account for the host is chosen. `, "`"), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { From af8bcd3ed2bb9bb416192335ffd05bb225d73968 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 4 Dec 2023 16:15:49 +0100 Subject: [PATCH 49/62] Print useful error when switch fails outside user control --- internal/config/auth_config_test.go | 4 ++-- internal/config/config.go | 6 ++++-- pkg/cmd/auth/switch/switch.go | 6 +++++- pkg/cmd/auth/switch/switch_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index eb35159a8..944bcea65 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -507,7 +507,7 @@ func TestSwitchUserErrorsAndRestoresUserAndInsecureConfigUnderFailure(t *testing err = authCfg.SwitchUser("github.com", "test-user-1") // Then it returns an error - require.EqualError(t, err, "no token found for 'test-user-1'") + require.EqualError(t, err, "no token found for test-user-1") // And restores the previous state activeUser, err := authCfg.User("github.com") @@ -533,7 +533,7 @@ func TestSwitchUserErrorsAndRestoresUserAndKeyringUnderFailure(t *testing.T) { err = authCfg.SwitchUser("github.com", "test-user-1") // Then it returns an error - require.EqualError(t, err, "no token found for 'test-user-1'") + require.EqualError(t, err, "no token found for test-user-1") // And restores the previous state activeUser, err := authCfg.User("github.com") diff --git a/internal/config/config.go b/internal/config/config.go index 5f9b34ca8..8dadf03e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -369,7 +369,9 @@ func (c *AuthConfig) SwitchUser(hostname, user string) error { // to its previous clean state just in case something else tries to make use of the config, or tries // to write it again. if previousSource == "keyring" { - err = errors.Join(err, keyring.Set(keyringServiceName(hostname), "", previouslyActiveToken)) + if setErr := keyring.Set(keyringServiceName(hostname), "", previouslyActiveToken); setErr != nil { + err = errors.Join(err, setErr) + } } if previousSource == "oauth_token" { @@ -442,7 +444,7 @@ func (c *AuthConfig) activateUser(hostname, user string) error { } if !tokenSwitched { - return fmt.Errorf("no token found for '%s'", user) + return fmt.Errorf("no token found for %s", user) } // Then we'll update the active user for the host diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go index 1106b7832..c705291a6 100644 --- a/pkg/cmd/auth/switch/switch.go +++ b/pkg/cmd/auth/switch/switch.go @@ -163,11 +163,15 @@ func switchRun(opts *SwitchOptions) error { return cmdutil.SilentError } + cs := opts.IO.ColorScheme() + if err := authCfg.SwitchUser(hostname, username); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to switch account for %s to %s\n", + cs.FailureIcon(), hostname, cs.Bold(username)) + return err } - cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.ErrOut, "%s Switched active account for %s to %s\n", cs.SuccessIcon(), hostname, cs.Bold(username)) diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index f16d41317..ee56c4c9d 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -3,10 +3,12 @@ package authswitch import ( "bytes" "errors" + "fmt" "io" "testing" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/keyring" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -104,6 +106,8 @@ func TestSwitchRun(t *testing.T) { stderr string } + userWithMissingToken := "user-that-is-broken-by-the-test" + tests := []struct { name string opts SwitchOptions @@ -316,6 +320,22 @@ func TestSwitchRun(t *testing.T) { stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, + { + name: "when switching fails due to something other than user error, an informative message is printed to explain their new state", + opts: SwitchOptions{ + Username: userWithMissingToken, + }, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {userWithMissingToken, "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + }, + expectedFailure: failedExpectation{ + err: fmt.Errorf("no token found for %s", userWithMissingToken), + stderr: fmt.Sprintf("X Failed to switch account for github.com to %s", userWithMissingToken), + }, + }, } for _, tt := range tests { @@ -341,6 +361,10 @@ func TestSwitchRun(t *testing.T) { user.token, "ssh", true, ) require.NoError(t, err) + + if user.name == userWithMissingToken { + require.NoError(t, keyring.Delete(fmt.Sprintf("gh:%s", hostUsers.host), userWithMissingToken)) + } } } From 92a902e45303696e3fa162ec6715988ce915e341 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 4 Dec 2023 14:40:32 -0400 Subject: [PATCH 50/62] Add context to auth token command error message --- pkg/cmd/auth/token/token.go | 10 ++++++++-- pkg/cmd/auth/token/token_test.go | 22 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index 2c57771b6..a582684a8 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -33,7 +33,7 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm Without the %[1]s--hostname%[1]s flag, the default host is chosen. - Without the %[1]s--user%[1]s flag, the currently active account for the host is chosen. + Without the %[1]s--user%[1]s flag, the active account for the host is chosen. `, "`"), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { @@ -82,12 +82,18 @@ func tokenRun(opts *TokenOptions) error { val, _, _ = authCfg.TokenForUser(hostname, opts.Username) } } + if val == "" { - return fmt.Errorf("no oauth token") + errMsg := fmt.Sprintf("no oauth token found for %s", hostname) + if opts.Username != "" { + errMsg += fmt.Sprintf(" account %s", opts.Username) + } + return fmt.Errorf(errMsg) } if val != "" { fmt.Fprintf(opts.IO.Out, "%s\n", val) } + return nil } diff --git a/pkg/cmd/auth/token/token_test.go b/pkg/cmd/auth/token/token_test.go index dc9968b31..6e9e246af 100644 --- a/pkg/cmd/auth/token/token_test.go +++ b/pkg/cmd/auth/token/token_test.go @@ -124,7 +124,16 @@ func TestTokenRun(t *testing.T) { name: "no token", opts: TokenOptions{}, wantErr: true, - wantErrMsg: "no oauth token", + wantErrMsg: "no oauth token found for github.com", + }, + { + name: "no token for hostname user", + opts: TokenOptions{ + Hostname: "ghe.io", + Username: "test-user", + }, + wantErr: true, + wantErrMsg: "no oauth token found for ghe.io account test-user", }, { name: "uses default host when one is not provided", @@ -211,7 +220,16 @@ func TestTokenRunSecureStorage(t *testing.T) { name: "no token", opts: TokenOptions{}, wantErr: true, - wantErrMsg: "no oauth token", + wantErrMsg: "no oauth token found for github.com", + }, + { + name: "no token for hostname user", + opts: TokenOptions{ + Hostname: "ghe.io", + Username: "test-user", + }, + wantErr: true, + wantErrMsg: "no oauth token found for ghe.io account test-user", }, { name: "token for user", From 8cdbc1a8ca7bda3a70a9f90602e8bc0420783268 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 4 Dec 2023 14:52:11 -0400 Subject: [PATCH 51/62] Refactor authCfg.UsersForHost to not return an error --- internal/config/auth_config_test.go | 12 +++++------- internal/config/config.go | 9 ++++----- pkg/cmd/auth/logout/logout.go | 7 ++----- pkg/cmd/auth/shared/login_flow.go | 4 ++-- pkg/cmd/auth/shared/login_flow_test.go | 4 ++-- pkg/cmd/auth/status/status.go | 2 +- pkg/cmd/auth/switch/switch.go | 7 ++----- 7 files changed, 18 insertions(+), 27 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 944bcea65..bd934f506 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -379,8 +379,7 @@ func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) { require.NoError(t, err) require.Equal(t, "test-token-1", token) - usersForHost, err := authCfg.UsersForHost("github.com") - require.NoError(t, err) + usersForHost := authCfg.UsersForHost("github.com") require.NotContains(t, "active-user", usersForHost) } @@ -581,10 +580,10 @@ func TestUsersForHostNoHost(t *testing.T) { authCfg := newTestAuthConfig(t) // When we get the users for a host that doesn't exist - _, err := authCfg.UsersForHost("github.com") + users := authCfg.UsersForHost("github.com") - // Then it returns an error - require.EqualError(t, err, "unknown host: github.com") + // Then it returns nil + require.Nil(t, users) } func TestUsersForHostWithUsers(t *testing.T) { @@ -596,10 +595,9 @@ func TestUsersForHostWithUsers(t *testing.T) { require.NoError(t, err) // When we get the users for that host - users, err := authCfg.UsersForHost("github.com") + users := authCfg.UsersForHost("github.com") // Then it succeeds and returns the users - require.NoError(t, err) require.Equal(t, []string{"test-user-1", "test-user-2"}, users) } diff --git a/internal/config/config.go b/internal/config/config.go index 8dadf03e8..d9d4f6692 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -388,8 +388,7 @@ func (c *AuthConfig) SwitchUser(hostname, user string) error { // Logout will remove user, git protocol, and auth token for the given hostname. // It will remove the auth token from the encrypted storage if it exists there. func (c *AuthConfig) Logout(hostname, username string) error { - // This error is ignorable because if there is no host then no logout is required - users, _ := c.UsersForHost(hostname) + users := c.UsersForHost(hostname) // If there is only one (or zero) users, then we remove the host // and unset the keyring tokens. @@ -453,13 +452,13 @@ func (c *AuthConfig) activateUser(hostname, user string) error { return ghConfig.Write(c.cfg) } -func (c *AuthConfig) UsersForHost(hostname string) ([]string, error) { +func (c *AuthConfig) UsersForHost(hostname string) []string { users, err := c.cfg.Keys([]string{hostsKey, hostname, usersKey}) if err != nil { - return nil, fmt.Errorf("unknown host: %s", hostname) + return nil } - return users, nil + return users } func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) { diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index a47435dad..c1755c715 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -84,7 +84,7 @@ func logoutRun(opts *LogoutOptions) error { } if username != "" { - knownUsers, _ := cfg.Authentication().UsersForHost(hostname) + knownUsers := cfg.Authentication().UsersForHost(hostname) if !slices.Contains(knownUsers, username) { return fmt.Errorf("not logged in to %s account %s", hostname, username) } @@ -101,10 +101,7 @@ func logoutRun(opts *LogoutOptions) error { if hostname != "" && host != hostname { continue } - knownUsers, err := cfg.Authentication().UsersForHost(host) - if err != nil { - return err - } + knownUsers := cfg.Authentication().UsersForHost(host) for _, user := range knownUsers { if username != "" && user != username { continue diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 9e3f369af..7bb974547 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -24,7 +24,7 @@ const defaultSSHKeyTitle = "GitHub CLI" type iconfig interface { Login(string, string, string, string, bool) (bool, error) - UsersForHost(string) ([]string, error) + UsersForHost(string) []string } type LoginOptions struct { @@ -191,7 +191,7 @@ func Login(opts *LoginOptions) error { // In this case we ignore the error if the host doesn't exist // because that can occur when the user is logging into a host // for the first time. - usersForHost, _ := cfg.UsersForHost(hostname) + usersForHost := cfg.UsersForHost(hostname) userWasAlreadyLoggedIn := slices.Contains(usersForHost, username) if gitProtocol != "" { diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index b14d2228c..263ecc3d8 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -25,8 +25,8 @@ func (c tinyConfig) Login(host, username, token, gitProtocol string, encrypt boo return false, nil } -func (c tinyConfig) UsersForHost(hostname string) ([]string, error) { - return nil, nil +func (c tinyConfig) UsersForHost(hostname string) []string { + return nil } func TestLogin_ssh(t *testing.T) { diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 2caef6700..d2713aeac 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -205,7 +205,7 @@ func statusRun(opts *StatusOptions) error { }) statuses[hostname] = append(statuses[hostname], entry) - users, _ := authCfg.UsersForHost(hostname) + users := authCfg.UsersForHost(hostname) for _, username := range users { if username == activeUser { continue diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go index c705291a6..bac323cf3 100644 --- a/pkg/cmd/auth/switch/switch.go +++ b/pkg/cmd/auth/switch/switch.go @@ -99,7 +99,7 @@ func switchRun(opts *SwitchOptions) error { } if username != "" { - knownUsers, _ := cfg.Authentication().UsersForHost(hostname) + knownUsers := cfg.Authentication().UsersForHost(hostname) if !slices.Contains(knownUsers, username) { return fmt.Errorf("not logged in to %s account %s", hostname, username) } @@ -116,10 +116,7 @@ func switchRun(opts *SwitchOptions) error { if err != nil { return err } - knownUsers, err := cfg.Authentication().UsersForHost(host) - if err != nil { - return err - } + knownUsers := cfg.Authentication().UsersForHost(host) for _, user := range knownUsers { if username != "" && user != username { continue From 1a3392a3794365f34942169279086e33b361c79a Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 4 Dec 2023 14:57:36 -0400 Subject: [PATCH 52/62] Rename authCfg.User to authCfg.ActiveUser --- internal/config/auth_config_test.go | 18 +++++++++--------- internal/config/config.go | 9 +++++---- pkg/cmd/auth/gitcredential/helper.go | 4 ++-- pkg/cmd/auth/gitcredential/helper_test.go | 2 +- pkg/cmd/auth/logout/logout.go | 4 ++-- pkg/cmd/auth/refresh/refresh.go | 2 +- pkg/cmd/auth/status/status.go | 2 +- pkg/cmd/auth/switch/switch.go | 2 +- pkg/cmd/auth/switch/switch_test.go | 2 +- 9 files changed, 23 insertions(+), 22 deletions(-) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index bd934f506..366019d63 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -153,7 +153,7 @@ func TestUserNotLoggedIn(t *testing.T) { authCfg := newTestAuthConfig(t) // When we get the user - _, err := authCfg.User("github.com") + _, err := authCfg.ActiveUser("github.com") // Then it returns failure, bubbling the KeyNotFoundError var keyNotFoundError *ghConfig.KeyNotFoundError @@ -286,7 +286,7 @@ func TestLoginSetsUserForProvidedHost(t *testing.T) { // Then it returns success and the user is set require.NoError(t, err) - user, err := authCfg.User("github.com") + user, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "test-user", user) } @@ -371,7 +371,7 @@ func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) { // Then we return success and the inactive user is now active require.NoError(t, err) - activeUser, err := authCfg.User("github.com") + activeUser, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "inactive-user", activeUser) @@ -400,7 +400,7 @@ func TestLogoutOfInactiveUserDoesNotSwitchUser(t *testing.T) { // Then we return success and the active user is still active require.NoError(t, err) - activeUser, err := authCfg.User("github.com") + activeUser, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "active-user", activeUser) } @@ -471,7 +471,7 @@ func TestSwitchUserUpdatesTheActiveUser(t *testing.T) { require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) // Then the active user is updated - activeUser, err := authCfg.User("github.com") + activeUser, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "test-user-1", activeUser) } @@ -509,7 +509,7 @@ func TestSwitchUserErrorsAndRestoresUserAndInsecureConfigUnderFailure(t *testing require.EqualError(t, err, "no token found for test-user-1") // And restores the previous state - activeUser, err := authCfg.User("github.com") + activeUser, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "test-user-2", activeUser) @@ -535,7 +535,7 @@ func TestSwitchUserErrorsAndRestoresUserAndKeyringUnderFailure(t *testing.T) { require.EqualError(t, err, "no token found for test-user-1") // And restores the previous state - activeUser, err := authCfg.User("github.com") + activeUser, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "test-user-2", activeUser) @@ -673,7 +673,7 @@ func TestUserWorksRightAfterMigration(t *testing.T) { require.NoError(t, c.Migrate(m)) // Then we can still get the user correctly - user, err := authCfg.User("github.com") + user, err := authCfg.ActiveUser("github.com") require.NoError(t, err) require.Equal(t, "test-user", user) } @@ -817,7 +817,7 @@ func TestLoginPostMigrationSetsUser(t *testing.T) { require.NoError(t, err) // When we get the user - user, err := authCfg.User("github.com") + user, err := authCfg.ActiveUser("github.com") // Then it returns success and the user we provided on login require.NoError(t, err) diff --git a/internal/config/config.go b/internal/config/config.go index d9d4f6692..6c580f851 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -277,8 +277,9 @@ func (c *AuthConfig) TokenFromKeyringForUser(hostname, username string) (string, return keyring.Get(keyringServiceName(hostname), username) } -// User will retrieve the username for the logged in user at the given hostname. -func (c *AuthConfig) User(hostname string) (string, error) { +// ActiveUser will retrieve the username for the active user at the given hostname. +// This will not be accurate if the oauth token is set from an environment variable. +func (c *AuthConfig) ActiveUser(hostname string) (string, error) { return c.cfg.Get([]string{hostsKey, hostname, userKey}) } @@ -352,7 +353,7 @@ func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secure } func (c *AuthConfig) SwitchUser(hostname, user string) error { - previouslyActiveUser, err := c.User(hostname) + previouslyActiveUser, err := c.ActiveUser(hostname) if err != nil { return fmt.Errorf("failed to get active user: %s", err) } @@ -403,7 +404,7 @@ func (c *AuthConfig) Logout(hostname, username string) error { _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username}) // This error is ignorable because we already know there is an active user for the host - activeUser, _ := c.User(hostname) + activeUser, _ := c.ActiveUser(hostname) // If the user we're removing isn't active, then we just write the config if activeUser != username { diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index a40043e7a..c7d9da8bb 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -15,7 +15,7 @@ const tokenUser = "x-access-token" type config interface { Token(string) (string, string) - User(string) (string, error) + ActiveUser(string) (string, error) } type CredentialOptions struct { @@ -122,7 +122,7 @@ func helperRun(opts *CredentialOptions) error { if strings.HasSuffix(source, "_TOKEN") { gotUser = tokenUser } else { - gotUser, _ = cfg.User(lookupHost) + gotUser, _ = cfg.ActiveUser(lookupHost) if gotUser == "" { gotUser = tokenUser } diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go index f66df1d16..550c7c635 100644 --- a/pkg/cmd/auth/gitcredential/helper_test.go +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -14,7 +14,7 @@ func (c tinyConfig) Token(host string) (string, string) { return c[fmt.Sprintf("%s:%s", host, "oauth_token")], c["_source"] } -func (c tinyConfig) User(host string) (string, error) { +func (c tinyConfig) ActiveUser(host string) (string, error) { return c[fmt.Sprintf("%s:%s", host, "user")], nil } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index c1755c715..9d7642ee9 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -138,13 +138,13 @@ func logoutRun(opts *LogoutOptions) error { } // We can ignore the error here because a host must always have an active user - preLogoutActiveUser, _ := authCfg.User(hostname) + preLogoutActiveUser, _ := authCfg.ActiveUser(hostname) if err := authCfg.Logout(hostname, username); err != nil { return err } - postLogoutActiveUser, _ := authCfg.User(hostname) + postLogoutActiveUser, _ := authCfg.ActiveUser(hostname) hasSwitchedToNewUser := preLogoutActiveUser != postLogoutActiveUser && postLogoutActiveUser != "" diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index cae48de4a..b911a8a52 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -195,7 +195,7 @@ func refreshRun(opts *RefreshOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon()) if credentialFlow.ShouldSetup() { - username, _ := authCfg.User(hostname) + username, _ := authCfg.ActiveUser(hostname) password, _ := authCfg.Token(hostname) if err := credentialFlow.Setup(hostname, username, password); err != nil { return err diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index d2713aeac..47faf6292 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -192,7 +192,7 @@ func statusRun(opts *StatusOptions) error { gitProtocol := cfg.GitProtocol(hostname) activeUserToken, activeUserTokenSource := authCfg.Token(hostname) if authTokenWriteable(activeUserTokenSource) { - activeUser, _ = authCfg.User(hostname) + activeUser, _ = authCfg.ActiveUser(hostname) } entry := buildEntry(httpClient, buildEntryOptions{ active: true, diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go index bac323cf3..750af098b 100644 --- a/pkg/cmd/auth/switch/switch.go +++ b/pkg/cmd/auth/switch/switch.go @@ -112,7 +112,7 @@ func switchRun(opts *SwitchOptions) error { if hostname != "" && host != hostname { continue } - hostActiveUser, err := authCfg.User(host) + hostActiveUser, err := authCfg.ActiveUser(host) if err != nil { return err } diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index ee56c4c9d..87148f266 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -386,7 +386,7 @@ func TestSwitchRun(t *testing.T) { require.NoError(t, err) - activeUser, err := cfg.Authentication().User(tt.expectedSuccess.switchedHost) + activeUser, err := cfg.Authentication().ActiveUser(tt.expectedSuccess.switchedHost) require.NoError(t, err) require.Equal(t, tt.expectedSuccess.activeUser, activeUser) From 024cb939afff0821806a1d107a7a8cb7b984d84f Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 4 Dec 2023 15:10:28 -0400 Subject: [PATCH 53/62] Rename authCfg.Token to authCfg.ActiveToken and authCfg.SetToken to authCfg.SetActiveToken --- api/http_client.go | 4 ++-- api/http_client_test.go | 2 +- internal/authflow/flow.go | 2 +- internal/config/auth_config_test.go | 14 +++++++------- internal/config/config.go | 12 ++++++------ pkg/cmd/auth/gitcredential/helper.go | 6 +++--- pkg/cmd/auth/gitcredential/helper_test.go | 2 +- pkg/cmd/auth/logout/logout_test.go | 4 ++-- pkg/cmd/auth/refresh/refresh.go | 4 ++-- pkg/cmd/auth/shared/writeable.go | 2 +- pkg/cmd/auth/status/status.go | 2 +- pkg/cmd/auth/token/token.go | 2 +- pkg/cmd/config/get/get.go | 2 +- pkg/cmd/factory/default_test.go | 4 ++-- pkg/cmd/factory/remote_resolver_test.go | 4 ++-- 15 files changed, 33 insertions(+), 33 deletions(-) diff --git a/api/http_client.go b/api/http_client.go index fcf036008..f6e133f1f 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -13,7 +13,7 @@ import ( ) type tokenGetter interface { - Token(string) (string, string) + ActiveToken(string) (string, string) } type HTTPClientOptions struct { @@ -99,7 +99,7 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper // If the host has changed during a redirect do not add the authentication token header. if !redirectHostnameChange { hostname := ghinstance.NormalizeHostname(getHost(req)) - if token, _ := cfg.Token(hostname); token != "" { + if token, _ := cfg.ActiveToken(hostname); token != "" { req.Header.Set(authorization, fmt.Sprintf("token %s", token)) } } diff --git a/api/http_client_test.go b/api/http_client_test.go index f8fe28d40..dca032e6f 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -265,7 +265,7 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) { type tinyConfig map[string]string -func (c tinyConfig) Token(host string) (string, string) { +func (c tinyConfig) ActiveToken(host string) (string, string) { return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token" } diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index ddac948cd..370e08784 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -109,7 +109,7 @@ type cfg struct { token string } -func (c cfg) Token(hostname string) (string, string) { +func (c cfg) ActiveToken(hostname string) (string, string) { return c.token, "oauth_token" } diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index 366019d63..52373f375 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -59,7 +59,7 @@ func TestTokenStoredInConfig(t *testing.T) { require.NoError(t, err) // When we get the token - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") // Then the token is successfully fetched // and the source is set to oauth_token but this isn't great: @@ -74,7 +74,7 @@ func TestTokenStoredInEnv(t *testing.T) { t.Setenv("GH_TOKEN", "test-token") // When we get the token - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") // Then the token is successfully fetched // and the source is set to the name of the env var @@ -89,7 +89,7 @@ func TestTokenStoredInKeyring(t *testing.T) { require.NoError(t, err) // When we get the token - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") // Then the token is successfully fetched // and the source is set to keyring @@ -454,7 +454,7 @@ func TestSwitchUserMakesInsecureTokenActive(t *testing.T) { require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1")) // Their insecure token is now active - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") require.Equal(t, "test-token-1", token) require.Equal(t, oauthTokenKey, source) } @@ -513,7 +513,7 @@ func TestSwitchUserErrorsAndRestoresUserAndInsecureConfigUnderFailure(t *testing require.NoError(t, err) require.Equal(t, "test-user-2", activeUser) - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") require.Equal(t, "test-token-2", token) require.Equal(t, "oauth_token", source) } @@ -539,7 +539,7 @@ func TestSwitchUserErrorsAndRestoresUserAndKeyringUnderFailure(t *testing.T) { require.NoError(t, err) require.Equal(t, "test-user-2", activeUser) - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") require.Equal(t, "test-token-2", token) require.Equal(t, "keyring", source) } @@ -740,7 +740,7 @@ func TestTokenWorksRightAfterMigration(t *testing.T) { require.NoError(t, c.Migrate(m)) // Then we can still get the token correctly - token, source := authCfg.Token("github.com") + token, source := authCfg.ActiveToken("github.com") require.Equal(t, "test-token", token) require.Equal(t, oauthTokenKey, source) } diff --git a/internal/config/config.go b/internal/config/config.go index 6c580f851..d615a57c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -210,10 +210,10 @@ type AuthConfig struct { tokenOverride func(string) (string, string) } -// Token will retrieve the auth token for the given hostname, +// ActiveToken will retrieve the active auth token for the given hostname, // searching environment variables, plain text config, and // lastly encrypted storage. -func (c *AuthConfig) Token(hostname string) (string, string) { +func (c *AuthConfig) ActiveToken(hostname string) (string, string) { if c.tokenOverride != nil { return c.tokenOverride(hostname) } @@ -249,9 +249,9 @@ func (c *AuthConfig) HasEnvToken() bool { return token != "" } -// SetToken will override any token resolution and return the given -// token and source for all calls to Token. Use for testing purposes only. -func (c *AuthConfig) SetToken(token, source string) { +// SetActiveToken will override any token resolution and return the given +// token and source for all calls to ActiveToken. Use for testing purposes only. +func (c *AuthConfig) SetActiveToken(token, source string) { c.tokenOverride = func(_ string) (string, string) { return token, source } @@ -358,7 +358,7 @@ func (c *AuthConfig) SwitchUser(hostname, user string) error { return fmt.Errorf("failed to get active user: %s", err) } - previouslyActiveToken, previousSource := c.Token(hostname) + previouslyActiveToken, previousSource := c.ActiveToken(hostname) if previousSource != "keyring" && previousSource != "oauth_token" { return fmt.Errorf("currently active token for %s is from %s", hostname, previousSource) } diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index c7d9da8bb..dfa5690c1 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -14,7 +14,7 @@ import ( const tokenUser = "x-access-token" type config interface { - Token(string) (string, string) + ActiveToken(string) (string, string) ActiveUser(string) (string, error) } @@ -113,10 +113,10 @@ func helperRun(opts *CredentialOptions) error { lookupHost := wants["host"] var gotUser string - gotToken, source := cfg.Token(lookupHost) + gotToken, source := cfg.ActiveToken(lookupHost) if gotToken == "" && strings.HasPrefix(lookupHost, "gist.") { lookupHost = strings.TrimPrefix(lookupHost, "gist.") - gotToken, source = cfg.Token(lookupHost) + gotToken, source = cfg.ActiveToken(lookupHost) } if strings.HasSuffix(source, "_TOKEN") { diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go index 550c7c635..a3c6e2056 100644 --- a/pkg/cmd/auth/gitcredential/helper_test.go +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -10,7 +10,7 @@ import ( type tinyConfig map[string]string -func (c tinyConfig) Token(host string) (string, string) { +func (c tinyConfig) ActiveToken(host string) (string, string) { return c[fmt.Sprintf("%s:%s", host, "oauth_token")], c["_source"] } diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 28c4ed36f..ca37770c3 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -555,7 +555,7 @@ func hasNoToken(hostname string) tokenAssertion { return func(t *testing.T, cfg config.Config) { t.Helper() - token, _ := cfg.Authentication().Token(hostname) + token, _ := cfg.Authentication().ActiveToken(hostname) require.Empty(t, token) } } @@ -564,7 +564,7 @@ func hasActiveToken(hostname string, expectedToken string) tokenAssertion { return func(t *testing.T, cfg config.Config) { t.Helper() - token, _ := cfg.Authentication().Token(hostname) + token, _ := cfg.Authentication().ActiveToken(hostname) require.Equal(t, expectedToken, token) } } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index b911a8a52..2c978f854 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -158,7 +158,7 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes := set.NewStringSet() if !opts.ResetScopes { - if oldToken, _ := authCfg.Token(hostname); oldToken != "" { + if oldToken, _ := authCfg.ActiveToken(hostname); oldToken != "" { if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) @@ -196,7 +196,7 @@ func refreshRun(opts *RefreshOptions) error { if credentialFlow.ShouldSetup() { username, _ := authCfg.ActiveUser(hostname) - password, _ := authCfg.Token(hostname) + password, _ := authCfg.ActiveToken(hostname) if err := credentialFlow.Setup(hostname, username, password); err != nil { return err } diff --git a/pkg/cmd/auth/shared/writeable.go b/pkg/cmd/auth/shared/writeable.go index cf8e678e4..e5ae91469 100644 --- a/pkg/cmd/auth/shared/writeable.go +++ b/pkg/cmd/auth/shared/writeable.go @@ -7,6 +7,6 @@ import ( ) func AuthTokenWriteable(authCfg *config.AuthConfig, hostname string) (string, bool) { - token, src := authCfg.Token(hostname) + token, src := authCfg.ActiveToken(hostname) return src, (token == "" || !strings.HasSuffix(src, "_TOKEN")) } diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 47faf6292..e79e9ba45 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -190,7 +190,7 @@ func statusRun(opts *StatusOptions) error { var activeUser string gitProtocol := cfg.GitProtocol(hostname) - activeUserToken, activeUserTokenSource := authCfg.Token(hostname) + activeUserToken, activeUserTokenSource := authCfg.ActiveToken(hostname) if authTokenWriteable(activeUserTokenSource) { activeUser, _ = authCfg.ActiveUser(hostname) } diff --git a/pkg/cmd/auth/token/token.go b/pkg/cmd/auth/token/token.go index a582684a8..c507645ce 100644 --- a/pkg/cmd/auth/token/token.go +++ b/pkg/cmd/auth/token/token.go @@ -77,7 +77,7 @@ func tokenRun(opts *TokenOptions) error { } } else { if opts.Username == "" { - val, _ = authCfg.Token(hostname) + val, _ = authCfg.ActiveToken(hostname) } else { val, _, _ = authCfg.TokenForUser(hostname, opts.Username) } diff --git a/pkg/cmd/config/get/get.go b/pkg/cmd/config/get/get.go index f391decc4..b65cf6bd3 100644 --- a/pkg/cmd/config/get/get.go +++ b/pkg/cmd/config/get/get.go @@ -56,7 +56,7 @@ func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Co func getRun(opts *GetOptions) error { // search keyring storage when fetching the `oauth_token` value if opts.Hostname != "" && opts.Key == "oauth_token" { - token, _ := opts.Config.Authentication().Token(opts.Hostname) + token, _ := opts.Config.Authentication().ActiveToken(opts.Hostname) if token == "" { return errors.New(`could not find key "oauth_token"`) } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index b58544b44..efd2f7793 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -78,7 +78,7 @@ func Test_BaseRepo(t *testing.T) { hosts = append([]string{tt.override}, hosts...) } authCfg.SetHosts(hosts) - authCfg.SetToken("", "") + authCfg.SetActiveToken("", "") authCfg.SetDefaultHost("nonsense.com", "hosts") if tt.override != "" { authCfg.SetDefaultHost(tt.override, "GH_HOST") @@ -216,7 +216,7 @@ func Test_SmartBaseRepo(t *testing.T) { hosts = append([]string{tt.override}, hosts...) } authCfg.SetHosts(hosts) - authCfg.SetToken("", "") + authCfg.SetActiveToken("", "") authCfg.SetDefaultHost("nonsense.com", "hosts") if tt.override != "" { authCfg.SetDefaultHost(tt.override, "GH_HOST") diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go index a38470595..0b4447ca0 100644 --- a/pkg/cmd/factory/remote_resolver_test.go +++ b/pkg/cmd/factory/remote_resolver_test.go @@ -71,7 +71,7 @@ func Test_remoteResolver(t *testing.T) { cfg.AuthenticationFunc = func() *config.AuthConfig { authCfg := &config.AuthConfig{} authCfg.SetHosts([]string{"example.com"}) - authCfg.SetToken("", "") + authCfg.SetActiveToken("", "") authCfg.SetDefaultHost("example.com", "hosts") return authCfg } @@ -151,7 +151,7 @@ func Test_remoteResolver(t *testing.T) { cfg.AuthenticationFunc = func() *config.AuthConfig { authCfg := &config.AuthConfig{} authCfg.SetHosts([]string{"example.com", "github.com"}) - authCfg.SetToken("", "") + authCfg.SetActiveToken("", "") authCfg.SetDefaultHost("example.com", "default") return authCfg } From 80fc413592489e1ab9e56ad9790bf20859d051f4 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 5 Dec 2023 09:54:11 -0400 Subject: [PATCH 54/62] Polish auth status timeout error message --- pkg/cmd/auth/status/status.go | 18 +++++++++++++++--- pkg/cmd/auth/status/status_test.go | 7 ++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index e79e9ba45..fa027e193 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -87,13 +87,22 @@ func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string { } type timeoutErrorEntry struct { - host string + active bool + host string + user string + tokenSource string } func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder - sb.WriteString(fmt.Sprintf(" %s %s: timeout trying to connect to host\n", cs.Red("X"), e.host)) + if e.user != "" { + sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource)) + } else { + sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource)) + } + activeStr := fmt.Sprintf("%v", e.active) + sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) return sb.String() } @@ -314,7 +323,10 @@ func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry { var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { return timeoutErrorEntry{ - host: opts.hostname, + active: opts.active, + host: opts.hostname, + user: opts.username, + tokenSource: opts.tokenSource, } } diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index a17644f1b..a96b36c5b 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -100,9 +100,10 @@ func Test_statusRun(t *testing.T) { }) }, wantOut: heredoc.Doc(` - github.com - X github.com: timeout trying to connect to host - `), + github.com + X Timeout trying to log in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) + - Active account: true + `), }, { name: "hostname set", From fd7dc25e2ad3693ccf446c277d31d4ce1f2b6c14 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 6 Dec 2023 16:51:48 -0400 Subject: [PATCH 55/62] Add error to auth refresh when active user does not match newly authenticated token user --- pkg/cmd/auth/gitcredential/helper.go | 1 - pkg/cmd/auth/refresh/refresh.go | 22 ++++++++------- pkg/cmd/auth/refresh/refresh_test.go | 41 +++++++++++++++++++++++----- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index dfa5690c1..9b64f74b9 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -55,7 +55,6 @@ func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) * return cmd } -// TODO: In multi-account we should use active user token only if the username is not passed in. func helperRun(opts *CredentialOptions) error { if opts.Operation == "store" { // We pretend to implement the "store" operation, but do nothing since we already have a cached token. diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 2c978f854..7be4bf91a 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -29,24 +29,18 @@ type RefreshOptions struct { Scopes []string RemoveScopes []string ResetScopes bool - AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error + AuthFlow func(*iostreams.IOStreams, string, []string, bool) (string, string, error) Interactive bool InsecureStorage bool } -// TODO: Determine if this is super wonky in multi-account world. Do we need a --user flag? func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { opts := &RefreshOptions{ IO: f.IOStreams, Config: f.Config, - AuthFlow: func(authCfg *config.AuthConfig, io *iostreams.IOStreams, hostname string, scopes []string, interactive, secureStorage bool) error { - token, username, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) - if err != nil { - return err - } - _, loginErr := authCfg.Login(hostname, username, token, "", secureStorage) - return loginErr + AuthFlow: func(io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (string, string, error) { + return authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) }, HttpClient: &http.Client{}, GitClient: f.GitClient, @@ -187,7 +181,15 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes.RemoveValues(opts.RemoveScopes) - if err := opts.AuthFlow(authCfg, opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive, !opts.InsecureStorage); err != nil { + username, token, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive) + if err != nil { + return err + } + activeUser, _ := authCfg.ActiveUser(hostname) + if activeUser != "" && activeUser != username { + return fmt.Errorf("error refreshing credentials for %s received credentials for %s", activeUser, username) + } + if _, err := authCfg.Login(hostname, username, token, "", !opts.InsecureStorage); err != nil { return err } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 97c3bff11..cf5b9d524 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -176,12 +176,19 @@ type authArgs struct { secureStorage bool } +type authOut struct { + username string + token string + err error +} + func Test_refreshRun(t *testing.T) { tests := []struct { name string opts *RefreshOptions prompterStubs func(*prompter.PrompterMock) cfgHosts []string + authOut authOut oldScopes string wantErr string nontty bool @@ -193,7 +200,7 @@ func Test_refreshRun(t *testing.T) { wantErr: `not logged in to any hosts`, }, { - name: "hostname given but dne", + name: "hostname given but not previously authenticated with it", cfgHosts: []string{ "github.com", "aline.cedrac", @@ -403,16 +410,30 @@ func Test_refreshRun(t *testing.T) { secureStorage: true, }, }, + { + name: "errors when active user does not match user returned by auth flow", + cfgHosts: []string{ + "github.com", + }, + authOut: authOut{ + username: "not-test-user", + token: "xyz456", + }, + opts: &RefreshOptions{}, + wantErr: "error refreshing credentials for test-user received credentials for not-test-user", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aa := authArgs{} - tt.opts.AuthFlow = func(_ *config.AuthConfig, _ *iostreams.IOStreams, hostname string, scopes []string, interactive, secureStorage bool) error { + tt.opts.AuthFlow = func(_ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (string, string, error) { aa.hostname = hostname aa.scopes = scopes aa.interactive = interactive - aa.secureStorage = secureStorage - return nil + if tt.authOut != (authOut{}) { + return tt.authOut.username, tt.authOut.token, tt.authOut.err + } + return "test-user", "xyz456", nil } cfg, _ := config.NewIsolatedTestConfig(t) @@ -458,14 +479,20 @@ func Test_refreshRun(t *testing.T) { err := refreshRun(tt.opts) if tt.wantErr != "" { require.Contains(t, err.Error(), tt.wantErr) - } else { - require.NoError(t, err) + return } + require.NoError(t, err) + require.Equal(t, tt.wantAuthArgs.hostname, aa.hostname) require.Equal(t, tt.wantAuthArgs.scopes, aa.scopes) require.Equal(t, tt.wantAuthArgs.interactive, aa.interactive) - require.Equal(t, tt.wantAuthArgs.secureStorage, aa.secureStorage) + + authCfg := cfg.Authentication() + activeUser, _ := authCfg.ActiveUser(aa.hostname) + activeToken, _ := authCfg.ActiveToken(aa.hostname) + require.Equal(t, "test-user", activeUser) + require.Equal(t, "xyz456", activeToken) }) } } From 16cfe5f21a4a9c92e8b0badd5738c9ec4b4d3b95 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 11:35:02 +0100 Subject: [PATCH 56/62] Fix token username return ordering in auth refresh --- pkg/cmd/auth/refresh/refresh.go | 4 ++-- pkg/cmd/auth/refresh/refresh_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 7be4bf91a..80bc4d573 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -181,13 +181,13 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes.RemoveValues(opts.RemoveScopes) - username, token, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive) + token, username, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive) if err != nil { return err } activeUser, _ := authCfg.ActiveUser(hostname) if activeUser != "" && activeUser != username { - return fmt.Errorf("error refreshing credentials for %s received credentials for %s", activeUser, username) + return fmt.Errorf("error refreshing credentials for %s, received credentials for %s, did you use the correct account in the browser?", activeUser, username) } if _, err := authCfg.Login(hostname, username, token, "", !opts.InsecureStorage); err != nil { return err diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index cf5b9d524..fb526d38f 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -420,7 +420,7 @@ func Test_refreshRun(t *testing.T) { token: "xyz456", }, opts: &RefreshOptions{}, - wantErr: "error refreshing credentials for test-user received credentials for not-test-user", + wantErr: "error refreshing credentials for test-user, received credentials for not-test-user, did you use the correct account in the browser?", }, } for _, tt := range tests { @@ -431,9 +431,9 @@ func Test_refreshRun(t *testing.T) { aa.scopes = scopes aa.interactive = interactive if tt.authOut != (authOut{}) { - return tt.authOut.username, tt.authOut.token, tt.authOut.err + return tt.authOut.token, tt.authOut.username, tt.authOut.err } - return "test-user", "xyz456", nil + return "xyz456", "test-user", nil } cfg, _ := config.NewIsolatedTestConfig(t) From 0763c1d4a782be46176fd8620dba796018daa973 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 11:43:05 +0100 Subject: [PATCH 57/62] Locally prevent mixup of username and token in refresh --- pkg/cmd/auth/refresh/refresh.go | 18 +++++++++++------- pkg/cmd/auth/refresh/refresh_test.go | 6 +++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 80bc4d573..a918493e8 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -16,6 +16,9 @@ import ( "github.com/spf13/cobra" ) +type token string +type username string + type RefreshOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) @@ -29,7 +32,7 @@ type RefreshOptions struct { Scopes []string RemoveScopes []string ResetScopes bool - AuthFlow func(*iostreams.IOStreams, string, []string, bool) (string, string, error) + AuthFlow func(*iostreams.IOStreams, string, []string, bool) (token, username, error) Interactive bool InsecureStorage bool @@ -39,8 +42,9 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. opts := &RefreshOptions{ IO: f.IOStreams, Config: f.Config, - AuthFlow: func(io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (string, string, error) { - return authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) + AuthFlow: func(io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (token, username, error) { + t, u, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) + return token(t), username(u), err }, HttpClient: &http.Client{}, GitClient: f.GitClient, @@ -181,15 +185,15 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes.RemoveValues(opts.RemoveScopes) - token, username, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive) + authedToken, authedUser, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive) if err != nil { return err } activeUser, _ := authCfg.ActiveUser(hostname) - if activeUser != "" && activeUser != username { - return fmt.Errorf("error refreshing credentials for %s, received credentials for %s, did you use the correct account in the browser?", activeUser, username) + if activeUser != "" && username(activeUser) != authedUser { + return fmt.Errorf("error refreshing credentials for %s, received credentials for %s, did you use the correct account in the browser?", activeUser, authedUser) } - if _, err := authCfg.Login(hostname, username, token, "", !opts.InsecureStorage); err != nil { + if _, err := authCfg.Login(hostname, string(authedUser), string(authedToken), "", !opts.InsecureStorage); err != nil { return err } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index fb526d38f..51fa66bd6 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -426,14 +426,14 @@ func Test_refreshRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aa := authArgs{} - tt.opts.AuthFlow = func(_ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (string, string, error) { + tt.opts.AuthFlow = func(_ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (token, username, error) { aa.hostname = hostname aa.scopes = scopes aa.interactive = interactive if tt.authOut != (authOut{}) { - return tt.authOut.token, tt.authOut.username, tt.authOut.err + return token(tt.authOut.token), username(tt.authOut.username), tt.authOut.err } - return "xyz456", "test-user", nil + return token("xyz456"), username("test-user"), nil } cfg, _ := config.NewIsolatedTestConfig(t) From 21d94165bbbbd51d43bc64f171fa7bbf9748b14f Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 13:39:19 +0100 Subject: [PATCH 58/62] Add documentation for multiple accounts --- docs/multiple-accounts.md | 237 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/multiple-accounts.md diff --git a/docs/multiple-accounts.md b/docs/multiple-accounts.md new file mode 100644 index 000000000..aef2cc333 --- /dev/null +++ b/docs/multiple-accounts.md @@ -0,0 +1,237 @@ +# Multiple Accounts with the CLI - v2.40.0 + +Since its creation, `gh` has enforced a mapping of one account per host. Functionally, this meant that when targeting a +single host (e.g. github.com) each `gh auth login` would replace the token being used for API requests, and for git +operations when `gh` was configured as a git credential manager. Removing this limitation has been a [long requested +feature](https://github.com/cli/cli/issues/326), with many community members offering workarounds for a variety of use cases. +A particular shoutout to @gabe565 and his long term community support for https://github.com/gabe565/gh-profile in this space. + +With the release of `v2.40.0`, `gh` has begun supporting multiple accounts for some use cases on github.com and +in GitHub Enterprise. We recognise that there are a number of missing quality of life features, and we've opted +not to address the use case of automatic account switching based on some context (e.g. `pwd`, `git remote`, etc) though +we hope many of those using these custom solutions will now find it easier to obtain and update tokens (via the standard +OAuth flow rather than as a PAT), and to store them securely in the system keyring managed by `gh`. + +We are by no means excluding these things from ever being native to `gh` but we wanted to ship this MVP and get more +feedback so that we can iterate on it with the community. + +## What is in scope for this release? + +The support for multiple accounts in this release is focused around `auth login` becoming additive in behaviour, +allowing for multiple accounts to be easily switched between using the new `auth switch` command. Switching the "active" +user for a host will swap the token used by `gh` for API requests, and for git operations when `gh` was configured as a +git credential manager. We have extended the `auth logout` command to switch the active user where possible if the +current active user has been logged out. Finally we have extended `auth token`, `auth switch`, and `auth logout` with a +`--user` flag. This new flag in combination with `--hostname` can be used to disambiguate accounts when running +non-interactively. + +Here's an example usage. First, we can see that I have a single acocunt `wilmartin_microsoft` logged in, and +`auth status` reports that this is the active account: + +``` +➜ gh auth status +github.com + ✓ Logged in to github.com account wilmartin_microsoft (keyring) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +``` + +Running `auth login` and proceeding through the browser based OAuth flow as `williammartin`, we can see that +`auth status` now reports two accounts under `github.com`, and our new account is now marked as active. + +``` +➜ gh auth login +? What account do you want to log into? GitHub.com +? What is your preferred protocol for Git operations on this host? HTTPS +? How would you like to authenticate GitHub CLI? Login with a web browser + +! First copy your one-time code: A1F4-3B3C +Press Enter to open github.com in your browser... +✓ Authentication complete. +- gh config set -h github.com git_protocol https +✓ Configured git protocol +✓ Logged in as williammartin + +➜ gh auth status +github.com + ✓ Logged in to github.com account williammartin (keyring) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' + + ✓ Logged in to github.com account wilmartin_microsoft (keyring) + - Active account: false + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +``` + +Fetching our username from the API shows that our active token correctly corresponds to `williammartin`: + +``` +➜ gh api /user | jq .login +"williammartin" +``` + +Now we can easily switch users using `gh auth switch`, and hitting the API shows that the active token has been +changed: + +``` +➜ gh auth switch +✓ Switched active account for github.com to wilmartin_microsoft + +➜ gh api /user | jq .login +"wilmartin_microsoft" +``` + +We can use `gh auth token --user` to get a specific token for a user (which should be handy for automated switching +solutions): + +``` +➜ GH_TOKEN=$(gh auth token --user williammartin) gh api /user | jq .login +"williammartin" +``` + +Finally, running `gh auth logout` presents a prompt when there are multiple choices for logout, and switches account +if there are any remaining logged into the host: + +``` +➜ gh auth logout +? What account do you want to log out of? wilmartin_microsoft (github.com) +✓ Logged out of github.com account wilmartin_microsoft +✓ Switched active account for github.com to williammartin +``` + +## What is out of scope for this release? + +As mentioned above, we know that this only addreses some of the requests around supporting multiple accounts. While +these are not out of scope forever, for this release some of the big things we have intentionally not included are: + * Automatic account switching based on some context (e.g. `pwd`, `git remote`, etc) + * Automatic configuration of git config such as `user.name` and `user.email` when switching + * User level configuration e.g. `williammartin` uses `vim` but `wilmartin_microsoft` uses `emacs` + +## What are some sharp edges in this release? + +As in any MVP there are going to be some sharp edges that need to be smoothed out over time. Here are a list of known +sharp edges in this release. + +### Data Migration + +The trickiest piece of this work was that the `hosts.yml` file only supported a mapping of one-to-one in the host to +account relationship. Having persistent data on disk that required a schema change presented a compatability challenge +both backwards for those who use [`go-gh`](https://github.com/cli/go-gh/) outside of `gh`, and forward for `gh` itself +where we try to ensure that it's possible to use older versions in case we accidentally make a breaking change for users. + +As such, from this release, running any command will attempt to migrate this data into a new format, and will +additionally add a `version` field into the `config.yml` to aid in our future maintainability. While we have tried +to maintain forward compatability (except in one edge case outlined below), and in the worst case you should be able +to remove these files and start from scratch, if you are concerned about the data in these files, we advise you to take +a backup. + +#### Forward Compatability Exclusion + +There is one case using `--insecure-storage` that we don't maintain complete forward and backward compatability. This +occurs if you `auth login --insecure-storage`, upgrade to this release (which performs the data migration), run +`auth login --insecure-storage` again on an older release, then at some time later use `auth switch` to make this +account active. The symptom here would be usage of an older token (which may for example have different scopes). + +This occurs because we will only perform the data migration once, moving the original insecure token to a place where +it would later be used by `auth switch`. + +#### Immutable Config Users + +Some of our users lean on tools to manage their application configuration in an immutable manner for example using +https://github.com/nix-community/home-manager. These users will hit an error when we attempt to persist the new +`version` field to the `config.yml`. They will need to ensure that the `home-manager` configuration scripts are updated +to add `version: 1`. + +___ + +Meta: Draft text for home-manager issue: + +## Description + +In `gh` v2.40.0, we are going to be releasing initial support for [multiple accounts](https://github.com/cli/cli/issues/326) +on a single host. As part of this process there is a migration of on-disk data and we will be adding a `version` field +with value `1` to the `config.yml` file. + +I'm not too familiar with `nix` or how people use it, so I'm not sure if the following is even an expected workflow. If +it is expected that you can update the version of `gh` in place without regenerating the `config.yml` file from +`home-manager` scripts it is likely that users will run into an error when `gh` tries to write the `config.yml` file. + +Although it's our position that `gh` manages its own configuration file, we also try not to break users that choose to +configure their systems this way. Unfortunately, in this case we value the future maintainability of having this +`version` to support future migrations should we need them. + +As such, this is just an informative issue for people to find that this new field will be required to perform any operations +with v2.40.0. Since I'm not totally sure on the workflows used in this project, here are some possible workarounds: + 1. Temporarily allow writing to `config.yml` one time so that this `version` can be written + 1. After seeing a failure, install updated `home-manager` scripts with `version: 1` declared in the config + 1. Update the `home-manager` scripts with `version: 1` declared in the config and then blow everything away and start with v2.40.0 from scratch + +To me it feels like option `3` is the easiest, since home manager enforces config to already be defined, the only consequence +will be requiring `gh auth login` to be run for any accounts that were removed when blowing it all away. + +We apologise for the inconvenience. +___ + +### Auth Refresh + +Although this has always been possible, the multi account flow increases the likelihood of doing something surprising +with `auth refresh`. This command allows for a token to be updated with additional or fewer scopes. For example, +in the following example we add the `read:project` scope to the scopes for our currently active user `williammartin`, +and proceed through the OAuth browser flow as `williammartin`: + +``` +➜ gh auth refresh -s read:project +? What account do you want to refresh auth for? github.com + +! First copy your one-time code: E79E-5FA2 +Press Enter to open github.com in your browser... +✓ Authentication complete. + +➜ gh auth status +github.com + ✓ Logged in to github.com account williammartin (keyring) + - Active account: true + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'read:project', 'repo', 'workflow' + + ✓ Logged in to github.com account wilmartin_microsoft (keyring) + + ✓ Logged in to github.com account wilmartin_microsoft (keyring) + - Active account: false + - Git operations protocol: https + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo', 'workflow' +``` + +However, what happens if I try to remove the `workflow` scope from my active user `williammartin` but proceed through +the OAuth browser flow as `wilmartin_microsoft`? + +``` +➜ gh auth refresh -r workflow + +! First copy your one-time code: EEA3-091C +Press Enter to open github.com in your browser... +error refreshing credentials for williammartin, received credentials for wilmartin_microsoft, did you use the correct account in the browser? +``` + +When adding or removing scopes for a user, the CLI gets the scopes for the current token and then requests a new token with the requested amended scopes. Unfortunately, when we go through the account switcher flow as a different user, we end up getting a token for the wrong user with surprising scopes. We don't believe that starting and ending a `refresh` as different users is +a user case we wish to support and has the potential for misuse. As such, we have begun erroring in this case. + +Note that a token has still been minted on the platform but `gh` will refuse to store it. We are investigating +alternative approaches with the platform team to prevent this occurring earlier in the flow. + +### Account Switcher on GitHub Enterprise + +When using `auth login` with github.com, if a user has multiple accounts in the browser, they should be presented +with an interstitial page that allows for proceeding as any of their users. However, for Device Control Flow OAuth +flows, this feature has not yet made it into GHES. + +For the moment, if you have multiple accounts on GHES that you wish to log in as, you will need to ensure that you +are authenticated as the correct user in the browser before running `auth login`. From 5b9d56a6e414ad2d3f34ff3187910c7b4c1f62ca Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 13:52:31 +0100 Subject: [PATCH 59/62] Tweak docs for multi-account --- docs/multiple-accounts.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/multiple-accounts.md b/docs/multiple-accounts.md index aef2cc333..130101a2a 100644 --- a/docs/multiple-accounts.md +++ b/docs/multiple-accounts.md @@ -1,15 +1,15 @@ # Multiple Accounts with the CLI - v2.40.0 Since its creation, `gh` has enforced a mapping of one account per host. Functionally, this meant that when targeting a -single host (e.g. github.com) each `gh auth login` would replace the token being used for API requests, and for git +single host (e.g. github.com) each `auth login` would replace the token being used for API requests, and for git operations when `gh` was configured as a git credential manager. Removing this limitation has been a [long requested feature](https://github.com/cli/cli/issues/326), with many community members offering workarounds for a variety of use cases. A particular shoutout to @gabe565 and his long term community support for https://github.com/gabe565/gh-profile in this space. With the release of `v2.40.0`, `gh` has begun supporting multiple accounts for some use cases on github.com and in GitHub Enterprise. We recognise that there are a number of missing quality of life features, and we've opted -not to address the use case of automatic account switching based on some context (e.g. `pwd`, `git remote`, etc) though -we hope many of those using these custom solutions will now find it easier to obtain and update tokens (via the standard +not to address the use case of automatic account switching based on some context (e.g. `pwd`, `git remote`, etc). +However, we hope many of those using these custom solutions will now find it easier to obtain and update tokens (via the standard OAuth flow rather than as a PAT), and to store them securely in the system keyring managed by `gh`. We are by no means excluding these things from ever being native to `gh` but we wanted to ship this MVP and get more @@ -17,15 +17,17 @@ feedback so that we can iterate on it with the community. ## What is in scope for this release? -The support for multiple accounts in this release is focused around `auth login` becoming additive in behaviour, -allowing for multiple accounts to be easily switched between using the new `auth switch` command. Switching the "active" +The support for multiple accounts in this release is focused around `auth login` becoming additive in behaviour. +This allows for multiple accounts to be easily switched between using the new `auth switch` command. Switching the "active" user for a host will swap the token used by `gh` for API requests, and for git operations when `gh` was configured as a -git credential manager. We have extended the `auth logout` command to switch the active user where possible if the -current active user has been logged out. Finally we have extended `auth token`, `auth switch`, and `auth logout` with a +git credential manager. + +We have extended the `auth logout` command to switch account where possible if the currently active user is the target +of the `logout`. Finally we have extended `auth token`, `auth switch`, and `auth logout` with a `--user` flag. This new flag in combination with `--hostname` can be used to disambiguate accounts when running non-interactively. -Here's an example usage. First, we can see that I have a single acocunt `wilmartin_microsoft` logged in, and +Here's an example usage. First, we can see that I have a single account `wilmartin_microsoft` logged in, and `auth status` reports that this is the active account: ``` @@ -76,7 +78,7 @@ Fetching our username from the API shows that our active token correctly corresp "williammartin" ``` -Now we can easily switch users using `gh auth switch`, and hitting the API shows that the active token has been +Now we can easily switch accounts using `gh auth switch`, and hitting the API shows that the active token has been changed: ``` @@ -133,8 +135,8 @@ a backup. #### Forward Compatability Exclusion -There is one case using `--insecure-storage` that we don't maintain complete forward and backward compatability. This -occurs if you `auth login --insecure-storage`, upgrade to this release (which performs the data migration), run +There is one known case using `--insecure-storage` that we don't maintain complete forward and backward compatability. +This occurs if you `auth login --insecure-storage`, upgrade to this release (which performs the data migration), run `auth login --insecure-storage` again on an older release, then at some time later use `auth switch` to make this account active. The symptom here would be usage of an older token (which may for example have different scopes). @@ -221,16 +223,16 @@ Press Enter to open github.com in your browser... error refreshing credentials for williammartin, received credentials for wilmartin_microsoft, did you use the correct account in the browser? ``` -When adding or removing scopes for a user, the CLI gets the scopes for the current token and then requests a new token with the requested amended scopes. Unfortunately, when we go through the account switcher flow as a different user, we end up getting a token for the wrong user with surprising scopes. We don't believe that starting and ending a `refresh` as different users is -a user case we wish to support and has the potential for misuse. As such, we have begun erroring in this case. +When adding or removing scopes for a user, the CLI gets the scopes for the current token and then requests a new token with the requested amended scopes. Unfortunately, when we go through the account switcher flow as a different user, we end up getting a token for the wrong user with surprising scopes. We don't believe that starting and ending a `refresh` as different accounts is +a use case we wish to support and has the potential for misuse. As such, we have begun erroring in this case. Note that a token has still been minted on the platform but `gh` will refuse to store it. We are investigating -alternative approaches with the platform team to prevent this occurring earlier in the flow. +alternative approaches with the platform team to put some better guardrails in place earlier in the flow. ### Account Switcher on GitHub Enterprise When using `auth login` with github.com, if a user has multiple accounts in the browser, they should be presented -with an interstitial page that allows for proceeding as any of their users. However, for Device Control Flow OAuth +with an interstitial page that allows for proceeding as any of their accounts. However, for Device Control Flow OAuth flows, this feature has not yet made it into GHES. For the moment, if you have multiple accounts on GHES that you wish to log in as, you will need to ensure that you From 94de5290e06f665f3719f42907280b87f47b0491 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 7 Dec 2023 09:28:19 -0400 Subject: [PATCH 60/62] Auth switch special case one host with two users to avoid unnecessary prompting --- pkg/cmd/auth/switch/switch.go | 23 ++++++++------------ pkg/cmd/auth/switch/switch_test.go | 35 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/auth/switch/switch.go b/pkg/cmd/auth/switch/switch.go index 750af098b..76abbe705 100644 --- a/pkg/cmd/auth/switch/switch.go +++ b/pkg/cmd/auth/switch/switch.go @@ -68,16 +68,6 @@ type hostUser struct { type candidates []hostUser -func (c candidates) inactiveOptions() []hostUser { - var inactive []hostUser - for _, candidate := range c { - if !candidate.active { - inactive = append(inactive, candidate) - } - } - return inactive -} - func switchRun(opts *SwitchOptions) error { hostname := opts.Hostname username := opts.Username @@ -125,15 +115,20 @@ func switchRun(opts *SwitchOptions) error { } } - inactiveCandidates := candidates.inactiveOptions() if len(candidates) == 0 { return errors.New("no accounts matched that criteria") } else if len(candidates) == 1 { hostname = candidates[0].host username = candidates[0].user - } else if len(inactiveCandidates) == 1 { - hostname = inactiveCandidates[0].host - username = inactiveCandidates[0].user + } else if len(candidates) == 2 && + candidates[0].host == candidates[1].host { + // If there is a single host with two users, automatically swith to the + // inactive user without prompting. + hostname = candidates[0].host + username = candidates[0].user + if candidates[0].active { + username = candidates[1].user + } } else if !opts.IO.CanPrompt() { return errors.New("unable to determine which account to switch to, please specify `--hostname` and `--user`") } else { diff --git a/pkg/cmd/auth/switch/switch_test.go b/pkg/cmd/auth/switch/switch_test.go index 87148f266..efb69a56b 100644 --- a/pkg/cmd/auth/switch/switch_test.go +++ b/pkg/cmd/auth/switch/switch_test.go @@ -320,6 +320,38 @@ func TestSwitchRun(t *testing.T) { stderr: "✓ Switched active account for ghe.io to inactive-user", }, }, + { + name: "options need to be disambiguated given two hosts, one with two users", + opts: SwitchOptions{}, + cfgHosts: []hostUsers{ + {"github.com", []user{ + {"inactive-user", "inactive-user-token"}, + {"active-user", "active-user-token"}, + }}, + {"ghe.io", []user{ + {"active-user", "active-user-token"}, + }}, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) { + require.Equal(t, "What account do you want to switch to?", prompt) + require.Equal(t, []string{ + "inactive-user (github.com)", + "active-user (github.com) - active", + "active-user (ghe.io) - active", + }, opts) + + return prompter.IndexFor(opts, "inactive-user (github.com)") + } + }, + expectedSuccess: successfulExpectation{ + switchedHost: "github.com", + activeUser: "inactive-user", + activeToken: "inactive-user-token", + hostsCfg: "github.com:\n git_protocol: ssh\n users:\n inactive-user:\n active-user:\n user: inactive-user\nghe.io:\n git_protocol: ssh\n users:\n active-user:\n user: active-user\n", + stderr: "✓ Switched active account for github.com to inactive-user", + }, + }, { name: "when switching fails due to something other than user error, an informative message is printed to explain their new state", opts: SwitchOptions{ @@ -351,6 +383,9 @@ func TestSwitchRun(t *testing.T) { pm := &prompter.PrompterMock{} tt.prompterStubs(pm) tt.opts.Prompter = pm + defer func() { + require.Len(t, pm.SelectCalls(), 1) + }() } for _, hostUsers := range tt.cfgHosts { From b28126905212beb70b17df366f22e36d7492d594 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 15:03:26 +0100 Subject: [PATCH 61/62] Fix affect effect typo in login cmd --- pkg/cmd/auth/login/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 9018a9d0b..34a345632 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -74,7 +74,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm The git protocol to use for git operations on this host can be set with %[1]s--git-protocol%[1]s, or during the interactive prompting. Although login is for a single account on a host, setting - the git protocol will take affect for all users on the host. + the git protocol will take effect for all users on the host. `, "`"), Example: heredoc.Doc(` # Start interactive setup From c243f31a6ca2571b9e536c9d6cdd308ded51f4f5 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 Dec 2023 15:12:43 +0100 Subject: [PATCH 62/62] Add home-manager issue link to multi account doc --- docs/multiple-accounts.md | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/docs/multiple-accounts.md b/docs/multiple-accounts.md index 130101a2a..696ce52c0 100644 --- a/docs/multiple-accounts.md +++ b/docs/multiple-accounts.md @@ -150,35 +150,7 @@ https://github.com/nix-community/home-manager. These users will hit an error whe `version` field to the `config.yml`. They will need to ensure that the `home-manager` configuration scripts are updated to add `version: 1`. -___ - -Meta: Draft text for home-manager issue: - -## Description - -In `gh` v2.40.0, we are going to be releasing initial support for [multiple accounts](https://github.com/cli/cli/issues/326) -on a single host. As part of this process there is a migration of on-disk data and we will be adding a `version` field -with value `1` to the `config.yml` file. - -I'm not too familiar with `nix` or how people use it, so I'm not sure if the following is even an expected workflow. If -it is expected that you can update the version of `gh` in place without regenerating the `config.yml` file from -`home-manager` scripts it is likely that users will run into an error when `gh` tries to write the `config.yml` file. - -Although it's our position that `gh` manages its own configuration file, we also try not to break users that choose to -configure their systems this way. Unfortunately, in this case we value the future maintainability of having this -`version` to support future migrations should we need them. - -As such, this is just an informative issue for people to find that this new field will be required to perform any operations -with v2.40.0. Since I'm not totally sure on the workflows used in this project, here are some possible workarounds: - 1. Temporarily allow writing to `config.yml` one time so that this `version` can be written - 1. After seeing a failure, install updated `home-manager` scripts with `version: 1` declared in the config - 1. Update the `home-manager` scripts with `version: 1` declared in the config and then blow everything away and start with v2.40.0 from scratch - -To me it feels like option `3` is the easiest, since home manager enforces config to already be defined, the only consequence -will be requiring `gh auth login` to be run for any accounts that were removed when blowing it all away. - -We apologise for the inconvenience. -___ +See https://github.com/nix-community/home-manager/issues/4744 for more details. ### Auth Refresh