feat(cli): add ermite config show, set and unset
set validates the url, keeps the comments of the file and creates it with mode 0600. show prints the stored values and never runs token.cmd.
- commit
- fc19772855169ef08622939c96317324a6144571
- author
- brice · · 2026-10-12 14:59 UTC
- parent
- c0892a5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +295 -1
A internal/app/cli_config.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io/fs" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/kvconf" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // notSet is what `config show` prints for a key without a value. | |
| 19 | + | const notSet = "(not set)" | |
| 20 | + | ||
| 21 | + | // configKeys are the keys of the CLI config file. | |
| 22 | + | var configKeys = []string{keyURL, keyTokenCmd} | |
| 23 | + | ||
| 24 | + | func newConfigCmd(env *cliEnv) *cobra.Command { | |
| 25 | + | cmd := &cobra.Command{ | |
| 26 | + | Use: "config", | |
| 27 | + | Short: "Show and edit the config file of the remote commands", | |
| 28 | + | Long: "The remote commands read the server address and a way to get a token from a config file,\n" + | |
| 29 | + | "~/.config/ermite/config (or the file named by ERMITE_CONFIG):\n\n" + | |
| 30 | + | " url = https://git.example.com\n" + | |
| 31 | + | " token.cmd = pass show ermite/token\n\n" + | |
| 32 | + | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |
| 33 | + | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |
| 34 | + | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |
| 35 | + | "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" + | |
| 36 | + | "command is not run when the file is writable by other users.", | |
| 37 | + | } | |
| 38 | + | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |
| 39 | + | return cmd | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func newConfigShowCmd(env *cliEnv) *cobra.Command { | |
| 43 | + | return &cobra.Command{ | |
| 44 | + | Use: "show", | |
| 45 | + | Short: "Print the config file path and its settings", | |
| 46 | + | Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" + | |
| 47 | + | "absent). It never runs token.cmd.", | |
| 48 | + | Args: cobra.NoArgs, | |
| 49 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 50 | + | f, path, err := env.readFile(cmd, true) | |
| 51 | + | if err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | if path == "" { | |
| 55 | + | if path, err = env.path(); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if env.getenv(client.EnvURL) != "" && f.URL != "" { | |
| 60 | + | noticef(cmd, "%s is set and takes precedence over url", client.EnvURL) | |
| 61 | + | } | |
| 62 | + | if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" { | |
| 63 | + | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |
| 64 | + | } | |
| 65 | + | return fields(cmd.OutOrStdout(), [][2]string{ | |
| 66 | + | {"file", path}, | |
| 67 | + | {keyURL, orNotSet(f.URL)}, | |
| 68 | + | {keyTokenCmd, orNotSet(f.TokenCmd)}, | |
| 69 | + | }) | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func orNotSet(s string) string { | |
| 75 | + | if strings.TrimSpace(s) == "" { | |
| 76 | + | return notSet | |
| 77 | + | } | |
| 78 | + | return s | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func newConfigSetCmd(env *cliEnv) *cobra.Command { | |
| 82 | + | return &cobra.Command{ | |
| 83 | + | Use: "set <url|token.cmd> <value>", | |
| 84 | + | Short: "Set the server address or the token command in the config file", | |
| 85 | + | Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" + | |
| 86 | + | "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" + | |
| 87 | + | "token, for example `pass show ermite/token`; it is stored as is and not run.", | |
| 88 | + | Args: cobra.ExactArgs(2), | |
| 89 | + | ValidArgsFunction: completeConfigKeys, | |
| 90 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 91 | + | key, value := args[0], strings.TrimSpace(args[1]) | |
| 92 | + | if err := checkConfigKey(key); err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | switch key { | |
| 96 | + | case keyURL: | |
| 97 | + | u, err := client.ParseURL(value) | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("invalid url: %w", err) | |
| 100 | + | } | |
| 101 | + | value = u.String() | |
| 102 | + | case keyTokenCmd: | |
| 103 | + | if value == "" { | |
| 104 | + | return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it") | |
| 105 | + | } | |
| 106 | + | if strings.ContainsAny(value, "\r\n") { | |
| 107 | + | return errors.New("token.cmd must be a single line") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | path, err := env.path() | |
| 111 | + | if err != nil { | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil { | |
| 115 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 116 | + | } | |
| 117 | + | noticef(cmd, "%s set in %s", key, path) | |
| 118 | + | return nil | |
| 119 | + | }, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func newConfigUnsetCmd(env *cliEnv) *cobra.Command { | |
| 124 | + | return &cobra.Command{ | |
| 125 | + | Use: "unset <url|token.cmd>", | |
| 126 | + | Short: "Remove a setting from the config file", | |
| 127 | + | Args: cobra.ExactArgs(1), | |
| 128 | + | ValidArgsFunction: completeConfigKeys, | |
| 129 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 130 | + | key := args[0] | |
| 131 | + | if err := checkConfigKey(key); err != nil { | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | path, err := env.path() | |
| 135 | + | if err != nil { | |
| 136 | + | return err | |
| 137 | + | } | |
| 138 | + | if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { | |
| 139 | + | noticef(cmd, "%s is not set: %s does not exist", key, path) | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil { | |
| 143 | + | return fmt.Errorf("writing %s: %w", path, err) | |
| 144 | + | } | |
| 145 | + | noticef(cmd, "%s removed from %s", key, path) | |
| 146 | + | return nil | |
| 147 | + | }, | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func checkConfigKey(key string) error { | |
| 152 | + | for _, k := range configKeys { | |
| 153 | + | if key == k { | |
| 154 | + | return nil | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and ")) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { | |
| 161 | + | if len(args) > 0 { | |
| 162 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 163 | + | } | |
| 164 | + | return configKeys, cobra.ShellCompDirectiveNoFileComp | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io/fs" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/kvconf" | |||
| 13 | + | "github.com/spf13/cobra" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // notSet is what `config show` prints for a key without a value. | |||
| 19 | + | const notSet = "(not set)" | |||
| 20 | + | ||||
| 21 | + | // configKeys are the keys of the CLI config file. | |||
| 22 | + | var configKeys = []string{keyURL, keyTokenCmd} | |||
| 23 | + | ||||
| 24 | + | func newConfigCmd(env *cliEnv) *cobra.Command { | |||
| 25 | + | cmd := &cobra.Command{ | |||
| 26 | + | Use: "config", | |||
| 27 | + | Short: "Show and edit the config file of the remote commands", | |||
| 28 | + | Long: "The remote commands read the server address and a way to get a token from a config file,\n" + | |||
| 29 | + | "~/.config/ermite/config (or the file named by ERMITE_CONFIG):\n\n" + | |||
| 30 | + | " url = https://git.example.com\n" + | |||
| 31 | + | " token.cmd = pass show ermite/token\n\n" + | |||
| 32 | + | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |||
| 33 | + | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |||
| 34 | + | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |||
| 35 | + | "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" + | |||
| 36 | + | "command is not run when the file is writable by other users.", | |||
| 37 | + | } | |||
| 38 | + | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |||
| 39 | + | return cmd | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func newConfigShowCmd(env *cliEnv) *cobra.Command { | |||
| 43 | + | return &cobra.Command{ | |||
| 44 | + | Use: "show", | |||
| 45 | + | Short: "Print the config file path and its settings", | |||
| 46 | + | Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" + | |||
| 47 | + | "absent). It never runs token.cmd.", | |||
| 48 | + | Args: cobra.NoArgs, | |||
| 49 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 50 | + | f, path, err := env.readFile(cmd, true) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return err | |||
| 53 | + | } | |||
| 54 | + | if path == "" { | |||
| 55 | + | if path, err = env.path(); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if env.getenv(client.EnvURL) != "" && f.URL != "" { | |||
| 60 | + | noticef(cmd, "%s is set and takes precedence over url", client.EnvURL) | |||
| 61 | + | } | |||
| 62 | + | if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" { | |||
| 63 | + | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |||
| 64 | + | } | |||
| 65 | + | return fields(cmd.OutOrStdout(), [][2]string{ | |||
| 66 | + | {"file", path}, | |||
| 67 | + | {keyURL, orNotSet(f.URL)}, | |||
| 68 | + | {keyTokenCmd, orNotSet(f.TokenCmd)}, | |||
| 69 | + | }) | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func orNotSet(s string) string { | |||
| 75 | + | if strings.TrimSpace(s) == "" { | |||
| 76 | + | return notSet | |||
| 77 | + | } | |||
| 78 | + | return s | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func newConfigSetCmd(env *cliEnv) *cobra.Command { | |||
| 82 | + | return &cobra.Command{ | |||
| 83 | + | Use: "set <url|token.cmd> <value>", | |||
| 84 | + | Short: "Set the server address or the token command in the config file", | |||
| 85 | + | Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" + | |||
| 86 | + | "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" + | |||
| 87 | + | "token, for example `pass show ermite/token`; it is stored as is and not run.", | |||
| 88 | + | Args: cobra.ExactArgs(2), | |||
| 89 | + | ValidArgsFunction: completeConfigKeys, | |||
| 90 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 91 | + | key, value := args[0], strings.TrimSpace(args[1]) | |||
| 92 | + | if err := checkConfigKey(key); err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | switch key { | |||
| 96 | + | case keyURL: | |||
| 97 | + | u, err := client.ParseURL(value) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("invalid url: %w", err) | |||
| 100 | + | } | |||
| 101 | + | value = u.String() | |||
| 102 | + | case keyTokenCmd: | |||
| 103 | + | if value == "" { | |||
| 104 | + | return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it") | |||
| 105 | + | } | |||
| 106 | + | if strings.ContainsAny(value, "\r\n") { | |||
| 107 | + | return errors.New("token.cmd must be a single line") | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | path, err := env.path() | |||
| 111 | + | if err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil { | |||
| 115 | + | return fmt.Errorf("writing %s: %w", path, err) | |||
| 116 | + | } | |||
| 117 | + | noticef(cmd, "%s set in %s", key, path) | |||
| 118 | + | return nil | |||
| 119 | + | }, | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func newConfigUnsetCmd(env *cliEnv) *cobra.Command { | |||
| 124 | + | return &cobra.Command{ | |||
| 125 | + | Use: "unset <url|token.cmd>", | |||
| 126 | + | Short: "Remove a setting from the config file", | |||
| 127 | + | Args: cobra.ExactArgs(1), | |||
| 128 | + | ValidArgsFunction: completeConfigKeys, | |||
| 129 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 130 | + | key := args[0] | |||
| 131 | + | if err := checkConfigKey(key); err != nil { | |||
| 132 | + | return err | |||
| 133 | + | } | |||
| 134 | + | path, err := env.path() | |||
| 135 | + | if err != nil { | |||
| 136 | + | return err | |||
| 137 | + | } | |||
| 138 | + | if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { | |||
| 139 | + | noticef(cmd, "%s is not set: %s does not exist", key, path) | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil { | |||
| 143 | + | return fmt.Errorf("writing %s: %w", path, err) | |||
| 144 | + | } | |||
| 145 | + | noticef(cmd, "%s removed from %s", key, path) | |||
| 146 | + | return nil | |||
| 147 | + | }, | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func checkConfigKey(key string) error { | |||
| 152 | + | for _, k := range configKeys { | |||
| 153 | + | if key == k { | |||
| 154 | + | return nil | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and ")) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { | |||
| 161 | + | if len(args) > 0 { | |||
| 162 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 163 | + | } | |||
| 164 | + | return configKeys, cobra.ShellCompDirectiveNoFileComp | |||
| 165 | + | } | |||
A internal/app/cli_config_test.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "runtime" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestConfigShowWithoutFile(t *testing.T) { | |
| 16 | + | env := testEnv(t, nil) | |
| 17 | + | path, _ := env.configPath() | |
| 18 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |
| 19 | + | assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout) | |
| 20 | + | _, err := os.Stat(path) | |
| 21 | + | assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestConfigSetUnsetShowRoundTrip(t *testing.T) { | |
| 25 | + | env := testEnv(t, nil) | |
| 26 | + | path, _ := env.configPath() | |
| 27 | + | writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600) | |
| 28 | + | ||
| 29 | + | runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t) | |
| 30 | + | runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t) | |
| 31 | + | // a fresh process sees the new content | |
| 32 | + | res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 33 | + | assert.Contains(t, res.stdout, "url https://git.example.com\n") | |
| 34 | + | assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n") | |
| 35 | + | ||
| 36 | + | data, err := os.ReadFile(path) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | assert.Contains(t, string(data), "# my forge\n") | |
| 39 | + | assert.Contains(t, string(data), "# secrets stay in pass\n") | |
| 40 | + | ||
| 41 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t) | |
| 42 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t) | |
| 43 | + | data, err = os.ReadFile(path) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.NotContains(t, string(data), "token.cmd") | |
| 46 | + | assert.NotContains(t, string(data), "url") | |
| 47 | + | assert.Contains(t, string(data), "# my forge\n", "comments survive") | |
| 48 | + | ||
| 49 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |
| 50 | + | assert.Contains(t, res.stdout, "url (not set)\n") | |
| 51 | + | assert.Contains(t, res.stdout, "token.cmd (not set)\n") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // testEnvAt is testEnv with the config file at path: a new process. | |
| 55 | + | func testEnvAt(t *testing.T, path string) *cliEnv { | |
| 56 | + | t.Helper() | |
| 57 | + | env := testEnv(t, nil) | |
| 58 | + | env.configPath = func() (string, error) { return path, nil } | |
| 59 | + | return env | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestConfigSetCreatesPrivateFile(t *testing.T) { | |
| 63 | + | env := testEnv(t, nil) | |
| 64 | + | path, _ := env.configPath() | |
| 65 | + | res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t) | |
| 66 | + | assert.Contains(t, res.stderr, path) | |
| 67 | + | info, err := os.Stat(path) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | if runtime.GOOS != "windows" { | |
| 70 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 71 | + | } | |
| 72 | + | // a file created by `config set` is accepted by the permission check | |
| 73 | + | data, _ := os.ReadFile(path) | |
| 74 | + | assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data)) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestConfigSetRefusesBadInput(t *testing.T) { | |
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | args []string | |
| 81 | + | want string | |
| 82 | + | }{ | |
| 83 | + | {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`}, | |
| 84 | + | {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`}, | |
| 85 | + | {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"}, | |
| 86 | + | {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"}, | |
| 87 | + | {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"}, | |
| 88 | + | {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"}, | |
| 89 | + | {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"}, | |
| 90 | + | {"missing value", []string{"set", "url"}, "accepts 2 arg"}, | |
| 91 | + | } | |
| 92 | + | for _, tt := range tests { | |
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |
| 94 | + | env := testEnv(t, nil) | |
| 95 | + | path, _ := env.configPath() | |
| 96 | + | res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...) | |
| 97 | + | require.Error(t, res.err) | |
| 98 | + | assert.Contains(t, res.err.Error(), tt.want) | |
| 99 | + | assert.NotContains(t, res.err.Error(), "s3cret") | |
| 100 | + | _, err := os.Stat(path) | |
| 101 | + | assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written") | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestConfigUnsetWithoutFile(t *testing.T) { | |
| 107 | + | env := testEnv(t, nil) | |
| 108 | + | path, _ := env.configPath() | |
| 109 | + | res := runCLIWith(t, env, "", "config", "unset", "url").ok(t) | |
| 110 | + | assert.Contains(t, res.stderr, "does not exist") | |
| 111 | + | _, err := os.Stat(path) | |
| 112 | + | assert.ErrorIs(t, err, os.ErrNotExist) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) { | |
| 116 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"}) | |
| 117 | + | writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666) | |
| 118 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs | |
| 119 | + | assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored") | |
| 120 | + | assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence") | |
| 121 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used") | |
| 122 | + | assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestConfigHelpShowsAnExample(t *testing.T) { | |
| 126 | + | res := runCLI(t, nil, "", "config", "--help").ok(t) | |
| 127 | + | assert.Contains(t, res.stdout, "pass show ermite/token") | |
| 128 | + | assert.Contains(t, res.stdout, "https://git.example.com") | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "runtime" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestConfigShowWithoutFile(t *testing.T) { | |||
| 16 | + | env := testEnv(t, nil) | |||
| 17 | + | path, _ := env.configPath() | |||
| 18 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) | |||
| 19 | + | assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout) | |||
| 20 | + | _, err := os.Stat(path) | |||
| 21 | + | assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestConfigSetUnsetShowRoundTrip(t *testing.T) { | |||
| 25 | + | env := testEnv(t, nil) | |||
| 26 | + | path, _ := env.configPath() | |||
| 27 | + | writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600) | |||
| 28 | + | ||||
| 29 | + | runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t) | |||
| 30 | + | runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t) | |||
| 31 | + | // a fresh process sees the new content | |||
| 32 | + | res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |||
| 33 | + | assert.Contains(t, res.stdout, "url https://git.example.com\n") | |||
| 34 | + | assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n") | |||
| 35 | + | ||||
| 36 | + | data, err := os.ReadFile(path) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.Contains(t, string(data), "# my forge\n") | |||
| 39 | + | assert.Contains(t, string(data), "# secrets stay in pass\n") | |||
| 40 | + | ||||
| 41 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t) | |||
| 42 | + | runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t) | |||
| 43 | + | data, err = os.ReadFile(path) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | assert.NotContains(t, string(data), "token.cmd") | |||
| 46 | + | assert.NotContains(t, string(data), "url") | |||
| 47 | + | assert.Contains(t, string(data), "# my forge\n", "comments survive") | |||
| 48 | + | ||||
| 49 | + | res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t) | |||
| 50 | + | assert.Contains(t, res.stdout, "url (not set)\n") | |||
| 51 | + | assert.Contains(t, res.stdout, "token.cmd (not set)\n") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // testEnvAt is testEnv with the config file at path: a new process. | |||
| 55 | + | func testEnvAt(t *testing.T, path string) *cliEnv { | |||
| 56 | + | t.Helper() | |||
| 57 | + | env := testEnv(t, nil) | |||
| 58 | + | env.configPath = func() (string, error) { return path, nil } | |||
| 59 | + | return env | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestConfigSetCreatesPrivateFile(t *testing.T) { | |||
| 63 | + | env := testEnv(t, nil) | |||
| 64 | + | path, _ := env.configPath() | |||
| 65 | + | res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t) | |||
| 66 | + | assert.Contains(t, res.stderr, path) | |||
| 67 | + | info, err := os.Stat(path) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | if runtime.GOOS != "windows" { | |||
| 70 | + | assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 71 | + | } | |||
| 72 | + | // a file created by `config set` is accepted by the permission check | |||
| 73 | + | data, _ := os.ReadFile(path) | |||
| 74 | + | assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data)) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestConfigSetRefusesBadInput(t *testing.T) { | |||
| 78 | + | tests := []struct { | |||
| 79 | + | name string | |||
| 80 | + | args []string | |||
| 81 | + | want string | |||
| 82 | + | }{ | |||
| 83 | + | {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`}, | |||
| 84 | + | {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`}, | |||
| 85 | + | {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"}, | |||
| 86 | + | {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"}, | |||
| 87 | + | {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"}, | |||
| 88 | + | {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"}, | |||
| 89 | + | {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"}, | |||
| 90 | + | {"missing value", []string{"set", "url"}, "accepts 2 arg"}, | |||
| 91 | + | } | |||
| 92 | + | for _, tt := range tests { | |||
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | + | env := testEnv(t, nil) | |||
| 95 | + | path, _ := env.configPath() | |||
| 96 | + | res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...) | |||
| 97 | + | require.Error(t, res.err) | |||
| 98 | + | assert.Contains(t, res.err.Error(), tt.want) | |||
| 99 | + | assert.NotContains(t, res.err.Error(), "s3cret") | |||
| 100 | + | _, err := os.Stat(path) | |||
| 101 | + | assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written") | |||
| 102 | + | }) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestConfigUnsetWithoutFile(t *testing.T) { | |||
| 107 | + | env := testEnv(t, nil) | |||
| 108 | + | path, _ := env.configPath() | |||
| 109 | + | res := runCLIWith(t, env, "", "config", "unset", "url").ok(t) | |||
| 110 | + | assert.Contains(t, res.stderr, "does not exist") | |||
| 111 | + | _, err := os.Stat(path) | |||
| 112 | + | assert.ErrorIs(t, err, os.ErrNotExist) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) { | |||
| 116 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"}) | |||
| 117 | + | writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666) | |||
| 118 | + | res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs | |||
| 119 | + | assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored") | |||
| 120 | + | assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence") | |||
| 121 | + | assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used") | |||
| 122 | + | assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestConfigHelpShowsAnExample(t *testing.T) { | |||
| 126 | + | res := runCLI(t, nil, "", "config", "--help").ok(t) | |||
| 127 | + | assert.Contains(t, res.stdout, "pass show ermite/token") | |||
| 128 | + | assert.Contains(t, res.stdout, "https://git.example.com") | |||
| 129 | + | } | |||
M internal/app/root.go +1 -1
| @@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
| 28 | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), | |
| 29 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 30 | 30 | ) | |
| 31 | 31 | return root | |
| 32 | 32 | } | |
| @@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 26 | root.AddCommand( | 26 | root.AddCommand( | ||
| 27 | newServeCmd(), newHookCmd(), | 27 | newServeCmd(), newHookCmd(), | ||
| 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | ||
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), | 29 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), |
| 30 | ) | 30 | ) | ||
| 31 | return root | 31 | return root | ||
| 32 | } | 32 | } | ||