Skip to content

Commit 508ddf3

Browse files
Fix: Allow username/password connection for base client. (#157)
Co-authored-by: Richard Bell <[email protected]>
1 parent 9ec7585 commit 508ddf3

File tree

3 files changed

+268
-0
lines changed

3 files changed

+268
-0
lines changed

nisystemlink/clients/core/_http_configuration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def __init__(
5959
self._server_uri = urllib.parse.urlunsplit(uri[:2] + ("", "", ""))
6060

6161
self._api_keys = None # type: Optional[Dict[str, str]]
62+
self._username = None # type: Optional[str]
63+
self._password = None # type: Optional[str]
6264
if api_key:
6365
self._api_keys = {self._SYSTEM_LINK_API_KEY_HEADER: api_key}
6466
elif username or password:

nisystemlink/clients/core/_uplink/_base_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import TypeAdapter
88
from requests import JSONDecodeError, Response
99
from uplink import commands, Consumer, converters, response_handler, utils
10+
from uplink.auth import BasicAuth
1011

1112
from ._json_model import JsonModel
1213

@@ -98,12 +99,18 @@ def __init__(self, configuration: core.HttpConfiguration, base_path: str = ""):
9899
"""
99100
session = requests.Session()
100101
session.verify = configuration.verify
102+
auth = None # type: Optional[BasicAuth]
103+
if (configuration.username is not None) and (
104+
configuration.password is not None
105+
):
106+
auth = BasicAuth(configuration.username, configuration.password)
101107

102108
super().__init__(
103109
base_url=configuration.server_uri + base_path,
104110
converter=_JsonModelConverter(),
105111
hooks=[_handle_http_status],
106112
client=session,
113+
auth=auth,
107114
)
108115
if configuration.api_keys:
109116
self.session.headers.update(configuration.api_keys)
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""Tests for HttpConfiguration."""
4+
5+
import pathlib
6+
7+
import pytest
8+
from nisystemlink.clients.core._http_configuration import HttpConfiguration
9+
10+
11+
class TestHttpConfiguration:
12+
"""Test cases for HttpConfiguration class."""
13+
14+
def test__valid_server_uri__initializes_correctly(self):
15+
"""Test that a valid server URI initializes the configuration correctly."""
16+
config = HttpConfiguration("https://example.com")
17+
18+
assert config.server_uri == "https://example.com"
19+
assert config.api_keys is None
20+
assert config.username is None
21+
assert config.password is None
22+
assert config.cert_path is None
23+
assert config.workspace is None
24+
assert config.verify is True
25+
assert (
26+
config.timeout_milliseconds
27+
== HttpConfiguration.DEFAULT_TIMEOUT_MILLISECONDS
28+
)
29+
assert config.user_agent is None
30+
31+
def test__server_uri_with_port__preserves_port(self):
32+
"""Test that server URI with port preserves the port."""
33+
config = HttpConfiguration("https://example.com:8080")
34+
35+
assert config.server_uri == "https://example.com:8080"
36+
37+
def test__server_uri_with_path_and_query__strips_path_and_query(self):
38+
"""Test that path and query components are stripped from server URI."""
39+
config = HttpConfiguration("https://example.com/path?query=value")
40+
41+
assert config.server_uri == "https://example.com"
42+
43+
def test__api_key_authentication__sets_api_keys(self):
44+
"""Test that API key authentication sets the api_keys property."""
45+
config = HttpConfiguration("https://example.com", api_key="test-key")
46+
47+
assert config.api_keys == {"x-ni-api-key": "test-key"}
48+
assert config.username is None
49+
assert config.password is None
50+
51+
def test__username_password_authentication__sets_credentials(self):
52+
"""Test that username/password authentication sets credentials."""
53+
config = HttpConfiguration(
54+
"https://example.com", username="testuser", password="testpass"
55+
)
56+
57+
assert config.username == "testuser"
58+
assert config.password == "testpass"
59+
assert config.api_keys is None
60+
61+
def test__all_optional_parameters__sets_all_properties(self):
62+
"""Test that all optional parameters are set correctly."""
63+
cert_path = pathlib.Path("/path/to/cert.pem")
64+
config = HttpConfiguration(
65+
"https://example.com",
66+
api_key="test-key",
67+
cert_path=cert_path,
68+
workspace="workspace-123",
69+
verify=False,
70+
)
71+
72+
assert config.server_uri == "https://example.com"
73+
assert config.api_keys == {"x-ni-api-key": "test-key"}
74+
assert config.cert_path == cert_path
75+
assert config.workspace == "workspace-123"
76+
assert config.verify is False
77+
78+
def test__missing_scheme__raises_value_error(self):
79+
"""Test that missing scheme in server URI raises ValueError."""
80+
with pytest.raises(ValueError, match="Scheme.*not included"):
81+
HttpConfiguration("example.com")
82+
83+
def test__missing_hostname__raises_value_error(self):
84+
"""Test that missing hostname in server URI raises ValueError."""
85+
with pytest.raises(ValueError, match="Host.*not included"):
86+
HttpConfiguration("https://")
87+
88+
def test__username_without_password__raises_value_error(self):
89+
"""Test that username without password raises ValueError."""
90+
with pytest.raises(
91+
ValueError, match="If username or password is set, both must be set"
92+
):
93+
HttpConfiguration("https://example.com", username="testuser")
94+
95+
def test__password_without_username__raises_value_error(self):
96+
"""Test that password without username raises ValueError."""
97+
with pytest.raises(
98+
ValueError, match="If username or password is set, both must be set"
99+
):
100+
HttpConfiguration("https://example.com", password="testpass")
101+
102+
def test__timeout_milliseconds_property__getter_and_setter(self):
103+
"""Test timeout_milliseconds property getter and setter."""
104+
config = HttpConfiguration("https://example.com")
105+
106+
# Test default value
107+
assert (
108+
config.timeout_milliseconds
109+
== HttpConfiguration.DEFAULT_TIMEOUT_MILLISECONDS
110+
)
111+
112+
# Test setter
113+
config.timeout_milliseconds = 30000
114+
assert config.timeout_milliseconds == 30000
115+
116+
def test__user_agent_property__getter_and_setter(self):
117+
"""Test user_agent property getter and setter."""
118+
config = HttpConfiguration("https://example.com")
119+
120+
# Test default value
121+
assert config.user_agent is None
122+
123+
# Test setter with value
124+
config.user_agent = "TestAgent/1.0"
125+
assert config.user_agent == "TestAgent/1.0"
126+
127+
# Test setter with None
128+
config.user_agent = None
129+
assert config.user_agent is None
130+
131+
# Test setter with empty string
132+
config.user_agent = ""
133+
assert config.user_agent is None
134+
135+
def test__verify_property__getter_and_setter(self):
136+
"""Test verify property getter and setter."""
137+
config = HttpConfiguration("https://example.com")
138+
139+
# Test default value
140+
assert config.verify is True
141+
142+
# Test setter
143+
config.verify = False
144+
assert config.verify is False
145+
146+
config.verify = True
147+
assert config.verify is True
148+
149+
def test__api_keys_property__returns_copy(self):
150+
"""Test that api_keys property returns a copy of the internal dictionary."""
151+
config = HttpConfiguration("https://example.com", api_key="test-key")
152+
153+
api_keys = config.api_keys
154+
assert api_keys == {"x-ni-api-key": "test-key"}
155+
156+
# Modify the returned dictionary
157+
api_keys["new-key"] = "new-value"
158+
159+
# Original should be unchanged
160+
assert config.api_keys == {"x-ni-api-key": "test-key"}
161+
162+
def test__api_keys_property__returns_none_when_no_api_key(self):
163+
"""Test that api_keys property returns None when no API key is set."""
164+
config = HttpConfiguration("https://example.com")
165+
166+
assert config.api_keys is None
167+
168+
def test__server_uri_property__is_read_only(self):
169+
"""Test that server_uri property is read-only."""
170+
config = HttpConfiguration("https://example.com")
171+
172+
# These properties should only have getters, not setters
173+
# We can test this by trying to set and expecting an AttributeError
174+
with pytest.raises(AttributeError):
175+
config.server_uri = "https://different.com"
176+
177+
def test__username_property__is_read_only(self):
178+
"""Test that username property is read-only."""
179+
config = HttpConfiguration(
180+
"https://example.com", username="test", password="test"
181+
)
182+
183+
# Username should be read-only after initialization
184+
with pytest.raises(AttributeError):
185+
config.username = "different"
186+
187+
def test__password_property__is_read_only(self):
188+
"""Test that password property is read-only."""
189+
config = HttpConfiguration(
190+
"https://example.com", username="test", password="test"
191+
)
192+
193+
# Password should be read-only after initialization
194+
with pytest.raises(AttributeError):
195+
config.password = "different"
196+
197+
def test__cert_path_property__is_read_only(self):
198+
"""Test that cert_path property is read-only."""
199+
config = HttpConfiguration("https://example.com")
200+
201+
# Cert path should be read-only after initialization
202+
with pytest.raises(AttributeError):
203+
config.cert_path = pathlib.Path("/different/path")
204+
205+
def test__workspace_property__is_read_only(self):
206+
"""Test that workspace property is read-only."""
207+
config = HttpConfiguration("https://example.com")
208+
209+
# Workspace should be read-only after initialization
210+
with pytest.raises(AttributeError):
211+
config.workspace = "different-workspace"
212+
213+
def test__default_timeout_constant__has_expected_value(self):
214+
"""Test that the default timeout constant has the expected value."""
215+
assert HttpConfiguration.DEFAULT_TIMEOUT_MILLISECONDS == 60000
216+
217+
def test__http_scheme__is_accepted(self):
218+
"""Test that HTTP scheme is accepted."""
219+
config = HttpConfiguration("http://example.com")
220+
221+
assert config.server_uri == "http://example.com"
222+
223+
def test__case_insensitive_scheme__is_normalized_to_lowercase(self):
224+
"""Test that case-insensitive schemes are normalized to lowercase."""
225+
config = HttpConfiguration("HTTPS://example.com")
226+
227+
assert config.server_uri == "https://example.com"
228+
229+
def test__ipv4_address__is_accepted(self):
230+
"""Test that IPv4 addresses are accepted as hostnames."""
231+
config = HttpConfiguration("https://192.168.1.1")
232+
233+
assert config.server_uri == "https://192.168.1.1"
234+
235+
def test__ipv6_address__is_accepted(self):
236+
"""Test that IPv6 addresses are accepted as hostnames."""
237+
config = HttpConfiguration("https://[::1]")
238+
239+
assert config.server_uri == "https://[::1]"
240+
241+
def test__empty_api_key__does_not_set_api_keys(self):
242+
"""Test that empty API key does not set api_keys."""
243+
config = HttpConfiguration("https://example.com", api_key="")
244+
245+
assert config.api_keys is None
246+
247+
def test__none_api_key__does_not_set_api_keys(self):
248+
"""Test that None API key does not set api_keys."""
249+
config = HttpConfiguration("https://example.com", api_key=None)
250+
251+
assert config.api_keys is None
252+
253+
def test__pathlib_path__cert_path(self):
254+
"""Test that pathlib.Path objects work for cert_path."""
255+
cert_path = pathlib.Path("/etc/ssl/certs/ca-bundle.crt")
256+
config = HttpConfiguration("https://example.com", cert_path=cert_path)
257+
258+
assert config.cert_path == cert_path
259+
assert isinstance(config.cert_path, pathlib.Path)

0 commit comments

Comments
 (0)