feat: handle Garmin MFA and activity retries
This commit is contained in:
139
tests/web/test_mfa.py
Normal file
139
tests/web/test_mfa.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.models import Activity, ActivityStatus
|
||||
|
||||
|
||||
def test_mfa_code_is_used_once_and_not_persisted(authenticated_client, fake_sync_manager, app, caplog) -> None:
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": "123456"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.mfa_calls == [(1, "123456")]
|
||||
|
||||
# Defense-in-depth: the MFA code must never end up written to the real
|
||||
# app database (the one authenticated_client's requests actually hit),
|
||||
# not some unrelated in-memory db.
|
||||
with app.state.session_factory() as session:
|
||||
persisted_text = " ".join(str(row) for row in session.execute(text("select * from sync_runs")).all())
|
||||
assert "123456" not in persisted_text
|
||||
|
||||
# The check that actually matters: the code must never be logged,
|
||||
# regardless of which SyncManager implementation is in play.
|
||||
assert "123456" not in caplog.text
|
||||
|
||||
|
||||
def test_mfa_code_rejects_empty_code(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": " "},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert fake_sync_manager.mfa_calls == []
|
||||
|
||||
|
||||
def test_mfa_code_rejects_overlong_code(authenticated_client, fake_sync_manager) -> None:
|
||||
response = authenticated_client.post(
|
||||
"/users/1/garmin-mfa",
|
||||
data={"csrf_token": authenticated_client.csrf_token, "code": "1" * 21},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert fake_sync_manager.mfa_calls == []
|
||||
|
||||
|
||||
def _seed_activity(app, *, status: ActivityStatus, last_completed_stage: ActivityStatus, retryable: bool, last_error: str | None = None) -> tuple[int, int]:
|
||||
"""Seed a real Activity (and its owning user) in the app fixture's actual
|
||||
database -- the same database authenticated_client's HTTP requests hit --
|
||||
and return (user_id, activity_id)."""
|
||||
from app.db.repositories import ActivityRepository, UserRepository
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
user = UserRepository(session).create(
|
||||
name="MFA Test User",
|
||||
enabled=True,
|
||||
mywhoosh_email_enc="mw@example.com",
|
||||
mywhoosh_password_enc="mw-pass",
|
||||
garmin_email_enc="garmin@example.com",
|
||||
garmin_password_enc="garmin-pass",
|
||||
)
|
||||
activity_repo = ActivityRepository(session)
|
||||
activity, _ = activity_repo.get_or_create_discovered(
|
||||
user_id=user.id,
|
||||
mywhoosh_activity_id="mw-activity-1",
|
||||
activity_name="Test Activity",
|
||||
activity_timestamp=None,
|
||||
)
|
||||
activity.status = status
|
||||
activity.last_completed_stage = last_completed_stage
|
||||
activity.retryable = retryable
|
||||
if last_error is not None:
|
||||
activity.last_error = last_error
|
||||
session.commit()
|
||||
return user.id, activity.id
|
||||
|
||||
|
||||
def test_retry_resets_and_calls_sync_for_retryable_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.FAILED,
|
||||
last_completed_stage=ActivityStatus.CONVERTED,
|
||||
retryable=True,
|
||||
last_error="some transient error",
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_sync_manager.user_calls == [user_id]
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
reloaded = session.get(Activity, activity_id)
|
||||
assert reloaded.status == ActivityStatus.CONVERTED
|
||||
assert reloaded.last_error is None
|
||||
|
||||
|
||||
def test_retry_rejects_non_retryable_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.FAILED,
|
||||
last_completed_stage=ActivityStatus.CONVERTED,
|
||||
retryable=False,
|
||||
last_error="permanent failure",
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert fake_sync_manager.user_calls == []
|
||||
|
||||
with app.state.session_factory() as session:
|
||||
reloaded = session.get(Activity, activity_id)
|
||||
assert reloaded.status == ActivityStatus.FAILED
|
||||
assert reloaded.retryable is False
|
||||
assert reloaded.last_error == "permanent failure"
|
||||
|
||||
|
||||
def test_retry_rejects_non_failed_activity(authenticated_client, fake_sync_manager, app) -> None:
|
||||
user_id, activity_id = _seed_activity(
|
||||
app,
|
||||
status=ActivityStatus.DISCOVERED,
|
||||
last_completed_stage=ActivityStatus.DISCOVERED,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/activities/{activity_id}/retry",
|
||||
data={"csrf_token": authenticated_client.csrf_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert fake_sync_manager.user_calls == []
|
||||
Reference in New Issue
Block a user