diff --git a/README.md b/README.md index 83ddd1e..8f20303 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,48 @@ This repo has been developed by the DevOps Lan&Wi-Fi to automate site creation on juniper mist. -## Run script +## Run script as end user (Assuming you don't have the repo cloned) + +Run the following: + +1. Copy this in your terminal and paste to create the working directory. + +``` +mkdir -p ~/mist_working_directory/data_src && cd ~/mist_working_directory +``` + +2. Copy this in your terminal and paste + +``` +wget -O .env https://raw.githubusercontent.com/ministryofjustice/main/scope-creep-copy-button/example.env +``` + +3. Configure .env file: + You must either provide MIST_USERNAME and MIST_PASSWORD or MIST_API_TOKEN. If you opt for username + and password MFA will be requested during runtime. All other inputs are mandatory: ORG_ID, SITE_GROUP_IDS + , RF_TEMPLATE_ID + +4. Create a csv file named: `sites_with_clients.csv` within '~/mist_working_directory/data_src' + Below is an [example](./example.sites_with_clients.csv) of how the CSV should be formatted. + +``` +Site Name,Site Address,Enable GovWifi,Enable MoJWifi, GovWifi Radius Key, Wired NACS Radius Key +Test location 1,"40 Mayflower Dr, Plymouth PL2 3DG", TRUE, FALSE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 +Test location 2,"102 Petty France, London SW1H 9AJ", FALSE, TRUE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 +Test location 3,"Met Office, FitzRoy Road, Exeter, Devon, EX1 3PB", FALSE, FALSE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 +``` + +or copy the example CSV with the following command to the data directory: + +``` +wget -O data_src/sites_with_clients.csv https://raw.githubusercontent.com/ministryofjustice/juniper-mist-integration/main/example.sites_with_clients.csv +``` + +5. Copy this in your terminal and paste to download and run the Dockerized tooling: + +``` +docker run -it -v $(pwd)/data_src:/data_src --env-file .env ghcr.io/ministryofjustice/nvvs/juniper-mist-integration/app:latest +``` ## Development setup @@ -16,6 +57,7 @@ This repo has been developed by the DevOps Lan&Wi-Fi to automate site creation o - Run `make build` - Integrate built docker container with IDE. [here](https://www.jetbrains.com/help/idea/configuring-remote-python-sdks.html#2546d02c) is the example for intelliJ - mark src directory & test directory within the IDE. [here](https://www.jetbrains.com/help/idea/content-roots.html) +- Setup environment vars within the IDE. [IntelliJ_docs](https://www.jetbrains.com/help/objc/add-environment-variables-and-program-arguments.html) & [env_file](example.env) # Notes diff --git a/example.env b/example.env new file mode 100644 index 0000000..45963f3 --- /dev/null +++ b/example.env @@ -0,0 +1,7 @@ +;MIST_USERNAME= +;MIST_PASSWORD= +;MIST_API_TOKEN= +;ORG_ID= +;MIST_API_TOKEN= +;SITE_GROUP_IDS={"moj_wifi": "foo","gov_wifi": "bar"} +;RF_TEMPLATE_ID= diff --git a/example.sites_with_clients.csv b/example.sites_with_clients.csv new file mode 100644 index 0000000..fb7f7e7 --- /dev/null +++ b/example.sites_with_clients.csv @@ -0,0 +1,4 @@ +Site Name,Site Address,Enable GovWifi,Enable MoJWifi, GovWifi Radius Key, Wired NACS Radius Key +Test location 1,"40 Mayflower Dr, Plymouth PL2 3DG", TRUE, FALSE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 +Test location 2,"102 Petty France, London SW1H 9AJ", FALSE, TRUE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 +Test location 3,"Met Office, FitzRoy Road, Exeter, Devon, EX1 3PB", FALSE, FALSE, 00000DD0000BC0EEE000, 00000DD0000BC0EEE000 diff --git a/makefile b/makefile index cf319a3..7662785 100644 --- a/makefile +++ b/makefile @@ -60,23 +60,21 @@ build: ## Build the docker container docker tag ${IMG} ${LATEST} .PHONO: create-dir -make create-dir: ## Creates a directory for end user to put CSV file into +make setup-working-directory: ## Setups CSV directory mkdir data_src; echo "Please put csv file into data_src then run 'make run-prod'"; .PHONY: run-prod run-prod: ## Run the python script only mounting the host for csv-file. Format: MIST_API_TOKEN=foo ORG_ID=bar make run-prod - docker run -v $(shell pwd)/data_src:/data_src \ - -e MIST_API_TOKEN=$$MIST_API_TOKEN \ - -e ORG_ID=$$ORG_ID \ + docker run -it -v $(shell pwd)/data_src:/data_src \ + --env-file .env \ $(NAME) .PHONY: run-dev run-dev: ## Run the python script while mounting the host. This enables using the latest local src code without needing to wait for a container build. Format: MIST_API_TOKEN=foo ORG_ID=bar make run-dev - docker run -v $(shell pwd)/src:/app/src \ + docker run -it -v $(shell pwd)/src:/app/src \ -v $(shell pwd)/data_src:/data_src \ - -e MIST_API_TOKEN=$$MIST_API_TOKEN \ - -e ORG_ID=$$ORG_ID \ + --env-file .env \ $(NAME) .PHONY: tests diff --git a/src/juniper.py b/src/juniper.py index 1f27ff6..5a67f1c 100644 --- a/src/juniper.py +++ b/src/juniper.py @@ -1,17 +1,52 @@ -import sys import requests import json # Mist CRUD operations -class Admin(object): - def __init__(self, token=''): - self.session = requests.Session() - self.headers = { - 'Content-Type': 'application/json', - 'Authorization': 'Token ' + token +class Admin: + + def login_via_username_and_password(self, username, password): + login_url = self.base_url + "/login" + login_payload = {'email': username, 'password': password} + self.session.post(login_url, data=login_payload) + mfa_headers = { + # Include CSRF token in headers + 'X-CSRFToken': self.session.cookies.get('csrftoken.eu'), } + self.session.headers.update(mfa_headers) + mfa_code = input("Enter MFA:") + login_response = self.session.post( + "https://api.eu.mist.com/api/v1/login/two_factor", + data={"two_factor": mfa_code} + ) + if login_response.status_code == 200: + print("Login successful") + else: + raise ValueError("Login was not successful: {response}".format( + response=login_response)) + + def login_via_token(self, token): + self.headers['Authorization'] = 'Token ' + token + request_url = self.base_url + "/self/apitokens" + responce = self.session.get(request_url, headers=self.headers) + if responce.status_code == 200: + print("Login successful") + else: + raise ValueError( + "Login was not successful via token: {response}".format(response=responce)) + + def __init__(self, token=None, username=None, password=None): + self.session = requests.Session() + self.headers = {'Content-Type': 'application/json'} + self.base_url = 'https://api.eu.mist.com/api/v1' + + if token: + self.login_via_token(token) + elif username and password: + self.login_via_username_and_password(username, password) + else: + raise ValueError("Invalid parameters provided for authentication.") def post(self, url, payload, timeout=60): url = 'https://api.eu.mist.com{}'.format(url) @@ -27,7 +62,6 @@ def post(self, url, payload, timeout=60): print('\tPayload: {}'.format(payload)) print('\tResponse: {} ({})'.format( response.text, response.status_code)) - return False return json.loads(response.text) @@ -46,31 +80,46 @@ def put(self, url, payload): print('\tPayload: {}'.format(payload)) print('\tResponse: {} ({})'.format( response.text, response.status_code)) - return False return json.loads(response.text) +def check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups(gov_wifi, moj_wifi, site_group_ids: dict): + result = [] + if moj_wifi == 'TRUE': + result.append(site_group_ids['moj_wifi']) + if gov_wifi == 'TRUE': + result.append(site_group_ids['gov_wifi']) + return result + + # Main function def juniper_script( data, - mist_api_token='', - org_id=''): + mist_api_token=None, + org_id=None, + mist_username=None, + mist_password=None, + site_group_ids=None, + rf_template_id=None +): # Configure True/False to enable/disable additional logging of the API response objects show_more_details = True - # Check for required variables - if mist_api_token == '': - print('Please provide your Mist API token as mist_api_token') - sys.exit(1) - elif org_id == '': - print('Please provide your Mist Organization UUID as org_id') - sys.exit(1) + if org_id is None or org_id == '': + raise ValueError('Please provide Mist org_id') + if (mist_api_token is None) and (mist_username is None or mist_password is None): + raise ValueError( + 'No authentication provided, provide mist username and password or API key') + if site_group_ids is None: + raise ValueError('Must provide site_group_ids for GovWifi & MoJWifi') + if rf_template_id is None: + raise ValueError('Must rf_template_id') # Establish Mist session - admin = Admin(mist_api_token) + admin = Admin(mist_api_token, mist_username, mist_password) # Create each site from the CSV file for d in data: @@ -80,19 +129,25 @@ def juniper_script( 'address': d.get('Site Address', ''), "latlng": {"lat": d.get('gps', '')[0], "lng": d.get('gps', '')[1]}, "country_code": d.get('country_code', ''), + "rftemplate_id": rf_template_id, "timezone": d.get('time_zone', ''), - } + "sitegroup_ids": check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups( + gov_wifi=d.get('Enable GovWifi', ''), + moj_wifi=d.get('Enable MoJWifi', ''), + site_group_ids=json.loads(site_group_ids) + ), + } # MOJ specific attributes site_setting = { "vars": { - "Enable GovWifi": d.get('Enable GovWifi', ''), - "Enable MoJWifi": d.get('Enable MoJWifi', ''), - "Wired NACS Radius Key": d.get('Wired NACS Radius Key', ''), - "GovWifi Radius Key": d.get('GovWifi Radius Key', '') + "site_specific_radius_wired_nacs_secret": d.get('Wired NACS Radius Key', ''), + "site_specific_radius_govwifi_secret": d.get('GovWifi Radius Key', ''), + "address": d.get('Site Address', ''), + "site_name": d.get('Site Name', '') + }, - } } diff --git a/src/main.py b/src/main.py index de7b708..2b152d6 100644 --- a/src/main.py +++ b/src/main.py @@ -50,7 +50,11 @@ def add_geocoding_to_json(data): json_data_without_geocoding) juniper_script( - mist_api_token=os.environ['MIST_API_TOKEN'], - org_id=os.environ['ORG_ID'], + mist_api_token=os.environ.get('MIST_API_TOKEN'), + mist_username=os.environ.get('MIST_USERNAME'), + mist_password=os.environ.get('MIST_PASSWORD'), + site_group_ids=os.environ.get('SITE_GROUP_IDS'), + org_id=os.environ.get('ORG_ID'), + rf_template_id=os.environ.get('RF_TEMPLATE_ID'), data=json_data_with_geocoding ) diff --git a/test/test_juniper.py b/test/test_juniper.py index 42cbdc9..4897079 100644 --- a/test/test_juniper.py +++ b/test/test_juniper.py @@ -1,13 +1,14 @@ import unittest from unittest.mock import patch, MagicMock -from src.juniper import juniper_script, Admin +from src.juniper import juniper_script, Admin, check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups class TestJuniperScript(unittest.TestCase): + @patch('src.juniper.requests.Session.get', return_value=MagicMock(status_code=200)) @patch('src.juniper.Admin.post') @patch('src.juniper.Admin.put') - def test_juniper_script(self, mock_put, mock_post): + def test_juniper_script(self, mock_put, mock_post, mock_successful_login): # Mock Mist API responses mock_post.return_value = {'id': '123', 'name': 'TestSite'} mock_put.return_value = {'status': 'success'} @@ -21,7 +22,13 @@ def test_juniper_script(self, mock_put, mock_post): ] # Call the function - juniper_script(data, mist_api_token='your_token', org_id='your_org_id') + juniper_script( + data, + mist_api_token='your_token', + org_id='your_org_id', + site_group_ids='{"moj_wifi": "foo","gov_wifi": "bar"}', + rf_template_id='8542a5fa-51e4-41be-83b9-acb416362cc0' + ) # Assertions mock_post.assert_called_once_with('/api/v1/orgs/your_org_id/sites', { @@ -29,28 +36,198 @@ def test_juniper_script(self, mock_put, mock_post): 'address': '123 Main St', 'latlng': {'lat': 1.23, 'lng': 4.56}, 'country_code': 'US', - 'timezone': 'UTC' + 'rftemplate_id': '8542a5fa-51e4-41be-83b9-acb416362cc0', + 'timezone': 'UTC', + 'sitegroup_ids': [] }) mock_put.assert_called_once_with('/api/v1/sites/123/setting', { 'vars': { - 'Enable GovWifi': 'true', - 'Enable MoJWifi': 'false', - 'Wired NACS Radius Key': 'key1', - 'GovWifi Radius Key': 'key2' + 'site_specific_radius_wired_nacs_secret': 'key1', + 'site_specific_radius_govwifi_secret': 'key2', + 'address': '123 Main St', + 'site_name': 'TestSite' } }) - def test_juniper_script_missing_token(self): + def test_juniper_script_missing_site_group_ids(self): + with self.assertRaises(ValueError) as cm: + juniper_script([], org_id='your_org_id', mist_api_token='token') + + self.assertEqual(str(cm.exception), + 'Must provide site_group_ids for GovWifi & MoJWifi') + + def test_juniper_script_missing_rf_template_id(self): + # Test when rf_template_id is missing + with self.assertRaises(ValueError) as cm: + juniper_script([], + org_id='your_org_id', + mist_api_token='token', + site_group_ids={ + 'moj_wifi': '0b33c61d-8f51-4757-a14d-29263421a904', + 'gov_wifi': '70f3e8af-85c3-484d-8d90-93e28b911efb' + }) + + self.assertEqual(str(cm.exception), 'Must rf_template_id') + + def test_juniper_script_missing_api_token(self): + # Test when mist_api_token is missing + with self.assertRaises(ValueError) as cm: + juniper_script([], org_id='your_org_id', mist_api_token=None) + + self.assertEqual(str( + cm.exception), 'No authentication provided, provide mist username and password or API key') + + def test_juniper_script_missing_password(self): + # Test when mist_api_token is missing + with self.assertRaises(ValueError) as cm: + juniper_script([], org_id='your_org_id', mist_username='username') + + self.assertEqual(str( + cm.exception), 'No authentication provided, provide mist username and password or API key') + + def test_juniper_script_missing_username(self): # Test when mist_api_token is missing - with self.assertRaises(SystemExit) as cm: - juniper_script([], org_id='your_org_id') + with self.assertRaises(ValueError) as cm: + juniper_script([], org_id='your_org_id', mist_password='password') - self.assertEqual(cm.exception.code, 1) + self.assertEqual(str( + cm.exception), 'No authentication provided, provide mist username and password or API key') def test_juniper_script_missing_org_id(self): # Test when org_id is missing - with self.assertRaises(SystemExit) as cm: - juniper_script([], mist_api_token='your_token') + with self.assertRaises(ValueError) as cm: + juniper_script([], org_id=None) + + self.assertEqual(str(cm.exception), 'Please provide Mist org_id') + + # Mocking the input function to provide a static MFA code + @patch('builtins.input', return_value='123456') + @patch('src.juniper.requests.Session.post', return_value=MagicMock(status_code=200)) + def test_login_successfully_via_username_and_password(self, mock_post, mock_input): + admin = Admin(username='test@example.com', password='password') + self.assertIsNotNone(admin) + + mock_post.assert_called_with( + 'https://api.eu.mist.com/api/v1/login/two_factor', data={'two_factor': '123456'}) + self.assertEqual(mock_post.call_count, 2) + + @patch('builtins.input', return_value='123456') + @patch('src.juniper.requests.Session.post', return_value=MagicMock(status_code=400)) + def test_given_valid_username_and_password_when_post_to_api_and_non_200_status_code_received_then_raise_error_to_user(self, mock_post, mock_input): + with self.assertRaises(ValueError) as context: + admin = Admin(username='test@example.com', password='password') + + # Check the expected part of the exception message + expected_error_message = "Login was not successful:" + self.assertTrue(expected_error_message in str(context.exception)) + + # Ensure the post method is called with the correct parameters + mock_post.assert_called_with( + 'https://api.eu.mist.com/api/v1/login/two_factor', + data={'two_factor': '123456'} + ) + + # Ensure the post method is called twice + self.assertEqual(mock_post.call_count, 2) + + @patch('src.juniper.requests.Session') + def test_given_valid_api_token_when_post_to_api_and_non_200_status_code_received_then_raise_error_to_user(self, mock_session): + mock_get = mock_session.return_value.get + mock_get.return_value = MagicMock(status_code=400) + + with self.assertRaises(ValueError) as context: + admin = Admin(token='test_token') + + # Check the expected part of the exception message + expected_error_message = "Login was not successful via token:" + self.assertTrue(expected_error_message in str(context.exception)) + + # Ensure the get method is called with the correct parameters + expected_url = 'https://api.eu.mist.com/api/v1/self/apitokens' + mock_get.assert_called_with(expected_url, + headers={'Content-Type': 'application/json', + 'Authorization': 'Token test_token'} + ) + + self.assertEqual(mock_get.call_count, 1) + + @patch('src.juniper.requests.Session.get', return_value=MagicMock(status_code=200)) + @patch('src.juniper.requests.Session.post') + def test_post(self, mock_post, mock_successful_login): + # Set up the mock to return a response with a valid JSON payload + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"key": "value"}' + mock_post.return_value = mock_response + + admin = Admin(token='test_token') + self.assertIsNotNone(admin) + + result = admin.post('/some_endpoint', {'key': 'value'}) + + self.assertEqual(mock_post.call_count, 1) + + expected_result = {'key': 'value'} + self.assertEqual(result, expected_result) + + @patch('src.juniper.requests.Session.get', return_value=MagicMock(status_code=200)) + @patch('src.juniper.requests.Session.put') + def test_put(self, mock_put, mock_successful_login): + # Set up the mock to return a response with a valid JSON payload + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"key": "value"}' + mock_put.return_value = mock_response + + admin = Admin(token='test_token') + self.assertIsNotNone(admin) + + # Call the method being tested + result = admin.put('/some_endpoint', {'key': 'value'}) + + # Assert that the mock put method was called once + self.assertEqual(mock_put.call_count, 1) + + # Assert that the method returns the expected result + self.assertIsNotNone(result) + + +class TestCheckIfNeedToAppend(unittest.TestCase): + + def setUp(self): + # Define sample site group IDs for testing + self.site_group_ids = { + 'moj_wifi': '0b33c61d-8f51-4757-a14d-29263421a904', + 'gov_wifi': '70f3e8af-85c3-484d-8d90-93e28b911efb' + } + + def test_append_gov_wifi(self): + gov_wifi = 'TRUE' + moj_wifi = 'FALSE' + result = check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups( + gov_wifi, moj_wifi, self.site_group_ids) + self.assertEqual(result, [self.site_group_ids['gov_wifi']]) + + def test_append_moj_wifi(self): + gov_wifi = 'FALSE' + moj_wifi = 'TRUE' + result = check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups( + gov_wifi, moj_wifi, self.site_group_ids) + self.assertEqual(result, [self.site_group_ids['moj_wifi']]) + + def test_append_both_wifi(self): + gov_wifi = 'TRUE' + moj_wifi = 'TRUE' + result = check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups( + gov_wifi, moj_wifi, self.site_group_ids) + expected_result = [self.site_group_ids['moj_wifi'], + self.site_group_ids['gov_wifi']] + self.assertEqual(result, expected_result) - self.assertEqual(cm.exception.code, 1) + def test_append_neither_wifi(self): + gov_wifi = 'FALSE' + moj_wifi = 'FALSE' + result = check_if_we_need_to_append_gov_wifi_or_moj_wifi_site_groups( + gov_wifi, moj_wifi, self.site_group_ids) + self.assertEqual(result, [])