diff --git a/fit_file_faker/app.py b/fit_file_faker/app.py index 454ca18..6e2be1c 100644 --- a/fit_file_faker/app.py +++ b/fit_file_faker/app.py @@ -209,7 +209,9 @@ def on_modified(self, event: FileModifiedEvent) -> None: source_file = Path(p).absolute() # Edit the file and upload it - with NamedTemporaryFile(delete=True, delete_on_close=False) as fp: + with NamedTemporaryFile( + delete=True, delete_on_close=False, suffix=".fit" + ) as fp: fit_editor.set_profile(self.profile) output = fit_editor.edit_fit(source_file, output=Path(fp.name)) if output: @@ -291,7 +293,20 @@ def upload( "Run with --config-menu to update the profile." ) - client = Garmin(email, password) + def _prompt_mfa() -> str: + _logger.info( + "Garmin Connect requires a multi-factor authentication code " + "(check your email or authenticator app)" + ) + return ( + questionary.text( + "MFA code:", + validate=lambda v: bool(v.strip()) or "MFA code cannot be empty", + ).ask() + or "" + ) + + client = Garmin(email, password, prompt_mfa=_prompt_mfa) _logger.info("Authenticating to Garmin Connect") client.login(tokenstore=str(token_dir)) @@ -379,7 +394,9 @@ def upload_all( _logger.info(f'Processing "{f}"') # type: ignore if not preinitialize: - with NamedTemporaryFile(delete=True, delete_on_close=False) as fp: + with NamedTemporaryFile( + delete=True, delete_on_close=False, suffix=".fit" + ) as fp: fit_editor.set_profile(profile) output = fit_editor.edit_fit(dir.joinpath(f), output=Path(fp.name)) if output: diff --git a/tests/test_app.py b/tests/test_app.py index 741141f..ad88d10 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -134,6 +134,30 @@ def test_upload_missing_password(self, tpv_fit_file, mock_garmin_client): with pytest.raises(ValueError, match="missing Garmin credentials"): upload(tpv_fit_file, profile=mock_profile, dryrun=False) + @patch("fit_file_faker.app.questionary") + def test_upload_prompt_mfa_callback_delegates_to_questionary( + self, mock_questionary, tpv_fit_file, mock_garmin_client, mock_valid_config + ): + """The prompt_mfa callback passed to Garmin() must ask the user via + questionary and return the entered code. + + Regression test: previously Garmin() was constructed without prompt_mfa, + so MFA-required logins failed with + "MFA Required but no prompt_mfa mechanism supplied". + """ + mock_questionary.text.return_value.ask.return_value = "123456" + mock_cls, _ = mock_garmin_client + mock_profile = mock_valid_config.config.get_default_profile() + + upload(tpv_fit_file, profile=mock_profile, dryrun=True) + + prompt_mfa = mock_cls.call_args.kwargs.get("prompt_mfa") + assert callable(prompt_mfa), ( + "Garmin() must be constructed with a prompt_mfa callback" + ) + assert prompt_mfa() == "123456" + mock_questionary.text.assert_called_once() + class TestUploadAllFunction: """Tests for batch upload functionality."""