From 2c7d252eb3885987fe1b72a4f040370afdb1a0f1 Mon Sep 17 00:00:00 2001 From: Joao Ferrete Date: Mon, 13 Jul 2026 10:08:05 -0300 Subject: [PATCH 1/3] fix: change google fit behavior on multi users --- README.md | 21 ++++- commands/fit.py | 106 ++++++++++++++++----- fit/auth.py | 86 ++++++++++++++--- fit/client.py | 6 +- locales/en.json | 17 +++- locales/pt_BR.json | 17 +++- tests/test_config_paths.py | 31 ++++++ tests/test_fit_auth_errors.py | 159 +++++++++++++++++++++++++++++++ tests/test_fit_setup_wizard.py | 166 +++++++++++++++++++++++++++++++++ 9 files changed, 566 insertions(+), 43 deletions(-) create mode 100644 tests/test_fit_auth_errors.py create mode 100644 tests/test_fit_setup_wizard.py diff --git a/README.md b/README.md index 84f77e9..13e9f61 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ reinstalls never touch your data: | Contents | Location | Override | |---|---|---| | Profiles, databases, exports | `~/.local/share/lifter/` | `$XDG_DATA_HOME` | -| `.env` (API keys), `fit_credentials.json` | `~/.config/lifter/` | `$XDG_CONFIG_HOME` | +| `.env` (API keys), `fit_credentials.json` (shared OAuth client) | `~/.config/lifter/` | `$XDG_CONFIG_HOME` | | Debug logs, chat history | `~/.local/state/lifter/` | `$XDG_STATE_HOME` | Setting `LIFTER_HOME=/some/dir` forces all three into a single directory @@ -316,9 +316,12 @@ Adds sleep, steps, calories, and heart rate data to your analytics and AI contex 4. **APIs & Services → OAuth consent screen** - User type: External - Fill in app name (anything, e.g. "lifter") - - Add your Gmail as a **Test user** → Save + - Add **every Google account you'll connect from any profile** as a **Test user** + (up to 100) → Save. An account that isn't listed gets **Error 403: access_denied** + in the browser when it tries to connect. 5. **APIs & Services → Credentials → Create Credentials → OAuth client ID** - - Application type: **Desktop app** + - Application type: **Desktop app** — ⚠ **not** "Web application"; a web client + cannot complete the local sign-in flow - Name: anything - Click Create 6. **Download JSON** → Google will download a file named something like @@ -332,12 +335,22 @@ In the menu: **Google Fit → Connect / re-authenticate** Lifter asks for the path to the downloaded JSON (first time only), then a browser window opens. Sign in with the Gmail you added as a test user and approve the fitness permissions. The token is saved per profile as `fit_token.json` and reused automatically. +If an OAuth client is already configured (e.g. by another profile), Connect shows which client/project is in use and lets you either reuse it — sign in with any account added as a Test user — or supply a different OAuth JSON just for this profile. + ### Step 3 — Sync **Google Fit → Sync health data → 30 days** After syncing, the recovery score appears in the header and the AI coach uses your sleep and HR data in all suggestions. +### Multiple profiles + +All profiles share the OAuth client saved at `~/.config/lifter/fit_credentials.json` by default — each profile just signs in with its own Google account (add every account as a Test user, step 1.4). To use a completely separate Google Cloud project for one profile, choose **"Use a different OAuth JSON"** during Connect; it is stored at `~/.local/share/lifter/profiles/{slug}/fit_credentials.json` and takes precedence over the shared one for that profile. + +### Token expires weekly + +While the Google Cloud project is in **Testing** publishing status, Google expires the connection roughly every **7 days**. Lifter detects this and asks you to reconnect — just run **Google Fit → Connect** again. To get rid of the weekly re-auth, publish the app in Cloud Console (**OAuth consent screen → Publish app**); Google shows an "unverified app" warning screen during sign-in, which is fine for personal use. + ### Samsung Health users Samsung Health syncs to Google Fit by default on Android. Enable it in the Samsung Health app under **Settings → Connected services → Google Fit**. @@ -643,7 +656,7 @@ Each profile stores its own settings that are set through the in-app menus: | `slug` | URL-safe identifier used as the directory name | | `hevy_api_key` | API key for this profile's Hevy account | -Profile databases live at `~/.local/share/lifter/profiles/{slug}/hevy.db` and Google Fit tokens at `~/.local/share/lifter/profiles/{slug}/fit_token.json`. +Profile databases live at `~/.local/share/lifter/profiles/{slug}/hevy.db` and Google Fit tokens at `~/.local/share/lifter/profiles/{slug}/fit_token.json`. A profile may also have its own `fit_credentials.json` in the same directory (optional — overrides the shared OAuth client in `~/.config/lifter/`). All in-app preferences are stored in the `user_preferences` table in each profile's database, not in `.env`. Notable keys: `units`, `auto_sync`, `sync_stale_hours`, `goals_checkin_days`, `default_stats_weeks`, `report_weeks`, `ui_language`, `ai_provider` / `ai_model` (per-profile override of `.env`), `ai_send_name` / `ai_send_body` (privacy), `ai_tokens_month_budget`, `memories_max`, `debug_logging`. diff --git a/commands/fit.py b/commands/fit.py index ae4857c..630fe63 100644 --- a/commands/fit.py +++ b/commands/fit.py @@ -108,32 +108,90 @@ def _do_fit() -> None: console.print(_("fit.disconnected")) +def _prompt_credentials_json(dest: Path, saved_key: str) -> bool: + """Ask for a downloaded client-secrets JSON, validate it and copy to dest. + + Returns True when a valid Desktop-app ("installed") JSON was saved.""" + raw = questionary.path(_("fit.credentials_path_prompt"), style=STYLE).ask() + if not raw or not raw.strip(): + return False + source = Path(raw.strip()).expanduser() + try: + payload = json.loads(source.read_text(encoding="utf-8")) + assert isinstance(payload, dict) and ("installed" in payload or "web" in payload) + except Exception: + console.print(_("fit.credentials_invalid")) + return False + if "installed" not in payload: + console.print(_("fit.credentials_web_client")) + return False + import shutil as _shutil + + import paths as _paths + + _paths.ensure_dirs() + _shutil.copy2(source, dest) + dest.chmod(0o600) + console.print(_(saved_key, path=_esc(str(dest)))) + return True + + def _fit_setup() -> None: - from fit.auth import credentials_file + import paths as _paths + from fit.auth import credentials_file, describe_client, profile_credentials_file console.rule(_("fit.connect_rule")) - console.print(_("fit.setup_instructions")) - if not credentials_file().exists(): - raw = questionary.path(_("fit.credentials_path_prompt"), style=STYLE).ask() - if not raw or not raw.strip(): - return - source = Path(raw.strip()).expanduser() - try: - payload = json.loads(source.read_text(encoding="utf-8")) - assert isinstance(payload, dict) and ("installed" in payload or "web" in payload) - except Exception: + creds_path = credentials_file() + client = describe_client(creds_path) if creds_path.exists() else None + + if client is None: + # First-ever setup (or unreadable file): full instructions + JSON prompt. + # An existing-but-broken file is replaced in place so resolution still finds it. + console.print(_("fit.setup_instructions")) + if creds_path.exists(): console.print(_("fit.credentials_invalid")) + dest = creds_path if creds_path.exists() else _paths.FIT_CREDENTIALS_FILE + if not _prompt_credentials_json(dest, "fit.credentials_saved"): return - import shutil as _shutil - - import paths as _paths - - _paths.ensure_dirs() - _shutil.copy2(source, _paths.FIT_CREDENTIALS_FILE) - _paths.FIT_CREDENTIALS_FILE.chmod(0o600) - console.print(_("fit.credentials_saved", path=_esc(str(_paths.FIT_CREDENTIALS_FILE)))) + elif client["type"] == "web": + # A web-type client can never complete the loopback flow for anyone — + # replace it in place with a proper Desktop-app JSON. + console.print(_("fit.credentials_web_client")) + console.print(_("fit.setup_instructions")) + if not _prompt_credentials_json(creds_path, "fit.credentials_saved"): + return + else: + console.print( + Panel( + _( + "fit.client_in_use", + client_id=_esc(client["client_id"]), + project_id=_esc(client["project_id"]), + path=_esc(str(creds_path)), + ), + border_style="cyan", + padding=(0, 2), + ) + ) + choice = questionary.select( + _("fit.reuse_prompt"), + choices=[ + questionary.Choice(_("fit.reuse_choice_existing"), value="existing"), + questionary.Choice(_("fit.reuse_choice_new"), value="new"), + questionary.Choice(_("fit.reuse_choice_cancel"), value="cancel"), + ], + style=STYLE, + ).ask() + if choice in (None, "cancel"): + return + if choice == "new": + console.print(_("fit.setup_instructions")) + if not _prompt_credentials_json(profile_credentials_file(), "fit.credentials_saved_profile"): + return + console.print(_("fit.test_user_reminder")) + console.print(_("fit.testing_mode_note")) if not questionary.confirm(_("fit.ready_to_auth"), default=True, style=STYLE).ask(): return @@ -148,9 +206,15 @@ def _fit_setup() -> None: _dlog("ERROR", "Google Fit connect failed: credentials file not found") console.print(f"\n[red]{e}[/red]") # safe: our own message, no secrets except Exception as e: + from fit.auth import _FLOW_TIMEOUT_S, classify_auth_error + _dlog("ERROR", f"Google Fit connect failed: {type(e).__name__}", error=str(e)[:200]) - console.print(_("error.fit_auth_failed")) - console.print(f"[dim]{type(e).__name__}[/dim]") + key = classify_auth_error(e) + if key: + console.print(_(key, minutes=_FLOW_TIMEOUT_S // 60)) + else: + console.print(_("error.fit_auth_failed")) + console.print(f"[dim]{type(e).__name__}: {_esc(str(e)[:150])}[/dim]") def _render_fit_dashboard() -> None: diff --git a/fit/auth.py b/fit/auth.py index 7d27e46..d9816d1 100644 --- a/fit/auth.py +++ b/fit/auth.py @@ -1,5 +1,6 @@ """Google OAuth flow for the Fitness API.""" +import json import os from pathlib import Path from typing import Any @@ -15,21 +16,70 @@ ] +def profile_credentials_file() -> Path: + """Optional per-profile client-secrets file, next to the profile's DB.""" + return config.DB_PATH.parent / "fit_credentials.json" + + def credentials_file() -> Path: """OAuth client-secrets location, resolved at call time so a file copied - in via the in-app setup is picked up without restarting.""" + in via the in-app setup is picked up without restarting. + + Resolution order: GOOGLE_CREDENTIALS_FILE env → per-profile file → + global file shared by all profiles.""" raw = os.environ.get("GOOGLE_CREDENTIALS_FILE", "") if raw: p = Path(raw).expanduser() return p if p.is_absolute() else paths.CONFIG_DIR / p + pcf = profile_credentials_file() + if pcf.exists(): + return pcf return paths.FIT_CREDENTIALS_FILE +def describe_client(path: Path) -> dict | None: + """{'client_id', 'project_id', 'type'} from a client-secrets JSON, or None.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + return None + kind = "installed" if "installed" in payload else "web" if "web" in payload else None + if kind is None: + return None + info = payload.get(kind) or {} + return { + "client_id": info.get("client_id", "?"), + "project_id": info.get("project_id", "?"), + "type": kind, + } + except Exception: + return None + + +def classify_auth_error(e: BaseException) -> str | None: + """Map an OAuth-flow exception to an i18n error key, or None if unknown. + + Matches by class name (not isinstance) so tests and callers don't need + oauthlib/google-auth imported.""" + msg = str(e).lower() + names = {c.__name__ for c in type(e).__mro__} + if "AccessDeniedError" in names or "access_denied" in msg: + return "error.fit_access_denied" + if "WSGITimeoutError" in names or isinstance(e, AttributeError): + return "error.fit_auth_timeout" + if "redirect_uri_mismatch" in msg or (isinstance(e, ValueError) and "client secrets" in msg): + return "error.fit_web_client" + if "invalid_grant" in msg or "RefreshError" in names: + return "error.fit_token_refresh_expired" + return None + + def _token_file() -> Path: return config.DB_PATH.parent / "fit_token.json" _REFRESH_TIMEOUT_S = 20 +_FLOW_TIMEOUT_S = 300 def refresh_transport() -> Any: @@ -56,6 +106,19 @@ def _write_token(creds: Any) -> None: ) from e +def _run_browser_flow() -> Any: + creds_file = credentials_file() + if not creds_file.exists(): + raise FileNotFoundError( + f"Google OAuth credentials file not found at '{creds_file}'.\n" + "Follow the setup instructions in the menu to create one." + ) + from google_auth_oauthlib.flow import InstalledAppFlow + + flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES) + return flow.run_local_server(port=0, open_browser=True, timeout_seconds=_FLOW_TIMEOUT_S) + + def get_credentials() -> Any: """Return valid Google credentials, running the OAuth flow if needed.""" from google.oauth2.credentials import Credentials @@ -67,18 +130,17 @@ def get_credentials() -> Any: if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: - creds.refresh(refresh_transport()) + from google.auth.exceptions import RefreshError + + try: + creds.refresh(refresh_transport()) + except RefreshError: + # Testing-mode projects expire refresh tokens after ~7 days; + # drop the stale token and re-run the full browser flow. + disconnect() + creds = _run_browser_flow() else: - creds_file = credentials_file() - if not creds_file.exists(): - raise FileNotFoundError( - f"Google OAuth credentials file not found at '{creds_file}'.\n" - "Follow the setup instructions in the menu to create one." - ) - from google_auth_oauthlib.flow import InstalledAppFlow - - flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES) - creds = flow.run_local_server(port=0, open_browser=True) + creds = _run_browser_flow() _write_token(creds) diff --git a/fit/client.py b/fit/client.py index dfb964b..3b05e47 100644 --- a/fit/client.py +++ b/fit/client.py @@ -25,7 +25,8 @@ def _headers(self) -> dict: except RefreshError as e: self._disconnect() raise RuntimeError( - "Google Fit token expired and could not be refreshed.\n" + "Google Fit token expired and could not be refreshed — projects in " + "Testing mode expire tokens every ~7 days.\n" "Go to Menu → Google Fit → Connect to re-authenticate." ) from e return {"Authorization": f"Bearer {self._creds.token}"} @@ -36,7 +37,8 @@ def _check(self, resp: httpx.Response, operation: str) -> None: if resp.status_code == 401: self._disconnect() raise RuntimeError( - "Google Fit session expired. Token has been cleared — " + "Google Fit session expired (Testing-mode projects expire tokens every " + "~7 days). Token has been cleared — " "go to Menu → Google Fit → Connect to re-authenticate. (error 401)" ) if resp.status_code == 403: diff --git a/locales/en.json b/locales/en.json index d1b1311..aae8144 100644 --- a/locales/en.json +++ b/locales/en.json @@ -63,6 +63,10 @@ "error.not_enough_data": "[yellow]Not enough data yet.[/yellow]", "error.fit_sync_failed": "[red]{error}[/red]", "error.fit_auth_failed": "\n[red]Authentication failed. Check that fit_credentials.json is valid.[/red]", + "error.fit_access_denied": "\n[red]Google denied access (access_denied).[/red]\nAdd the Google account you signed in with as a [bold]Test user[/bold] of the project:\nCloud Console → APIs & Services → OAuth consent screen → Test users.\nThen run Connect again.", + "error.fit_auth_timeout": "\n[red]No response from the browser after {minutes} minutes.[/red]\nIf Google showed [bold]Error 403: access_denied[/bold], the account you used is not a\n[bold]Test user[/bold] of the project — add it in Cloud Console → APIs & Services →\nOAuth consent screen → Test users, then run Connect again.", + "error.fit_web_client": "\n[red]The OAuth client is a [bold]Web application[/bold] client — Lifter needs a [bold]Desktop app[/bold] client.[/red]\nIn Cloud Console → Credentials, create a new OAuth client ID with type\n[bold]Desktop app[/bold], download its JSON and run Connect again.", + "error.fit_token_refresh_expired": "\n[yellow]The saved Google token expired (projects in [bold]Testing[/bold] mode expire tokens\nevery ~7 days). Run Connect again to re-authorize in the browser.[/yellow]", "wizard.no_exercises": "[yellow] No exercises found. Run Sync first.[/yellow]", "wizard.lift_hint": "\n [dim]Add one or more lift targets. Leave blank to stop.[/dim]", "wizard.lift_exercise_prompt": " Exercise (start typing or press Enter to stop):", @@ -561,14 +565,23 @@ "fit.metric_sleep": "Sleep {hours}h avg", "fit.metric_steps": "Steps {steps}/day", "fit.metric_rhr": "RHR {rhr} bpm", - "fit.setup_instructions": "\n [bold]Step 1[/bold] — Create OAuth credentials in Google Cloud Console:\n\n 1. Go to [link]https://console.cloud.google.com[/link]\n 2. Create a new project (or select an existing one)\n 3. Go to [bold]APIs & Services → Library[/bold] and enable [bold]Fitness API[/bold]\n 4. Go to [bold]APIs & Services → OAuth consent screen[/bold]\n → External → fill in app name → add your Gmail as a test user\n 5. Go to [bold]APIs & Services → Credentials → Create Credentials → OAuth client ID[/bold]\n → Application type: [bold]Desktop app[/bold]\n 6. Download the JSON file — Lifter will ask you for its location\n\n [bold]Step 2[/bold] — The browser will open for you to approve access.\n", + "fit.setup_instructions": "\n [bold]Step 1[/bold] — Create OAuth credentials in Google Cloud Console:\n\n 1. Go to [link]https://console.cloud.google.com[/link]\n 2. Create a new project (or select an existing one)\n 3. Go to [bold]APIs & Services → Library[/bold] and enable [bold]Fitness API[/bold]\n 4. Go to [bold]APIs & Services → OAuth consent screen[/bold]\n → External → fill in app name → add [bold]every[/bold] Google account that will\n connect (one per profile) as a [bold]Test user[/bold]\n 5. Go to [bold]APIs & Services → Credentials → Create Credentials → OAuth client ID[/bold]\n → Application type: [bold]Desktop app[/bold] ([red]NOT[/red] Web application)\n 6. Download the JSON file — Lifter will ask you for its location\n\n [bold]Step 2[/bold] — The browser will open for you to approve access.\n\n [dim]Note: while the project is in Testing mode, Google expires the connection\n every ~7 days — just run Connect again when that happens.[/dim]\n", "fit.disconnect_confirm": " Disconnect Google Fit? (local data stays)", "fit.disconnected": "[dim]Disconnected. Local Fit data kept in DB.[/dim]", "fit.connect_rule": "[bold cyan]Connect Google Fit[/bold cyan]", "fit.ready_to_auth": " Ready to authenticate?", "fit.credentials_path_prompt": " Path to the downloaded OAuth JSON file:", - "fit.credentials_invalid": "[red]That file is not a Google OAuth client-secrets JSON.[/red]", + "fit.credentials_invalid": "[red]That file is not a Google OAuth client-secrets JSON — it must be the\ndownloaded client_secret_*.json containing an \"installed\" section.[/red]", "fit.credentials_saved": "[green]✓ Credentials saved to[/green] [dim]{path}[/dim]", + "fit.credentials_saved_profile": "[green]✓ Credentials saved for this profile at[/green] [dim]{path}[/dim]", + "fit.credentials_web_client": "[red]This OAuth client is a [bold]Web application[/bold] client — Lifter needs a\n[bold]Desktop app[/bold] client. In Cloud Console → Credentials, create a new OAuth\nclient ID with type Desktop app and download its JSON.[/red]", + "fit.client_in_use": "Using OAuth client [bold]{client_id}[/bold]\nproject [bold]{project_id}[/bold] · [dim]{path}[/dim]", + "fit.reuse_prompt": "An OAuth client is already configured. What do you want to do?", + "fit.reuse_choice_existing": "Use this client — sign in with any Google account added as a Test user of this project", + "fit.reuse_choice_new": "Use a different OAuth JSON (its own Google Cloud project) for this profile", + "fit.reuse_choice_cancel": "Cancel", + "fit.test_user_reminder": "[yellow]The Google account you sign in with must be listed as a [bold]Test user[/bold] of this\nproject (Cloud Console → APIs & Services → OAuth consent screen → Test users).\nOtherwise Google shows [bold]Error 403: access_denied[/bold] in the browser.[/yellow]", + "fit.testing_mode_note": "[dim]While the project is in Testing mode, Google expires the connection every\n~7 days — just run Connect again when that happens.[/dim]", "fit.connected_ok": "\n[bold green]✓ Connected to Google Fit![/bold green]", "fit.connected_hint": "[dim]Run 'Google Fit → Sync health data' to import your data.[/dim]\n", "fit.dashboard_rule": "[bold green]Recovery Dashboard[/bold green]", diff --git a/locales/pt_BR.json b/locales/pt_BR.json index 5a649af..78168d5 100644 --- a/locales/pt_BR.json +++ b/locales/pt_BR.json @@ -63,6 +63,10 @@ "error.not_enough_data": "[yellow]Dados insuficientes ainda.[/yellow]", "error.fit_sync_failed": "[red]{error}[/red]", "error.fit_auth_failed": "\n[red]Autenticação falhou. Verifique se o fit_credentials.json é válido.[/red]", + "error.fit_access_denied": "\n[red]O Google negou o acesso (access_denied).[/red]\nAdicione a conta Google que você usou como [bold]Usuário de teste[/bold] do projeto:\nCloud Console → APIs e Serviços → Tela de consentimento OAuth → Usuários de teste.\nDepois execute Conectar novamente.", + "error.fit_auth_timeout": "\n[red]Sem resposta do navegador após {minutes} minutos.[/red]\nSe o Google mostrou [bold]Erro 403: access_denied[/bold], a conta usada não é um\n[bold]Usuário de teste[/bold] do projeto — adicione-a em Cloud Console → APIs e Serviços →\nTela de consentimento OAuth → Usuários de teste e execute Conectar novamente.", + "error.fit_web_client": "\n[red]O cliente OAuth é do tipo [bold]Aplicativo da Web[/bold] — o Lifter precisa de um cliente [bold]App para computador[/bold].[/red]\nNo Cloud Console → Credenciais, crie um novo ID do cliente OAuth com tipo\n[bold]App para computador[/bold], baixe o JSON e execute Conectar novamente.", + "error.fit_token_refresh_expired": "\n[yellow]O token do Google salvo expirou (projetos em modo [bold]Testing[/bold] expiram tokens\na cada ~7 dias). Execute Conectar novamente para reautorizar no navegador.[/yellow]", "wizard.no_exercises": "[yellow] Nenhum exercício encontrado. Execute Sincronizar primeiro.[/yellow]", "wizard.lift_hint": "\n [dim]Adicione um ou mais alvos de carga. Deixe em branco para parar.[/dim]", "wizard.lift_exercise_prompt": " Exercício (comece a digitar ou pressione Enter para parar):", @@ -561,14 +565,23 @@ "fit.metric_sleep": "Sono {hours}h média", "fit.metric_steps": "Passos {steps}/dia", "fit.metric_rhr": "FCR {rhr} bpm", - "fit.setup_instructions": "\n [bold]Passo 1[/bold] — Crie credenciais OAuth no Google Cloud Console:\n\n 1. Acesse [link]https://console.cloud.google.com[/link]\n 2. Crie um novo projeto (ou selecione um existente)\n 3. Vá em [bold]APIs e Serviços → Biblioteca[/bold] e ative a [bold]Fitness API[/bold]\n 4. Vá em [bold]APIs e Serviços → Tela de consentimento OAuth[/bold]\n → Externo → preencha o nome do app → adicione seu Gmail como usuário de teste\n 5. Vá em [bold]APIs e Serviços → Credenciais → Criar credenciais → ID do cliente OAuth[/bold]\n → Tipo de aplicativo: [bold]App para computador[/bold]\n 6. Baixe o arquivo JSON — o Lifter vai perguntar onde ele está\n\n [bold]Passo 2[/bold] — O navegador abrirá para você aprovar o acesso.\n", + "fit.setup_instructions": "\n [bold]Passo 1[/bold] — Crie credenciais OAuth no Google Cloud Console:\n\n 1. Acesse [link]https://console.cloud.google.com[/link]\n 2. Crie um novo projeto (ou selecione um existente)\n 3. Vá em [bold]APIs e Serviços → Biblioteca[/bold] e ative a [bold]Fitness API[/bold]\n 4. Vá em [bold]APIs e Serviços → Tela de consentimento OAuth[/bold]\n → Externo → preencha o nome do app → adicione [bold]todas[/bold] as contas Google\n que vão conectar (uma por perfil) como [bold]Usuário de teste[/bold]\n 5. Vá em [bold]APIs e Serviços → Credenciais → Criar credenciais → ID do cliente OAuth[/bold]\n → Tipo de aplicativo: [bold]App para computador[/bold] ([red]NÃO[/red] Aplicativo da Web)\n 6. Baixe o arquivo JSON — o Lifter vai perguntar onde ele está\n\n [bold]Passo 2[/bold] — O navegador abrirá para você aprovar o acesso.\n\n [dim]Nota: enquanto o projeto estiver em modo Testing, o Google expira a conexão\n a cada ~7 dias — basta executar Conectar novamente quando isso acontecer.[/dim]\n", "fit.disconnect_confirm": " Desconectar o Google Fit? (dados locais são mantidos)", "fit.disconnected": "[dim]Desconectado. Dados locais do Fit mantidos no banco.[/dim]", "fit.connect_rule": "[bold cyan]Conectar Google Fit[/bold cyan]", "fit.ready_to_auth": " Pronto para autenticar?", "fit.credentials_path_prompt": " Caminho do arquivo JSON OAuth baixado:", - "fit.credentials_invalid": "[red]Esse arquivo não é um JSON de credenciais OAuth do Google.[/red]", + "fit.credentials_invalid": "[red]Esse arquivo não é um JSON de credenciais OAuth do Google — deve ser o\nclient_secret_*.json baixado, contendo uma seção \"installed\".[/red]", "fit.credentials_saved": "[green]✓ Credenciais salvas em[/green] [dim]{path}[/dim]", + "fit.credentials_saved_profile": "[green]✓ Credenciais salvas para este perfil em[/green] [dim]{path}[/dim]", + "fit.credentials_web_client": "[red]Este cliente OAuth é do tipo [bold]Aplicativo da Web[/bold] — o Lifter precisa de um\ncliente [bold]App para computador[/bold]. No Cloud Console → Credenciais, crie um novo ID\ndo cliente OAuth com tipo App para computador e baixe o JSON.[/red]", + "fit.client_in_use": "Usando cliente OAuth [bold]{client_id}[/bold]\nprojeto [bold]{project_id}[/bold] · [dim]{path}[/dim]", + "fit.reuse_prompt": "Já existe um cliente OAuth configurado. O que você quer fazer?", + "fit.reuse_choice_existing": "Usar este cliente — entre com qualquer conta Google adicionada como Usuário de teste deste projeto", + "fit.reuse_choice_new": "Usar outro JSON OAuth (projeto próprio no Google Cloud) para este perfil", + "fit.reuse_choice_cancel": "Cancelar", + "fit.test_user_reminder": "[yellow]A conta Google usada para entrar precisa estar listada como [bold]Usuário de teste[/bold]\ndeste projeto (Cloud Console → APIs e Serviços → Tela de consentimento OAuth →\nUsuários de teste). Caso contrário o Google mostra [bold]Erro 403: access_denied[/bold] no navegador.[/yellow]", + "fit.testing_mode_note": "[dim]Enquanto o projeto estiver em modo Testing, o Google expira a conexão a cada\n~7 dias — basta executar Conectar novamente quando isso acontecer.[/dim]", "fit.connected_ok": "\n[bold green]✓ Conectado ao Google Fit![/bold green]", "fit.connected_hint": "[dim]Execute 'Google Fit → Sincronizar dados de saúde' para importar seus dados.[/dim]\n", "fit.dashboard_rule": "[bold green]Dashboard de Recuperação[/bold green]", diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py index 8814953..ba4c292 100644 --- a/tests/test_config_paths.py +++ b/tests/test_config_paths.py @@ -43,6 +43,37 @@ def test_credentials_file_env_relative_anchors_at_config_dir(monkeypatch): assert credentials_file() == paths.CONFIG_DIR / "my-creds.json" +def test_credentials_file_prefers_profile_file(monkeypatch, tmp_path): + import config + from fit.auth import credentials_file + + monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) + monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") + profile_creds = tmp_path / "fit_credentials.json" + profile_creds.write_text("{}") + assert credentials_file() == profile_creds + + +def test_credentials_file_env_beats_profile_file(monkeypatch, tmp_path): + import config + from fit.auth import credentials_file + + monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") + (tmp_path / "fit_credentials.json").write_text("{}") + monkeypatch.setenv("GOOGLE_CREDENTIALS_FILE", str(tmp_path / "env-creds.json")) + assert credentials_file() == tmp_path / "env-creds.json" + + +def test_credentials_file_falls_back_to_global_without_profile_file(monkeypatch, tmp_path): + import paths + import config + from fit.auth import credentials_file + + monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) + monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") + assert credentials_file() == paths.FIT_CREDENTIALS_FILE + + def test_token_file_is_sibling_of_db(): import config from fit.auth import _token_file diff --git a/tests/test_fit_auth_errors.py b/tests/test_fit_auth_errors.py new file mode 100644 index 0000000..1be4ae3 --- /dev/null +++ b/tests/test_fit_auth_errors.py @@ -0,0 +1,159 @@ +"""Tests for fit/auth.py — error classification, client-secrets inspection, +and the refresh-failure fallthrough to the browser flow.""" + +import json +from unittest.mock import MagicMock + +import pytest + + +# ── classify_auth_error ─────────────────────────────────────────────────────── + + +def test_classify_access_denied_real_exception(): + from oauthlib.oauth2.rfc6749.errors import AccessDeniedError + + from fit.auth import classify_auth_error + + assert classify_auth_error(AccessDeniedError()) == "error.fit_access_denied" + + +def test_classify_access_denied_by_message(): + from fit.auth import classify_auth_error + + assert classify_auth_error(Exception("(access_denied) blocked")) == "error.fit_access_denied" + + +def test_classify_flow_timeout(): + from google_auth_oauthlib.flow import WSGITimeoutError + + from fit.auth import classify_auth_error + + assert classify_auth_error(WSGITimeoutError("timed out")) == "error.fit_auth_timeout" + + +def test_classify_web_client_secrets_valueerror(): + from fit.auth import classify_auth_error + + e = ValueError("Client secrets must be for a web or installed app.") + assert classify_auth_error(e) == "error.fit_web_client" + + +def test_classify_redirect_uri_mismatch(): + from fit.auth import classify_auth_error + + assert classify_auth_error(Exception("Error: redirect_uri_mismatch")) == "error.fit_web_client" + + +def test_classify_refresh_error(): + from google.auth.exceptions import RefreshError + + from fit.auth import classify_auth_error + + assert classify_auth_error(RefreshError("invalid_grant: Token has been expired")) == ( + "error.fit_token_refresh_expired" + ) + + +def test_classify_unknown_returns_none(): + from fit.auth import classify_auth_error + + assert classify_auth_error(KeyError("x")) is None + + +# ── describe_client ─────────────────────────────────────────────────────────── + + +def _write_json(path, payload): + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_describe_client_installed(tmp_path): + from fit.auth import describe_client + + p = _write_json( + tmp_path / "c.json", + {"installed": {"client_id": "abc.apps.googleusercontent.com", "project_id": "my-proj"}}, + ) + assert describe_client(p) == { + "client_id": "abc.apps.googleusercontent.com", + "project_id": "my-proj", + "type": "installed", + } + + +def test_describe_client_web(tmp_path): + from fit.auth import describe_client + + p = _write_json(tmp_path / "c.json", {"web": {"client_id": "web-id", "project_id": "p"}}) + assert describe_client(p)["type"] == "web" + + +def test_describe_client_garbage_returns_none(tmp_path): + from fit.auth import describe_client + + p = tmp_path / "c.json" + p.write_text("not json") + assert describe_client(p) is None + + +def test_describe_client_missing_file_returns_none(tmp_path): + from fit.auth import describe_client + + assert describe_client(tmp_path / "nope.json") is None + + +def test_describe_client_wrong_shape_returns_none(tmp_path): + from fit.auth import describe_client + + p = _write_json(tmp_path / "c.json", {"other": {}}) + assert describe_client(p) is None + + +# ── get_credentials refresh fallthrough ─────────────────────────────────────── + + +def test_get_credentials_reruns_flow_when_refresh_fails(monkeypatch, tmp_path): + """A RefreshError (7-day Testing expiry) must drop the token and re-run the + browser flow instead of bubbling invalid_grant to the caller.""" + from google.auth.exceptions import RefreshError + + import config + import fit.auth as auth + + monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") + token_file = tmp_path / "fit_token.json" + token_file.write_text("{}") + + stale = MagicMock() + stale.valid = False + stale.expired = True + stale.refresh_token = "stale-token" + stale.refresh.side_effect = RefreshError("invalid_grant") + monkeypatch.setattr( + "google.oauth2.credentials.Credentials.from_authorized_user_file", + staticmethod(lambda *a, **k: stale), + ) + + fresh = MagicMock() + fresh.to_json.return_value = '{"token": "fresh"}' + monkeypatch.setattr(auth, "_run_browser_flow", lambda: fresh) + + creds = auth.get_credentials() + + assert creds is fresh + assert json.loads(token_file.read_text()) == {"token": "fresh"} + + +def test_get_credentials_raises_when_no_credentials_file(monkeypatch, tmp_path): + import config + import paths + import fit.auth as auth + + monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) + monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") + monkeypatch.setattr(paths, "FIT_CREDENTIALS_FILE", tmp_path / "absent.json") + + with pytest.raises(FileNotFoundError): + auth.get_credentials() diff --git a/tests/test_fit_setup_wizard.py b/tests/test_fit_setup_wizard.py new file mode 100644 index 0000000..a0baf41 --- /dev/null +++ b/tests/test_fit_setup_wizard.py @@ -0,0 +1,166 @@ +"""Tests for commands.fit._fit_setup — the Connect wizard flow.""" + +import json +from unittest.mock import MagicMock + +import pytest + + +def _answer(value): + """A questionary.* stand-in whose .ask() returns `value`.""" + + def _factory(*args, **kwargs): + m = MagicMock() + m.ask.return_value = value + return m + + return _factory + + +@pytest.fixture +def wizard_env(monkeypatch, tmp_path): + """Sandbox global + per-profile credential locations for the wizard.""" + import config + import paths + + monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) + profile_dir = tmp_path / "profile" + profile_dir.mkdir() + monkeypatch.setattr(config, "DB_PATH", profile_dir / "hevy.db") + global_creds = tmp_path / "global" / "fit_credentials.json" + global_creds.parent.mkdir() + monkeypatch.setattr(paths, "FIT_CREDENTIALS_FILE", global_creds) + monkeypatch.setattr(paths, "ensure_dirs", lambda: None) + return {"global": global_creds, "profile": profile_dir / "fit_credentials.json", "tmp": tmp_path} + + +def _run_setup(): + from commands.fit import _fit_setup + from ui.console import console + + with console.capture() as cap: + _fit_setup() + return cap.get() + + +INSTALLED_JSON = {"installed": {"client_id": "abc123.apps.googleusercontent.com", "project_id": "lifter-proj"}} +WEB_JSON = {"web": {"client_id": "web-id", "project_id": "web-proj"}} + + +def test_first_setup_rejects_web_client_json(monkeypatch, wizard_env, tmp_path): + source = tmp_path / "downloaded.json" + source.write_text(json.dumps(WEB_JSON)) + monkeypatch.setattr("questionary.path", _answer(str(source))) + + called = [] + monkeypatch.setattr("fit.auth.get_credentials", lambda: called.append(1)) + + out = _run_setup() + + assert "Desktop app" in out + assert not wizard_env["global"].exists() + assert not called + + +def test_first_setup_saves_valid_json_to_global(monkeypatch, wizard_env, tmp_path): + source = tmp_path / "downloaded.json" + source.write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.path", _answer(str(source))) + monkeypatch.setattr("questionary.confirm", _answer(False)) # stop before auth + + out = _run_setup() + + assert wizard_env["global"].exists() + assert (wizard_env["global"].stat().st_mode & 0o777) == 0o600 + assert json.loads(wizard_env["global"].read_text()) == INSTALLED_JSON + assert "Test user" in out # pre-auth reminder shown + + +def test_existing_client_shows_reuse_menu_with_client_id(monkeypatch, wizard_env): + wizard_env["global"].write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.select", _answer("cancel")) + + out = _run_setup() + + assert "abc123.apps.googleusercontent.com" in out + assert "lifter-proj" in out + + +def test_existing_client_new_json_goes_to_profile_file(monkeypatch, wizard_env, tmp_path): + wizard_env["global"].write_text(json.dumps(INSTALLED_JSON)) + other = {"installed": {"client_id": "other-id", "project_id": "other-proj"}} + source = tmp_path / "other.json" + source.write_text(json.dumps(other)) + + monkeypatch.setattr("questionary.select", _answer("new")) + monkeypatch.setattr("questionary.path", _answer(str(source))) + monkeypatch.setattr("questionary.confirm", _answer(False)) + + _run_setup() + + assert json.loads(wizard_env["profile"].read_text()) == other + # global file untouched + assert json.loads(wizard_env["global"].read_text()) == INSTALLED_JSON + + +def test_existing_web_client_is_replaced_in_place(monkeypatch, wizard_env, tmp_path): + wizard_env["global"].write_text(json.dumps(WEB_JSON)) + source = tmp_path / "fixed.json" + source.write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.path", _answer(str(source))) + monkeypatch.setattr("questionary.confirm", _answer(False)) + + out = _run_setup() + + assert "Desktop app" in out + assert json.loads(wizard_env["global"].read_text()) == INSTALLED_JSON + + +def test_access_denied_maps_to_actionable_message(monkeypatch, wizard_env): + from oauthlib.oauth2.rfc6749.errors import AccessDeniedError + + wizard_env["global"].write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.select", _answer("existing")) + monkeypatch.setattr("questionary.confirm", _answer(True)) + + def _boom(): + raise AccessDeniedError() + + monkeypatch.setattr("fit.auth.get_credentials", _boom) + + out = _run_setup() + + assert "access_denied" in out + assert "Test user" in out + + +def test_flow_timeout_maps_to_timeout_message(monkeypatch, wizard_env): + from google_auth_oauthlib.flow import WSGITimeoutError + + wizard_env["global"].write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.select", _answer("existing")) + monkeypatch.setattr("questionary.confirm", _answer(True)) + + def _boom(): + raise WSGITimeoutError("timed out") + + monkeypatch.setattr("fit.auth.get_credentials", _boom) + + out = _run_setup() + + assert "5 minutes" in out + + +def test_unknown_error_shows_generic_with_detail(monkeypatch, wizard_env): + wizard_env["global"].write_text(json.dumps(INSTALLED_JSON)) + monkeypatch.setattr("questionary.select", _answer("existing")) + monkeypatch.setattr("questionary.confirm", _answer(True)) + + def _boom(): + raise KeyError("weird") + + monkeypatch.setattr("fit.auth.get_credentials", _boom) + + out = _run_setup() + + assert "KeyError" in out From 358a4c44af284dcf03ec03ea71df1adddbc9c982 Mon Sep 17 00:00:00 2001 From: Joao Ferrete Date: Mon, 13 Jul 2026 10:10:32 -0300 Subject: [PATCH 2/3] style: sort imports in fit auth tests (ruff I001) Co-Authored-By: Claude Fable 5 --- tests/test_config_paths.py | 2 +- tests/test_fit_auth_errors.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py index ba4c292..3236cb9 100644 --- a/tests/test_config_paths.py +++ b/tests/test_config_paths.py @@ -65,8 +65,8 @@ def test_credentials_file_env_beats_profile_file(monkeypatch, tmp_path): def test_credentials_file_falls_back_to_global_without_profile_file(monkeypatch, tmp_path): - import paths import config + import paths from fit.auth import credentials_file monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) diff --git a/tests/test_fit_auth_errors.py b/tests/test_fit_auth_errors.py index 1be4ae3..44c4fea 100644 --- a/tests/test_fit_auth_errors.py +++ b/tests/test_fit_auth_errors.py @@ -6,7 +6,6 @@ import pytest - # ── classify_auth_error ─────────────────────────────────────────────────────── @@ -148,8 +147,8 @@ def test_get_credentials_reruns_flow_when_refresh_fails(monkeypatch, tmp_path): def test_get_credentials_raises_when_no_credentials_file(monkeypatch, tmp_path): import config - import paths import fit.auth as auth + import paths monkeypatch.delenv("GOOGLE_CREDENTIALS_FILE", raising=False) monkeypatch.setattr(config, "DB_PATH", tmp_path / "hevy.db") From 51c5c4f58fc4a6b80771cdb9ba248a3b4e7c2980 Mon Sep 17 00:00:00 2001 From: Joao Ferrete Date: Mon, 13 Jul 2026 10:15:31 -0300 Subject: [PATCH 3/3] fix: lint and tests --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_fit_auth_errors.py | 4 +++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d4400..e1bd10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Connecting Google Fit from a second profile no longer dead-ends: the + Connect wizard was silently skipping the credentials step when a global + `fit_credentials.json` already existed, and a Google account not listed + as a Test user of the project hit "Error 403: access_denied" in the + browser — with the CLI hanging forever since Google never redirects back. + The OAuth flow now times out after 5 minutes with a message explaining + the Test-user requirement. +- OAuth client-secrets JSON of type "Web application" is now rejected up + front with instructions to recreate it as a Desktop app client (it can + never complete the local sign-in flow). +- When the saved token expires (Testing-mode projects expire refresh tokens + every ~7 days), Connect now falls through to the browser flow instead of + failing with a cryptic `invalid_grant`; sync error messages explain the + 7-day cause. + +### Changed + +- The Google Fit Connect wizard is transparent about which OAuth client is + in use: it shows the client ID, project and file path, and offers to + reuse it (signing in with any Test-user account) or supply a different + OAuth JSON for the current profile only. +- Google Fit credentials can now be set per profile: an optional + `profiles/{slug}/fit_credentials.json` takes precedence over the shared + global file (`GOOGLE_CREDENTIALS_FILE` still wins over both). +- OAuth failures are mapped to actionable messages (access denied → add the + account as a Test user; timeout; web-type client; expired token) in both + English and pt_BR, and the setup instructions now stress adding every + connecting Google account as a Test user and the weekly Testing-mode + expiry. + ## [0.4.2] - 2026-07-10 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 532e69e..304b570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,7 @@ module = [ "questionary.*", "google.*", "google_auth_oauthlib.*", + "oauthlib.*", "pandas.*", "boto3.*", "botocore.*", diff --git a/tests/test_fit_auth_errors.py b/tests/test_fit_auth_errors.py index 44c4fea..6fc547b 100644 --- a/tests/test_fit_auth_errors.py +++ b/tests/test_fit_auth_errors.py @@ -86,7 +86,9 @@ def test_describe_client_web(tmp_path): from fit.auth import describe_client p = _write_json(tmp_path / "c.json", {"web": {"client_id": "web-id", "project_id": "p"}}) - assert describe_client(p)["type"] == "web" + client = describe_client(p) + assert client is not None + assert client["type"] == "web" def test_describe_client_garbage_returns_none(tmp_path):