diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d508e27 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,24 @@ +[run] +source = + umbr_api + +# branch = True + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: + +ignore_errors = True + +[paths] +source = + umbr_api diff --git a/.gitignore b/.gitignore index de84f58..7bec6fb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ venv/ *.egg-info/ dist/ docs/_build/ +.coverage +*.py,cover +.pytest_cache/ customer_key.json diff --git a/.travis.yml b/.travis.yml index f5eed9f..7eee5f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,41 @@ language: python +env: + - DEV=true python: - '3.4' - '3.5' - '3.6' - 3.6-dev - 3.7-dev +matrix: + fast_finish: true + # include: + # - python: '3.4' + # - python: '3.5' + # - python: '3.6' + # - python: '3.6-dev' + # - python: '3.7-dev' + allow_failures: + - python: '3.6-dev' + env: DEV=true + - python: '3.7-dev' + env: DEV=true install: - pip install . -- pip install -r requirements.txt +- pip install .[dev] +# branches: +# only: +# - master +# except: +# - develop +before_script: + - pip install coveralls script: -- pytest + - make test-offline + # - coverage run -m pytest -k 'not Online' -q --cache-clear tests/ + - if [[ "$TRAVIS_PYTHON_VERSION" = 3.6 ]]; then coverage run -m pytest -k 'not Online' -q --cache-clear tests/; fi +after_success: + - if [[ "$TRAVIS_PYTHON_VERSION" = 3.6 ]]; then coveralls; fi notifications: email: on_success: change diff --git a/MANIFEST.in b/MANIFEST.in index bf00f2f..352e325 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include LICENSE include README.rst +include Makefile include requirements.txt +include requirements_dev.txt include umbr_api/data/customer_key_example.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4b78e4e --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.PHONY: test +test: clear-all install-dev + pytest -q --cache-clear tests/ + +.PHONY: test-online +test-online: clear-all install-dev + pytest -k 'Online' -q --cache-clear tests/ + +.PHONY: test-offline +test-offline: clear-all install-dev + pytest -k 'not Online' -q --cache-clear tests/ + +.PHONY: install-dev +install-dev: + pip install -q -e .[dev] + +.PHONY: coverage-offline +coverage-offline: clear-pyc clear-cov + coverage run -m pytest -k 'not Online' -q --cache-clear tests/ + coverage report + coverage annotate + +.PHONY: coverage +coverage: clear-pyc clear-cov + coverage run -m pytest + coverage report + coverage annotate + +.PHONY: cov +cov: coverage + +.PHONY: upload +upload: clear-all built + twine upload dist/* + +.PHONY: docs +docs: clear-pyc install-dev + $(MAKE) -C docs html + +.PHONY: built +built: + python3 setup.py sdist + +.PHONY: clear-all +clear-all: clear-pyc clear-cov clear-build + +.PHONY: clear-pyc +clear-pyc: + find . -type d -name '__pycache__' -exec rm -rf {} + + find . -type f -name '*.py[co]' -exec rm -f {} + + find . -type f -name '*~' -exec rm -f {} + + +.PHONY: clear-cov +clear-cov: + find . -type f -name '*.py,cover' -exec rm -f {} + + rm -fr .pytest_cache + coverage erase + +.PHONY: clear-build +clear-build: + rm -fr docs/_build/ + rm -fr dist/ + rm -fr *.egg-info diff --git a/build.command b/build.command deleted file mode 100755 index b012f00..0000000 --- a/build.command +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# turn echo on -set -x - -# set directory of the script as a current directory -cd "$( dirname "$0" )" - -# activate Python's virtual environment -source venv/bin/activate - -# build the package -python3 setup.py sdist - -# build the docs -pushd docs ; make html ; popd - -# read -p "Press Enter to upload package to test PyPI [ENTER]" =1.3.4 +coverage>=4.5.1 pep257>=0.7.0 pycodestyle>=2.3.1 pydocstyle>=2.1.1 pylint>=1.8.2 +pytest>=3.4.1 setuptools>=38.5.1 Sphinx>=1.7.1 sphinx_rtd_theme>=0.2.4 diff --git a/setup.py b/setup.py index f7cfdff..6843423 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,23 @@ 'logzero >= 1.3.1', 'keyring >= 11.0.0', ], + extras_require={ + 'dev': [ + "coverage>=4.5.1", + "pytest>=3.4.1", + "setuptools>=38.5.1", + "Sphinx>=1.7.1", + "sphinx_rtd_theme>=0.2.4", + "twine>=1.9.1" + ], + 'dev_recommended': [ + "autopep8>=1.3.4", + "pep257>=0.7.0", + "pycodestyle>=2.3.1", + "pydocstyle>=2.1.1", + "pylint>=1.8.2" + ], + }, package_data={ 'umbr_api': ['data/customer_key_example.json'], }, diff --git a/tests/data/customer_key_incorrect.json b/tests/data/customer_key_incorrect.json new file mode 100644 index 0000000..71c1b6a --- /dev/null +++ b/tests/data/customer_key_incorrect.json @@ -0,0 +1,3 @@ +{ + "incorrect_key_in_json": "YOUR-CUSTOMER-KEY-IS-HERE-0123456789" +} diff --git a/tests/data/templates/add/case1/body.json b/tests/data/templates/add/case1/body.json new file mode 100644 index 0000000..5ccf317 --- /dev/null +++ b/tests/data/templates/add/case1/body.json @@ -0,0 +1 @@ +{"id":"6ece47ef,7afc,4a50,89e6-b0a10adc2d8a"} \ No newline at end of file diff --git a/tests/data/templates/add/case1/code.txt b/tests/data/templates/add/case1/code.txt new file mode 100644 index 0000000..252b382 --- /dev/null +++ b/tests/data/templates/add/case1/code.txt @@ -0,0 +1 @@ +202 \ No newline at end of file diff --git a/tests/data/templates/add/case1/headers.json b/tests/data/templates/add/case1/headers.json new file mode 100644 index 0000000..1d9d41e --- /dev/null +++ b/tests/data/templates/add/case1/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 08:37:24 GMT', 'Content-Type': 'application/json', 'Content-Length': '45', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'} \ No newline at end of file diff --git a/tests/data/templates/add/case2/body.json b/tests/data/templates/add/case2/body.json new file mode 100644 index 0000000..abb745d --- /dev/null +++ b/tests/data/templates/add/case2/body.json @@ -0,0 +1 @@ +{"message":"There were one or more missing or required fields","errors":{"dstDomain":"is invalid"},"statusCode":400} \ No newline at end of file diff --git a/tests/data/templates/add/case2/code.txt b/tests/data/templates/add/case2/code.txt new file mode 100644 index 0000000..6b3ed8d --- /dev/null +++ b/tests/data/templates/add/case2/code.txt @@ -0,0 +1 @@ +400 \ No newline at end of file diff --git a/tests/data/templates/add/case2/headers.json b/tests/data/templates/add/case2/headers.json new file mode 100644 index 0000000..b97b37a --- /dev/null +++ b/tests/data/templates/add/case2/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 08:51:22 GMT', 'Content-Type': 'application/json', 'Content-Length': '116', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'} \ No newline at end of file diff --git a/tests/data/templates/get/case1/body.json b/tests/data/templates/get/case1/body.json new file mode 100644 index 0000000..c1943fb --- /dev/null +++ b/tests/data/templates/get/case1/body.json @@ -0,0 +1 @@ +{"meta":{"page":1,"limit":10,"prev":false,"next":false},"data":[{"id":2201,"name":"example.com","lastSeenAt":1520190490},{"id":55522,"name":"www.example.com","lastSeenAt":1520188469}]} \ No newline at end of file diff --git a/tests/data/templates/get/case1/code.txt b/tests/data/templates/get/case1/code.txt new file mode 100644 index 0000000..ae4ee13 --- /dev/null +++ b/tests/data/templates/get/case1/code.txt @@ -0,0 +1 @@ +200 \ No newline at end of file diff --git a/tests/data/templates/get/case1/headers.json b/tests/data/templates/get/case1/headers.json new file mode 100644 index 0000000..a59d35b --- /dev/null +++ b/tests/data/templates/get/case1/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 19:09:31 GMT', 'Content-Type': 'application/json', 'Content-Length': '184', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'X-Frame-Options': 'SAMEORIGIN', 'X-XSS-Protection': '1; mode=block'} \ No newline at end of file diff --git a/tests/data/templates/get/case2/body.json b/tests/data/templates/get/case2/body.json new file mode 100644 index 0000000..9f1b800 --- /dev/null +++ b/tests/data/templates/get/case2/body.json @@ -0,0 +1 @@ +{"code":"BadRequestError","message":"Limit must be a number less than or equal to 200","statusCode":400} \ No newline at end of file diff --git a/tests/data/templates/get/case2/code.txt b/tests/data/templates/get/case2/code.txt new file mode 100644 index 0000000..6b3ed8d --- /dev/null +++ b/tests/data/templates/get/case2/code.txt @@ -0,0 +1 @@ +400 \ No newline at end of file diff --git a/tests/data/templates/get/case2/headers.json b/tests/data/templates/get/case2/headers.json new file mode 100644 index 0000000..2c261a0 --- /dev/null +++ b/tests/data/templates/get/case2/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 18:40:50 GMT', 'Content-Type': 'application/json', 'Content-Length': '104', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'} \ No newline at end of file diff --git a/tests/data/templates/remove/case1/body.json b/tests/data/templates/remove/case1/body.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/templates/remove/case1/code.txt b/tests/data/templates/remove/case1/code.txt new file mode 100644 index 0000000..cbd6012 --- /dev/null +++ b/tests/data/templates/remove/case1/code.txt @@ -0,0 +1 @@ +204 \ No newline at end of file diff --git a/tests/data/templates/remove/case1/headers.json b/tests/data/templates/remove/case1/headers.json new file mode 100644 index 0000000..3bb198a --- /dev/null +++ b/tests/data/templates/remove/case1/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 11:03:32 GMT', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'X-Frame-Options': 'SAMEORIGIN', 'X-XSS-Protection': '1; mode=block'} \ No newline at end of file diff --git a/tests/data/templates/remove/case2/body.json b/tests/data/templates/remove/case2/body.json new file mode 100644 index 0000000..b213fa7 --- /dev/null +++ b/tests/data/templates/remove/case2/body.json @@ -0,0 +1 @@ +{"code":"NotFoundError","message":"Domain not in domain list","statusCode":404} \ No newline at end of file diff --git a/tests/data/templates/remove/case2/code.txt b/tests/data/templates/remove/case2/code.txt new file mode 100644 index 0000000..57db2e9 --- /dev/null +++ b/tests/data/templates/remove/case2/code.txt @@ -0,0 +1 @@ +404 \ No newline at end of file diff --git a/tests/data/templates/remove/case2/headers.json b/tests/data/templates/remove/case2/headers.json new file mode 100644 index 0000000..e27d308 --- /dev/null +++ b/tests/data/templates/remove/case2/headers.json @@ -0,0 +1 @@ +{'Server': 'nginx/1.10.1', 'Date': 'Sun, 04 Mar 2018 11:05:29 GMT', 'Content-Type': 'application/json', 'Content-Length': '79', 'Connection': 'keep-alive', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'} \ No newline at end of file diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..bdd7167 --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest + + +class OnlineTestCase(unittest.TestCase): + """Main class.""" + + # def test_default(self): + # """Call get_list() with default args.""" + # import umbr_api + # umbr_api.add.add(domain='example.com', url='example.com', key=None) + + def test_main(self): + """Call main.""" + from umbr_api.add import main + main() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_get.py b/tests/test_get.py new file mode 100644 index 0000000..6cfb174 --- /dev/null +++ b/tests/test_get.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest + + +class OnlineTestCase(unittest.TestCase): + """Main class.""" + + def test_default(self): + """Call get_list() with default args.""" + import umbr_api + umbr_api.get.get_list() + + def test_main(self): + """Call main.""" + import umbr_api + umbr_api.get.main() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_http_requests.py b/tests/test_http_requests.py new file mode 100644 index 0000000..468ba7a --- /dev/null +++ b/tests/test_http_requests.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest + + +class TestCase(unittest.TestCase): + """Main class.""" + + def test_send_post(self): + """Call incorrect send_post, get None.""" # import requests + from umbr_api._http_requests import send_post + + response = send_post(' ') + self.assertEqual(response, None) + + def test_send_get(self): + """Call incorrect send_get, get None.""" # import requests + from umbr_api._http_requests import send_get + + response = send_get(' ') + self.assertEqual(response, None) + + def test_send_delete(self): + """Call incorrect send_delete, get None.""" # import requests + from umbr_api._http_requests import send_delete + + response = send_delete(' ') + self.assertEqual(response, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_import.py b/tests/test_import.py index e6666c2..86c7bba 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -5,13 +5,18 @@ import unittest -class TestKey(unittest.TestCase): +class TestCase(unittest.TestCase): """Main class.""" def test_import_package(self): """Import of the package.""" import umbr_api + def test_package_has_version_string(self): + """Have a __version__ string.""" + import umbr_api + self.assertTrue(isinstance(umbr_api.__version__, str)) + def test_import_modules(self): """Import of modules.""" import umbr_api.add diff --git a/tests/test_key.py b/tests/test_key.py index 42de353..77d0ed2 100644 --- a/tests/test_key.py +++ b/tests/test_key.py @@ -5,10 +5,10 @@ import unittest -class TestKey(unittest.TestCase): +class TestCase(unittest.TestCase): """Main class.""" - def test_get_key_direct(self): + def test_get_key_from_api_call(self): """Check if key returns.""" from umbr_api._key import get_key assert get_key(key='123456789012345678901234567890123456') == \ @@ -20,23 +20,69 @@ def test_get_key_from_file(self): assert get_key(filename='customer_key_example.json') == \ 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' - def test_strings_a_3(self): + def test_key_incorrect_chars(self): """Check for incorrect chars in the key.""" from umbr_api._key import get_key - self.assertRaises(AssertionError, get_key, - key='12345678901234567890123456789012345+') + with self.assertRaises(SystemExit) as expected_exc: + get_key(key='12345678901234567890123456789012345+') + self.assertEqual(expected_exc.exception.code, 1) - def test_strings_a_4(self): + def test_wrong_file(self): + """Check if non existing json file was provided.""" + from umbr_api._key import get_key + with self.assertRaises(SystemExit) as expected_exc: + get_key(key=None, filename='unique12345.json') + # check exit code + self.assertEqual(expected_exc.exception.code, 2) + + def test_key_length_1(self): """Check for a longer key.""" from umbr_api._key import get_key - self.assertRaises(AssertionError, get_key, - key='1234567890123456789012345678901234567') + with self.assertRaises(SystemExit) as expected_exc: + get_key(key='1234567890123456789012345678901234567') + self.assertEqual(expected_exc.exception.code, 1) - def test_strings_a_5(self): + def test_key_length_2(self): """Check for a shorter key.""" from umbr_api._key import get_key - self.assertRaises(AssertionError, get_key, - key='12345678901234567890123456789012345') + with self.assertRaises(SystemExit) as expected_exc: + get_key(key='12345678901234567890123456789012345') + self.assertEqual(expected_exc.exception.code, 1) + + def test_key_1(self): + """Check reading from file if empty key as a str.""" + from umbr_api._key import get_key + assert get_key(key='', filename='customer_key_example.json') == \ + 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + def test_key_2(self): + """Check reading from file if key=None.""" + from umbr_api._key import get_key + assert get_key(key=None, filename='customer_key_example.json') == \ + 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + def test_incorrect_json(self): + """Check if incorrect json was provided.""" + import os.path + from unittest import mock + + from umbr_api._key import get_key + + json_test_file = os.path.join(os.path.dirname(__file__), + 'data', 'customer_key_incorrect.json') + + with mock.patch('os.path.join') as mock_path_join: + mock_path_join.return_value = json_test_file + + with self.assertRaises(SystemExit) as expected_exc: + get_key(filename='customer_key_incorrect.json') + # check exit code + self.assertEqual(expected_exc.exception.code, 1) + + def test_main(self): + """Check main() from _key module.""" + from umbr_api._key import main + main() if __name__ == '__main__': diff --git a/tests/test_offline_add.py b/tests/test_offline_add.py new file mode 100644 index 0000000..2a52c59 --- /dev/null +++ b/tests/test_offline_add.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest +from ast import literal_eval +import os + +FAKE_KEY = 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + +# pylint: disable=R0903 +class FakeResponse(): + """To mimic ``requests`` response obj.""" + + def __init__(self, code, headers, body_txt): + """Create fake class.""" + self.status_code = code + self.headers = headers + self.text = body_txt + + +class TestCaseMocking(unittest.TestCase): + """Main class.""" + + def test_add_main(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + from umbr_api.add import main + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.post') as mock_requests_post: + mock_requests_post.return_value = my_response + main(test_key=FAKE_KEY) + + def test_add(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.post') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.add(domain='example.com', url='example.com', + key=FAKE_KEY, + bypass=False) + assert response.status_code == status_code + + def test_add_fail(self): + """Call add to fail.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case2', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case2', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/add/case2', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.post') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.add(domain='example.com', url='2example.com', + key=FAKE_KEY, + bypass=True) + assert response.status_code == status_code + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_offline_get.py b/tests/test_offline_get.py new file mode 100644 index 0000000..e60deaa --- /dev/null +++ b/tests/test_offline_get.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest +from ast import literal_eval +import os + +FAKE_KEY = 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + +# pylint: disable=R0903 +class FakeResponse(): + """To mimic ``requests`` response obj.""" + + def __init__(self, code, headers, body_txt): + """Create fake class.""" + self.status_code = code + self.headers = headers + self.text = body_txt + + +class TestCaseMocking(unittest.TestCase): + """Main class.""" + + def test_get_main(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + from umbr_api.get import main + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.get') as mock_requests_post: + mock_requests_post.return_value = my_response + main(test_key=FAKE_KEY) + + def test_get(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.get') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.get_list(page=1, limit=10, key=FAKE_KEY) + assert response.status_code == status_code + + def test_get_fail(self): + """Call add to fail.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case2', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case2', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case2', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.get') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.get_list(page=1, limit=201, key=FAKE_KEY) + assert response.status_code == status_code + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_offline_remove.py b/tests/test_offline_remove.py new file mode 100644 index 0000000..ac79afb --- /dev/null +++ b/tests/test_offline_remove.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest +from ast import literal_eval +import os + +FAKE_KEY = 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + +# pylint: disable=R0903 +class FakeResponse(): + """To mimic ``requests`` response obj.""" + + def __init__(self, code, headers, body_txt): + """Create fake class.""" + self.status_code = code + self.headers = headers + self.text = body_txt + + +class TestCaseMocking(unittest.TestCase): + """Main class.""" + + def test_remove_main(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + from umbr_api.remove import main + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.delete') as mock_requests_post: + mock_requests_post.return_value = my_response + main(test_key=FAKE_KEY) + + def test_remove(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.delete') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.remove(record_id='29765170', + domain_name='www.example.com', + key=FAKE_KEY) + assert response.status_code == status_code + + def test_remove_fail(self): + """Call add to fail.""" + # pylint: disable=W0612 + import requests + from unittest import mock + import umbr_api + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case2', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case2', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/remove/case2', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('requests.delete') as mock_requests_post: + mock_requests_post.return_value = my_response + response = umbr_api.remove(domain_name='www.example.com', + key=FAKE_KEY) + assert response.status_code == status_code + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_offline_umbrella.py b/tests/test_offline_umbrella.py new file mode 100644 index 0000000..7ea510d --- /dev/null +++ b/tests/test_offline_umbrella.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest +from ast import literal_eval +import os + +FAKE_KEY = 'YOUR-CUSTOMER-KEY-IS-HERE-0123456789' + + +# pylint: disable=R0903 +class FakeResponse(): + """To mimic ``requests`` response obj.""" + + def __init__(self, code, headers, body_txt): + """Create fake class.""" + self.status_code = code + self.headers = headers + self.text = body_txt + + +class TestCaseMocking(unittest.TestCase): + """Main class.""" + + def test_umbrella_main(self): + """Call main.""" + # pylint: disable=W0612 + import requests + from unittest import mock + from umbr_api.umbrella import main + + status_code_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'code.txt') + body_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'body.json') + headers_file = os.path.join(os.path.dirname(__file__), + 'data/templates/get/case1', + 'headers.json') + with open(status_code_file) as file: + status_code = int(file.read()) + with open(headers_file) as file: + headers = literal_eval(file.read()) + with open(body_file) as file: + body_text = file.read() + + my_response = FakeResponse(status_code, headers, body_text) + + with mock.patch('umbr_api.get.get_list') as mock_requests_post: + mock_requests_post.return_value = my_response + + with self.assertRaises(SystemExit) as expected_exc: + main() + self.assertEqual(expected_exc.exception.code, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_remove.py b/tests/test_remove.py new file mode 100644 index 0000000..e11145a --- /dev/null +++ b/tests/test_remove.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest + + +class OnlineTestCase(unittest.TestCase): + """Main class.""" + + # def test_default(self): + # """Call get_list() with default args.""" + # import umbr_api + # umbr_api.remove.remove(record_id=None, domain_name=None, key=None) + + def test_main(self): + """Call main.""" + from umbr_api.remove import main + main() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_umbrella.py b/tests/test_umbrella.py new file mode 100644 index 0000000..b75f00d --- /dev/null +++ b/tests/test_umbrella.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# pylint: disable=R0201 +"""Test unit.""" + +import unittest + + +class TestCase(unittest.TestCase): + """Main online class.""" + + def test_with_empty_args(self): + """User passes no args, should exit with SystemExit.""" + from umbr_api.umbrella import create_parser + + # pylint: disable=W0612 + with self.assertRaises(SystemExit) as expected_exc: + create_parser() + # cannot use for tests under diff environments + # self.assertEqual(expected_exc.exception.code, 0) + + def test_version(self): + """User passes no args, should exit with SystemExit.""" + import argparse + from unittest import mock + # pylint: disable=W0612 + import umbr_api.umbrella + from umbr_api.umbrella import main + + args = argparse.Namespace(add=None, get_list=None, key=None, + keyring_add=None, remove_domain=None, + remove_id=None, verbose=2, version=1) + + with mock.patch('umbr_api.umbrella.create_parser') as \ + mock_create_parser: + + mock_create_parser.return_value = args + with self.assertRaises(SystemExit) as expected_exc: + main() + self.assertEqual(expected_exc.exception.code, 0) + + +class OnlineTestCase(unittest.TestCase): + """Main online class.""" + + def test_keyring_general(self): + """Save a key in the keyring, read back and compare.""" + import keyring + import umbr_api + from umbr_api.umbrella import save_key + from umbr_api._key import get_key + + # check existing key + old_key = get_key(keyring.get_password('python', umbr_api.__title__)) + + # save new test key + code = save_key('YOUR-CUSTOMER-KEY-IS-HERE-0123456789') + assert code == 0 + + code = save_key(old_key) + assert code == 0 + + def test_get(self): + """User passes '--get', should exit with SystemExit(0).""" + import argparse + from unittest import mock + # pylint: disable=W0612 + import umbr_api.umbrella + from umbr_api.umbrella import main + + args = argparse.Namespace(add=None, get_list=10, key=None, + keyring_add=None, remove_domain=None, + remove_id=None, verbose=2, version=None) + + with mock.patch('umbr_api.umbrella.create_parser') as \ + mock_create_parser: + + mock_create_parser.return_value = args + with self.assertRaises(SystemExit) as expected_exc: + main() + self.assertEqual(expected_exc.exception.code, 200) + + def test_get_fail(self): + """User passes '--get 201', should exit with SystemExit(400).""" + import argparse + from unittest import mock + # pylint: disable=W0612 + import umbr_api.umbrella + from umbr_api.umbrella import main + + args = argparse.Namespace(add=None, get_list=201, key=None, + keyring_add=None, remove_domain=None, + remove_id=None, verbose=1, version=None) + + with mock.patch('umbr_api.umbrella.create_parser') as \ + mock_create_parser: + + mock_create_parser.return_value = args + with self.assertRaises(SystemExit) as expected_exc: + main() + self.assertEqual(expected_exc.exception.code, 400) + + +if __name__ == '__main__': + unittest.main() diff --git a/umbr_api/__about__.py b/umbr_api/__about__.py index 24dad9f..4afd89e 100644 --- a/umbr_api/__about__.py +++ b/umbr_api/__about__.py @@ -10,7 +10,7 @@ __summary__ = "Cisco Umbrella Enforcement API wrapper and command-line utility" __uri__ = "https://github.com/kolatz/umbr_api" -__version__ = '0.2' +__version__ = '0.3a8' __author__ = "kolatz" __email__ = "private@example.com" diff --git a/umbr_api/_key.py b/umbr_api/_key.py index b2d5a73..bb371f8 100644 --- a/umbr_api/_key.py +++ b/umbr_api/_key.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Return Umbrella Enforcement API key.""" -import json +from json import load from os import path from re import match from logzero import logger @@ -11,37 +11,45 @@ def get_key(key=None, filename='customer_key.json'): """Check API key if provided or read it from the file.""" if not key: logger.debug('No customer API key was provided') - logger.debug('Reading configuration file: %s', filename) - json_config_file_name = path.join(path.split(__file__)[0], + logger.debug('Reading the configuration file: %s', filename) + json_config_file_name = path.join(path.dirname(__file__), 'data', filename) try: file = open(json_config_file_name, 'r') except FileNotFoundError as msg: - print('Cannot find `{}`.'.format(filename)) + print('Error: Cannot find `{}`.'.format(filename)) print('Please use `--key` optional argument.') logger.exception(msg) - raise SystemExit(1) + raise SystemExit(2) + try: - json_data = json.load(file) + json_data = load(file) key = json_data['customer_key'] except KeyError as msg: - print('Cannot find data with in `%s`', filename) + print('Error: Cannot find data with in `%s`', filename) logger.exception(msg) raise SystemExit(1) + finally: + file.close() - assert isinstance(key, str) - assert len(key) == 36 - - assert match('^[0-9A-Za-z-]*$', key) + if not(isinstance(key, str) and len(key) == 36 and + match('^[0-9A-Za-z-]*$', key)): + print('Error: Key is invalid') + raise SystemExit(1) return key def main(): """Test if executed directly.""" - logger.debug('Provided API key:\n%s', - get_key('12345678-980a-bcde-fghi-jklmnopqrstu')) - logger.debug('Read API key from file:\n%s', get_key()) + logger.debug('Testing getting the key from ``get_key`` API call') + test_key = '12345678-980a-bcde-fghi-jklmnopqrstu' + new_key = get_key(test_key) + assert test_key == new_key + logger.debug('Keys match each other') + logger.debug('Testing reading API key from file') + logger.debug('API key from file: %s', + get_key(filename='customer_key_example.json')) if __name__ == '__main__': diff --git a/umbr_api/add.py b/umbr_api/add.py index 0e0f6c1..1ae1d95 100644 --- a/umbr_api/add.py +++ b/umbr_api/add.py @@ -1,8 +1,24 @@ #!/usr/bin/env python3 +# pylint: disable=C0301 """API call to add a record via Umbrella Enforcement API. +Note: + When posting data to the Security Platform API, the following steps are taken before the domain appears in a customer's block list. The optional parameter "disableDstSafeguards" can be used to bypass parts of this process as outlined in the Generic Event Format Field Descriptions. The domain acceptance process is outlined from start to finish here: + + 1. An external source identifies malicious activity occurring when a user visits a particular URL. This source could be a third party vendor’s data feed, an entry in one of your security logs or something identified as malicious on a security related website. + 2. The event is sent to the Umbrella Security Platform API via a POST request, following the steps and syntax outlined earlier in this documentation. + 3. Before the domain included API POST event is added to the specified Umbrella customer’s block list, the following checks are performed: + + - Does the domain already exist in the Umbrella Security global block list under one of the Security Categories? + - Is the domain considered benign, or safe, under the Cisco Umbrella Investigate? + - Is the status of the domain uncategorized? + - Is the domain already present on the customer’s allow list within the organization? + + 4. If the domain is then added to the customer’s domain list, then any domains in that list will be blocked in accordance with that customer’s Umbrella policy security settings. + References: https://docs.umbrella.com/developer/enforcement-api/events2/ + https://docs.umbrella.com/developer/enforcement-api/domain-acceptance-process2/ """ @@ -10,11 +26,12 @@ from datetime import datetime from urllib.parse import urlparse from logzero import logger +import umbr_api from umbr_api._key import get_key from umbr_api._http_requests import send_post -def add(domain=None, url=None, key=None): +def add(domain=None, url=None, key=None, bypass=False): """Add domain name to block list.""" assert domain and url assert isinstance(domain, str) @@ -35,24 +52,32 @@ def add(domain=None, url=None, key=None): key = get_key(key=key) response = None - time_str = (datetime.utcnow()).isoformat(sep='T', - timespec='milliseconds') + 'Z' + time_str = (datetime.utcnow()).isoformat(sep='T') + 'Z' api_uri = 'https://s-platform.api.opendns.com/1.0/events?customerKey=' + \ key + + if bypass: + bypass_str = "true" + else: + bypass_str = "false" + block_request_txt = """ {{ "alertTime": "{alertTime}", "deviceId": "ba6a59f4-e692-4724-ba36-c28132a761de", - "deviceVersion": "10.13.3", + "deviceVersion": "{deviceVersion}", "dstDomain": "{dstDomain}", "dstUrl": "{dstUrl}", "eventTime": "{eventTime}", "protocolVersion": "1.0a", - "providerName": "umbr api call" + "providerName": "Security Platform", + "disableDstSafeguards": {disableDstSafeguards} }}""".format(alertTime=time_str, + deviceVersion=umbr_api.__version__, dstDomain=domain, dstUrl=url, - eventTime=time_str) + eventTime=time_str, + disableDstSafeguards=bypass_str) response = send_post(api_uri, data=block_request_txt, @@ -75,10 +100,20 @@ def format_response(response): logger.error('Status code: %d', json_response['statusCode']) -def main(): +def main(test_key=None): """Test if executed directly.""" - response = add(domain='www.shopdisney.com', - url='https://www.shopdisney.com/test') + response = add(domain='www.example.com', + url='https://www.example.com/test', + key=test_key) + response = add(domain='www.example.com', + url='www.example.com', + key=test_key) + response = add(domain='www.example.com', + url='www.example.com/test', + key=test_key) + response = add(domain='www.example.com', + url='https://www.example.com/test', + key=test_key) print(response.status_code, json.dumps(dict(response.headers), indent=4), json.dumps(json.loads(response.text), indent=4), sep='\n\n') diff --git a/umbr_api/get.py b/umbr_api/get.py index 64c6fb8..db8fd65 100644 --- a/umbr_api/get.py +++ b/umbr_api/get.py @@ -30,7 +30,6 @@ from umbr_api._http_requests import send_get - def get_list(page=1, limit=10, key=None): """Return response tuple as response to API call. @@ -85,16 +84,22 @@ def format_response(code, json_response): logger.exception(msg) -def main(): +def main(test_key=None): """Test if executed directly.""" # Standard request - response = get_list() + response = get_list(key=test_key) + print(response.status_code, + json.dumps(dict(response.headers), indent=4), + json.dumps(json.loads(response.text), indent=4), sep='\n\n') + + # Request with pagination + response = get_list(page=2, limit=2, key=test_key) print(response.status_code, json.dumps(dict(response.headers), indent=4), json.dumps(json.loads(response.text), indent=4), sep='\n\n') # Request with pagination - response = get_list(page=2, limit=2) + response = get_list(page=1, limit=201, key=test_key) print(response.status_code, json.dumps(dict(response.headers), indent=4), json.dumps(json.loads(response.text), indent=4), sep='\n\n') diff --git a/umbr_api/remove.py b/umbr_api/remove.py index f50305c..63e9257 100644 --- a/umbr_api/remove.py +++ b/umbr_api/remove.py @@ -62,19 +62,18 @@ def format_response(response): logger.error('Status code: %d', json_response['statusCode']) -def main(): +def main(test_key=None): """Test if executed directly.""" - response = remove(record_id='29765170') + response = remove(record_id='29765170', + domain_name='www.example.com', + key=test_key) print(response.status_code, json.dumps(dict(response.headers), indent=4), sep='\n\n') - # response = remove(record_id=29765171) - # print(response.status_code, - # json.dumps(dict(response.headers), indent=4), sep='\n\n') - - # response = remove(domain_name='www.shopdisney.com') - # print(response.status_code, - # json.dumps(dict(response.headers), indent=4), sep='\n\n') + response = remove(domain_name='www.example.com', + key=test_key) + print(response.status_code, + json.dumps(dict(response.headers), indent=4), sep='\n\n') if __name__ == '__main__': diff --git a/umbr_api/umbrella.py b/umbr_api/umbrella.py index b5aed4d..a09adc1 100644 --- a/umbr_api/umbrella.py +++ b/umbr_api/umbrella.py @@ -19,10 +19,10 @@ import sys import argparse import logging -from os import path import keyring import logzero from logzero import logger +import umbr_api from umbr_api.get import get_list from umbr_api.add import add from umbr_api.remove import remove @@ -74,27 +74,49 @@ def create_parser(): nargs=1, type=str) - parser.add_argument('--keyring-add', - help='Add API key to the keyring (MacOS)', - nargs=1, - type=str) + parser.add_argument('--force', + help='Bypass Domain Acceptance Process while adding', + action='store_true') + + group.add_argument('--keyring-add', + help='Add API key to the keyring (MacOS)', + nargs=1, + type=str) parser.add_argument('-v', '--verbose', - help='Enable detail messages; Option is additive, ' + help='Enable detailed logging; Option is additive, ' 'can be used up to 2 times', action='count') parser.add_argument('-V', '--version', - help='Show version and continue', - action='count') + help='Show version', + action='store_true') + # print short usage description if no args was provided if len(sys.argv) == 1: - parser.print_help(sys.stderr) - raise SystemExit(1) + parser.print_usage(sys.stderr) + raise SystemExit(0) return parser.parse_args() +def save_key(key): + """Save API key to the keychain.""" + logger.debug('Saving API key to keyring') + keyring.set_password('python', umbr_api.__title__, key) + read_key = keyring.get_password('python', umbr_api.__title__) + print('Note: Any python program may have an access to this record\n' + 'in the keychain under your credentials.') + if read_key == key: + code = 0 + print('OK') + else: + code = 1 + print('Error: Provided key doesn''t match to saved key.') + logging.error('Provided key doesn''t match to saved key.') + return code + + def setup_logging_level(verbose_level): """Define logging level. @@ -110,24 +132,12 @@ def setup_logging_level(verbose_level): level=logging.DEBUG) -def get_version(): - """Read and return package version number.""" - logger.debug('Opening "__about__.py" to read version') - - about = {} - with open(path.join(path.dirname(__file__), "__about__.py")) as py_file: - # pylint: disable=W0122 - exec(py_file.read(), about) - return about['__version__'] - - def main(): """Execute main body, console_script entry point.""" key = None - - args = create_parser() - response = None + exit_code = 0 + args = create_parser() if args.verbose: setup_logging_level(args.verbose) @@ -137,40 +147,40 @@ def main(): logger.debug('Run with arguments: %s', str(args)) if args.version: - print('Version: {}'.format(get_version())) + print('Version: {}'.format(umbr_api.__version__)) raise SystemExit(0) if args.keyring_add: - logger.debug('Save API key to keyring') - keyring.set_password('umbrella', 'umbr_api', args.keyring_add[0]) - raise SystemExit(0) + exit_code = save_key(args.keyring_add[0]) + raise SystemExit(exit_code) if args.key: key = args.key[0] logger.debug('Use API key from command-line arguments') else: - logger.debug('Try to read API key from a keyring') - key = keyring.get_password('umbrella', 'umbr_api') + key = keyring.get_password('python', __file__) + if key: + logger.debug('Use API key from a keyring') if args.get_list: response = get_list(page=1, limit=args.get_list, key=key) + exit_code = response.status_code if args.add: - response = add(domain=args.add[0], url=args.add[1], key=key) + response = add(domain=args.add[0], url=args.add[1], + key=key, bypass=args.force) + exit_code = response.status_code if args.remove_domain: response = remove(domain_name=args.remove_domain[0], key=key) + exit_code = response.status_code if args.remove_id: response = remove(record_id=args.remove_id[0], key=key) + exit_code = response.status_code - if response: - return_code = response.status_code - else: - return_code = 0 - - logger.debug('Return code: %d', return_code) - return return_code + logger.debug('Exit code: %d', exit_code) + raise SystemExit(exit_code) if __name__ == '__main__':