From a7612ff6fc2557fb2b1d394947cf1f1bd8738c53 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Mon, 25 May 2026 15:25:29 +0100 Subject: [PATCH 1/4] fix: utilize the users/{EMAIL} endpoint for role. --- src/quads_lib/quads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quads_lib/quads.py b/src/quads_lib/quads.py index e7d00a8..e577a08 100644 --- a/src/quads_lib/quads.py +++ b/src/quads_lib/quads.py @@ -37,8 +37,8 @@ def login(self) -> dict: self.session.headers.update({"Authorization": f"Bearer {self.token}"}) return json_response - def get_current_user(self) -> dict: - return self.get("me") + def get_user(self, email: str) -> dict: + return self.get(f"users/{email}") def logout(self) -> dict: json_response = self._make_request("POST", "logout") From e55fa70515ed97685505f19d838834d52bc60781 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Mon, 25 May 2026 15:26:48 +0100 Subject: [PATCH 2/4] bump versions --- rpm/quads-lib.spec | 2 +- setup.py | 2 +- src/quads_lib/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rpm/quads-lib.spec b/rpm/quads-lib.spec index f9886ca..b8c87c9 100644 --- a/rpm/quads-lib.spec +++ b/rpm/quads-lib.spec @@ -12,7 +12,7 @@ %define name quads-lib %define reponame python-quads-lib %define branch development -%define version 0.1.13 +%define version 0.1.14 %define build_timestamp %{lua: print(os.date("%Y%m%d"))} Summary: Python client library for interacting with the QUADS API diff --git a/setup.py b/setup.py index 64f320a..8e0d588 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(*names, **kwargs): setup( name="quads-lib", - version="0.1.13", + version="0.1.14", license="LGPL-3.0-only", description="Python client library for interacting with the QUADS API", long_description="{}\n{}".format( diff --git a/src/quads_lib/__init__.py b/src/quads_lib/__init__.py index 7ba8667..b22daeb 100644 --- a/src/quads_lib/__init__.py +++ b/src/quads_lib/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.13" +__version__ = "0.1.14" from .quads import QuadsApi From 3b6e78f807a85ed48b0d38069defe53dc412a7e4 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Mon, 25 May 2026 15:54:45 +0100 Subject: [PATCH 3/4] chore: fix tests --- tests/test_quads.py | 118 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 12 deletions(-) diff --git a/tests/test_quads.py b/tests/test_quads.py index 10eccf7..276146f 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -933,7 +933,11 @@ def test_get_schedules(self, mock_get): @patch("requests.Session.request") def test_get_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1", "start": "2024-03-20"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -941,14 +945,20 @@ def test_get_schedules_with_params(self, mock_get): result = self.api.get_schedules(query_data) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith("/schedules?cloud=cloud1&start=2024-03-20") or str(mock_get.call_args[0][1]).endswith( + assert str(mock_get.call_args[0][1]).endswith( + "/schedules?cloud=cloud1&start=2024-03-20" + ) or str(mock_get.call_args[0][1]).endswith( "/schedules?start=2024-03-20&cloud=cloud1" ) assert result == expected_response @patch("requests.Session.request") def test_get_current_schedules(self, mock_get): - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -962,7 +972,11 @@ def test_get_current_schedules(self, mock_get): @patch("requests.Session.request") def test_get_current_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1041,7 +1055,11 @@ def test_get_future_schedules(self, mock_get): @patch("requests.Session.request") def test_get_future_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} + expected_response = { + "schedules": [ + {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1293,7 +1311,9 @@ def test_update_notification(self, mock_patch): result = self.api.update_notification(notification_id, update_data) mock_patch.assert_called_once() - assert str(mock_patch.call_args[0][1]).endswith(f"/notifications/{notification_id}") + assert str(mock_patch.call_args[0][1]).endswith( + f"/notifications/{notification_id}" + ) assert mock_patch.call_args[1]["json"] == update_data assert result == update_data @@ -1358,7 +1378,9 @@ def test_get_active_cloud_assignment(self, mock_get): result = self.api.get_active_cloud_assignment(cloud_name) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith(f"/assignments/active/{cloud_name}") + assert str(mock_get.call_args[0][1]).endswith( + f"/assignments/active/{cloud_name}" + ) assert result == expected_response @patch("requests.Session.request") @@ -1444,7 +1466,9 @@ def test_remove_interface(self, mock_delete): result = self.api.remove_interface(hostname, if_name) mock_delete.assert_called_once() - assert str(mock_delete.call_args[0][1]).endswith(f"/interfaces/{hostname}/{if_name}") + assert str(mock_delete.call_args[0][1]).endswith( + f"/interfaces/{hostname}/{if_name}" + ) assert result == {} @patch("requests.Session.request") @@ -1752,7 +1776,11 @@ def test_get_moves(self, mock_get): @patch("requests.Session.request") def test_get_moves_with_date(self, mock_get): date = "2024-03-20" - expected_response = {"moves": [{"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"}]} + expected_response = { + "moves": [ + {"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"} + ] + } mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1843,7 +1871,9 @@ def test_create_self_assignment(self, mock_post): with patch.object(self.api, "post") as mock_post: mock_post.return_value = expected_response result = self.api.create_self_assignment(test_data) - mock_post.assert_called_once_with(str(Path("assignments") / "self"), test_data) + mock_post.assert_called_once_with( + str(Path("assignments") / "self"), test_data + ) assert result == expected_response @patch("requests.Session.request") @@ -2019,7 +2049,9 @@ def test_create_self_assignment_logging(self, mock_request, mock_print): self.api.create_self_assignment(assignment_data) - mock_print.assert_called_once_with("Self-assignment created - ID: 123, Cloud: cloud1") + mock_print.assert_called_once_with( + "Self-assignment created - ID: 123, Cloud: cloud1" + ) @patch("builtins.print") @patch("requests.Session.request") @@ -2067,6 +2099,66 @@ def test_create_self_assignment_limit_reached(self, mock_request, mock_print): assert result == error_response +class TestApiTokenAuth: + """Tests for qat_ API token authentication""" + + def test_init_with_api_token(self): + """Test that api_token sets Bearer header and skips BasicAuth""" + api = QuadsApi("", "", "http://example.com/", api_token="qat_test123") + assert api.token == "qat_test123" + assert api.auth is None + assert api.session.headers.get("Authorization") == "Bearer qat_test123" + + def test_init_without_api_token(self): + """Test that without api_token, BasicAuth is set""" + api = QuadsApi("user", "pass", "http://example.com/") + assert api.token is None + assert api.auth is not None + + def test_login_noop_with_qat_token(self): + """Test that login() returns synthetic success for qat_ tokens""" + api = QuadsApi("", "", "http://example.com/", api_token="qat_abc123") + result = api.login() + assert result["status_code"] == 201 + assert result["status"] == "success" + assert result["auth_token"] == "qat_abc123" + + def test_login_noop_does_not_make_request(self): + """Test that login() with qat_ token makes no HTTP request""" + api = QuadsApi("", "", "http://example.com/", api_token="qat_abc123") + api.session.post = Mock(side_effect=AssertionError("should not be called")) + result = api.login() + assert result["status"] == "success" + + @patch("requests.Session.request") + def test_get_user(self, mock_request): + """Test get_user calls /users/{email}""" + api = QuadsApi("user", "pass", "http://example.com/") + expected = {"email": "bob@example.com", "roles": ["user"]} + mock_response = Mock() + mock_response.json.return_value = expected + mock_request.return_value = mock_response + + result = api.get_user("bob@example.com") + + mock_request.assert_called_once() + assert str(mock_request.call_args[0][1]).endswith("/users/bob@example.com") + assert result == expected + + @patch("requests.Session.request") + def test_get_user_with_api_token(self, mock_request): + """Test get_user works with api_token auth""" + api = QuadsApi("", "", "http://example.com/", api_token="qat_test") + expected = {"email": "admin@example.com", "roles": ["admin"]} + mock_response = Mock() + mock_response.json.return_value = expected + mock_request.return_value = mock_response + + result = api.get_user("admin@example.com") + + assert result == expected + + class TestQuadsBase: @pytest.fixture(autouse=True) def setup(self): @@ -2076,7 +2168,9 @@ def setup(self): @pytest.fixture def quads_base(self): - return QuadsBase(username=self.username, password=self.password, base_url=self.base_url) + return QuadsBase( + username=self.username, password=self.password, base_url=self.base_url + ) def test_context_manager_enter(self, quads_base): quads_base.login = Mock() From 699b86d658566c7b110f9f3becf93278a6ab1d66 Mon Sep 17 00:00:00 2001 From: Will Foster Date: Mon, 25 May 2026 15:58:04 +0100 Subject: [PATCH 4/4] chore: ruff --- tests/test_quads.py | 58 ++++++++++----------------------------------- 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/tests/test_quads.py b/tests/test_quads.py index 276146f..bbefc80 100644 --- a/tests/test_quads.py +++ b/tests/test_quads.py @@ -933,11 +933,7 @@ def test_get_schedules(self, mock_get): @patch("requests.Session.request") def test_get_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1", "start": "2024-03-20"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -945,20 +941,14 @@ def test_get_schedules_with_params(self, mock_get): result = self.api.get_schedules(query_data) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith( - "/schedules?cloud=cloud1&start=2024-03-20" - ) or str(mock_get.call_args[0][1]).endswith( + assert str(mock_get.call_args[0][1]).endswith("/schedules?cloud=cloud1&start=2024-03-20") or str(mock_get.call_args[0][1]).endswith( "/schedules?start=2024-03-20&cloud=cloud1" ) assert result == expected_response @patch("requests.Session.request") def test_get_current_schedules(self, mock_get): - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -972,11 +962,7 @@ def test_get_current_schedules(self, mock_get): @patch("requests.Session.request") def test_get_current_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1055,11 +1041,7 @@ def test_get_future_schedules(self, mock_get): @patch("requests.Session.request") def test_get_future_schedules_with_params(self, mock_get): query_data = {"cloud": "cloud1"} - expected_response = { - "schedules": [ - {"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"} - ] - } + expected_response = {"schedules": [{"id": 1, "cloud": "cloud1", "start": "2024-03-20", "end": "2024-03-21"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1311,9 +1293,7 @@ def test_update_notification(self, mock_patch): result = self.api.update_notification(notification_id, update_data) mock_patch.assert_called_once() - assert str(mock_patch.call_args[0][1]).endswith( - f"/notifications/{notification_id}" - ) + assert str(mock_patch.call_args[0][1]).endswith(f"/notifications/{notification_id}") assert mock_patch.call_args[1]["json"] == update_data assert result == update_data @@ -1378,9 +1358,7 @@ def test_get_active_cloud_assignment(self, mock_get): result = self.api.get_active_cloud_assignment(cloud_name) mock_get.assert_called_once() - assert str(mock_get.call_args[0][1]).endswith( - f"/assignments/active/{cloud_name}" - ) + assert str(mock_get.call_args[0][1]).endswith(f"/assignments/active/{cloud_name}") assert result == expected_response @patch("requests.Session.request") @@ -1466,9 +1444,7 @@ def test_remove_interface(self, mock_delete): result = self.api.remove_interface(hostname, if_name) mock_delete.assert_called_once() - assert str(mock_delete.call_args[0][1]).endswith( - f"/interfaces/{hostname}/{if_name}" - ) + assert str(mock_delete.call_args[0][1]).endswith(f"/interfaces/{hostname}/{if_name}") assert result == {} @patch("requests.Session.request") @@ -1776,11 +1752,7 @@ def test_get_moves(self, mock_get): @patch("requests.Session.request") def test_get_moves_with_date(self, mock_get): date = "2024-03-20" - expected_response = { - "moves": [ - {"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"} - ] - } + expected_response = {"moves": [{"id": 1, "host": "host1", "from_cloud": "cloud1", "to_cloud": "cloud2"}]} mock_response = Mock() mock_response.json.return_value = expected_response mock_get.return_value = mock_response @@ -1871,9 +1843,7 @@ def test_create_self_assignment(self, mock_post): with patch.object(self.api, "post") as mock_post: mock_post.return_value = expected_response result = self.api.create_self_assignment(test_data) - mock_post.assert_called_once_with( - str(Path("assignments") / "self"), test_data - ) + mock_post.assert_called_once_with(str(Path("assignments") / "self"), test_data) assert result == expected_response @patch("requests.Session.request") @@ -2049,9 +2019,7 @@ def test_create_self_assignment_logging(self, mock_request, mock_print): self.api.create_self_assignment(assignment_data) - mock_print.assert_called_once_with( - "Self-assignment created - ID: 123, Cloud: cloud1" - ) + mock_print.assert_called_once_with("Self-assignment created - ID: 123, Cloud: cloud1") @patch("builtins.print") @patch("requests.Session.request") @@ -2168,9 +2136,7 @@ def setup(self): @pytest.fixture def quads_base(self): - return QuadsBase( - username=self.username, password=self.password, base_url=self.base_url - ) + return QuadsBase(username=self.username, password=self.password, base_url=self.base_url) def test_context_manager_enter(self, quads_base): quads_base.login = Mock()