diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5f87181 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +## Translation Table for Disconnect JSON Response + +When calling `disconnect_mac_address`, `count`, `count_success` and `count_failed` may be returned. + +When `count` is `0`, no active session was found. This is a success, +since the `mac` is not connected. + +Otherwise, look for `count_success` or `count_failed`. + + count - 0 - no active session found (success) + count - 1 - count_success 1 = found and disconnected active session + count - 1 - count_failed 1 = found active session, but could not disconnect diff --git a/cassettes/test_404_disable_mac.yaml b/cassettes/test_404_disable_mac.yaml index 15848a1..6132b8f 100644 --- a/cassettes/test_404_disable_mac.yaml +++ b/cassettes/test_404_disable_mac.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:31 GMT + - Mon, 26 Jan 2026 17:53:03 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -82,7 +82,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:31 GMT + - Mon, 26 Jan 2026 17:53:04 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/cassettes/test_404_enable_mac.yaml b/cassettes/test_404_enable_mac.yaml index c545c36..09e52f2 100644 --- a/cassettes/test_404_enable_mac.yaml +++ b/cassettes/test_404_enable_mac.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:33 GMT + - Mon, 26 Jan 2026 17:52:28 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -82,7 +82,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:34 GMT + - Mon, 26 Jan 2026 17:52:29 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/cassettes/test_connectivity.yaml b/cassettes/test_connectivity.yaml index bf2cf93..212a2cb 100644 --- a/cassettes/test_connectivity.yaml +++ b/cassettes/test_connectivity.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:28 GMT + - Mon, 26 Jan 2026 17:53:35 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/cassettes/test_disable_mac.yaml b/cassettes/test_disable_mac.yaml index 0776e75..d7ca263 100644 --- a/cassettes/test_disable_mac.yaml +++ b/cassettes/test_disable_mac.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:29 GMT + - Mon, 26 Jan 2026 17:51:42 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -71,7 +71,7 @@ interactions: response: body: string: '{"id":4524,"mac_address":"123123123123","status":"Known","randomized_mac":true,"attributes":{},"added_at":"Nov - 15, 2023 14:52:34 CST","updated_at":"Nov 17, 2025 15:02:29 CST","_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint\/4524"}}}' + 15, 2023 14:52:34 CST","updated_at":"Jan 26, 2026 11:50:00 CST","_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint\/4524"}}}' headers: Cache-Control: - no-store, no-cache, must-revalidate @@ -82,7 +82,7 @@ interactions: Content-Type: - application/hal+json Date: - - Mon, 17 Nov 2025 22:02:30 GMT + - Mon, 26 Jan 2026 17:51:43 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -129,7 +129,7 @@ interactions: body: string: '{"id":4524,"mac_address":"123123123123","status":"Disabled","randomized_mac":true,"attributes":{"Disabled By":"TESTING","Disabled Reason":"Still testing..."},"added_at":"Nov 15, 2023 - 14:52:34 CST","updated_at":"Nov 17, 2025 16:02:30 CST","_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint\/4524"}}}' + 14:52:34 CST","updated_at":"Jan 26, 2026 11:51:43 CST","_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint\/4524"}}}' headers: Cache-Control: - no-store, no-cache, must-revalidate @@ -140,7 +140,7 @@ interactions: Content-Type: - application/hal+json Date: - - Mon, 17 Nov 2025 22:02:30 GMT + - Mon, 26 Jan 2026 17:51:43 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/cassettes/test_disconnect_mac_address.yaml b/cassettes/test_disconnect_mac_address.yaml new file mode 100644 index 0000000..47846a0 --- /dev/null +++ b/cassettes/test_disconnect_mac_address.yaml @@ -0,0 +1,111 @@ +interactions: +- request: + body: '{"grant_type": "password", "username": "JOE", "password": "NOTAPASSWORD", + "client_id": "FAKEID", "client_secret": "NOTASECRET"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '238' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.5 + method: POST + uri: https://cleaned.example.edu/api/oauth + response: + body: + string: '{"access_token": "NOTASECRET"}' + headers: + Cache-Control: + - no-store + Connection: + - Keep-Alive + Content-Length: + - '172' + Content-Type: + - application/json + Date: + - Tue, 10 Mar 2026 20:25:17 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=4, max=500 + Pragma: + - no-cache + Server: + - Apache + Set-Cookie: NO-COOKIE-FOR-YOU + Vary: + - X-Forwarded-For + X-Content-Type-Options: + - nosniff + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1;mode=block + - 1;mode=block + status: + code: 200 + message: OK +- request: + body: '{}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Bearer FAKE_TOKEN + Connection: + - keep-alive + Content-Length: + - '2' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.5 + method: POST + uri: https://cleaned.example.edu/api/session-action/disconnect/mac/123123123123?async=false + response: + body: + string: '{"action_id":"async_action_1773174318368","status":"complete","count":1,"count_success":1,"count_failed":0,"count_queue":0,"_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/session-action\/disconnect%2Fmac%2F123123123123"}}}' + headers: + Cache-Control: + - no-store, no-cache, must-revalidate + Connection: + - Keep-Alive + Content-Length: + - '251' + Content-Type: + - application/hal+json + Date: + - Tue, 10 Mar 2026 20:25:18 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=4, max=500 + Pragma: + - no-cache + Server: + - Apache + Set-Cookie: NO-COOKIE-FOR-YOU + Vary: + - X-Forwarded-For + X-Content-Type-Options: + - nosniff + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1;mode=block + - 1;mode=block + status: + code: 200 + message: OK +version: 1 diff --git a/cassettes/test_disconnect_mac_not_found.yaml b/cassettes/test_disconnect_mac_not_found.yaml new file mode 100644 index 0000000..5da5773 --- /dev/null +++ b/cassettes/test_disconnect_mac_not_found.yaml @@ -0,0 +1,111 @@ +interactions: +- request: + body: '{"grant_type": "password", "username": "JOE", "password": "NOTAPASSWORD", + "client_id": "FAKEID", "client_secret": "NOTASECRET"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '238' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.5 + method: POST + uri: https://cleaned.example.edu/api/oauth + response: + body: + string: '{"access_token": "NOTASECRET"}' + headers: + Cache-Control: + - no-store + Connection: + - Keep-Alive + Content-Length: + - '172' + Content-Type: + - application/json + Date: + - Tue, 10 Mar 2026 20:25:17 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=4, max=500 + Pragma: + - no-cache + Server: + - Apache + Set-Cookie: NO-COOKIE-FOR-YOU + Vary: + - X-Forwarded-For + X-Content-Type-Options: + - nosniff + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1;mode=block + - 1;mode=block + status: + code: 200 + message: OK +- request: + body: '{}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Authorization: + - Bearer FAKE_TOKEN + Connection: + - keep-alive + Content-Length: + - '2' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.5 + method: POST + uri: https://cleaned.example.edu/api/session-action/disconnect/mac/123123123123?async=false + response: + body: + string: '{"action_id":"async_action_1773174318368","status":"failed","count":0,"count_success":0,"count_failed":0,"count_queue":0,"_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/session-action\/disconnect%2Fmac%2F123123123123"}}}' + headers: + Cache-Control: + - no-store, no-cache, must-revalidate + Connection: + - Keep-Alive + Content-Length: + - '251' + Content-Type: + - application/hal+json + Date: + - Tue, 10 Mar 2026 20:25:18 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=4, max=500 + Pragma: + - no-cache + Server: + - Apache + Set-Cookie: NO-COOKIE-FOR-YOU + Vary: + - X-Forwarded-For + X-Content-Type-Options: + - nosniff + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1;mode=block + - 1;mode=block + status: + code: 200 + message: OK +version: 1 diff --git a/cassettes/test_enable_mac.yaml b/cassettes/test_enable_mac.yaml index a336e37..13ea955 100644 --- a/cassettes/test_enable_mac.yaml +++ b/cassettes/test_enable_mac.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:32 GMT + - Mon, 26 Jan 2026 17:49:59 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -70,20 +70,19 @@ interactions: uri: https://cleaned.example.edu/api/endpoint/mac-address/123123123123 response: body: - string: '{"id":4524,"mac_address":"123123123123","status":"Disabled","randomized_mac":true,"attributes":{"Disabled - By":"TESTING","Disabled Reason":"Still testing..."},"added_at":"Nov 15, 2023 - 14:52:34 CST","updated_at":"Nov 17, 2025 16:02:30 CST","_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint\/4524"}}}' + string: '{"id":4524,"mac_address":"123123123123","status":"Known","randomized_mac":true,"attributes":{},"added_at":"Nov + 15, 2023 14:52:34 CST","updated_at":"Jan 26, 2026 11:49:20 CST","_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint\/4524"}}}' headers: Cache-Control: - no-store, no-cache, must-revalidate Connection: - Keep-Alive Content-Length: - - '330' + - '267' Content-Type: - application/hal+json Date: - - Mon, 17 Nov 2025 22:02:32 GMT + - Mon, 26 Jan 2026 17:50:00 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -128,7 +127,7 @@ interactions: response: body: string: '{"id":4524,"mac_address":"123123123123","status":"Known","randomized_mac":true,"attributes":{},"added_at":"Nov - 15, 2023 14:52:34 CST","updated_at":"Nov 17, 2025 16:02:33 CST","_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint\/4524"}}}' + 15, 2023 14:52:34 CST","updated_at":"Jan 26, 2026 11:50:00 CST","_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint\/4524"}}}' headers: Cache-Control: - no-store, no-cache, must-revalidate @@ -139,7 +138,7 @@ interactions: Content-Type: - application/hal+json Date: - - Mon, 17 Nov 2025 22:02:33 GMT + - Mon, 26 Jan 2026 17:50:00 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/cassettes/test_get_info_for_mac_address.yaml b/cassettes/test_get_info_for_mac_address.yaml index 9b9ee49..65b6798 100644 --- a/cassettes/test_get_info_for_mac_address.yaml +++ b/cassettes/test_get_info_for_mac_address.yaml @@ -30,7 +30,7 @@ interactions: Content-Type: - application/json Date: - - Mon, 17 Nov 2025 22:02:34 GMT + - Mon, 26 Jan 2026 17:55:57 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -70,8 +70,9 @@ interactions: uri: https://cleaned.example.edu/api/endpoint?filter=%7B%22mac_address%22%3A%20%22123123123123%22%7D response: body: - string: '{"_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint?calculate_count=false\u0026offset=0\u0026limit=25\u0026sort=%2Bid\u0026filter=%7B%22mac_address%22%3A+%22123123123123%22%7D"},"first":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint?calculate_count=false\u0026offset=0\u0026limit=25\u0026sort=%2Bid\u0026filter=%7B%22mac_address%22%3A+%22123123123123%22%7D"}},"_embedded":{"items":[{"id":4524,"mac_address":"123123123123","status":"Known","randomized_mac":true,"attributes":{},"added_at":"Nov - 15, 2023 14:52:34 CST","updated_at":"Nov 17, 2025 16:02:33 CST","_links":{"self":{"href":"https:\/\/cleaned.example.edu\/api\/endpoint\/4524"}}}]}}' + string: '{"_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint?calculate_count=false\u0026offset=0\u0026limit=25\u0026sort=%2Bid\u0026filter=%7B%22mac_address%22%3A+%22123123123123%22%7D"},"first":{"href":"https:\/\/notauri.edu\/api\/endpoint?calculate_count=false\u0026offset=0\u0026limit=25\u0026sort=%2Bid\u0026filter=%7B%22mac_address%22%3A+%22123123123123%22%7D"}},"_embedded":{"items":[{"id":4524,"mac_address":"123123123123","status":"Disabled","randomized_mac":true,"attributes":{"Disabled + By":"TESTING","Disabled Reason":"Still testing..."},"added_at":"Nov 15, 2023 + 14:52:34 CST","updated_at":"Jan 26, 2026 11:51:43 CST","_links":{"self":{"href":"https:\/\/notauri.edu\/api\/endpoint\/4524"}}}]}}' headers: Cache-Control: - no-store, no-cache, must-revalidate @@ -82,7 +83,7 @@ interactions: Content-Type: - application/hal+json Date: - - Mon, 17 Nov 2025 22:02:35 GMT + - Mon, 26 Jan 2026 17:55:57 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -134,7 +135,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:35 GMT + - Mon, 26 Jan 2026 17:55:58 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -186,7 +187,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:36 GMT + - Mon, 26 Jan 2026 17:55:58 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -238,7 +239,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:36 GMT + - Mon, 26 Jan 2026 17:55:58 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: @@ -290,7 +291,7 @@ interactions: Content-Type: - application/problem+json Date: - - Mon, 17 Nov 2025 22:02:37 GMT + - Mon, 26 Jan 2026 17:55:58 GMT Expires: - Thu, 19 Nov 1981 08:52:00 GMT Keep-Alive: diff --git a/src/clearpass/client.py b/src/clearpass/client.py index 59dd2c3..532a6bd 100644 --- a/src/clearpass/client.py +++ b/src/clearpass/client.py @@ -1,8 +1,14 @@ +import json import logging import re import requests -import json -import urllib.parse + +from urllib.parse import ( + SplitResult, + quote, + urlencode, + urlunsplit, +) from clearpass.exceptions import TokenError @@ -54,6 +60,7 @@ def hyphenate_mac(macstring): class APIConnection(): def __init__(self, username, password, endpoint, client_id, client_secret): self._baseurl = f"https://{endpoint}/" + self._endpoint = endpoint self._authurl = f"{self._baseurl}api/oauth" self._authpayload = { @@ -111,19 +118,36 @@ def postheaders(self): self._postheaders.update(self.getheaders) return self._postheaders - def _put_api(self, resource, payload): + def _put_api(self, resource, **kwargs): return requests.put( url=f"{self._baseurl}api/{resource}", headers=self.postheaders, - data=json.dumps(payload), verify=False, + **kwargs + ) + + def _post_api(self, resource, **kwargs): + return requests.post( + url=f"{self._baseurl}api/{resource}", + headers=self.postheaders, + verify=False, + **kwargs ) - def _get_api(self, resource, filter=None): + def _get_api(self, resource, **kwargs): url = f"{self._baseurl}api/{resource}" - if filter: - url += f"?filter={urllib.parse.quote(json.dumps(filter))}" + params = {} + for parameter, value in kwargs.items(): + if value is not None: + params[parameter] = json.dumps(value) + url = urlunsplit(SplitResult( + scheme="https", + netloc=f"{self._endpoint}", + path=f"api/{resource}", + query=urlencode(params, quote_via=quote), + fragment=None, + )) return requests.get( url=url, headers=self.getheaders, @@ -176,7 +200,7 @@ def set_mac_address( if attributes is not None: data["attributes"] = attributes - return self._put_api(f"endpoint/{mac_id}", data) + return self._put_api(f"endpoint/{mac_id}", json=data) def enable_mac_address(self, mac): mac_id = self.get_mac_id(mac) @@ -196,3 +220,16 @@ def disable_mac_address(self, mac, disabled_by, reason): if res.status_code == 404: raise ValueError(f"{mac} not found.") return res + + def disconnect_mac_address(self, mac): + res = self._post_api( + f"session-action/disconnect/mac/{mac}?async=false", + json={} + ) + if res.status_code == 404: + raise ValueError(f"{mac} not found.") + return res + + def block_mac_address(self, mac, disabled_by, reason): + self.disable_mac_address(mac, disabled_by, reason) + self.disconnect_mac_address(mac) diff --git a/tests/conftest.py b/tests/conftest.py index 892a92c..eadfcda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,8 @@ import logging from clearpass.client import APIConnection -from vcr_cleaner import CleanYAMLSerializer -from vcr_cleaner.filters import if_uri_endswith -from vcr_cleaner.cleaners.uri import clean_domains +from vcr_cleaner import CleanYAMLSerializer, filters +from vcr_cleaner.cleaners import uri # Set up logger logger = logging.getLogger(__name__) @@ -19,6 +18,7 @@ CASSETTE_ENDPOINT = "cleaned.example.edu" CASSETTE_CLIENT_ID = "FAKEID" CASSETTE_CLIENT_SECRET = "NOTASECRET" # pragma: allowlist secret +URL = f"https://{CASSETTE_ENDPOINT}" CLEANER_SALT = 'salty' CLEANER_JWT_TOKEN = {'exp': datetime.datetime(2049, 6, 25)} @@ -26,10 +26,11 @@ # To record, `export VCR_RECORD=True` VCR_RECORD = "VCR_RECORD" in os.environ MAC_404 = 'deadbeef1234' # pragma: allowlist secret -TEST_DATA = {'mac': '123123123123', # pragma: allowlist secret - 'disabled_by': 'TESTING', - 'reason': 'Still testing...' - } +TEST_DATA = { + 'mac': '123123123123', # pragma: allowlist secret + 'disabled_by': 'TESTING', + 'reason': 'Still testing...' +} @pytest.fixture @@ -93,6 +94,14 @@ def remove_creds(request): return request +def clean_test_mac(request: dict, response: dict): + test_mac = os.environ.get('CLEARPASS_MAC', '') + request['uri'] = request['uri'].replace(test_mac, TEST_DATA['mac']) + response['body']['string'] = ( + response['body']['string'].replace(test_mac, TEST_DATA['mac']) + ) + + @pytest.fixture def cassette(request) -> vcr.cassette.Cassette: my_vcr = vcr.VCR( @@ -105,9 +114,17 @@ def cassette(request) -> vcr.cassette.Cassette: yaml_cleaner = CleanYAMLSerializer() my_vcr.register_serializer("cleanyaml", yaml_cleaner) - yaml_cleaner.register_cleaner(clean_domains('illinois.edu')) - yaml_cleaner.register_cleaner(if_uri_endswith("/api/oauth", clean_token)) + yaml_cleaner.register_cleaner(uri.clean_domains( + os.environ.get("CLEARPASS_ENDPOINT", CASSETTE_ENDPOINT), + CASSETTE_ENDPOINT, + ) + ) + yaml_cleaner.register_cleaner(filters.if_uri_contains( + f"{URL}/api/oauth", + clean_token), + ) yaml_cleaner.register_cleaner(clean_cookie) + yaml_cleaner.register_cleaner(clean_test_mac) with my_vcr.use_cassette(f'{request.function.__name__}.yaml', serializer='cleanyaml') as tape: diff --git a/tests/test_connector.py b/tests/test_connector.py index 215ff32..ae31363 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -1,3 +1,5 @@ +import json +import os import pytest from conftest import TEST_DATA, MAC_404 @@ -41,3 +43,13 @@ def test_404_enable_mac(cassette, clearpass_client): def test_get_info_for_mac_address(cassette, clearpass_client): result = clearpass_client.get_info_for_mac_address(mac=TEST_DATA['mac']) assert result + + +def test_disconnect_mac_address(cassette, clearpass_client): + result = clearpass_client.disconnect_mac_address( + mac=os.environ.get("CLEARPASS_MAC", TEST_DATA['mac'])) + assert result + decoded_json = json.loads(result.text) + # breakpoint() + assert decoded_json['count_success'] == 1 +