Repository: jugmac00/hibpcli

Issue:
Catch network/API errors
**current behaviour**
When the hibp API is not available, the user gets a nasty traceback.

**wished behaviour**
When the hibp API is not available, the user gets a decent error message.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42be033..f21e4fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - add new subcommand "password" for checking a single password
 - add pdb++ to dev dependencies
+- add some basic error handling
 
 ## 0.2.0 (02.11.2019)
 
diff --git a/hibpcli/cli.py b/hibpcli/cli.py
index d82084e..41240b5 100644
--- a/hibpcli/cli.py
+++ b/hibpcli/cli.py
@@ -1,5 +1,6 @@
 import click
 
+from hibpcli.exceptions import ApiError
 from hibpcli.keepass import check_passwords_from_db
 from hibpcli.password import Password
 
@@ -23,29 +24,36 @@ def keepass(path, password):
          password = click.prompt(
             "Please enter the master password for the database", hide_input=True
         )
-    # needs error handling
-    rv = check_passwords_from_db(path=path, master_password=password)
-    if rv:
-        click.echo("The passwords of following entries are leaked:")
-        click.echo(rv)
+    try:
+        rv = check_passwords_from_db(path=path, master_password=password)
+    except ApiError as e:
+        click.echo(str(e))
     else:
-        click.echo("Hooray, everything is safe!")
+        if rv:
+            click.echo("The passwords of following entries are leaked:")
+            click.echo(rv)
+        else:
+            click.echo("Hooray, everything is safe!")
 
 
 @click.command()
 @click.option('--password', default=None, help='Password which should be checked.')
 def password(password):
     """Check a single password."""
-    #breakpoint()
     if password is None:
          password = click.prompt(
             "Please enter a password which should be checked", hide_input=True
         )
     p = Password(password)
-    if p.is_leaked():
-        click.echo("Please change your password!")
+    try:
+        is_leaked = p.is_leaked()
+    except ApiError as e:
+        click.echo(str(e))
     else:
-        click.echo("Your password is safe!")
+        if is_leaked:
+            click.echo("Please change your password!")
+        else:
+            click.echo("Your password is safe!")
 
 main.add_command(keepass)
 main.add_command(password)
diff --git a/hibpcli/exceptions.py b/hibpcli/exceptions.py
new file mode 100644
index 0000000..d71d7bd
--- /dev/null
+++ b/hibpcli/exceptions.py
@@ -0,0 +1,5 @@
+class HibpError(Exception):
+    pass
+
+class ApiError(HibpError):
+    pass
\ No newline at end of file
diff --git a/hibpcli/password.py b/hibpcli/password.py
index 52aec95..5b9ce80 100644
--- a/hibpcli/password.py
+++ b/hibpcli/password.py
@@ -1,5 +1,8 @@
 import hashlib
 import httpx
+import socket
+
+from hibpcli.exceptions import ApiError
 
 
 class Password:
@@ -9,15 +12,19 @@ class Password:
     def is_leaked(self):
         hex_digest = self._generate_hash()
         first_hash_part, second_hash_part = hex_digest[:5], hex_digest[5:]
-        result = httpx.get(
-            f"https://api.pwnedpasswords.com/range/{first_hash_part}"
-        ).text
-        # the result is text with entries split by new line
-        # one entry consists of the rest of the hash and count of
-        # leak, separated by a colon
-        # cut off string - information after the hash is of no interest
-        partial_hash_list = [line[:35] for line in result.splitlines()]
-        return second_hash_part in partial_hash_list
+        try:
+            result = httpx.get(
+                f"https://api.pwnedpasswords.com/range/{first_hash_part}"
+            ).text
+        except socket.gaierror as e:
+            raise ApiError("Error: Could not get a result from the API.")
+        else:
+            # the result is text with entries split by new line
+            # one entry consists of the rest of the hash and count of
+            # leak, separated by a colon
+            # cut off string - information after the hash is of no interest
+            partial_hash_list = [line[:35] for line in result.splitlines()]
+            return second_hash_part in partial_hash_list
 
     def _generate_hash(self):
         hash_object = hashlib.sha1(bytes(self.password, "UTF-8"))


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d58f909..0f28614 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -4,6 +4,7 @@ from click.testing import CliRunner
 from unittest.mock import patch
 
 from hibpcli.cli import main
+from hibpcli.exceptions import ApiError
 from hibpcli.password import Password
 
 
@@ -98,3 +99,24 @@ def test_password_subcommand_with_prompt(mock_password):
         Your password is safe!
     """
     assert result.output == textwrap.dedent(expected_output)
+
+
+@patch.object(Password,"is_leaked")
+def test_keepass_subcommand_error_handling(mock_password):
+    mock_password.side_effect = ApiError("Error")
+    runner = CliRunner()
+    result = runner.invoke(
+        main, ["keepass", "--path", "tests/test.kdbx", "--password", "test"]
+    )
+    expected_output = "Error\n"
+    assert result.output == expected_output
+
+
+@patch.object(Password, "is_leaked", side_effect=ApiError("Error"))
+def test_password_subcommand_error_handling(mock_password):
+    runner = CliRunner()
+    result = runner.invoke(
+        main, ["password", "--password", "test"]
+    )
+    expected_output = "Error\n"
+    assert result.output == expected_output
diff --git a/tests/test_password.py b/tests/test_password.py
index e218f07..7878b1b 100644
--- a/tests/test_password.py
+++ b/tests/test_password.py
@@ -1,7 +1,9 @@
 import pytest
+import socket
 
 from unittest.mock import patch
 
+from hibpcli.exceptions import ApiError
 from hibpcli.password import Password
 
 # this is just a small part of a real response
@@ -19,3 +21,11 @@ def test_is_leaked_password(mock_get):
     p = Password("test")
     assert p.is_leaked() is True
     mock_get.assert_called_with("https://api.pwnedpasswords.com/range/A94A8")
+
+
+@patch("hibpcli.password.httpx.get")
+def test_is_leaked_raises_api_error(mock_get):
+    mock_get.side_effect = socket.gaierror
+    p = Password("test")
+    with pytest.raises(ApiError):
+        p.is_leaked()