Skip to content

Commit 5ca1e20

Browse files
authored
fix(cm8200): persist status cache across poll cycle, add re-auth validation (#168)
The CM8200 driver cached the status page during login() but consumed it on the first _fetch_status_page() call. In the real collector flow (login -> get_device_info -> get_docsis_data), get_device_info consumed the cache, forcing get_docsis_data to trigger a full re-authentication. This is problematic because the CM8200A is sensitive to repeated auth attempts and can enter a 5-minute brute-force lockout. Changes: - Keep status cache for the entire poll cycle instead of consuming it - Invalidate cache at the start of login() so each poll gets fresh data - Add token and login-page validation to _fetch_status_page() re-auth path (matching the existing checks in login()) - Cache successful re-auth results to avoid further round-trips - Add tests covering the real collector flow (single auth for full poll) and re-auth validation (token rejection, login page detection) Co-authored-by: itsDNNS <itsDNNS@users.noreply.github.com>
1 parent e59b511 commit 5ca1e20

File tree

2 files changed

+123
-10
lines changed

2 files changed

+123
-10
lines changed

app/drivers/cm8200.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def login(self) -> None:
9191
error. It returns the login page (HTTP 200, ~4170 bytes) instead
9292
of the status page when locked out.
9393
"""
94+
self._status_html = None
9495
creds = base64.b64encode(f"{self._user}:{self._password}".encode()).decode()
9596
for attempt in range(2):
9697
try:
@@ -189,14 +190,17 @@ def get_connection_info(self) -> dict:
189190
def _fetch_status_page(self) -> BeautifulSoup:
190191
"""Fetch and parse the status page HTML.
191192
192-
Reuses cached HTML from login if available (same page).
193-
Re-authenticates when the cache has been consumed.
193+
Reuses cached HTML from login for the entire poll cycle. The
194+
cache is refreshed on the next ``login()`` call, so both
195+
``get_device_info()`` and ``get_docsis_data()`` share the same
196+
snapshot without triggering a second auth round-trip.
197+
198+
If no cache is available (e.g. session expired mid-poll), falls
199+
back to a full re-auth with the same validation as ``login()``.
194200
"""
195201
if self._status_html:
196-
html = self._status_html
197-
self._status_html = None
198-
log.debug("CM8200 using cached status HTML (%d bytes)", len(html))
199-
return BeautifulSoup(html, "html.parser")
202+
log.debug("CM8200 using cached status HTML (%d bytes)", len(self._status_html))
203+
return BeautifulSoup(self._status_html, "html.parser")
200204

201205
creds = base64.b64encode(f"{self._user}:{self._password}".encode()).decode()
202206
try:
@@ -205,6 +209,14 @@ def _fetch_status_page(self) -> BeautifulSoup:
205209
timeout=30,
206210
)
207211
token = r1.text.strip()
212+
213+
if len(token) > 64 or not token.isalnum():
214+
raise RuntimeError(
215+
"CM8200 re-auth failed: expected session token but received "
216+
f"{len(token)} bytes (wrong credentials or brute-force "
217+
"lockout, wait 5 minutes)"
218+
)
219+
208220
self._cookie_header = f"HttpOnly: true, Secure: true; credential={token}"
209221

210222
r = self._session.get(
@@ -213,8 +225,17 @@ def _fetch_status_page(self) -> BeautifulSoup:
213225
timeout=30,
214226
)
215227
r.raise_for_status()
228+
229+
if len(r.text) < 5000 or "downstream bonded" not in r.text.lower():
230+
raise RuntimeError(
231+
"CM8200 re-auth succeeded but status page not returned "
232+
f"({len(r.text)} bytes). Modem may be in brute-force "
233+
"lockout (wait 5 minutes)."
234+
)
216235
except requests.RequestException as e:
217236
raise RuntimeError(f"CM8200 status page retrieval failed: {e}")
237+
238+
self._status_html = r.text
218239
log.debug("CM8200 status page fetched (%d bytes)", len(r.text))
219240
return BeautifulSoup(r.text, "html.parser")
220241

tests/test_cm8200_driver.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,12 +462,104 @@ def test_missing_tr_on_column_headers(self, driver):
462462
assert len(data["channelUs"]["docsis30"]) == 1
463463
assert data["channelUs"]["docsis30"][0]["channelID"] == 3
464464

465-
def test_status_html_cache_consumed(self, driver):
466-
"""Cached HTML from login is consumed on first fetch, then cleared."""
465+
def test_status_html_cache_persists_across_calls(self, driver):
466+
"""Cached HTML from login is reused across multiple fetches."""
467467
driver._status_html = SAMPLE_STATUS_HTML
468-
soup = driver._fetch_status_page()
468+
soup1 = driver._fetch_status_page()
469+
assert soup1.find("span", id="thisModelNumberIs") is not None
470+
assert driver._status_html is not None
471+
soup2 = driver._fetch_status_page()
472+
assert soup2.find("span", id="thisModelNumberIs") is not None
473+
474+
def test_login_clears_stale_cache(self, driver):
475+
"""login() invalidates old cache before authenticating."""
476+
driver._status_html = "<html>stale</html>"
477+
478+
token_response = MagicMock()
479+
token_response.raise_for_status = MagicMock()
480+
token_response.text = "freshtoken123"
481+
482+
status_response = MagicMock()
483+
status_response.raise_for_status = MagicMock()
484+
status_response.text = SAMPLE_STATUS_HTML
485+
486+
with patch.object(driver._session, "get", side_effect=[token_response, status_response]):
487+
driver.login()
488+
489+
assert driver._status_html == SAMPLE_STATUS_HTML
490+
491+
492+
# -- Collector flow (login -> device_info -> docsis_data) --
493+
494+
class TestCollectorFlow:
495+
def test_single_auth_for_full_poll(self, driver):
496+
"""Real collector calls login(), get_device_info(), get_docsis_data().
497+
498+
All three must work with a single auth round-trip. get_device_info()
499+
must not consume the cache so get_docsis_data() can reuse it.
500+
"""
501+
token_response = MagicMock()
502+
token_response.raise_for_status = MagicMock()
503+
token_response.text = "token123"
504+
505+
status_response = MagicMock()
506+
status_response.raise_for_status = MagicMock()
507+
status_response.text = SAMPLE_STATUS_HTML
508+
509+
with patch.object(driver._session, "get", side_effect=[token_response, status_response]) as mock_get:
510+
driver.login()
511+
info = driver.get_device_info()
512+
data = driver.get_docsis_data()
513+
514+
# Only 2 GETs: auth token + status page (both during login)
515+
assert mock_get.call_count == 2
516+
assert info["model"] == "CM8200A"
517+
assert len(data["channelDs"]["docsis30"]) == 32
518+
assert len(data["channelDs"]["docsis31"]) == 1
519+
520+
def test_fetch_reauth_validates_token(self, driver):
521+
"""_fetch_status_page re-auth rejects HTML instead of token."""
522+
driver._status_html = None
523+
driver._cookie_header = None
524+
525+
html_response = MagicMock()
526+
html_response.text = "<html><body>Login page</body></html>"
527+
528+
with patch.object(driver._session, "get", return_value=html_response):
529+
with pytest.raises(RuntimeError, match="re-auth failed"):
530+
driver._fetch_status_page()
531+
532+
def test_fetch_reauth_validates_status_page(self, driver):
533+
"""_fetch_status_page re-auth rejects small/login pages."""
534+
driver._status_html = None
535+
536+
token_response = MagicMock()
537+
token_response.text = "validtoken99"
538+
539+
login_page = MagicMock()
540+
login_page.raise_for_status = MagicMock()
541+
login_page.text = "<html>small login page</html>"
542+
543+
with patch.object(driver._session, "get", side_effect=[token_response, login_page]):
544+
with pytest.raises(RuntimeError, match="re-auth succeeded but status page not returned"):
545+
driver._fetch_status_page()
546+
547+
def test_fetch_reauth_caches_result(self, driver):
548+
"""Successful re-auth in _fetch_status_page caches the HTML."""
549+
driver._status_html = None
550+
551+
token_response = MagicMock()
552+
token_response.text = "reauth123"
553+
554+
status_response = MagicMock()
555+
status_response.raise_for_status = MagicMock()
556+
status_response.text = SAMPLE_STATUS_HTML
557+
558+
with patch.object(driver._session, "get", side_effect=[token_response, status_response]):
559+
soup = driver._fetch_status_page()
560+
561+
assert driver._status_html == SAMPLE_STATUS_HTML
469562
assert soup.find("span", id="thisModelNumberIs") is not None
470-
assert driver._status_html is None
471563

472564

473565
# -- Analyzer integration --

0 commit comments

Comments
 (0)