vhi-python 0.1.3__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vhi_python-0.1.3 → vhi_python-0.1.5}/PKG-INFO +1 -1
- {vhi_python-0.1.3 → vhi_python-0.1.5}/pyproject.toml +1 -1
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi/__init__.py +1 -1
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi/client.py +187 -73
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi/exceptions.py +7 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi/models.py +3 -3
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi_python.egg-info/PKG-INFO +1 -1
- {vhi_python-0.1.3 → vhi_python-0.1.5}/tests/test_client.py +53 -32
- {vhi_python-0.1.3 → vhi_python-0.1.5}/tests/test_integration.py +5 -4
- {vhi_python-0.1.3 → vhi_python-0.1.5}/README.md +0 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/setup.cfg +0 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi_python.egg-info/SOURCES.txt +0 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi_python.egg-info/dependency_links.txt +0 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi_python.egg-info/requires.txt +0 -0
- {vhi_python-0.1.3 → vhi_python-0.1.5}/src/vhi_python.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vhi-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "A Python client library for the Vhi API, handling authentication, MFA callbacks, claims retrieval, and document downloads."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -1,45 +1,104 @@
|
|
|
1
1
|
import requests
|
|
2
|
-
import os
|
|
3
2
|
import uuid
|
|
4
3
|
import json
|
|
4
|
+
import pickle
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
from typing import Callable, Optional, List
|
|
6
8
|
from .exceptions import VhiAuthenticationError, VhiMfaRequiredError, VhiApiError
|
|
7
9
|
from .models import ClaimStatement
|
|
8
10
|
|
|
11
|
+
|
|
9
12
|
class VhiClient:
|
|
10
13
|
"""
|
|
11
14
|
A client library for interacting with the Vhi API.
|
|
12
15
|
Handles authentication, MFA callbacks, claims retrieval, and document downloads.
|
|
13
16
|
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
DEFAULT_CONFIG_URL = (
|
|
19
|
+
"https://www.vhi.ie/myvhi/myclaimstatements" # Usually embedded here
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
username: str,
|
|
25
|
+
password: str,
|
|
26
|
+
mfa_callback: Optional[Callable[[], str]] = None,
|
|
27
|
+
cache_session: bool = True,
|
|
28
|
+
):
|
|
17
29
|
"""
|
|
18
30
|
Initialize the VhiClient.
|
|
19
|
-
|
|
31
|
+
|
|
20
32
|
Args:
|
|
21
33
|
username: User's email address.
|
|
22
34
|
password: User's password.
|
|
23
35
|
mfa_callback: A callable that returns the 2FA OTP string when invoked.
|
|
36
|
+
cache_session: Whether to cache session cookies locally to prevent re-authentication.
|
|
24
37
|
"""
|
|
25
38
|
self.username = username
|
|
26
39
|
self.password = password
|
|
27
40
|
self.mfa_callback = mfa_callback
|
|
28
|
-
|
|
41
|
+
self.cache_session = cache_session
|
|
42
|
+
|
|
29
43
|
# State management
|
|
30
44
|
self.session = requests.Session()
|
|
31
|
-
|
|
45
|
+
|
|
32
46
|
# Default Browser Headers to mimic browser and avoid WAF
|
|
33
|
-
self.session.headers.update(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
self.session.headers.update(
|
|
48
|
+
{
|
|
49
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
50
|
+
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
|
|
51
|
+
"sec-ch-ua-mobile": "?0",
|
|
52
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
40
56
|
self.is_authenticated = False
|
|
41
|
-
self.apis_base_url =
|
|
42
|
-
|
|
57
|
+
self.apis_base_url = (
|
|
58
|
+
"https://apis.vhi.ie" # Will be updated during discovery if necessary
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _get_cache_path(self) -> Path:
|
|
62
|
+
user_hash = hashlib.md5(self.username.encode("utf-8")).hexdigest()
|
|
63
|
+
cache_dir = Path.home() / ".vhi"
|
|
64
|
+
cache_dir.mkdir(exist_ok=True)
|
|
65
|
+
return cache_dir / f"session_{user_hash}.pkl"
|
|
66
|
+
|
|
67
|
+
def _save_session(self):
|
|
68
|
+
if not self.cache_session:
|
|
69
|
+
return
|
|
70
|
+
with open(self._get_cache_path(), "wb") as f:
|
|
71
|
+
pickle.dump(self.session.cookies, f)
|
|
72
|
+
|
|
73
|
+
def _load_session(self) -> bool:
|
|
74
|
+
if not self.cache_session:
|
|
75
|
+
return False
|
|
76
|
+
cache_path = self._get_cache_path()
|
|
77
|
+
if cache_path.exists():
|
|
78
|
+
try:
|
|
79
|
+
with open(cache_path, "rb") as f:
|
|
80
|
+
self.session.cookies = pickle.load(f)
|
|
81
|
+
return True
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def _is_session_valid(self) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
url = f"{self.apis_base_url}/claims/v1/statements"
|
|
89
|
+
headers = {
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
"Origin": "https://app.vhi.ie",
|
|
92
|
+
"Referer": "https://app.vhi.ie/",
|
|
93
|
+
"Sec-Fetch-Mode": "cors",
|
|
94
|
+
"Sec-Fetch-Site": "same-site",
|
|
95
|
+
}
|
|
96
|
+
# Only test to see if 401 is returned
|
|
97
|
+
response = self.session.get(url, headers=headers, timeout=5)
|
|
98
|
+
return response.status_code == 200
|
|
99
|
+
except Exception:
|
|
100
|
+
return False
|
|
101
|
+
|
|
43
102
|
def _fetch_environment_config(self):
|
|
44
103
|
"""
|
|
45
104
|
Optional: Dynamically discovers the API base URL.
|
|
@@ -56,6 +115,13 @@ class VhiClient:
|
|
|
56
115
|
"""
|
|
57
116
|
Performs the login flow. Handles the MFA challenge if required.
|
|
58
117
|
"""
|
|
118
|
+
if self._load_session():
|
|
119
|
+
if self._is_session_valid():
|
|
120
|
+
self.is_authenticated = True
|
|
121
|
+
return
|
|
122
|
+
else:
|
|
123
|
+
self.session.cookies.clear()
|
|
124
|
+
|
|
59
125
|
# Set proper fetch headers mimicking trace exactly
|
|
60
126
|
headers = {
|
|
61
127
|
"accept": "application/json, text/plain, */*",
|
|
@@ -70,96 +136,129 @@ class VhiClient:
|
|
|
70
136
|
"sec-fetch-dest": "empty",
|
|
71
137
|
"sec-fetch-mode": "cors",
|
|
72
138
|
"sec-fetch-site": "same-site",
|
|
73
|
-
"user-session-id": str(uuid.uuid4())
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
payload = {
|
|
77
|
-
"username": self.username,
|
|
78
|
-
"usercred": self.password
|
|
139
|
+
"user-session-id": str(uuid.uuid4()),
|
|
79
140
|
}
|
|
80
|
-
|
|
141
|
+
|
|
142
|
+
payload = {"username": self.username, "usercred": self.password}
|
|
143
|
+
|
|
81
144
|
# Format strictly without spaces to bypass strict WAF parsers
|
|
82
|
-
raw_payload = json.dumps(payload, separators=(
|
|
83
|
-
|
|
145
|
+
raw_payload = json.dumps(payload, separators=(",", ":"))
|
|
146
|
+
|
|
84
147
|
# 1. Primary Authentication
|
|
85
148
|
login_url = f"{self.apis_base_url}/api/myvhilogin/login"
|
|
86
|
-
|
|
149
|
+
|
|
87
150
|
response = self.session.post(login_url, data=raw_payload, headers=headers)
|
|
88
|
-
|
|
151
|
+
|
|
89
152
|
if response.status_code == 200:
|
|
90
153
|
try:
|
|
91
154
|
data = response.json()
|
|
92
155
|
except ValueError:
|
|
93
|
-
raise VhiApiError(
|
|
94
|
-
|
|
156
|
+
raise VhiApiError(
|
|
157
|
+
"Login response was not valid JSON",
|
|
158
|
+
status_code=response.status_code,
|
|
159
|
+
)
|
|
160
|
+
|
|
95
161
|
# The HAR trace uses a deeply nested response structure or a flattened one depending on Gateway
|
|
96
162
|
status_val = data.get("status") or data.get("data", {}).get("status")
|
|
97
|
-
|
|
163
|
+
|
|
98
164
|
if status_val == "MFA_REQUIRED":
|
|
99
|
-
state_token = data.get("stateToken") or data.get("data", {}).get(
|
|
100
|
-
|
|
165
|
+
state_token = data.get("stateToken") or data.get("data", {}).get(
|
|
166
|
+
"stateToken"
|
|
167
|
+
)
|
|
168
|
+
|
|
101
169
|
# Attempt to extract the verify url from the first factor
|
|
102
170
|
try:
|
|
103
|
-
factors = data.get("factors") or data.get("data", {}).get(
|
|
171
|
+
factors = data.get("factors") or data.get("data", {}).get(
|
|
172
|
+
"_embedded", {}
|
|
173
|
+
).get("factors", [])
|
|
104
174
|
verify_url = factors[0]["_links"]["verify"]["href"]
|
|
105
175
|
except (IndexError, KeyError):
|
|
106
176
|
# Fallback URL if extraction fails
|
|
107
|
-
verify_url =
|
|
108
|
-
|
|
177
|
+
verify_url = (
|
|
178
|
+
"https://admin-digital.vhi.ie/api/v1/authn/factors/verify"
|
|
179
|
+
)
|
|
180
|
+
|
|
109
181
|
if not state_token:
|
|
110
|
-
raise VhiApiError(
|
|
111
|
-
|
|
182
|
+
raise VhiApiError(
|
|
183
|
+
"MFA required but no state_token provided in response"
|
|
184
|
+
)
|
|
185
|
+
|
|
112
186
|
self._handle_mfa_challenge(state_token, verify_url, headers)
|
|
113
187
|
else:
|
|
114
188
|
self.is_authenticated = True
|
|
189
|
+
self._save_session()
|
|
115
190
|
return
|
|
116
|
-
|
|
191
|
+
|
|
117
192
|
elif response.status_code == 401:
|
|
118
193
|
raise VhiAuthenticationError("Invalid credentials provided.")
|
|
119
194
|
else:
|
|
120
|
-
raise VhiApiError(
|
|
121
|
-
|
|
122
|
-
|
|
195
|
+
raise VhiApiError(
|
|
196
|
+
f"Login failed with status {response.status_code}: {response.text}",
|
|
197
|
+
status_code=response.status_code,
|
|
198
|
+
response=response,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _handle_mfa_challenge(
|
|
202
|
+
self, state_token: str, verify_url: str, base_headers: dict
|
|
203
|
+
):
|
|
123
204
|
"""
|
|
124
205
|
Internal method to submit the MFA code.
|
|
125
206
|
"""
|
|
126
207
|
if not self.mfa_callback:
|
|
127
|
-
raise VhiMfaRequiredError(
|
|
128
|
-
|
|
208
|
+
raise VhiMfaRequiredError(
|
|
209
|
+
"MFA is required but no mfa_callback was provided to VhiClient."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 1. Trigger the SMS / challenge
|
|
213
|
+
trigger_payload = {"stateToken": state_token}
|
|
214
|
+
trigger_raw_payload = json.dumps(trigger_payload, separators=(",", ":"))
|
|
215
|
+
trigger_response = self.session.post(
|
|
216
|
+
verify_url, data=trigger_raw_payload, headers=base_headers
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if trigger_response.status_code != 200:
|
|
220
|
+
raise VhiApiError(
|
|
221
|
+
f"Failed to trigger MFA challenge. Status: {trigger_response.status_code} - {trigger_response.text}"
|
|
222
|
+
)
|
|
223
|
+
|
|
129
224
|
# Pause execution and invoke the user-defined callback to get the OTP
|
|
130
225
|
otp_code = self.mfa_callback()
|
|
131
|
-
|
|
226
|
+
|
|
132
227
|
if not otp_code:
|
|
133
|
-
raise VhiAuthenticationError(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
response = self.session.post(verify_url,
|
|
141
|
-
|
|
228
|
+
raise VhiAuthenticationError(
|
|
229
|
+
"MFA callback did not return a valid OTP code."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
payload = {"passCode": otp_code, "stateToken": state_token}
|
|
233
|
+
|
|
234
|
+
raw_payload = json.dumps(payload, separators=(",", ":"))
|
|
235
|
+
response = self.session.post(verify_url, data=raw_payload, headers=base_headers)
|
|
236
|
+
|
|
142
237
|
if response.status_code == 200:
|
|
143
238
|
self.is_authenticated = True
|
|
144
|
-
|
|
239
|
+
self._save_session()
|
|
145
240
|
# The session cookie jar has now been populated with the authorized session
|
|
146
241
|
# Or we might need to extract a Bearer token. This implementation relies
|
|
147
242
|
# on cookies being correctly set via Set-Cookie headers by the API.
|
|
148
243
|
else:
|
|
149
|
-
raise VhiAuthenticationError(
|
|
244
|
+
raise VhiAuthenticationError(
|
|
245
|
+
f"MFA verification failed. Status: {response.status_code} - {response.text}"
|
|
246
|
+
)
|
|
150
247
|
|
|
151
248
|
def get_claims(self) -> List[ClaimStatement]:
|
|
152
249
|
"""
|
|
153
250
|
Retrieves the list of claim statements.
|
|
154
|
-
|
|
251
|
+
|
|
155
252
|
Returns:
|
|
156
253
|
List of ClaimStatement objects.
|
|
157
254
|
"""
|
|
158
255
|
if not self.is_authenticated:
|
|
159
|
-
raise VhiAuthenticationError(
|
|
160
|
-
|
|
256
|
+
raise VhiAuthenticationError(
|
|
257
|
+
"Client is not authenticated. Call login() first."
|
|
258
|
+
)
|
|
259
|
+
|
|
161
260
|
url = f"{self.apis_base_url}/claims/v1/statements"
|
|
162
|
-
|
|
261
|
+
|
|
163
262
|
headers = {
|
|
164
263
|
"Accept": "application/json",
|
|
165
264
|
"Origin": "https://www.vhi.ie",
|
|
@@ -167,36 +266,45 @@ class VhiClient:
|
|
|
167
266
|
"Sec-Fetch-Mode": "cors",
|
|
168
267
|
"Sec-Fetch-Site": "same-site",
|
|
169
268
|
}
|
|
170
|
-
|
|
269
|
+
|
|
171
270
|
response = self.session.get(url, headers=headers)
|
|
172
|
-
|
|
271
|
+
|
|
173
272
|
if response.status_code == 200:
|
|
174
273
|
try:
|
|
175
274
|
data = response.json()
|
|
176
275
|
except ValueError:
|
|
177
|
-
raise VhiApiError(
|
|
178
|
-
|
|
276
|
+
raise VhiApiError(
|
|
277
|
+
"Invalid JSON received for claims statements.",
|
|
278
|
+
status_code=response.status_code,
|
|
279
|
+
)
|
|
280
|
+
|
|
179
281
|
claims_data = data.get("claims", [])
|
|
180
282
|
return [ClaimStatement.from_dict(claim) for claim in claims_data]
|
|
181
283
|
elif response.status_code == 401:
|
|
182
284
|
self.is_authenticated = False
|
|
183
285
|
raise VhiAuthenticationError("Session expired or unauthorized.")
|
|
184
286
|
else:
|
|
185
|
-
raise VhiApiError(
|
|
186
|
-
|
|
287
|
+
raise VhiApiError(
|
|
288
|
+
f"Failed to fetch claims: {response.status_code}",
|
|
289
|
+
status_code=response.status_code,
|
|
290
|
+
response=response,
|
|
291
|
+
)
|
|
292
|
+
|
|
187
293
|
def download_document(self, document_id: str, dest_path: str):
|
|
188
294
|
"""
|
|
189
295
|
Streams a claim PDF document to the local disk.
|
|
190
|
-
|
|
296
|
+
|
|
191
297
|
Args:
|
|
192
298
|
document_id: The ID of the document to download.
|
|
193
299
|
dest_path: The file path where the PDF should be saved.
|
|
194
300
|
"""
|
|
195
301
|
if not self.is_authenticated:
|
|
196
|
-
raise VhiAuthenticationError(
|
|
197
|
-
|
|
302
|
+
raise VhiAuthenticationError(
|
|
303
|
+
"Client is not authenticated. Call login() first."
|
|
304
|
+
)
|
|
305
|
+
|
|
198
306
|
url = f"{self.apis_base_url}/documents/v1/{document_id}/download"
|
|
199
|
-
|
|
307
|
+
|
|
200
308
|
headers = {
|
|
201
309
|
"Accept": "application/pdf",
|
|
202
310
|
"Origin": "https://www.vhi.ie",
|
|
@@ -204,16 +312,22 @@ class VhiClient:
|
|
|
204
312
|
"Sec-Fetch-Mode": "cors",
|
|
205
313
|
"Sec-Fetch-Site": "same-site",
|
|
206
314
|
}
|
|
207
|
-
|
|
315
|
+
|
|
208
316
|
# Use stream=True to prevent loading large PDFs into memory
|
|
209
317
|
with self.session.get(url, headers=headers, stream=True) as response:
|
|
210
318
|
if response.status_code == 200:
|
|
211
|
-
with open(dest_path,
|
|
319
|
+
with open(dest_path, "wb") as f:
|
|
212
320
|
for chunk in response.iter_content(chunk_size=8192):
|
|
213
321
|
if chunk:
|
|
214
322
|
f.write(chunk)
|
|
215
323
|
elif response.status_code == 401:
|
|
216
324
|
self.is_authenticated = False
|
|
217
|
-
raise VhiAuthenticationError(
|
|
325
|
+
raise VhiAuthenticationError(
|
|
326
|
+
"Session expired or unauthorized while downloading document."
|
|
327
|
+
)
|
|
218
328
|
else:
|
|
219
|
-
raise VhiApiError(
|
|
329
|
+
raise VhiApiError(
|
|
330
|
+
f"Failed to download document {document_id}: {response.status_code}",
|
|
331
|
+
status_code=response.status_code,
|
|
332
|
+
response=response,
|
|
333
|
+
)
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
class VhiError(Exception):
|
|
2
2
|
"""Base exception for Vhi Python Client"""
|
|
3
|
+
|
|
3
4
|
pass
|
|
4
5
|
|
|
6
|
+
|
|
5
7
|
class VhiAuthenticationError(VhiError):
|
|
6
8
|
"""Raised when authentication fails (invalid credentials)"""
|
|
9
|
+
|
|
7
10
|
pass
|
|
8
11
|
|
|
12
|
+
|
|
9
13
|
class VhiMfaRequiredError(VhiError):
|
|
10
14
|
"""Raised when MFA is required but no callback is provided or callback fails"""
|
|
15
|
+
|
|
11
16
|
pass
|
|
12
17
|
|
|
18
|
+
|
|
13
19
|
class VhiApiError(VhiError):
|
|
14
20
|
"""Raised when the Vhi API returns an error response"""
|
|
21
|
+
|
|
15
22
|
def __init__(self, message, status_code=None, response=None):
|
|
16
23
|
super().__init__(message)
|
|
17
24
|
self.status_code = status_code
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
|
|
4
4
|
@dataclass
|
|
5
5
|
class ClaimStatement:
|
|
@@ -8,7 +8,7 @@ class ClaimStatement:
|
|
|
8
8
|
practitioner: str
|
|
9
9
|
document_id: str
|
|
10
10
|
status: str
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
@classmethod
|
|
13
13
|
def from_dict(cls, data: dict) -> "ClaimStatement":
|
|
14
14
|
return cls(
|
|
@@ -16,5 +16,5 @@ class ClaimStatement:
|
|
|
16
16
|
date_of_service=data.get("dateOfService", ""),
|
|
17
17
|
practitioner=data.get("practitioner", ""),
|
|
18
18
|
document_id=data.get("documentId", ""),
|
|
19
|
-
status=data.get("status", "")
|
|
19
|
+
status=data.get("status", ""),
|
|
20
20
|
)
|
|
@@ -3,70 +3,90 @@ import responses
|
|
|
3
3
|
import os
|
|
4
4
|
import tempfile
|
|
5
5
|
from vhi.client import VhiClient
|
|
6
|
-
from vhi.exceptions import VhiAuthenticationError
|
|
6
|
+
from vhi.exceptions import VhiAuthenticationError
|
|
7
|
+
|
|
7
8
|
|
|
8
9
|
@responses.activate
|
|
9
10
|
def test_login_success_no_mfa():
|
|
10
|
-
client = VhiClient("test@example.com", "password")
|
|
11
|
-
|
|
11
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
12
|
+
|
|
12
13
|
responses.add(
|
|
13
14
|
responses.POST,
|
|
14
15
|
"https://apis.vhi.ie/api/myvhilogin/login",
|
|
15
16
|
json={"status": "SUCCESS", "sessionToken": "test-token"},
|
|
16
|
-
status=200
|
|
17
|
+
status=200,
|
|
17
18
|
)
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
client.login()
|
|
20
21
|
assert client.is_authenticated is True
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
@responses.activate
|
|
23
25
|
def test_login_requires_mfa_success():
|
|
24
26
|
def mfa_callback():
|
|
25
27
|
return "123456"
|
|
26
|
-
|
|
27
|
-
client = VhiClient(
|
|
28
|
-
|
|
28
|
+
|
|
29
|
+
client = VhiClient(
|
|
30
|
+
"test@example.com", "password", mfa_callback=mfa_callback, cache_session=False
|
|
31
|
+
)
|
|
32
|
+
|
|
29
33
|
# Mock primary login returning 200 with MFA_REQUIRED
|
|
30
34
|
responses.add(
|
|
31
35
|
responses.POST,
|
|
32
36
|
"https://apis.vhi.ie/api/myvhilogin/login",
|
|
33
37
|
json={
|
|
34
|
-
"status": "MFA_REQUIRED",
|
|
38
|
+
"status": "MFA_REQUIRED",
|
|
35
39
|
"stateToken": "state-123",
|
|
36
|
-
"factors": [
|
|
40
|
+
"factors": [
|
|
41
|
+
{
|
|
42
|
+
"_links": {
|
|
43
|
+
"verify": {
|
|
44
|
+
"href": "https://admin-digital.vhi.ie/api/v1/authn/factors/verify"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
],
|
|
37
49
|
},
|
|
38
|
-
status=200
|
|
50
|
+
status=200,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Mock MFA trigger returning 200 CHALLENGE
|
|
54
|
+
responses.add(
|
|
55
|
+
responses.POST,
|
|
56
|
+
"https://admin-digital.vhi.ie/api/v1/authn/factors/verify",
|
|
57
|
+
json={"status": "MFA_CHALLENGE"},
|
|
58
|
+
status=200,
|
|
39
59
|
)
|
|
40
|
-
|
|
60
|
+
|
|
41
61
|
# Mock MFA verify returning 200 SUCCESS
|
|
42
62
|
responses.add(
|
|
43
63
|
responses.POST,
|
|
44
64
|
"https://admin-digital.vhi.ie/api/v1/authn/factors/verify",
|
|
45
65
|
json={"status": "SUCCESS", "sessionToken": "final-token"},
|
|
46
|
-
status=200
|
|
66
|
+
status=200,
|
|
47
67
|
)
|
|
48
|
-
|
|
68
|
+
|
|
49
69
|
client.login()
|
|
50
70
|
assert client.is_authenticated is True
|
|
51
71
|
|
|
72
|
+
|
|
52
73
|
@responses.activate
|
|
53
74
|
def test_login_invalid_credentials():
|
|
54
|
-
client = VhiClient("test@example.com", "wrong")
|
|
55
|
-
|
|
75
|
+
client = VhiClient("test@example.com", "wrong", cache_session=False)
|
|
76
|
+
|
|
56
77
|
responses.add(
|
|
57
|
-
responses.POST,
|
|
58
|
-
"https://apis.vhi.ie/api/myvhilogin/login",
|
|
59
|
-
status=401
|
|
78
|
+
responses.POST, "https://apis.vhi.ie/api/myvhilogin/login", status=401
|
|
60
79
|
)
|
|
61
|
-
|
|
80
|
+
|
|
62
81
|
with pytest.raises(VhiAuthenticationError):
|
|
63
82
|
client.login()
|
|
64
83
|
|
|
84
|
+
|
|
65
85
|
@responses.activate
|
|
66
86
|
def test_get_claims_success():
|
|
67
|
-
client = VhiClient("test@example.com", "password")
|
|
68
|
-
client.is_authenticated = True
|
|
69
|
-
|
|
87
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
88
|
+
client.is_authenticated = True # Mock existing session
|
|
89
|
+
|
|
70
90
|
responses.add(
|
|
71
91
|
responses.GET,
|
|
72
92
|
"https://apis.vhi.ie/claims/v1/statements",
|
|
@@ -77,36 +97,37 @@ def test_get_claims_success():
|
|
|
77
97
|
"dateOfService": "2026-03-15",
|
|
78
98
|
"practitioner": "Dr. Smith",
|
|
79
99
|
"documentId": "DOC-123",
|
|
80
|
-
"status": "Processed"
|
|
100
|
+
"status": "Processed",
|
|
81
101
|
}
|
|
82
102
|
]
|
|
83
103
|
},
|
|
84
|
-
status=200
|
|
104
|
+
status=200,
|
|
85
105
|
)
|
|
86
|
-
|
|
106
|
+
|
|
87
107
|
claims = client.get_claims()
|
|
88
108
|
assert len(claims) == 1
|
|
89
109
|
assert claims[0].claim_id == "CLM-123"
|
|
90
110
|
assert claims[0].practitioner == "Dr. Smith"
|
|
91
111
|
|
|
112
|
+
|
|
92
113
|
@responses.activate
|
|
93
114
|
def test_download_document_success():
|
|
94
|
-
client = VhiClient("test@example.com", "password")
|
|
115
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
95
116
|
client.is_authenticated = True
|
|
96
|
-
|
|
117
|
+
|
|
97
118
|
pdf_content = b"%PDF-1.4 mock pdf content"
|
|
98
|
-
|
|
119
|
+
|
|
99
120
|
responses.add(
|
|
100
121
|
responses.GET,
|
|
101
122
|
"https://apis.vhi.ie/documents/v1/DOC-123/download",
|
|
102
123
|
body=pdf_content,
|
|
103
124
|
status=200,
|
|
104
|
-
content_type="application/pdf"
|
|
125
|
+
content_type="application/pdf",
|
|
105
126
|
)
|
|
106
|
-
|
|
127
|
+
|
|
107
128
|
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
108
129
|
dest_path = tmp.name
|
|
109
|
-
|
|
130
|
+
|
|
110
131
|
try:
|
|
111
132
|
client.download_document("DOC-123", dest_path)
|
|
112
133
|
with open(dest_path, "rb") as f:
|
|
@@ -3,9 +3,10 @@ import pytest
|
|
|
3
3
|
from vhi.client import VhiClient
|
|
4
4
|
from vhi.exceptions import VhiMfaRequiredError, VhiAuthenticationError
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
@pytest.mark.skipif(
|
|
7
8
|
not os.getenv("VHI_USERNAME") or not os.getenv("VHI_PASSWORD"),
|
|
8
|
-
reason="Integration tests require VHI_USERNAME and VHI_PASSWORD environment variables"
|
|
9
|
+
reason="Integration tests require VHI_USERNAME and VHI_PASSWORD environment variables",
|
|
9
10
|
)
|
|
10
11
|
def test_live_login():
|
|
11
12
|
"""
|
|
@@ -14,11 +15,11 @@ def test_live_login():
|
|
|
14
15
|
"""
|
|
15
16
|
username = os.environ["VHI_USERNAME"]
|
|
16
17
|
password = os.environ["VHI_PASSWORD"]
|
|
17
|
-
|
|
18
|
+
|
|
18
19
|
# We initialize without an mfa_callback to ensure it raises VhiMfaRequiredError
|
|
19
20
|
# if MFA is prompted, which validates the primary credentials were correct.
|
|
20
21
|
client = VhiClient(username, password)
|
|
21
|
-
|
|
22
|
+
|
|
22
23
|
try:
|
|
23
24
|
client.login()
|
|
24
25
|
# If it succeeds without MFA, we are authenticated.
|
|
@@ -28,4 +29,4 @@ def test_live_login():
|
|
|
28
29
|
# This confirms our primary login endpoint and payload reverse-engineering is correct.
|
|
29
30
|
pass
|
|
30
31
|
except VhiAuthenticationError as e:
|
|
31
|
-
|
|
32
|
+
pytest.fail(f"Integration login failed - incorrect credentials: {e}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|