Repository: peopledoc/vault-cli

Issue:
Move secret
`vault mv`, recursive.

- Only takes 2 args
- If the 2nd argument is an existing secret, fail
- `vault mv path1 path2` will result in:
  - path1 will be recursively read
  - the read value(s) will be written in `path2/{basename(path1)}`
  - path1 will be recursively deleted 

Discussion:

Patch:
diff --git a/README.md b/README.md
index 09f1def..3674f82 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ Commands:
   get          Return a single secret value.
   get-all      Return multiple secrets.
   list         List all the secrets at the given path.
+  mv           Recursively move secrets from source to destination path.
   set          Set a single secret to the given value(s).
 ```
 
@@ -118,6 +119,9 @@ test:
 $ vault delete my_other_secret
 Done
 
+# Move a secret
+$ vault mv test blob_secret/test
+
 # Launch a process with all secrets from folder blob_secret as environment variables
 $ vault env --path blob_secret -- env
 ...
diff --git a/vault_cli/__init__.py b/vault_cli/__init__.py
index 50c5647..03ad172 100644
--- a/vault_cli/__init__.py
+++ b/vault_cli/__init__.py
@@ -16,6 +16,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 """
-from vault_cli.client import VaultAPIException, get_client
+from vault_cli.client import get_client
+from vault_cli.exceptions import VaultAPIException
 
 __all__ = ["get_client", "VaultAPIException"]
diff --git a/vault_cli/cli.py b/vault_cli/cli.py
index 5ebf3f0..5bdfc14 100644
--- a/vault_cli/cli.py
+++ b/vault_cli/cli.py
@@ -23,7 +23,7 @@ from typing import Any, Dict, Mapping, NoReturn, Sequence
 import click
 import yaml
 
-from vault_cli import client, environment, settings, types
+from vault_cli import client, environment, exceptions, settings, types
 
 logger = logging.getLogger(__name__)
 
@@ -212,6 +212,13 @@ def get(client_obj: client.VaultClientBase, text: bool, name: str):
 @click.pass_obj
 @click.option("--yaml", "format_yaml", is_flag=True)
 @click.option("--stdin/--no-stdin", default=False)
+@click.option(
+    "--force",
+    "-f",
+    is_flag=True,
+    default=False,
+    help="In case the path already holds a secret, allow overwriting it.",
+)
 @click.argument("name")
 @click.argument("value", nargs=-1)
 def set_(
@@ -220,6 +227,7 @@ def set_(
     stdin: bool,
     name: str,
     value: Sequence[str],
+    force: bool,
 ):
     """
     Set a single secret to the given value(s).
@@ -244,7 +252,14 @@ def set_(
         assert isinstance(final_value, str)
         final_value = yaml.safe_load(final_value)
 
-    client_obj.set_secret(path=name, value=final_value)
+    try:
+        client_obj.set_secret(path=name, value=final_value, force=force)
+    except exceptions.VaultOverwriteSecretError as exc:
+        raise click.ClickException(
+            f"Secret already exists at {exc.path}. Use -f to force overwriting."
+        )
+    except exceptions.VaultMixSecretAndFolder as exc:
+        raise click.ClickException(str(exc))
     click.echo("Done")
 
 
@@ -337,6 +352,34 @@ def delete_all(
         click.echo(f"Deleted '{secret}'")
 
 
+@cli.command()
+@click.argument("source", required=True)
+@click.argument("dest", required=True)
+@click.option(
+    "--force",
+    "-f",
+    is_flag=True,
+    default=False,
+    help="In case the path already holds a secret, allow overwriting it.",
+)
+@click.pass_obj
+def mv(client_obj: client.VaultClientBase, source: str, dest: str, force: bool) -> None:
+    """
+    Recursively move secrets from source to destination path.
+    """
+    try:
+        for old_path, new_path in client_obj.move_secrets(
+            source=source, dest=dest, force=force
+        ):
+            click.echo(f"Move '{old_path}' to '{new_path}'")
+    except exceptions.VaultOverwriteSecretError as exc:
+        raise click.ClickException(
+            f"Secret already exists at {exc.path}. Use -f to force overwriting."
+        )
+    except exceptions.VaultMixSecretAndFolder as exc:
+        raise click.ClickException(str(exc))
+
+
 def main():
     # https://click.palletsprojects.com/en/7.x/python3/
     os.environ.setdefault("LC_ALL", "C.UTF-8")
diff --git a/vault_cli/client.py b/vault_cli/client.py
index 28cfa2a..8a7e6d3 100644
--- a/vault_cli/client.py
+++ b/vault_cli/client.py
@@ -16,11 +16,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 """
-import json
 import logging
-from typing import Iterable, Optional, Type, Union
+import pathlib
+from typing import Iterable, Optional, Tuple, Type, Union
 
-from vault_cli import settings, types, utils
+from vault_cli import exceptions, settings, types, utils
 
 logger = logging.getLogger(__name__)
 
@@ -103,19 +103,6 @@ def get_client_from_kwargs(
     return client_class(**kwargs)
 
 
-class VaultAPIException(Exception):
-    def __init__(self, status_code: int, body: str, *args):
-        super(VaultAPIException, self).__init__(*args)
-        self.status_code = status_code
-        try:
-            self.error = "\n".join(json.loads(body)["errors"])
-        except Exception:
-            self.error = body
-
-    def __str__(self) -> str:
-        return 'status={} error="{}"'.format(self.status_code, self.error)
-
-
 class VaultClientBase:
 
     saved_settings: Optional[types.SettingsDict] = None
@@ -237,6 +224,52 @@ class VaultClientBase:
                 yield secret_path
                 self.delete_secret(secret_path)
 
+    def move_secrets(
+        self, source: str, dest: str, force: bool = False
+    ) -> Iterable[Tuple[str, str]]:
+        source_secrets = self.get_secrets(path=source)
+
+        for old_path, secret in source_secrets.items():
+            new_path = dest + old_path[len(source) :]
+            secret = source_secrets[old_path]
+
+            yield (old_path, new_path)
+
+            self.set_secret(new_path, secret, force=force)
+            self.delete_secret(old_path)
+
+    def set_secret(
+        self, path: str, value: types.JSONValue, force: bool = False
+    ) -> None:
+        try:
+            self.get_secret(path=path)
+        except exceptions.VaultSecretDoesNotExist:
+            pass
+        else:
+            if not force:
+                raise exceptions.VaultOverwriteSecretError(path=path)
+
+        problematic_secrets = self.list_secrets(path=path)
+        if problematic_secrets:
+            secrets = [f"{path}/{secret}" for secret in problematic_secrets]
+            raise exceptions.VaultMixSecretAndFolder(
+                f"Cannot create a secret at {path} because it is already a "
+                f"folder containing {', '.join(secrets)}"
+            )
+
+        path = path.rstrip("/")
+        for parent in list(pathlib.PurePath(path).parents)[:-1]:
+            try:
+                self.get_secret(str(parent))
+            except exceptions.VaultSecretDoesNotExist:
+                pass
+            else:
+                raise exceptions.VaultMixSecretAndFolder(
+                    f"Cannot create a secret at {path} because {str(parent)} already exists as a secret"
+                )
+
+        self._set_secret(path=path, value=value)
+
     def _init_session(self, url: str, verify: types.VerifyOrCABundle) -> None:
         raise NotImplementedError
 
@@ -258,5 +291,5 @@ class VaultClientBase:
     def delete_secret(self, path: str) -> None:
         raise NotImplementedError
 
-    def set_secret(self, path: str, value: types.JSONValue) -> None:
+    def _set_secret(self, path: str, value: types.JSONValue) -> None:
         raise NotImplementedError
diff --git a/vault_cli/exceptions.py b/vault_cli/exceptions.py
new file mode 100644
index 0000000..ce649b9
--- /dev/null
+++ b/vault_cli/exceptions.py
@@ -0,0 +1,35 @@
+import json
+
+
+class VaultException(Exception):
+    pass
+
+
+class VaultOverwriteSecretError(VaultException):
+    def __init__(self, message: str = "", *, path: str):
+        self.path = path
+        super().__init__(message)
+
+    def __str__(self):
+        return f"VaultOverwriteSecretError: Secret at {self.path} already exists"
+
+
+class VaultMixSecretAndFolder(VaultException):
+    pass
+
+
+class VaultAPIException(VaultException):
+    def __init__(self, status_code: int, body: str, *args):
+        super(VaultAPIException, self).__init__(*args)
+        self.status_code = status_code
+        try:
+            self.error = "\n".join(json.loads(body)["errors"])
+        except Exception:
+            self.error = body
+
+    def __str__(self) -> str:
+        return 'status={} error="{}"'.format(self.status_code, self.error)
+
+
+class VaultSecretDoesNotExist(VaultAPIException):
+    pass
diff --git a/vault_cli/hvac.py b/vault_cli/hvac.py
index dd3f772..aa895c7 100644
--- a/vault_cli/hvac.py
+++ b/vault_cli/hvac.py
@@ -21,11 +21,10 @@ from typing import Iterable
 
 import hvac
 
-from vault_cli import sessions, types
-from vault_cli.client import VaultAPIException, VaultClientBase
+from vault_cli import client, exceptions, sessions, types
 
 
-class HVACVaultClient(VaultClientBase):
+class HVACVaultClient(client.VaultClientBase):
     def _init_session(self, url: str, verify: types.VerifyOrCABundle) -> None:
         self.session = sessions.Session()
         self.client = hvac.Client(url=url, verify=verify, session=self.session)
@@ -45,13 +44,13 @@ class HVACVaultClient(VaultClientBase):
     def get_secret(self, path: str) -> types.JSONValue:
         secret = self.client.read(self.base_path + path)
         if not secret:
-            raise VaultAPIException(404, "Not found")
+            raise exceptions.VaultSecretDoesNotExist(404, "Not found")
         return secret["data"]["value"]
 
     def delete_secret(self, path: str) -> None:
         self.client.delete(self.base_path + path)
 
-    def set_secret(self, path: str, value: types.JSONValue) -> None:
+    def _set_secret(self, path: str, value: types.JSONValue) -> None:
         self.client.write(self.base_path + path, value=value)
 
     def __exit__(self, exc_type, exc_value, traceback):
diff --git a/vault_cli/requests.py b/vault_cli/requests.py
index 08a3cb3..db11f6f 100644
--- a/vault_cli/requests.py
+++ b/vault_cli/requests.py
@@ -22,11 +22,10 @@ from urllib.parse import urljoin
 
 import requests
 
-from vault_cli import sessions, types
-from vault_cli.client import VaultAPIException, VaultClientBase
+from vault_cli import client, exceptions, sessions, types
 
 
-class RequestsVaultClient(VaultClientBase):
+class RequestsVaultClient(client.VaultClientBase):
     def _init_session(self, url: str, verify: types.VerifyOrCABundle) -> None:
         self.session = self.create_session(verify)
 
@@ -41,7 +40,11 @@ class RequestsVaultClient(VaultClientBase):
         response: requests.Response, expected_code: int = requests.codes.ok
     ):
         if response.status_code != expected_code:
-            raise VaultAPIException(response.status_code, response.text)
+            if response.status_code == 404:
+                raise exceptions.VaultSecretDoesNotExist(
+                    response.status_code, response.text
+                )
+            raise exceptions.VaultAPIException(response.status_code, response.text)
 
     @staticmethod
     def create_session(verify: types.VerifyOrCABundle) -> requests.Session:
@@ -83,14 +86,14 @@ class RequestsVaultClient(VaultClientBase):
         response = self.session.get(url, params={"list": "true"})
         try:
             self.handle_error(response)
-        except VaultAPIException as exc:
+        except exceptions.VaultAPIException as exc:
             if exc.status_code == 404:
                 return []
             raise
         json_response = response.json()
         return json_response["data"]["keys"]
 
-    def set_secret(self, path: str, value: types.JSONValue) -> None:
+    def _set_secret(self, path: str, value: types.JSONValue) -> None:
         url = self._full_url(path)
         response = self.session.put(url, json={"value": value})
         self.handle_error(response, requests.codes.no_content)


Test patch:
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index 76834f1..5c45db3 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -13,7 +13,14 @@ def call(cli_runner, *args):
     return call
 
 
-def test_integration_cli(cli_runner):
+@pytest.fixture
+def clean_vault(cli_runner):
+    call(cli_runner, ["delete-all", "-f"])
+    yield
+    call(cli_runner, ["delete-all", "-f"])
+
+
+def test_integration_cli(cli_runner, clean_vault):
 
     call(cli_runner, ["set", "a", "b"])
 
@@ -53,7 +60,7 @@ c:
     assert call(cli_runner, ["list"]).output == "\n"
 
 
-def test_integration_lib():
+def test_integration_lib(clean_vault):
 
     client = vault_cli.get_client()
 
@@ -114,7 +121,7 @@ def set_ACD(cli_runner):
     call(cli_runner, ["delete", "C/D"])
 
 
-def test_boostrap_env(set_ACD):
+def test_boostrap_env(clean_vault, set_ACD):
     env = subprocess.check_output("vault env -p A -p C -- env".split())
 
     assert b"A=B\n" in env
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 7567cb9..29cb4e5 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -3,26 +3,40 @@ import logging
 import pytest
 import yaml
 
-from vault_cli import cli, client, settings
+from vault_cli import cli, client, exceptions, settings
 
 
 class FakeClient(client.VaultClientBase):
     def __init__(self, **kwargs):
         self.init_kwargs = kwargs
-        print(kwargs)
         self.deleted = []
+        self.db = {}
 
     def get_secret(self, path):
-        return "bar"
+        try:
+            return self.db[path]
+        except KeyError:
+            raise exceptions.VaultSecretDoesNotExist(404, "nooooo")
 
     def list_secrets(self, path):
-        return ["foo", "baz"]
-
-    def set_secret(self, path, value):
-        self.set = [path, value]
+        # Just reproducing in memory the behaviour of the real list_secrets
+        # This is complicated enough to have its unit test, below (test_fake_client)
+        paths = [key for key in self.db if key.startswith(path)]
+        result = []
+        for element in paths:
+            element = element[len(path) + 1 if path else 0 :].split("/", 1)
+            if len(element) == 1:
+                result.append(element[0])
+            else:
+                result.append(f"{element[0]}/")
+
+        return sorted(set(result) - {""})
+
+    def _set_secret(self, path, value):
+        self.db[path] = value
 
     def delete_secret(self, path):
-        self.deleted.append(path)
+        self.db.pop(path, None)
 
 
 @pytest.fixture
@@ -32,6 +46,14 @@ def backend(mocker):
     yield backend
 
 
+@pytest.mark.parametrize(
+    "path, expected", [("", ["a", "b/"]), ("a", []), ("b", ["c", "d"])]
+)
+def test_fake_client_list_secrets(backend, path, expected):
+    backend.db = {"a": "A", "b/c": "BC", "b/d": "BD"}
+    assert backend.list_secrets(path) == expected
+
+
 def test_bad_backend(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["--backend", "bad", "list"])
 
@@ -92,14 +114,16 @@ def test_options(cli_runner, mocker):
 
 
 def test_list(cli_runner, backend):
+    backend.db = {"foo": "yay", "baz": "ho"}
     result = cli_runner.invoke(cli.cli, ["list"])
 
-    assert result.output == "foo\nbaz\n"
+    assert result.output == "baz\nfoo\n"
     assert result.exit_code == 0
 
 
 def test_get_text(cli_runner, backend):
 
+    backend.db = {"a": "bar"}
     result = cli_runner.invoke(cli.cli, ["get", "a", "--text"])
 
     assert result.output == "bar\n"
@@ -107,6 +131,7 @@ def test_get_text(cli_runner, backend):
 
 
 def test_get_yaml(cli_runner, backend):
+    backend.db = {"a": "bar"}
     result = cli_runner.invoke(cli.cli, ["get", "a"])
 
     assert yaml.safe_load(result.output) == "bar"
@@ -115,9 +140,11 @@ def test_get_yaml(cli_runner, backend):
 
 def test_get_all(cli_runner, backend):
 
+    backend.db = {"a/baz": "bar", "a/foo": "yay"}
     result = cli_runner.invoke(cli.cli, ["get-all", "a"])
 
-    assert yaml.safe_load(result.output) == {"a": {"baz": "bar", "foo": "bar"}}
+    print(result.output)
+    assert yaml.safe_load(result.output) == {"a": {"baz": "bar", "foo": "yay"}}
     assert result.exit_code == 0
 
 
@@ -126,7 +153,7 @@ def test_set(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["set", "a", "b"])
 
     assert result.exit_code == 0
-    assert backend.set == ["a", "b"]
+    assert backend.db == {"a": "b"}
 
 
 def test_set_arg_stdin(cli_runner, backend):
@@ -141,7 +168,7 @@ def test_set_stdin(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["set", "--stdin", "a"], input="b")
 
     assert result.exit_code == 0
-    assert backend.set == ["a", "b"]
+    assert backend.db == {"a": "b"}
 
 
 def test_set_stdin_yaml(cli_runner, backend):
@@ -151,7 +178,7 @@ def test_set_stdin_yaml(cli_runner, backend):
     )
 
     assert result.exit_code == 0
-    assert backend.set == ["a", {"b": "c"}]
+    assert backend.db == {"a": {"b": "c"}}
 
 
 def test_set_list(cli_runner, backend):
@@ -159,7 +186,7 @@ def test_set_list(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["set", "a", "b", "c"])
 
     assert result.exit_code == 0
-    assert backend.set == ["a", ["b", "c"]]
+    assert backend.db == {"a": ["b", "c"]}
 
 
 def test_set_yaml(cli_runner, backend):
@@ -167,28 +194,70 @@ def test_set_yaml(cli_runner, backend):
     result = cli_runner.invoke(cli.cli, ["set", "--yaml", "a", '{"b": "c"}'])
 
     assert result.exit_code == 0
-    assert backend.set == ["a", {"b": "c"}]
+    assert backend.db == {"a": {"b": "c"}}
+
+
+def test_set_overwrite(cli_runner, backend):
+
+    backend.db = {"a": "c"}
+
+    result = cli_runner.invoke(cli.cli, ["set", "a", "b"])
+
+    assert result.exit_code == 1
+    assert backend.db == {"a": "c"}
+
+
+def test_set_overwrite_force(cli_runner, backend):
+
+    backend.db = {"a": "c"}
+
+    result = cli_runner.invoke(cli.cli, ["set", "a", "b", "--force"])
+
+    assert result.exit_code == 0
+    assert backend.db == {"a": "b"}
+
+
+def test_set_mix_secrets_folders(cli_runner, backend):
+
+    backend.db = {"a/b": "c"}
+
+    result = cli_runner.invoke(cli.cli, ["set", "a/b/c", "d"])
+
+    assert result.exit_code == 1
+    assert backend.db == {"a/b": "c"}
+
+
+def test_set_mix_folders_secrets(cli_runner, backend):
+
+    backend.db = {"a/b/c": "d"}
+
+    result = cli_runner.invoke(cli.cli, ["set", "a/b", "c"])
+
+    assert result.exit_code == 1
+    assert backend.db == {"a/b/c": "d"}
 
 
 def test_delete(cli_runner, backend):
 
+    backend.db = {"a": "foo", "b": "bar"}
     result = cli_runner.invoke(cli.cli, ["delete", "a"])
 
     assert result.exit_code == 0
-    assert backend.deleted == ["a"]
+    assert backend.db == {"b": "bar"}
 
 
 def test_env(cli_runner, backend, mocker):
     exec_command = mocker.patch("vault_cli.environment.exec_command")
 
+    backend.db = {"foo/bar": "yay", "foo/baz": "yo"}
     cli_runner.invoke(
         cli.cli, ["env", "--path", "foo", "--", "echo", "yay"], catch_exceptions=False
     )
 
     _, kwargs = exec_command.call_args
     assert kwargs["command"] == ("echo", "yay")
-    assert kwargs["environ"]["FOO_FOO"] == "bar"
-    assert kwargs["environ"]["FOO_BAZ"] == "bar"
+    assert kwargs["environ"]["FOO_BAR"] == "yay"
+    assert kwargs["environ"]["FOO_BAZ"] == "yo"
 
 
 def test_main(mocker):
@@ -286,34 +355,86 @@ def test_dump_config(cli_runner, backend):
 
 
 def test_delete_all(cli_runner, backend):
+    backend.db = {"foo/bar": "yay", "foo/baz": "yo"}
 
     result = cli_runner.invoke(cli.cli, ["delete-all"], input="y\ny")
+
     assert result.output.splitlines() == [
-        "Delete 'foo'? [y/N]: y",
-        "Deleted 'foo'",
-        "Delete 'baz'? [y/N]: y",
-        "Deleted 'baz'",
+        "Delete 'foo/bar'? [y/N]: y",
+        "Deleted 'foo/bar'",
+        "Delete 'foo/baz'? [y/N]: y",
+        "Deleted 'foo/baz'",
     ]
-    assert backend.deleted == ["foo", "baz"]
+    assert backend.db == {}
     assert result.exit_code == 0
 
 
 def test_delete_all_cancel(cli_runner, backend):
+    backend.db = {"foo/bar": "yay", "foo/baz": "yo"}
 
     result = cli_runner.invoke(cli.cli, ["delete-all"], input="y\nn")
+
     assert result.output.splitlines() == [
-        "Delete 'foo'? [y/N]: y",
-        "Deleted 'foo'",
-        "Delete 'baz'? [y/N]: n",
+        "Delete 'foo/bar'? [y/N]: y",
+        "Deleted 'foo/bar'",
+        "Delete 'foo/baz'? [y/N]: n",
         "Aborted!",
     ]
-    assert backend.deleted == ["foo"]
+    assert backend.db == {"foo/baz": "yo"}
     assert result.exit_code != 0
 
 
 def test_delete_all_force(cli_runner, backend):
+    backend.db = {"foo/bar": "yay", "foo/baz": "yo"}
 
     result = cli_runner.invoke(cli.cli, ["delete-all", "--force"])
-    assert result.output.splitlines() == ["Deleted 'foo'", "Deleted 'baz'"]
-    assert backend.deleted == ["foo", "baz"]
+
+    assert result.output.splitlines() == ["Deleted 'foo/bar'", "Deleted 'foo/baz'"]
+    assert backend.db == {}
+    assert result.exit_code == 0
+
+
+def test_mv(cli_runner, backend):
+    backend.db = {"a/b": "c", "d/e": "f", "d/g": "h"}
+
+    result = cli_runner.invoke(cli.cli, ["mv", "d", "a"])
+
+    assert result.output.splitlines() == ["Move 'd/e' to 'a/e'", "Move 'd/g' to 'a/g'"]
+    assert backend.db == {"a/b": "c", "a/e": "f", "a/g": "h"}
     assert result.exit_code == 0
+
+
+def test_mv_overwrite(cli_runner, backend):
+    backend.db = {"a/b": "c", "d/b": "f"}
+
+    result = cli_runner.invoke(cli.cli, ["mv", "d", "a"])
+
+    assert backend.db == {"a/b": "c", "d/b": "f"}
+    assert result.exit_code != 0
+
+
+def test_mv_overwrite_force(cli_runner, backend):
+    backend.db = {"a/b": "c", "d/b": "f"}
+
+    result = cli_runner.invoke(cli.cli, ["mv", "d", "a", "--force"])
+
+    assert backend.db == {"a/b": "f"}
+    assert result.exit_code == 0
+
+
+def test_mv_mix_folders_secrets(cli_runner, backend):
+    backend.db = {"a/b": "c", "d": "e"}
+
+    result = cli_runner.invoke(cli.cli, ["mv", "d", "a"])
+
+    assert backend.db == {"a/b": "c", "d": "e"}
+    assert result.exit_code != 0
+
+
+def test_mv_mix_secrets_folders(cli_runner, backend):
+    backend.db = {"a/b": "c", "d": "e"}
+
+    result = cli_runner.invoke(cli.cli, ["mv", "a", "d"])
+
+    assert backend.db == {"a/b": "c", "d": "e"}
+    assert result.exit_code != 0
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index ee46d02..ddf2be2 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -1,6 +1,6 @@
 import pytest
 
-from vault_cli import client
+from vault_cli import client, exceptions
 
 
 @pytest.mark.parametrize(
@@ -49,7 +49,7 @@ def test_get_client(mocker):
     ],
 )
 def test_vault_api_exception(error, expected):
-    exc_str = str(client.VaultAPIException(404, error))
+    exc_str = str(exceptions.VaultAPIException(404, error))
 
     assert exc_str == expected
 
@@ -64,7 +64,7 @@ def test_vault_api_exception(error, expected):
         ("list_secrets", "path"),
         ("get_secret", "path"),
         ("delete_secret", "path"),
-        ("set_secret", "path value"),
+        ("_set_secret", "path value"),
     ],
 )
 def test_vault_client_base_not_implemented(func, args):
@@ -346,3 +346,181 @@ def test_vault_client_base_context_manager():
 
     with client_obj as c:
         assert c is client_obj
+
+
+def test_vault_client_set_secret():
+
+    written = {}
+    tested_get = []
+    tested_list = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def _set_secret(self, path, value):
+            written[path] = value
+
+        def list_secrets(self, path):
+            tested_list.append(path)
+            return []
+
+        def get_secret(self, path):
+            tested_get.append(path)
+            raise exceptions.VaultSecretDoesNotExist(404, "nooooo")
+
+    TestVaultClient().set_secret("a/b", "c")
+
+    assert written == {"a/b": "c"}
+    assert set(tested_get) == {"a/b", "a"}
+    assert tested_list == ["a/b"]
+
+
+def test_vault_client_set_secret_overwrite():
+
+    written = {}
+    tested_get = []
+    tested_list = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def _set_secret(self, path, value):
+            written[path] = value
+
+        def list_secrets(self, path):
+            tested_list.append(path)
+            return []
+
+        def get_secret(self, path):
+            tested_get.append(path)
+            return "lol"
+
+    with pytest.raises(exceptions.VaultOverwriteSecretError):
+        TestVaultClient().set_secret("a/b", "c")
+
+    assert written == {}
+    assert set(tested_get) == {"a/b"}
+    assert tested_list == []
+
+
+def test_vault_client_set_secret_overwrite_force():
+
+    written = {}
+    tested_get = []
+    tested_list = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def _set_secret(self, path, value):
+            written[path] = value
+
+        def list_secrets(self, path):
+            tested_list.append(path)
+            return []
+
+        def get_secret(self, path):
+            tested_get.append(path)
+            try:
+                return {"a/b": "lol"}[path]
+            except KeyError:
+                raise exceptions.VaultSecretDoesNotExist(404, "nooooo")
+
+    TestVaultClient().set_secret("a/b", "c", force=True)
+
+    assert written == {"a/b": "c"}
+    assert set(tested_get) == {"a/b", "a"}
+    assert tested_list == ["a/b"]
+
+
+def test_vault_client_set_secret_when_there_are_existing_secrets_beneath_path():
+
+    written = {}
+    tested_get = []
+    tested_list = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def _set_secret(self, path, value):
+            written[path] = value
+
+        def list_secrets(self, path):
+            tested_list.append(path)
+            return ["d/"]
+
+        def get_secret(self, path):
+            tested_get.append(path)
+            raise exceptions.VaultSecretDoesNotExist(404, "nooooo")
+
+    with pytest.raises(exceptions.VaultMixSecretAndFolder):
+        TestVaultClient().set_secret("a/b", "c")
+
+    assert written == {}
+    assert tested_get == ["a/b"]
+    assert tested_list == ["a/b"]
+
+
+def test_vault_client_set_secret_when_a_parent_is_an_existing_secret():
+
+    written = {}
+    tested_get = []
+    tested_list = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def _set_secret(self, path, value):
+            written[path] = value
+
+        def list_secrets(self, path):
+            tested_list.append(path)
+            return []
+
+        def get_secret(self, path):
+            tested_get.append(path)
+            try:
+                return {"a": "lol"}[path]
+            except KeyError:
+                raise exceptions.VaultSecretDoesNotExist(404, "nooooo")
+
+    with pytest.raises(exceptions.VaultMixSecretAndFolder):
+        TestVaultClient().set_secret("a/b", "c")
+
+    assert written == {}
+    assert set(tested_get) == {"a/b", "a"}
+    assert tested_list == ["a/b"]
+
+
+@pytest.mark.parametrize("force_value", [True, False])
+def test_vault_client_move_secrets(force_value):
+    written = {}
+    deleted = []
+
+    class TestVaultClient(client.VaultClientBase):
+        def __init__(self):
+            pass
+
+        def set_secret(self, path, value, force=False):
+            assert force is force_value
+            written[path] = value
+
+        def delete_secret(self, path):
+            deleted.append(path)
+
+        def get_secrets(self, path):
+            return {"foo/hello": "world", "foo/yay/haha": "baz"}
+
+    result = list(TestVaultClient().move_secrets("foo", "barbar", force=force_value))
+
+    assert result == [
+        ("foo/hello", "barbar/hello"),
+        ("foo/yay/haha", "barbar/yay/haha"),
+    ]
+    assert written == {"barbar/hello": "world", "barbar/yay/haha": "baz"}
+    assert deleted == ["foo/hello", "foo/yay/haha"]
diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py
index d551540..cb13115 100644
--- a/tests/unit/test_clients.py
+++ b/tests/unit/test_clients.py
@@ -1,6 +1,6 @@
 import pytest
 
-from vault_cli import client
+from vault_cli import client, exceptions
 
 
 def get_client(backend, **additional_kwargs):
@@ -71,7 +71,7 @@ def test_get_secret_not_found(requests_mock, any_backend):
     requests_mock.get(
         "http://vault:8000/v1/bla/a", status_code=404, json={"errors": ["Not found"]}
     )
-    with pytest.raises(client.VaultAPIException):
+    with pytest.raises(exceptions.VaultAPIException):
         assert client_obj.get_secret("a")
 
 
@@ -123,12 +123,14 @@ def test_delete_secret(requests_mock, any_backend):
 def test_set_secret(requests_mock, any_backend):
     client_obj = get_client(any_backend)
     # Both post and put can be used
+    requests_mock.get("http://vault:8000/v1/bla/a", status_code=404)
+    requests_mock.get("http://vault:8000/v1/bla/a?list=True", status_code=404)
     requests_mock.put("http://vault:8000/v1/bla/a", status_code=204, json={})
     requests_mock.post("http://vault:8000/v1/bla/a", status_code=204, json={})
     client_obj.set_secret("a", "b")
 
     assert requests_mock.called
-    assert requests_mock.request_history[0].json() == {"value": "b"}
+    assert requests_mock.request_history[2].json() == {"value": "b"}
 
 
 def test_set_context_manager(any_backend):
diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py
new file mode 100644
index 0000000..63d1a0b
--- /dev/null
+++ b/tests/unit/test_exceptions.py
@@ -0,0 +1,8 @@
+from vault_cli import exceptions
+
+
+def test_vault_overwrite_secret_error():
+    assert (
+        str(exceptions.VaultOverwriteSecretError(path="yay"))
+        == "VaultOverwriteSecretError: Secret at yay already exists"
+    )