Skip to content

Commit adcff0a

Browse files
authored
fix broken Mfa flow because of param returnUrl rename (#21)
* fix broken MFA flow because of `returnUrl` rename Signed-off-by: rafsaf <rafal.safin12@gmail.com> * bump and fix selenium breaking changes Signed-off-by: rafsaf <rafal.safin12@gmail.com> --------- Signed-off-by: rafsaf <rafal.safin12@gmail.com>
1 parent 6aeeddd commit adcff0a

File tree

6 files changed

+366
-169
lines changed

6 files changed

+366
-169
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
MEDICOVER_USER=login
22
MEDICOVER_PASS=pass
3+
4+
LOG_LEVEL=INFO
5+
36
NOTIFIERS_PUSHBULLET_TOKEN=mytoken
47
GOTIFY_TOKEN=mytoken
58
GOTIFY_HOST=https://mygotify_server.com

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v5.0.0
3+
rev: v6.0.0
44
hooks:
55
- id: check-yaml
66

77
- repo: https://github.com/astral-sh/ruff-pre-commit
8-
rev: v0.14.5
8+
rev: v0.15.2
99
hooks:
1010
- id: ruff-format
1111

1212
- repo: https://github.com/astral-sh/ruff-pre-commit
13-
rev: v0.14.5
13+
rev: v0.15.2
1414
hooks:
1515
- id: ruff-check
1616
args: [--fix]

medichaser.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
from requests.adapters import HTTPAdapter
4848
from rich.console import Console
4949
from rich.logging import RichHandler
50-
from selenium import webdriver
50+
from selenium.webdriver.chrome.options import Options as ChromeOptions
51+
from selenium.webdriver.chrome.webdriver import WebDriver as ChromeDriver
5152
from selenium.webdriver.common.by import By
52-
from selenium.webdriver.remote.webdriver import WebDriver
5353
from selenium.webdriver.support import expected_conditions as EC
5454
from selenium.webdriver.support.ui import WebDriverWait
5555
from selenium_stealth import stealth
@@ -79,14 +79,17 @@
7979

8080
DEFAULT_SLOT_SEARCH_TYPE: int = 0
8181

82+
# Load environment variables
83+
load_dotenv()
84+
8285
token_lock = FileLock(TOKEN_LOCK_PATH, timeout=60)
8386
login_lock = FileLock(LOGIN_LOCK_PATH, timeout=60)
8487

8588
# Setup logging
8689
console = Console()
8790

8891
logging.basicConfig(
89-
level="INFO",
92+
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
9093
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
9194
handlers=[
9295
RotatingFileHandler(
@@ -98,9 +101,6 @@
98101

99102
log = logging.getLogger("medichaser")
100103

101-
# Load environment variables
102-
load_dotenv()
103-
104104
retry_strategy = Retry(
105105
total=10,
106106
backoff_factor=1,
@@ -152,7 +152,7 @@ def __init__(self, username: str, password: str) -> None:
152152
self.tokenA: str | None = None
153153
self.tokenR: str | None = None
154154
self.expires_at: int | None = None
155-
self.driver: WebDriver | None = None
155+
self.driver: ChromeDriver | None = None
156156

157157
def _get_or_create_device_id(self) -> str:
158158
"""Gets the device ID from storage or creates a new one."""
@@ -261,7 +261,9 @@ def login_requests(self) -> None: # pragma: no cover
261261
allow_redirects=False,
262262
)
263263
response.raise_for_status()
264-
next_url = response.headers["Location"]
264+
next_url = response.headers.get("Location")
265+
if not next_url:
266+
raise ValueError("Missing Location header in authorize response.")
265267
time.sleep(2)
266268

267269
response = self.session.get(
@@ -279,7 +281,10 @@ def login_requests(self) -> None: # pragma: no cover
279281
csrf_token = match.group(1)
280282
parsed_url = urlparse(response.url)
281283
query_params = parse_qs(parsed_url.query)
282-
return_url = query_params["ReturnUrl"][0]
284+
return_url_values = query_params.get("ReturnUrl")
285+
if not return_url_values:
286+
raise MFAError("ReturnUrl not found in login page query string.")
287+
return_url = return_url_values[0]
283288
# Step 2: POST credentials
284289
login_data = {
285290
"Input.ReturnUrl": return_url,
@@ -308,7 +313,9 @@ def login_requests(self) -> None: # pragma: no cover
308313

309314
# Step 3: Handle redirects and potential MFA
310315
while response.status_code == 302:
311-
redirect_url = response.headers["Location"]
316+
redirect_url = response.headers.get("Location")
317+
if not redirect_url:
318+
raise ValueError("Missing Location header in login redirect response.")
312319
if not redirect_url.startswith("https://"):
313320
redirect_url = MEDICOVER_LOGIN_URL + redirect_url
314321

@@ -399,7 +406,10 @@ def _handle_mfa(self, mfa_url: str) -> requests.Response: # pragma: no cover
399406

400407
parsed_url = urlparse(mfa_url)
401408
query_params = parse_qs(parsed_url.query)
402-
return_url = query_params["returnUrl"][0]
409+
return_url_values = query_params.get("ReturnUrl")
410+
if not return_url_values:
411+
raise MFAError("ReturnUrl not found in MFA URL query string.")
412+
return_url = return_url_values[0]
403413

404414
mfa_data = {
405415
"Input.MfaCodeId": mfa_code_id,
@@ -456,7 +466,8 @@ def _exchange_code_for_token(
456466

457467
data = response.json()
458468
if "error" in data:
459-
raise ValueError(f"Failed to get token: {data['error_description']}")
469+
error_description = data.get("error_description", data.get("error"))
470+
raise ValueError(f"Failed to get token: {error_description}")
460471

461472
expires_in = data.get("expires_in")
462473
expires_at = int(time.time()) + expires_in if expires_in else None
@@ -475,7 +486,7 @@ def _exchange_code_for_token(
475486
@tenacity.retry(
476487
stop=tenacity.stop_after_attempt(2),
477488
wait=tenacity.wait_fixed(10),
478-
retry=tenacity.retry_if_not_exception_type(MFAError),
489+
retry=tenacity.retry_if_not_exception_type((MFAError, ValueError)),
479490
reraise=True,
480491
)
481492
def login(self) -> None:
@@ -500,16 +511,16 @@ def login(self) -> None:
500511
else:
501512
self.login_requests()
502513

503-
def _init_driver(self) -> WebDriver:
504-
"""Initializes the Selenium WebDriver if it's not already running."""
514+
def _init_driver(self) -> ChromeDriver:
515+
"""Initializes the Selenium ChromeDriver if it's not already running."""
505516
if self.driver is None:
506-
options = webdriver.ChromeOptions()
517+
options = ChromeOptions()
507518
options.add_argument("--headless") # Run in headless mode
508519
options.add_argument("--disable-gpu")
509520
options.add_argument("--no-sandbox")
510521
options.add_argument("--disable-dev-shm-usage")
511522
options.add_argument(f"user-data-dir={DATA_PATH / 'chrome_profile'}")
512-
self.driver = webdriver.Chrome(options=options)
523+
self.driver = ChromeDriver(options=options)
513524
stealth(
514525
self.driver,
515526
languages=["pl-PL", "pl"],
@@ -533,7 +544,7 @@ def _init_driver(self) -> WebDriver:
533544
return self.driver
534545

535546
def _quit_driver(self) -> None:
536-
"""Quits the Selenium WebDriver if it's running."""
547+
"""Quits the Selenium ChromeDriver if it's running."""
537548
if self.driver:
538549
self.driver.quit()
539550
self.driver = None
@@ -574,7 +585,8 @@ def refresh_token(self) -> None:
574585

575586
data = response.json()
576587
if "error" in data:
577-
if data["error"] == "invalid_grant":
588+
error = data.get("error")
589+
if error == "invalid_grant":
578590
log.error(
579591
"Refresh token is invalid or expired. Deleting token file and re-authenticating."
580592
)
@@ -584,7 +596,7 @@ def refresh_token(self) -> None:
584596
"Invalid grant: refresh token is likely expired or revoked."
585597
)
586598
raise ValueError(
587-
f"Failed to refresh token: {response.status_code} {response.text}"
599+
f"Failed to refresh token: {response.status_code} {data.get('error_description', response.text)}"
588600
)
589601

590602
# manually set expires_at
@@ -833,7 +845,8 @@ def find_appointments(
833845
items = [
834846
x
835847
for x in items
836-
if datetime.datetime.fromisoformat(x["appointmentDate"]).date()
848+
if x.get("appointmentDate")
849+
and datetime.datetime.fromisoformat(x["appointmentDate"]).date()
837850
<= end_date
838851
]
839852

@@ -1222,8 +1235,8 @@ def main() -> None:
12221235
else:
12231236
filters = finder.find_filters()
12241237

1225-
for r in filters[args.filter_type]:
1226-
log.info(f"{r['id']} - {r['value']}")
1238+
for r in filters.get(args.filter_type, []):
1239+
log.info(f"{r.get('id', 'N/A')} - {r.get('value', 'N/A')}")
12271240

12281241

12291242
if __name__ == "__main__":

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ medichaser = "medichaser:medichaser"
2727
[dependency-groups]
2828
dev = [
2929
"mypy>=1.16.1,<2",
30+
"pre-commit>=4.5.1",
3031
"pytest-cov>=6.2.1,<7",
3132
"pytest-xdist>=3.8.0,<4",
3233
"pytest>=8.4.1,<9",

tests.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ def test_init_driver(self, monkeypatch: pytest.MonkeyPatch) -> None:
164164
"userAgent": "Chrome User-Agent"
165165
}
166166
mock_chrome = MagicMock(return_value=mock_chrome_instance)
167-
monkeypatch.setattr("selenium.webdriver.Chrome", mock_chrome)
167+
monkeypatch.setattr("medichaser.ChromeDriver", mock_chrome)
168168
mock_stealth = MagicMock()
169169
monkeypatch.setattr("medichaser.stealth", mock_stealth)
170170

@@ -322,15 +322,17 @@ def test_login_with_valid_stored_token(
322322
def test_login_with_refresh_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
323323
"""Test the main login orchestrator with a successful token refresh."""
324324
auth = Authenticator("user", "pass")
325-
# First call to load is False, second is True after refresh
326-
mock_load_token = Mock(side_effect=[False, True])
325+
mock_load_token = Mock(return_value=True)
327326
mock_refresh = Mock()
327+
mock_login_requests = Mock()
328328
monkeypatch.setattr(auth, "_load_token_from_storage", mock_load_token)
329329
monkeypatch.setattr(auth, "refresh_token", mock_refresh)
330+
monkeypatch.setattr(auth, "login_requests", mock_login_requests)
330331

331332
auth.login()
332-
assert mock_load_token.call_count == 2
333+
mock_load_token.assert_called_once()
333334
mock_refresh.assert_called_once()
335+
mock_login_requests.assert_not_called()
334336

335337
def test_login_fallback_to_selenium_no_load(
336338
self, monkeypatch: pytest.MonkeyPatch
@@ -395,6 +397,40 @@ def test_login_method_selenium(self, monkeypatch: pytest.MonkeyPatch) -> None:
395397

396398
mock_login_selenium.assert_called_once()
397399

400+
def test_login_no_retry_on_value_error(
401+
self, monkeypatch: pytest.MonkeyPatch
402+
) -> None:
403+
"""Test that login does not retry when login_requests raises ValueError."""
404+
auth = Authenticator("user", "pass")
405+
mock_load_token = Mock(return_value=False)
406+
mock_login_requests = Mock(side_effect=ValueError("bad parse"))
407+
408+
monkeypatch.setattr(auth, "_load_token_from_storage", mock_load_token)
409+
monkeypatch.setattr(auth, "login_requests", mock_login_requests)
410+
monkeypatch.delenv("SELENIUM_LOGIN", raising=False)
411+
412+
with pytest.raises(ValueError, match="bad parse"):
413+
auth.login()
414+
415+
mock_login_requests.assert_called_once()
416+
417+
def test_login_requests_missing_location_header_in_authorize(self) -> None:
418+
"""Test login_requests fails fast when authorize response misses Location."""
419+
auth = Authenticator("user", "pass")
420+
mock_session = Mock()
421+
mock_response = Mock()
422+
mock_response.raise_for_status = Mock()
423+
mock_response.headers = {}
424+
mock_session.get.return_value = mock_response
425+
auth.session = mock_session
426+
427+
with pytest.raises(
428+
ValueError, match="Missing Location header in authorize response."
429+
):
430+
auth.login_requests()
431+
432+
assert mock_session.get.call_count == 1
433+
398434

399435
class TestAppointmentFinder:
400436
"""Test cases for the AppointmentFinder class."""

0 commit comments

Comments
 (0)