Skip to content

Commit

Permalink
Add captcha for rest_of_world (#675)
Browse files Browse the repository at this point in the history
* Require captcha for rest_of_world

* Update docs

* Update tests for captcha

* More docs changes
  • Loading branch information
rikroe authored Nov 22, 2024
1 parent cd0643d commit 40ba148
Show file tree
Hide file tree
Showing 15 changed files with 321 additions and 97 deletions.
15 changes: 8 additions & 7 deletions bimmer_connected/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ async def _login_row_na(self):
async with MyBMWLoginClient(region=self.region, verify=self.verify) as client:
_LOGGER.debug("Authenticating with MyBMW flow for North America & Rest of World.")

if not self.hcaptcha_token:
raise MyBMWCaptchaMissingError(
"Missing hCaptcha token for login. See https://bimmer-connected.readthedocs.io/en/stable/captcha.html"
)

# Get OAuth2 settings from BMW API
r_oauth_settings = await client.get(
OAUTH_CONFIG_URL,
Expand Down Expand Up @@ -185,13 +190,9 @@ async def _login_row_na(self):
"code_challenge_method": "S256",
}

authenticate_headers = {}
if self.region == Regions.NORTH_AMERICA:
if not self.hcaptcha_token:
raise MyBMWCaptchaMissingError("Missing hCaptcha token for North America login")
authenticate_headers = {
"hcaptchatoken": self.hcaptcha_token,
}
authenticate_headers = {
"hcaptchatoken": self.hcaptcha_token,
}
# Call authenticate endpoint first time (with user/pw) and get authentication
try:
response = await client.post(
Expand Down
4 changes: 3 additions & 1 deletion bimmer_connected/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ def _add_default_arguments(parser: argparse.ArgumentParser):
parser.add_argument("username", help="Connected Drive username")
parser.add_argument("password", help="Connected Drive password")
parser.add_argument("region", choices=valid_regions(), help="Region of the Connected Drive account")
parser.add_argument("--captcha-token", type=str, nargs="?", help="Captcha token required for North America.")
parser.add_argument(
"--captcha-token", type=str, nargs="?", help="Captcha token required for North America and Rest of World."
)


def _add_position_arguments(parser: argparse.ArgumentParser):
Expand Down
1 change: 1 addition & 0 deletions bimmer_connected/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
TEST_PASSWORD = "my_secret"
TEST_REGION = Regions.REST_OF_WORLD
TEST_REGION_STRING = "rest_of_world"
TEST_CAPTCHA = "P1_eY..."

VIN_F31 = "WBA00000000000F31"
VIN_G01 = "WBA00000000DEMO04"
Expand Down
3 changes: 2 additions & 1 deletion bimmer_connected/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ALL_CHARGING_SETTINGS,
ALL_PROFILES,
ALL_STATES,
TEST_CAPTCHA,
TEST_PASSWORD,
TEST_REGION,
TEST_USERNAME,
Expand Down Expand Up @@ -55,6 +56,6 @@ def cli_home_dir(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.M

async def prepare_account_with_vehicles(region: Optional[Regions] = None):
"""Initialize account and get vehicles."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, region or TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, region or TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()
return account
71 changes: 41 additions & 30 deletions bimmer_connected/tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from . import (
RESPONSE_DIR,
TEST_CAPTCHA,
TEST_PASSWORD,
TEST_REGION,
TEST_REGION_STRING,
Expand All @@ -40,15 +41,17 @@
@pytest.mark.asyncio
async def test_login_row(bmw_fixture: respx.Router):
"""Test the login flow."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING))
account = MyBMWAccount(
TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING), hcaptcha_token=TEST_CAPTCHA
)
await account.get_vehicles()
assert account is not None


@pytest.mark.asyncio
async def test_login_na(bmw_fixture: respx.Router):
"""Test the login flow for North America."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA, hcaptcha_token="SOME_TOKEN")
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, Regions.NORTH_AMERICA, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()
assert account is not None

Expand All @@ -65,7 +68,9 @@ async def test_login_na_without_hcaptcha(bmw_fixture: respx.Router):
async def test_login_refresh_token_row_na_expired(bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
with mock.patch("bimmer_connected.api.authentication.EXPIRES_AT_OFFSET", datetime.timedelta(seconds=30000)):
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING))
account = MyBMWAccount(
TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING), hcaptcha_token=TEST_CAPTCHA
)
await account.get_vehicles()

with mock.patch(
Expand All @@ -84,7 +89,9 @@ async def test_login_refresh_token_row_na_expired(bmw_fixture: respx.Router):
async def test_login_refresh_token_row_na_401(bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING))
account = MyBMWAccount(
TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING), hcaptcha_token=TEST_CAPTCHA
)
await account.get_vehicles()

with mock.patch(
Expand All @@ -111,7 +118,9 @@ async def test_login_refresh_token_row_na_invalid(caplog, bmw_fixture: respx.Rou
]
)

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING))
account = MyBMWAccount(
TEST_USERNAME, TEST_PASSWORD, get_region_from_name(TEST_REGION_STRING), hcaptcha_token=TEST_CAPTCHA
)
account.set_refresh_token("INVALID")

caplog.set_level(logging.DEBUG)
Expand Down Expand Up @@ -211,7 +220,7 @@ async def test_vehicles(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_vehicle_init(bmw_fixture: respx.Router):
"""Test vehicle initialization."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
with mock.patch(
"bimmer_connected.account.MyBMWAccount._init_vehicles",
wraps=account._init_vehicles,
Expand Down Expand Up @@ -240,7 +249,7 @@ async def test_invalid_password(bmw_fixture: respx.Router):
401, json=load_response(RESPONSE_DIR / "auth" / "auth_error_wrong_password.json")
)
with pytest.raises(MyBMWAuthError):
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()


Expand All @@ -262,7 +271,7 @@ async def test_server_error(bmw_fixture: respx.Router):
500, text=load_response(RESPONSE_DIR / "auth" / "auth_error_internal_error.txt")
)
with pytest.raises(MyBMWAPIError):
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()


Expand Down Expand Up @@ -290,7 +299,7 @@ async def test_get_fingerprints(monkeypatch: pytest.MonkeyPatch, bmw_fixture: re
+ get_fingerprint_count("charging_settings")
)

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA, log_responses=True)
await account.get_vehicles()

# This should have been successful
Expand All @@ -310,7 +319,7 @@ async def test_get_fingerprints(monkeypatch: pytest.MonkeyPatch, bmw_fixture: re
)
bmw_fixture.routes.add(state_route, "state")

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA, log_responses=True)
await account.get_vehicles()

filenames = [Path(f.filename) for f in account.get_stored_responses()]
Expand Down Expand Up @@ -363,7 +372,7 @@ async def test_set_use_metric_units(caplog):
"""Test (deprecated) use_metrics_units flag."""

# Default
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
assert len(caplog.records) == 0
metric_client = MyBMWClient(account.config)
assert (
Expand Down Expand Up @@ -392,7 +401,7 @@ async def test_set_use_metric_units(caplog):
@pytest.mark.asyncio
async def test_refresh_token_getset(bmw_fixture: respx.Router):
"""Test getting/setting the refresh_token and gcid."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
assert account.refresh_token is None
await account.get_vehicles()
assert account.refresh_token == "another_token_string"
Expand All @@ -414,7 +423,7 @@ async def test_refresh_token_getset(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_ok_oauth_config(caplog, bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -441,7 +450,7 @@ async def test_429_retry_ok_oauth_config(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_raise_oauth_config(caplog, bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -462,7 +471,7 @@ async def test_429_retry_raise_oauth_config(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_ok_authenticate(caplog, bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand Down Expand Up @@ -490,7 +499,7 @@ async def test_429_retry_ok_authenticate(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_raise_authenticate(caplog, bmw_fixture: respx.Router):
"""Test the login flow using refresh_token."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -511,7 +520,7 @@ async def test_429_retry_raise_authenticate(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_ok_vehicles(caplog, bmw_fixture: respx.Router):
"""Test waiting on 429 for vehicles."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand Down Expand Up @@ -541,7 +550,7 @@ async def test_429_retry_ok_vehicles(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_raise_vehicles(caplog, bmw_fixture: respx.Router):
"""Test waiting on 429 for vehicles and fail if it happens too often."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -562,7 +571,7 @@ async def test_429_retry_raise_vehicles(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_with_login_ok_vehicles(bmw_fixture: respx.Router):
"""Test the login flow but experiencing a 429 first."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -584,7 +593,7 @@ async def test_429_retry_with_login_ok_vehicles(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_429_retry_with_login_raise_vehicles(bmw_fixture: respx.Router):
"""Test the error handling, experiencing a 429, 401 and another two 429."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -607,7 +616,7 @@ async def test_429_retry_with_login_raise_vehicles(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_multiple_401(bmw_fixture: respx.Router):
"""Test the error handling, when multiple 401 are received in sequence."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

bmw_fixture.post(VEHICLES_URL).mock(
side_effect=[
Expand All @@ -623,7 +632,7 @@ async def test_multiple_401(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_401_after_429_ok(bmw_fixture: respx.Router):
"""Test the error handling, when a 401 is received after exactly 3 429."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}
Expand All @@ -647,7 +656,7 @@ async def test_401_after_429_ok(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_401_after_429_fail(bmw_fixture: respx.Router):
"""Test the error handling, when a 401 is received after exactly 3 429."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)

json_429 = {"statusCode": 429, "message": "Rate limit is exceeded. Try again in 2 seconds."}

Expand All @@ -669,7 +678,7 @@ async def test_401_after_429_fail(bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_403_quota_exceeded_vehicles_usa(caplog, bmw_fixture: respx.Router):
"""Test 403 quota issues for vehicle state and fail if it happens too often."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
# get vehicles once
await account.get_vehicles()

Expand All @@ -691,7 +700,7 @@ async def test_403_quota_exceeded_vehicles_usa(caplog, bmw_fixture: respx.Router
@pytest.mark.asyncio
async def test_incomplete_vehicle_details(caplog, bmw_fixture: respx.Router):
"""Test incorrect responses for vehicle details."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
# get vehicles once
await account.get_vehicles()

Expand All @@ -717,7 +726,7 @@ async def test_incomplete_vehicle_details(caplog, bmw_fixture: respx.Router):
@pytest.mark.asyncio
async def test_no_vehicle_details(caplog, bmw_fixture: respx.Router):
"""Test raising an exception if no responses for vehicle details are received."""
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
await account.get_vehicles()

bmw_fixture.get("/eadrax-vcs/v4/vehicles/state").mock(
Expand All @@ -737,9 +746,9 @@ async def test_no_vehicle_details(caplog, bmw_fixture: respx.Router):
async def test_client_async_only(bmw_fixture: respx.Router):
"""Test that the Authentication providers only work async."""

with httpx.Client(auth=MyBMWAuthentication(TEST_USERNAME, TEST_PASSWORD, TEST_REGION)) as client, pytest.raises(
RuntimeError
):
with httpx.Client(
auth=MyBMWAuthentication(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA)
) as client, pytest.raises(RuntimeError):
client.get("/eadrax-ucs/v1/presentation/oauth/config")

with httpx.Client(auth=MyBMWLoginRetry()) as client, pytest.raises(RuntimeError):
Expand All @@ -763,7 +772,9 @@ async def test_pillow_unavailable(monkeypatch: pytest.MonkeyPatch, bmw_fixture:
assert len(account.vehicles) == 0

# But rest_of_world and north_america should work
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, get_region_from_name("rest_of_world"))
account = MyBMWAccount(
TEST_USERNAME, TEST_PASSWORD, get_region_from_name("rest_of_world"), hcaptcha_token=TEST_CAPTCHA
)
await account.get_vehicles()
assert account is not None
assert len(account.vehicles) > 0
5 changes: 3 additions & 2 deletions bimmer_connected/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from . import (
RESPONSE_DIR,
TEST_CAPTCHA,
TEST_PASSWORD,
TEST_REGION,
TEST_USERNAME,
Expand Down Expand Up @@ -75,7 +76,7 @@ async def test_storing_fingerprints(tmp_path, bmw_fixture: respx.Router, bmw_log
)
bmw_fixture.routes.add(state_route, "state")

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA, log_responses=True)
await account.get_vehicles()

log_response_store_to_file(account.get_stored_responses(), tmp_path)
Expand All @@ -100,7 +101,7 @@ async def test_fingerprint_deque(monkeypatch: pytest.MonkeyPatch, bmw_fixture: r
"""Test storing fingerprints to file."""
# Prepare Number of good responses

account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, log_responses=True)
account = MyBMWAccount(TEST_USERNAME, TEST_PASSWORD, TEST_REGION, hcaptcha_token=TEST_CAPTCHA, log_responses=True)
await account.get_vehicles()
await account.get_vehicles()

Expand Down
8 changes: 5 additions & 3 deletions bimmer_connected/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from . import RESPONSE_DIR, get_fingerprint_count, load_response

ARGS_USER_PW_REGION = ["myuser", "mypassword", "rest_of_world"]
ARGS_USER_PW_REGION = ["--captcha-token", "P1_eY...", "myuser", "mypassword", "rest_of_world"]
FIXTURE_CLI_HELP = "Connect to MyBMW/MINI API and interact with your vehicle."


Expand All @@ -22,7 +22,6 @@ def test_run_entrypoint():
result = subprocess.run(["bimmerconnected", "--help"], capture_output=True, text=True)

assert FIXTURE_CLI_HELP in result.stdout
assert VERSION in result.stdout
assert result.returncode == 0


Expand Down Expand Up @@ -323,4 +322,7 @@ def test_captcha_unavailable(capsys: pytest.CaptureFixture):
with contextlib.suppress(SystemExit):
bimmer_connected.cli.main()
result = capsys.readouterr()
assert result.err.strip() == "MyBMWCaptchaMissingError: Missing hCaptcha token for North America login"
assert (
result.err.strip()
== "MyBMWCaptchaMissingError: Missing hCaptcha token for login. See https://bimmer-connected.readthedocs.io/en/stable/captcha.html"
)
Loading

0 comments on commit 40ba148

Please sign in to comment.