diff --git a/README.md b/README.md index bba33652..03311024 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- Get Started + Get Started View Docs


@@ -21,10 +21,6 @@ The SecretHub CLI provides the command-line interface to interact with the Secre > [SecretHub][secrethub] is a secrets management tool that works for every engineer. Securely provision passwords and keys throughout your entire stack with just a few lines of code. -## Get started - -Follow the [Getting Started Guide][getting-started] to quickly get up and running with SecretHub :rocket: - ## Usage Below you can find a selection of some of the most-used SecretHub commands. Run `secrethub --help` or the [CLI reference docs][cli-reference-docs] for a complete list of all commands. diff --git a/go.mod b/go.mod index 227fefe6..2bcb5bd4 100644 --- a/go.mod +++ b/go.mod @@ -17,14 +17,12 @@ require ( github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pkg/errors v0.9.1 // indirect github.com/secrethub/demo-app v0.1.0 - github.com/secrethub/secrethub-go v0.30.0 + github.com/secrethub/secrethub-go v0.31.0 github.com/zalando/go-keyring v0.0.0-20190208082241-fbe81aec3a07 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 - golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/sys v0.0.0-20200501052902-10377860bb8e golang.org/x/text v0.3.2 google.golang.org/api v0.26.0 - google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84 gopkg.in/yaml.v2 v2.2.2 gotest.tools v2.2.0+incompatible ) diff --git a/go.sum b/go.sum index 4a4de5e0..3c09732b 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ github.com/secrethub/secrethub-go v0.29.1-0.20200707154958-5e5602145597 h1:uC9OD github.com/secrethub/secrethub-go v0.29.1-0.20200707154958-5e5602145597/go.mod h1:tDeBtyjfFQX3UqgaZfY+H4dYkcGfiVzrwLDf0XtfOrw= github.com/secrethub/secrethub-go v0.30.0 h1:Nh1twPDwPbYQj/cYc1NG+j7sv76LZiXLPovyV83tZj0= github.com/secrethub/secrethub-go v0.30.0/go.mod h1:tDeBtyjfFQX3UqgaZfY+H4dYkcGfiVzrwLDf0XtfOrw= +github.com/secrethub/secrethub-go v0.31.0 h1:0KoG0KHBOa5knkvf3K0f6sKuPSQ5VGPXLD4ttC9Eul8= +github.com/secrethub/secrethub-go v0.31.0/go.mod h1:ZIco8Y0G0Pi0Vb7pQROjvEKgSreZiRMLhAbzWUneUSQ= github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= diff --git a/internals/cli/filemode/filemode.go b/internals/cli/filemode/filemode.go index c293405d..656767e7 100644 --- a/internals/cli/filemode/filemode.go +++ b/internals/cli/filemode/filemode.go @@ -2,6 +2,7 @@ package filemode import ( + "fmt" "os" "strconv" @@ -47,7 +48,7 @@ func (m *FileMode) Set(value string) error { // String implements the flag.Value interface. func (m FileMode) String() string { - return string(m) + return fmt.Sprintf("%#o", m.FileMode().Perm()) } // FileMode returns the file mode as an os.FileMode. diff --git a/internals/cli/masker/stream.go b/internals/cli/masker/stream.go index ce28f79a..d3c85de1 100644 --- a/internals/cli/masker/stream.go +++ b/internals/cli/masker/stream.go @@ -3,6 +3,7 @@ package masker import ( "bytes" "io" + "io/ioutil" "sync" "time" ) @@ -64,16 +65,14 @@ func (s *stream) flush(n int) error { if exists { // Get any unprocessed bytes before this match to the destination. - beforeMatch := s.buf.upToIndex(i) - - _, err := s.dest.Write(beforeMatch) + bytesBeforeMatch, err := s.buf.writeUpToIndex(s.dest, i) if err != nil { return err } // Only write the redaction text if there were bytes between this match and the previous match // or this is the first flush for the buffer. - if len(beforeMatch) > 0 || s.buf.currentIndex == 0 { + if bytesBeforeMatch > 0 || s.buf.currentIndex == 0 { _, err = s.dest.Write([]byte("")) if err != nil { return err @@ -81,14 +80,17 @@ func (s *stream) flush(n int) error { } // Drop all bytes until the end of the mask. - _ = s.buf.upToIndex(i + int64(length)) + _, err = s.buf.writeUpToIndex(ioutil.Discard, i+int64(length)) + if err != nil { + return err + } delete(s.matches, i) } } // Write all bytes after the last match. - _, err := s.dest.Write(s.buf.upToIndex(endIndex)) + _, err := s.buf.writeUpToIndex(s.dest, endIndex) if err != nil { return err } @@ -109,16 +111,17 @@ func (b *indexedBuffer) write(p []byte) (n int, err error) { return b.buffer.Write(p) } -// upToIndex pops and returns all bytes in the buffer up to the given index. -// If all bytes up to this given index have already been returned previously, an empty slice is returned. -func (b *indexedBuffer) upToIndex(index int64) []byte { +// writeUpToIndex pops all bytes in the buffer up to the given index and writes them to the given writer. +// The number of bytes written and any errors encountered are returned +func (b *indexedBuffer) writeUpToIndex(w io.Writer, index int64) (int, error) { b.mutex.Lock() defer b.mutex.Unlock() if index < b.currentIndex { - return []byte{} + return 0, nil } n := int(index - b.currentIndex) b.currentIndex = index - return b.buffer.Next(n) + bufferSlice := b.buffer.Next(n) + return w.Write(bufferSlice) } diff --git a/internals/demo/init.go b/internals/demo/init.go index 4f9e6c68..2efd5d02 100644 --- a/internals/demo/init.go +++ b/internals/demo/init.go @@ -64,8 +64,15 @@ func (cmd *InitCommand) Run() error { } _, err = client.Repos().Create(repoPath) - if err == api.ErrRepoAlreadyExists && cmd.repo == "" { - return fmt.Errorf("demo repo %s already exists, use --repo to specify another repo to use", repoPath) + if err == api.ErrRepoAlreadyExists { + demoRepo, err := cmd.isDemoRepo(client, repoPath) + if err != nil { + return err + } + if demoRepo { + return nil + } + return fmt.Errorf("repo %s already exists and is not a demo repo, use --repo to specify another repo to use", repoPath) } else if err != nil { return err } @@ -89,3 +96,34 @@ func (cmd *InitCommand) Run() error { return nil } + +// isDemoRepo checks whether the repo on the given path is a demo repository. +// It returns true iff the repository contains exactly two secrets named username and password. +func (cmd *InitCommand) isDemoRepo(client secrethub.ClientInterface, repoPath string) (bool, error) { + repo, err := client.Repos().Get(repoPath) + if err != nil { + return false, err + } + if repo.SecretCount != 2 { + return false, nil + } + + usernamePath := secretpath.Join(repoPath, "username") + exists, err := client.Secrets().Exists(usernamePath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + passwordPath := secretpath.Join(repoPath, "password") + exists, err = client.Secrets().Exists(passwordPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + return true, nil +} diff --git a/internals/secrethub/app.go b/internals/secrethub/app.go index e95a9c8b..7409eb71 100644 --- a/internals/secrethub/app.go +++ b/internals/secrethub/app.go @@ -79,6 +79,8 @@ func NewApp() *App { io := ui.NewUserIO() store := NewCredentialConfig(io) help := "The SecretHub command-line interface is a unified tool to manage your infrastructure secrets with SecretHub.\n\n" + + "If you do not yet have a SecretHub account, go here to create one:\n\n" + + " https://signup.secrethub.io/\n\n" + "For a step-by-step introduction, check out:\n\n" + " https://secrethub.io/docs/getting-started/\n\n" + "To get help, see:\n\n" + diff --git a/internals/secrethub/credential_store.go b/internals/secrethub/credential_store.go index 09f2ccf2..d6ca2d3a 100644 --- a/internals/secrethub/credential_store.go +++ b/internals/secrethub/credential_store.go @@ -11,7 +11,7 @@ import ( // Errors var ( - ErrCredentialNotExist = errMain.Code("credential_not_exist").Error("could not find credential file. Run `secrethub signup` to create an account.") + ErrCredentialNotExist = errMain.Code("credential_not_exist").Error("could not find credential file. Go to https://signup.secrethub.io/ to create an account or run `secrethub init` to use an already existing account on this machine.") ) // CredentialConfig handles the configuration necessary for local credentials. diff --git a/internals/secrethub/env.go b/internals/secrethub/env.go index 845d50f3..5e653311 100644 --- a/internals/secrethub/env.go +++ b/internals/secrethub/env.go @@ -24,5 +24,5 @@ func (cmd *EnvCommand) Register(r command.Registerer) { clause := r.Command("env", "[BETA] Manage environment variables.").Hidden() clause.HelpLong("This command is hidden because it is still in beta. Future versions may break.") NewEnvReadCommand(cmd.io, cmd.newClient).Register(clause) - NewEnvListCommand(cmd.io).Register(clause) + NewEnvListCommand(cmd.io, cmd.newClient).Register(clause) } diff --git a/internals/secrethub/env_ls.go b/internals/secrethub/env_ls.go index e4835701..c1cac212 100644 --- a/internals/secrethub/env_ls.go +++ b/internals/secrethub/env_ls.go @@ -14,10 +14,10 @@ type EnvListCommand struct { } // NewEnvListCommand creates a new EnvListCommand. -func NewEnvListCommand(io ui.IO) *EnvListCommand { +func NewEnvListCommand(io ui.IO, newClient newClientFunc) *EnvListCommand { return &EnvListCommand{ io: io, - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), } } diff --git a/internals/secrethub/env_read.go b/internals/secrethub/env_read.go index 028b2f69..af216e25 100644 --- a/internals/secrethub/env_read.go +++ b/internals/secrethub/env_read.go @@ -20,7 +20,7 @@ func NewEnvReadCommand(io ui.IO, newClient newClientFunc) *EnvReadCommand { return &EnvReadCommand{ io: io, newClient: newClient, - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), } } diff --git a/internals/secrethub/env_source.go b/internals/secrethub/env_source.go index 607d3e6f..242d4f59 100644 --- a/internals/secrethub/env_source.go +++ b/internals/secrethub/env_source.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "errors" + "fmt" "io" "io/ioutil" "os" @@ -22,8 +23,19 @@ import ( "gopkg.in/yaml.v2" ) +type errNameCollision struct { + name string + firstPath string + secondPath string +} + +func (e errNameCollision) Error() string { + return fmt.Sprintf("secrets at path %s and %s map to the same environment variable: %s. Rename one of the secrets or source them in a different way", e.firstPath, e.secondPath, e.name) +} + type environment struct { io ui.IO + newClient newClientFunc osEnv []string readFile func(filename string) ([]byte, error) osStat func(filename string) (os.FileInfo, error) @@ -32,12 +44,14 @@ type environment struct { templateVars map[string]string templateVersion string dontPromptMissingTemplateVar bool + secretsDir string secretsEnvDir string } -func newEnvironment(io ui.IO) *environment { +func newEnvironment(io ui.IO, newClient newClientFunc) *environment { return &environment{ io: io, + newClient: newClient, osEnv: os.Environ(), readFile: ioutil.ReadFile, osStat: os.Stat, @@ -53,6 +67,7 @@ func (env *environment) register(clause *cli.CommandClause) { clause.Flag("var", "Define the value for a template variable with `VAR=VALUE`, e.g. --var env=prod").Short('v').StringMapVar(&env.templateVars) clause.Flag("template-version", "The template syntax version to be used. The options are v1, v2, latest or auto to automatically detect the version.").Default("auto").StringVar(&env.templateVersion) clause.Flag("no-prompt", "Do not prompt when a template variable is missing and return an error instead.").BoolVar(&env.dontPromptMissingTemplateVar) + clause.Flag("secrets-dir", "Recursively include all secrets from a directory. Environment variable names are derived from the path of the secret: `/` are replaced with `_` and the name is uppercased.").StringVar(&env.secretsDir) clause.Flag("env", "The name of the environment prepared by the set command (default is `default`)").Default("default").Hidden().StringVar(&env.secretsEnvDir) } @@ -75,6 +90,12 @@ func (env *environment) env() (map[string]value, error) { sources = append(sources, dirSource) } + // --secrets-dir flag + if env.secretsDir != "" { + secretsDirEnv := newSecretsDirEnv(env.newClient, env.secretsDir) + sources = append(sources, secretsDirEnv) + } + //secrethub.env file if env.envFile == "" { _, err := env.osStat(defaultEnvFile) @@ -173,6 +194,69 @@ func newSecretValue(path string) value { return &secretValue{path: path} } +// secretsDirEnv sources environment variables from the directory specified with the --secrets-dir flag. +type secretsDirEnv struct { + newClient newClientFunc + dirPath string +} + +// env returns a map of environment variables containing all secrets from the specified path. +// The variable names are the relative paths of their corresponding secrets in uppercase snake case. +// An error is returned if two secret paths map to the same variable name. +func (s *secretsDirEnv) env() (map[string]value, error) { + client, err := s.newClient() + if err != nil { + return nil, err + } + + tree, err := client.Dirs().GetTree(s.dirPath, -1, false) + if err != nil { + return nil, err + } + + paths := make(map[string]string, tree.SecretCount()) + for id := range tree.Secrets { + secretPath, err := tree.AbsSecretPath(id) + if err != nil { + return nil, err + } + path := secretPath.String() + + envVarName := s.envVarName(path) + if prevPath, found := paths[envVarName]; found { + return nil, errNameCollision{ + name: envVarName, + firstPath: prevPath, + secondPath: path, + } + } + paths[envVarName] = path + } + + result := make(map[string]value, len(paths)) + for name, path := range paths { + result[name] = newSecretValue(path) + } + return result, nil +} + +// envVarName returns the environment variable name corresponding to the secret on the specified path +// by converting the relative path to uppercase snake case. +func (s *secretsDirEnv) envVarName(path string) string { + envVarName := strings.TrimPrefix(path, s.dirPath) + envVarName = strings.TrimPrefix(envVarName, "/") + envVarName = strings.ReplaceAll(envVarName, "/", "_") + envVarName = strings.ToUpper(envVarName) + return envVarName +} + +func newSecretsDirEnv(newClient newClientFunc, dirPath string) *secretsDirEnv { + return &secretsDirEnv{ + newClient: newClient, + dirPath: dirPath, + } +} + // EnvFlags defines environment variables sourced from command-line flags. type EnvFlags map[string]string diff --git a/internals/secrethub/env_source_test.go b/internals/secrethub/env_source_test.go new file mode 100644 index 00000000..a6ecad93 --- /dev/null +++ b/internals/secrethub/env_source_test.go @@ -0,0 +1,142 @@ +package secrethub + +import ( + "testing" + + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-go/internals/assert" + "github.com/secrethub/secrethub-go/pkg/secrethub" + "github.com/secrethub/secrethub-go/pkg/secrethub/fakeclient" +) + +func TestSecretsDirEnv(t *testing.T) { + const dirPath = "namespace/repo" + rootDirUUID := uuid.New() + subDirUUID := uuid.New() + secretUUID1 := uuid.New() + secretUUID2 := uuid.New() + + cases := map[string]struct { + newClient newClientFunc + expectedValues []string + err error + }{ + "success": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + expectedValues: []string{"FOO"}, + }, + "success secret in dir": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Dirs: map[uuid.UUID]*api.Dir{ + subDirUUID: { + DirID: subDirUUID, + ParentID: &rootDirUUID, + Name: "foo", + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: subDirUUID, + Name: "bar", + }, + }, + }, nil + }, + }, + }, nil + }, + expectedValues: []string{"FOO_BAR"}, + }, + "name collision": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Dirs: map[uuid.UUID]*api.Dir{ + subDirUUID: { + DirID: subDirUUID, + ParentID: &rootDirUUID, + Name: "foo", + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: subDirUUID, + Name: "bar", + }, + secretUUID2: { + SecretID: secretUUID2, + DirID: rootDirUUID, + Name: "foo_bar", + }, + }, + }, nil + }, + }, + }, nil + }, + err: errNameCollision{ + name: "FOO_BAR", + firstPath: "namespace/repo/foo/bar", + secondPath: "namespace/repo/foo_bar", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + source := newSecretsDirEnv(tc.newClient, dirPath) + secrets, err := source.env() + if tc.err != nil { + assert.Equal(t, err, tc.err) + } else { + assert.OK(t, err) + assert.Equal(t, len(secrets), len(tc.expectedValues)) + for _, name := range tc.expectedValues { + if _, ok := secrets[name]; !ok { + t.Errorf("expected but not found env var with name: %s", name) + } + } + } + }) + } +} diff --git a/internals/secrethub/init.go b/internals/secrethub/init.go index dce0c9ac..82b9eb31 100644 --- a/internals/secrethub/init.go +++ b/internals/secrethub/init.go @@ -16,23 +16,24 @@ import ( // InitCommand configures the user's SecretHub account for use on this machine. type InitCommand struct { - backupCode string - force bool - io ui.IO - newClient newClientFunc - newClientWithoutCredentials func(credentials.Provider) (secrethub.ClientInterface, error) - credentialStore CredentialConfig - progressPrinter progress.Printer + backupCode string + setupCode string + force bool + io ui.IO + newUnauthenticatedClient newClientFunc + newClientWithCredentials func(credentials.Provider) (secrethub.ClientInterface, error) + credentialStore CredentialConfig + progressPrinter progress.Printer } // NewInitCommand creates a new InitCommand. -func NewInitCommand(io ui.IO, newClient newClientFunc, newClientWithoutCredentials func(credentials.Provider) (secrethub.ClientInterface, error), credentialStore CredentialConfig) *InitCommand { +func NewInitCommand(io ui.IO, newUnauthenticatedClient newClientFunc, newClientWithCredentials func(credentials.Provider) (secrethub.ClientInterface, error), credentialStore CredentialConfig) *InitCommand { return &InitCommand{ - io: io, - newClient: newClient, - newClientWithoutCredentials: newClientWithoutCredentials, - credentialStore: credentialStore, - progressPrinter: progress.NewPrinter(io.Output(), 500*time.Millisecond), + io: io, + newUnauthenticatedClient: newUnauthenticatedClient, + newClientWithCredentials: newClientWithCredentials, + credentialStore: credentialStore, + progressPrinter: progress.NewPrinter(io.Output(), 500*time.Millisecond), } } @@ -40,6 +41,7 @@ func NewInitCommand(io ui.IO, newClient newClientFunc, newClientWithoutCredentia func (cmd *InitCommand) Register(r command.Registerer) { clause := r.Command("init", "Initialize the SecretHub client for first use on this device.") clause.Flag("backup-code", "The backup code used to restore an existing account to this device.").StringVar(&cmd.backupCode) + clause.Flag("setup-code", "The setup code used to configure the CLI to use an account created on the website.").StringVar(&cmd.setupCode) registerForceFlag(clause).BoolVar(&cmd.force) command.BindAction(clause, cmd.Run) @@ -50,11 +52,16 @@ type InitMode int const ( InitModeSignup InitMode = iota + 1 InitModeBackupCode + InitModeSetupCode ) // Run configures the user's SecretHub account for use on this machine. // If an account was already configured, the user is prompted for confirmation to overwrite it. func (cmd *InitCommand) Run() error { + if cmd.setupCode != "" && cmd.backupCode != "" { + return ErrFlagsConflict("--backup-code and --setup-code") + } + credentialPath := cmd.credentialStore.ConfigDir().Credential().Path() if cmd.credentialStore.ConfigDir().Credential().Exists() && !cmd.force { @@ -76,7 +83,9 @@ func (cmd *InitCommand) Run() error { } var mode InitMode - if cmd.backupCode != "" { + if cmd.setupCode != "" { + mode = InitModeSetupCode + } else if cmd.backupCode != "" { mode = InitModeBackupCode } @@ -86,16 +95,18 @@ func (cmd *InitCommand) Run() error { } option, err := ui.Choose(cmd.io, "How do you want to initialize your SecretHub account on this device?", []string{ - "Signup for a new account", + "Sign up for a new account", "Use a backup code to recover an existing account", }, 3) if err != nil { return err } + fmt.Fprintln(cmd.io.Output()) switch option { case 0: - mode = InitModeSignup + fmt.Fprintln(cmd.io.Output(), "Go to https://signup.secrethub.io/ and follow the steps to create an account and get it set up on this machine.") + return nil case 1: mode = InitModeBackupCode } @@ -105,12 +116,83 @@ func (cmd *InitCommand) Run() error { case InitModeSignup: signupCommand := SignUpCommand{ io: cmd.io, - newClient: cmd.newClient, + newClient: cmd.newUnauthenticatedClient, credentialStore: cmd.credentialStore, progressPrinter: cmd.progressPrinter, force: cmd.force, } return signupCommand.Run() + case InitModeSetupCode: + setupCode := cmd.setupCode + + fmt.Fprintf(cmd.io.Output(), credentialCreationMessage, credentialPath) + + // Only prompt for a passphrase when the user hasn't used --force. + // Otherwise, we assume the passphrase was intentionally not + // configured to output a plaintext credential. + var passphrase string + if !cmd.credentialStore.IsPassphraseSet() && !cmd.force { + var err error + passphrase, err = askCredentialPassphrase(cmd.io) + if err != nil { + return err + } + } + + deviceName, err := promptForDeviceName(cmd.io) + if err != nil { + return err + } + + fmt.Fprint(cmd.io.Output(), "Setting up your account...") + cmd.progressPrinter.Start() + + client, err := cmd.newClientWithCredentials(credentials.NewSetupCode(setupCode)) + if err != nil { + cmd.progressPrinter.Stop() + return err + } + + credential := credentials.CreateKey() + _, err = client.Credentials().Create(credential, deviceName) + if err != nil { + cmd.progressPrinter.Stop() + return err + } + + err = writeNewCredential(credential, passphrase, cmd.credentialStore.ConfigDir().Credential()) + if err != nil { + cmd.progressPrinter.Stop() + return err + } + + client, err = cmd.newClientWithCredentials(credential) + if err != nil { + cmd.progressPrinter.Stop() + return err + } + + me, err := client.Me().GetUser() + if err != nil { + cmd.progressPrinter.Stop() + return err + } + + secretPath, err := createStartRepo(client, me.Username, me.FullName) + if err != nil { + cmd.progressPrinter.Stop() + return err + } + cmd.progressPrinter.Stop() + fmt.Fprint(cmd.io.Output(), "Created your account.\n\n") + + err = createWorkspace(client, cmd.io, "", "", cmd.progressPrinter) + if err != nil { + return err + } + + fmt.Fprintf(cmd.io.Output(), "Setup complete. To read your first secret, run:\n\n secrethub read %s\n\n", secretPath) + return nil case InitModeBackupCode: backupCode := cmd.backupCode @@ -122,7 +204,7 @@ func (cmd *InitCommand) Run() error { } } - client, err := cmd.newClientWithoutCredentials(credentials.UseBackupCode(backupCode)) + client, err := cmd.newClientWithCredentials(credentials.UseBackupCode(backupCode)) if err != nil { return err } @@ -146,19 +228,9 @@ func (cmd *InitCommand) Run() error { return nil } - deviceName := "" - question := "What is the name of this device?" - hostName, err := os.Hostname() - if err == nil { - deviceName, err = ui.AskWithDefault(cmd.io, question, hostName) - if err != nil { - return err - } - } else { - deviceName, err = ui.Ask(cmd.io, question) - if err != nil { - return err - } + deviceName, err := promptForDeviceName(cmd.io) + if err != nil { + return err } // Only prompt for a passphrase when the user hasn't used --force. @@ -167,7 +239,7 @@ func (cmd *InitCommand) Run() error { var passphrase string if !cmd.credentialStore.IsPassphraseSet() && !cmd.force { var err error - passphrase, err = ui.AskPassphrase(cmd.io, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3) + passphrase, err = askCredentialPassphrase(cmd.io) if err != nil { return err } @@ -197,3 +269,21 @@ func (cmd *InitCommand) Run() error { return errors.New("invalid option") } } + +func promptForDeviceName(io ui.IO) (string, error) { + deviceName := "" + question := "What is the name of this device?" + hostName, err := os.Hostname() + if err == nil { + deviceName, err = ui.AskWithDefault(io, question, hostName) + if err != nil { + return "", err + } + } else { + deviceName, err = ui.Ask(io, question) + if err != nil { + return "", err + } + } + return deviceName, nil +} diff --git a/internals/secrethub/run.go b/internals/secrethub/run.go index 939726f4..db770e20 100644 --- a/internals/secrethub/run.go +++ b/internals/secrethub/run.go @@ -63,7 +63,7 @@ func NewRunCommand(io ui.IO, newClient newClientFunc) *RunCommand { return &RunCommand{ io: io, osEnv: os.Environ(), - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), newClient: newClient, } } diff --git a/internals/secrethub/run_test.go b/internals/secrethub/run_test.go index fb6d4295..87ed68a0 100644 --- a/internals/secrethub/run_test.go +++ b/internals/secrethub/run_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-cli/internals/cli/ui/fakeui" "github.com/secrethub/secrethub-cli/internals/secrethub/tpl" @@ -657,6 +659,11 @@ func osStatFunc(name string, err error) func(string) (os.FileInfo, error) { } func TestRunCommand_environment(t *testing.T) { + rootDirUUID := uuid.New() + secretUUID := uuid.New() + + const secretPathFoo = "namespace/repo/foo" + cases := map[string]struct { command RunCommand expectedEnv []string @@ -764,7 +771,159 @@ func TestRunCommand_environment(t *testing.T) { expectedSecrets: []string{"bbb"}, expectedEnv: []string{"TEST=bbb"}, }, + "env file has precedence over secrets-dir flag": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=bbb"}, + osStat: osStatFunc("secrethub.env", nil), + readFile: readFileFunc("secrethub.env", "FOO= {{ other/secret/path }}"), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } else if path == "other/secret/path" { + return &api.SecretVersion{Data: []byte("bbb")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"bbb"}, + expectedEnv: []string{"FOO=bbb"}, + }, + "secrets-dir flag has precedence over os environment": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=bbb"}, + osStat: osStatFunc("secrethub.env", os.ErrNotExist), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"aaa"}, + expectedEnv: []string{"FOO=aaa"}, + }, // TODO Add test case for: envar flag has precedence over secret reference - requires refactoring of fakeclient + "secret reference has precedence over secrets-dir flag": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=secrethub://test/test/test"}, + osStat: osStatFunc("secrethub.env", os.ErrNotExist), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == "test/test/test" { + return &api.SecretVersion{Data: []byte("bbb")}, nil + } else if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"bbb"}, + expectedEnv: []string{"FOO=bbb"}, + }, "secret reference has precedence over .env file": { command: RunCommand{ environment: &environment{ diff --git a/internals/secrethub/signup.go b/internals/secrethub/signup.go index 2d8f3eca..b4bc22a3 100644 --- a/internals/secrethub/signup.go +++ b/internals/secrethub/signup.go @@ -4,6 +4,10 @@ import ( "fmt" "time" + "github.com/secrethub/secrethub-go/pkg/secrethub/configdir" + + "github.com/secrethub/secrethub-go/pkg/secrethub" + "github.com/secrethub/secrethub-cli/internals/cli/progress" "github.com/secrethub/secrethub-cli/internals/cli/ui" "github.com/secrethub/secrethub-cli/internals/secrethub/command" @@ -18,6 +22,10 @@ var ( ErrLocalAccountFound = errMain.Code("local_account_found").Error("found a local account configuration. To overwrite it, run the same command with the --force or -f flag.") ) +const credentialCreationMessage = "An account credential will be generated and stored at %s. " + + "Losing this credential means you lose the ability to decrypt your secrets. " + + "So keep it safe.\n" + // SignUpCommand signs up a new user and configures his account for use on this machine. type SignUpCommand struct { username string @@ -44,7 +52,7 @@ func NewSignUpCommand(io ui.IO, newClient newClientFunc, credentialStore Credent // Register registers the command, arguments and flags on the provided Registerer. func (cmd *SignUpCommand) Register(r command.Registerer) { - clause := r.Command("signup", "Create a free personal developer account.") + clause := r.Command("signup", "Create a free personal developer account.").Hidden() clause.Flag("username", "The username you would like to use on SecretHub.").StringVar(&cmd.username) clause.Flag("full-name", "Your full name.").StringVar(&cmd.fullName) clause.Flag("email", "Your (work) email address we will use for all correspondence.").StringVar(&cmd.email) @@ -116,13 +124,7 @@ func (cmd *SignUpCommand) Run() error { } } - fmt.Fprintf( - cmd.io.Output(), - "An account credential will be generated and stored at %s. "+ - "Losing this credential means you lose the ability to decrypt your secrets. "+ - "So keep it safe.\n", - credentialPath, - ) + fmt.Fprintf(cmd.io.Output(), credentialCreationMessage, credentialPath) // Only prompt for a passphrase when the user hasn't used --force. // Otherwise, we assume the passphrase was intentionally not @@ -130,7 +132,7 @@ func (cmd *SignUpCommand) Run() error { var passphrase string if !cmd.credentialStore.IsPassphraseSet() && !cmd.force { var err error - passphrase, err = ui.AskPassphrase(cmd.io, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3) + passphrase, err = askCredentialPassphrase(cmd.io) if err != nil { return err } @@ -150,82 +152,110 @@ func (cmd *SignUpCommand) Run() error { return err } - exportKey := credential.Key - if passphrase != "" { - exportKey = exportKey.Passphrase(credentials.FromString(passphrase)) - } - - encodedCredential, err := credential.Export() + err = writeNewCredential(credential, passphrase, cmd.credentialStore.ConfigDir().Credential()) if err != nil { cmd.progressPrinter.Stop() return err } - err = cmd.credentialStore.ConfigDir().Credential().Write(encodedCredential) + + secretPath, err := createStartRepo(client, cmd.username, cmd.fullName) if err != nil { cmd.progressPrinter.Stop() return err } - // create a start repository and write a fist secret to it, so that - // the user can start by reading their first secret. - // This is intended to smoothen onboarding. - repoPath := secretpath.Join(cmd.username, "start") - _, err = client.Repos().Create(secretpath.Join(repoPath)) + cmd.progressPrinter.Stop() + fmt.Fprint(cmd.io.Output(), "Created your account.\n\n") + + err = createWorkspace(client, cmd.io, cmd.org, cmd.orgDescription, cmd.progressPrinter) if err != nil { - cmd.progressPrinter.Stop() return err } + fmt.Fprintf(cmd.io.Output(), "Setup complete. To read your first secret, run:\n\n secrethub read %s\n\n", secretPath) + + return nil +} + +// createStartRepo creates a start repository and writes a fist secret to it, so that +// the user can start by reading their first secret. It returns the secret's path. +// This is intended to smoothen onboarding. +func createStartRepo(client secrethub.ClientInterface, username string, fullName string) (string, error) { + repoPath := secretpath.Join(username, "start") + _, err := client.Repos().Create(secretpath.Join(repoPath)) + if err != nil { + return "", err + } + secretPath := secretpath.Join(repoPath, "hello") - message := fmt.Sprintf("Welcome %s! This is your first secret. To write a new version of this secret, run:\n\n secrethub write %s", cmd.fullName, secretPath) + message := fmt.Sprintf("Welcome %s! This is your first secret. To write a new version of this secret, run:\n\n secrethub write %s", fullName, secretPath) _, err = client.Secrets().Write(secretPath, []byte(message)) if err != nil { - cmd.progressPrinter.Stop() - return err + return "", err } + return secretPath, nil +} - cmd.progressPrinter.Stop() - fmt.Fprint(cmd.io.Output(), "Created your account.\n\n") - - createWorkspace := cmd.org != "" - if !createWorkspace { - createWorkspace, err = ui.AskYesNo(cmd.io, "Do you want to create a shared workspace for your team?", ui.DefaultYes) +// createWorkspace creates a new org with the given name and description. +func createWorkspace(client secrethub.ClientInterface, io ui.IO, org string, orgDescription string, progressPrinter progress.Printer) error { + if org == "" { + createWorkspace, err := ui.AskYesNo(io, "Do you want to create a shared workspace for your team?", ui.DefaultYes) if err != nil { return err } - fmt.Fprintln(cmd.io.Output()) + fmt.Fprintln(io.Output()) if !createWorkspace { - fmt.Fprint(cmd.io.Output(), "You can create a shared workspace later using `secrethub org init`.\n\n") + fmt.Fprint(io.Output(), "You can create a shared workspace later using `secrethub org init`.\n\n") + return nil } } - if createWorkspace { - if cmd.org == "" { - cmd.org, err = ui.AskAndValidate(cmd.io, "Workspace name (e.g. your company name): ", 2, api.ValidateOrgName) - if err != nil { - return err - } - } - if cmd.orgDescription == "" { - cmd.orgDescription, err = ui.AskAndValidate(cmd.io, "A description (max 144 chars) for your team workspace so others will recognize it:\n", 2, api.ValidateOrgDescription) - if err != nil { - return err - } - } - fmt.Fprint(cmd.io.Output(), "Creating your shared workspace...") - cmd.progressPrinter.Start() - _, err := client.Orgs().Create(cmd.org, cmd.orgDescription) - cmd.progressPrinter.Stop() - if err == api.ErrOrgAlreadyExists { - fmt.Fprintf(cmd.io.Output(), "The workspace %s already exists. If it is your organization, ask a colleague to invite you to the workspace. You can also create a new one using `secrethub org init`.\n", cmd.org) - } else if err != nil { + var err error + if org == "" { + org, err = ui.AskAndValidate(io, "Workspace name (e.g. your company name): ", 2, api.ValidateOrgName) + if err != nil { + return err + } + } + if orgDescription == "" { + orgDescription, err = ui.AskAndValidate(io, "A description (max 144 chars) for your team workspace so others will recognize it:\n", 2, api.ValidateOrgDescription) + if err != nil { return err - } else { - fmt.Fprint(cmd.io.Output(), "Created your shared workspace.\n\n") } } - fmt.Fprintf(cmd.io.Output(), "Setup complete. To read your first secret, run:\n\n secrethub read %s\n\n", secretPath) + fmt.Fprint(io.Output(), "Creating your shared workspace...") + progressPrinter.Start() + + _, err = client.Orgs().Create(org, orgDescription) + progressPrinter.Stop() + if err == api.ErrOrgAlreadyExists { + fmt.Fprintf(io.Output(), "The workspace %s already exists. If it is your organization, ask a colleague to invite you to the workspace. You can also create a new one using `secrethub org init`.\n", org) + } else if err != nil { + return err + } else { + fmt.Fprint(io.Output(), "Created your shared workspace.\n\n") + } return nil } + +// writeCredential writes the given credential to the configuration directory. +func writeNewCredential(credential *credentials.KeyCreator, passphrase string, credentialFile *configdir.CredentialFile) error { + exportKey := credential.Key + if passphrase != "" { + exportKey = exportKey.Passphrase(credentials.FromString(passphrase)) + } + + encodedCredential, err := credential.Export() + if err != nil { + return err + } + + return credentialFile.Write(encodedCredential) +} + +// askCredentialPassphrase prompts the user for a passphrase to protect the local credential. +func askCredentialPassphrase(io ui.IO) (string, error) { + return ui.AskPassphrase(io, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3) +} diff --git a/internals/secrethub/tree.go b/internals/secrethub/tree.go index 3fd756c3..e026b27f 100644 --- a/internals/secrethub/tree.go +++ b/internals/secrethub/tree.go @@ -13,9 +13,12 @@ import ( // TreeCommand lists the contents of a directory at a given path in a tree-like format. type TreeCommand struct { - path api.DirPath - io ui.IO - newClient newClientFunc + path api.DirPath + io ui.IO + fullPaths bool + noIndentation bool + noReport bool + newClient newClientFunc } // NewTreeCommand creates a new TreeCommand. @@ -38,7 +41,7 @@ func (cmd *TreeCommand) Run() error { return err } - printTree(t, cmd.io.Output()) + cmd.printTree(t, cmd.io.Output()) return nil } @@ -47,53 +50,90 @@ func (cmd *TreeCommand) Register(r command.Registerer) { clause := r.Command("tree", "List contents of a directory in a tree-like format.") clause.Arg("dir-path", "The path to to show contents for").Required().PlaceHolder(optionalDirPathPlaceHolder).SetValue(&cmd.path) + clause.Flag("full-paths", "Print the full path of each directory and secret.").Short('f').BoolVar(&cmd.fullPaths) + clause.Flag("no-indentation", "Don't print indentation lines.").Short('i').BoolVar(&cmd.noIndentation) + clause.Flag("no-report", "Turn off secret/directory count at end of tree listing.").BoolVar(&cmd.noReport) + clause.Flag("noreport", "Turn off secret/directory count at end of tree listing.").Hidden().BoolVar(&cmd.noReport) + command.BindAction(clause, cmd.Run) } // printTree recursively prints the tree's contents in a tree-like structure. -func printTree(t *api.Tree, w io.Writer) { - name := colorizeByStatus(t.RootDir.Status, t.RootDir.Name) - fmt.Fprintf(w, "%s/\n", name) - - printDirContentsRecursively(t.RootDir, "", w) +func (cmd *TreeCommand) printTree(t *api.Tree, w io.Writer) { - fmt.Fprintf(w, - "\n%s, %s\n", - pluralize("directory", "directories", t.DirCount()), - pluralize("secret", "secrets", t.SecretCount()), - ) + rootDirName := func() string { + if cmd.fullPaths { + return cmd.path.Value() + "/" + } + return t.RootDir.Name + "/" + }() + name := colorizeByStatus(t.RootDir.Status, rootDirName) + fmt.Fprintf(w, "%s\n", name) + + if cmd.fullPaths { + cmd.printDirContentsRecursively(t.RootDir, "", w, cmd.path.Value()) + } else { + cmd.printDirContentsRecursively(t.RootDir, "", w, "") + } + if !cmd.noReport { + fmt.Fprintf(w, + "\n%s, %s\n", + pluralize("directory", "directories", t.DirCount()), + pluralize("secret", "secrets", t.SecretCount()), + ) + } } // printDirContentsRecursively is a recursive function that prints the directory's contents // in a tree-like structure, subdirs first followed by secrets. -func printDirContentsRecursively(dir *api.Dir, prefix string, w io.Writer) { +func (cmd *TreeCommand) printDirContentsRecursively(dir *api.Dir, prefix string, w io.Writer, prevPath string) { sort.Sort(api.SortDirByName(dir.SubDirs)) sort.Sort(api.SortSecretByName(dir.Secrets)) total := len(dir.SubDirs) + len(dir.Secrets) + if cmd.fullPaths { + prevPath += "/" + } else { + prevPath = "" + } + i := 0 for _, sub := range dir.SubDirs { - name := colorizeByStatus(sub.Status, sub.Name) - if i == total-1 { - fmt.Fprintf(w, "%s└── %s/\n", prefix, name) - printDirContentsRecursively(sub, prefix+" ", w) + name := sub.Name + if cmd.fullPaths { + name = prevPath + name + } + colorName := colorizeByStatus(sub.Status, name+"/") + + if cmd.noIndentation { + fmt.Fprintf(w, "%s\n", colorName) + cmd.printDirContentsRecursively(sub, prefix, w, name) + } else if i == total-1 { + fmt.Fprintf(w, "%s└── %s\n", prefix, colorName) + cmd.printDirContentsRecursively(sub, prefix+" ", w, name) } else { - fmt.Fprintf(w, "%s├── %s/\n", prefix, name) - printDirContentsRecursively(sub, prefix+"│ ", w) + fmt.Fprintf(w, "%s├── %s\n", prefix, colorName) + cmd.printDirContentsRecursively(sub, prefix+"│ ", w, name) } i++ } for _, secret := range dir.Secrets { - name := colorizeByStatus(secret.Status, secret.Name) + name := secret.Name + if cmd.fullPaths { + name = prevPath + name + } + colorName := colorizeByStatus(secret.Status, name) - if i == total-1 { - fmt.Fprintf(w, "%s└── %s\n", prefix, name) + if cmd.noIndentation { + fmt.Fprintf(w, "%s\n", colorName) + } else if i == total-1 { + fmt.Fprintf(w, "%s└── %s\n", prefix, colorName) } else { - fmt.Fprintf(w, "%s├── %s\n", prefix, name) + fmt.Fprintf(w, "%s├── %s\n", prefix, colorName) } i++ } diff --git a/internals/secrethub/tree_test.go b/internals/secrethub/tree_test.go new file mode 100644 index 00000000..eb857cb0 --- /dev/null +++ b/internals/secrethub/tree_test.go @@ -0,0 +1,239 @@ +package secrethub + +import ( + "bytes" + "testing" + + "github.com/fatih/color" + "github.com/secrethub/secrethub-cli/internals/cli/ui" + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-go/internals/assert" + "github.com/secrethub/secrethub-go/pkg/secrethub" +) + +func TestSimpleTree(t *testing.T) { + uuid0, _ := uuid.FromString("0") + uuid1, _ := uuid.FromString("1") + tree := &api.Tree{ + RootDir: &api.Dir{ + Name: "test/repo", + DirID: uuid0, + SubDirs: []*api.Dir{ + { + Name: "secretFolder", + DirID: uuid1, + ParentID: &uuid0, + Secrets: []*api.Secret{ + {Name: "found you"}, + }, + }, + }, + Secrets: []*api.Secret{ + {Name: "mySecret"}, + }, + }, + Dirs: map[uuid.UUID]*api.Dir{ + uuid.New(): { + DirID: uuid0, + ParentID: &uuid0, + }, + uuid.New(): { + Name: "secretFolder", + DirID: uuid1, + ParentID: &uuid0, + Secrets: []*api.Secret{ + {Name: "found you"}, + }, + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + uuid.New(): {Name: "found you"}, + uuid.New(): {Name: "mySecret"}, + }, + } + cases := map[string]struct { + cmd *TreeCommand + expectedOutput string + }{ + "simple tree": { + cmd: &TreeCommand{ + io: ui.NewUserIO(), + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: "test/repo/\n" + + "├── secretFolder/\n" + + "│ └── found you\n" + + "└── mySecret\n\n" + + "1 directory, 2 secrets\n", + }, + "full path": { + cmd: &TreeCommand{ + path: "test/repo", + io: ui.NewUserIO(), + fullPaths: true, + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: "test/repo/\n" + + "├── test/repo/secretFolder/\n" + + "│ └── test/repo/secretFolder/found you\n" + + "└── test/repo/mySecret\n\n" + + "1 directory, 2 secrets\n", + }, + "no indent": { + cmd: &TreeCommand{ + io: ui.NewUserIO(), + noIndentation: true, + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: "test/repo/\n" + + "secretFolder/\n" + + "found you\n" + + "mySecret\n\n" + + "1 directory, 2 secrets\n", + }, + "no report": { + cmd: &TreeCommand{ + io: ui.NewUserIO(), + noReport: true, + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: "test/repo/\n" + + "├── secretFolder/\n" + + "│ └── found you\n" + + "└── mySecret\n", + }, + "all flags": { + cmd: &TreeCommand{ + path: "test/repo", + io: ui.NewUserIO(), + fullPaths: true, + noIndentation: true, + noReport: true, + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: "test/repo/\n" + + "test/repo/secretFolder/\n" + + "test/repo/secretFolder/found you\n" + + "test/repo/mySecret\n", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w := &bytes.Buffer{} + tc.cmd.printTree(tree, w) + assert.Equal(t, w.String(), tc.expectedOutput) + }) + } +} + +func TestTreeColoring(t *testing.T) { + color.NoColor = false + uuid0, _ := uuid.FromString("0") + uuid1, _ := uuid.FromString("1") + uuid2, _ := uuid.FromString("2") + tree := &api.Tree{ + RootDir: &api.Dir{ + Name: "test/repo", + Status: api.StatusFlagged, + DirID: uuid0, + SubDirs: []*api.Dir{ + { + Name: "happy", + DirID: uuid1, + ParentID: &uuid0, + }, + { + Name: "secretFolder", + DirID: uuid2, + ParentID: &uuid0, + Status: api.StatusFlagged, + Secrets: []*api.Secret{ + { + Name: "found you", + Status: api.StatusFlagged, + }, + }, + }, + }, + Secrets: []*api.Secret{ + {Name: "mySecret"}, + }, + }, + Dirs: map[uuid.UUID]*api.Dir{ + uuid.New(): { + DirID: uuid0, + ParentID: &uuid0, + }, + uuid.New(): { + Name: "happy", + DirID: uuid1, + ParentID: &uuid0, + }, + uuid.New(): { + Name: "secretFolder", + DirID: uuid2, + ParentID: &uuid0, + Secrets: []*api.Secret{ + {Name: "found you"}, + }, + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + uuid.New(): {Name: "found you"}, + uuid.New(): {Name: "mySecret"}, + }, + } + cases := map[string]struct { + cmd *TreeCommand + expectedOutput string + }{ + "simple tree": { + cmd: &TreeCommand{ + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: red.Sprint("test/repo/") + "\n" + + "├── happy/\n" + + "├── " + red.Sprint("secretFolder/") + "\n" + + "│ └── " + red.Sprint("found you") + "\n" + + "└── mySecret\n\n" + + "2 directories, 2 secrets\n", + }, + "full path": { + cmd: &TreeCommand{ + path: "test/repo", + fullPaths: true, + newClient: func() (secrethub.ClientInterface, error) { + return &secrethub.Client{}, nil + }, + }, + expectedOutput: red.Sprint("test/repo/") + "\n" + + "├── test/repo/happy/\n" + + "├── " + red.Sprint("test/repo/secretFolder/") + "\n" + + "│ └── " + red.Sprint("test/repo/secretFolder/found you") + "\n" + + "└── test/repo/mySecret\n\n" + + "2 directories, 2 secrets\n", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w := new(bytes.Buffer) + tc.cmd.printTree(tree, w) + assert.Equal(t, w.String(), tc.expectedOutput) + }) + } +}