from __future__ import annotations import json import os from unittest.mock import MagicMock import pytest from integrations.llm_cli.codex_oauth import CodexOAuthResult from surfaces.cli.wizard import _integration_configurators, _ui, flow from surfaces.cli.wizard import store as wizard_store from surfaces.cli.wizard.env_sync import sync_provider_env from surfaces.cli.wizard.probes import ProbeResult from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin @pytest.fixture(autouse=True) def _stub_managed_llm_secret_persistence(monkeypatch: pytest.MonkeyPatch) -> None: """Wizard flow tests should not touch the developer's real keychain.""" monkeypatch.setattr(_ui, "save_api_key", lambda *_args, **_kwargs: None) def test_run_wizard_advanced_remote_falls_back_to_local(monkeypatch, tmp_path, capsys) -> None: # advanced -> falls back to local -> change provider? Yes -> pick anthropic -> skip integrations select_responses = iter( ["advanced", "remote", "anthropic", "api_key", "claude-opus-4-7", "skip"] ) confirm_responses = iter([True, True]) # "use local instead?" and "Change provider?" def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_confirm(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(confirm_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = "secret-key" return m saved: dict[str, object] = {} saved_llm_keys: list[tuple[str, str]] = [] monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "confirm", _mock_confirm) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( flow, "probe_remote_target", lambda: ProbeResult("remote", True, "remote ok") ) def _save_local_config(**kwargs): saved.update(kwargs) return tmp_path / "opensre.json" monkeypatch.setattr(flow, "save_local_config", _save_local_config) monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved["wizard_mode"] == "advanced" assert saved["provider"] == "anthropic" assert "api_key" not in saved assert saved_llm_keys == [("anthropic", "secret-key")] output = capsys.readouterr().out assert "Summary" in output assert "4/4" in output assert "next" in output assert "Done." in output assert output.index("Summary") < output.index("Done.") def test_prompt_value_can_signal_wizard_back(monkeypatch) -> None: def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = None return m monkeypatch.setattr(_ui.questionary, "password", _mock_password) with pytest.raises(_ui.WizardBack): _ui._prompt_value("OpenAI API key", secret=True, back_on_cancel=True) def test_run_wizard_no_saved_provider_shows_selection(monkeypatch, tmp_path) -> None: """With no saved config the provider list is shown immediately (no confirm prompt).""" select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "skip"]) def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = "secret-key" return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) exit_code = flow.run_wizard() assert exit_code == 0 def test_run_wizard_shows_keyring_fix_steps_when_secure_storage_is_unavailable( monkeypatch, tmp_path, capsys ) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7"]) def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = "secret-key" return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _ui, "save_api_key", lambda *_args, **_kwargs: (_ for _ in ()).throw( RuntimeError("Secure local credential storage is unavailable on this machine.") ), ) monkeypatch.setattr( _ui, "get_keyring_setup_instructions", lambda _env_var: ( "Current keyring backend: keyring.backends.fail.Keyring.", "Install it first: sudo apt update && sudo apt install -y gnome-keyring dbus-user-session", "Start a D-Bus shell: dbus-run-session -- sh", ), ) exit_code = flow.run_wizard() assert exit_code == 1 output = capsys.readouterr().out assert "OpenSRE could not save your API key to the local system keychain." in output assert "Install it first: sudo apt update && sudo apt install -y gnome-keyring" in output assert "dbus-user-session" in output assert "Start a D-Bus shell: dbus-run-session -- sh" in output def test_run_wizard_configures_optional_integrations(monkeypatch, tmp_path, capsys) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "grafana"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m password_responses = iter( [ "llm-secret", "grafana-token", ] ) text_responses = iter(["https://grafana.example.com"]) def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_grafana_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Grafana ok"), ) monkeypatch.setattr( _integration_configurators, "validate_slack_webhook", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Slack ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "grafana", { "credentials": { "endpoint": "https://grafana.example.com", "api_key": "grafana-token", } }, ) ] assert synced_env_values == [ { "GRAFANA_INSTANCE_URL": "https://grafana.example.com", }, ] output = capsys.readouterr().out assert "Grafana" in output def test_run_wizard_configures_honeycomb(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "honeycomb"]) password_responses = iter(["llm-secret", "hny_test"]) text_responses = iter(["prod-api", "https://api.honeycomb.io"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_honeycomb_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Honeycomb ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "honeycomb", { "credentials": { "api_key": "hny_test", "dataset": "prod-api", "base_url": "https://api.honeycomb.io", } }, ) ] assert synced_env_values == [ { "HONEYCOMB_DATASET": "prod-api", "HONEYCOMB_API_URL": "https://api.honeycomb.io", } ] def test_run_wizard_configures_coralogix(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "coralogix"]) password_responses = iter(["llm-secret", "cx_test"]) text_responses = iter( [ "https://api.coralogix.com", "payments", "worker", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_coralogix_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Coralogix ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "coralogix", { "credentials": { "api_key": "cx_test", "base_url": "https://api.coralogix.com", "application_name": "payments", "subsystem_name": "worker", } }, ) ] assert synced_env_values == [ { "CORALOGIX_API_URL": "https://api.coralogix.com", "CORALOGIX_APPLICATION_NAME": "payments", "CORALOGIX_SUBSYSTEM_NAME": "worker", } ] def test_run_wizard_configures_dagster(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "dagster"]) password_responses = iter(["llm-secret", "dag_test_token"]) text_responses = iter(["http://localhost:3000"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_secrets: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_dagster_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Dagster ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "sync_env_secret", lambda key, value: synced_secrets.append((key, value)), ) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "dagster", { "credentials": { "endpoint": "http://localhost:3000", "api_token": "dag_test_token", } }, ) ] assert synced_env_values == [{"DAGSTER_ENDPOINT": "http://localhost:3000"}] assert synced_secrets == [("DAGSTER_API_TOKEN", "dag_test_token")] def test_run_wizard_configures_dagster_oss_skips_secret(monkeypatch, tmp_path) -> None: """OSS path: empty api_token must not call sync_env_secret.""" select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "dagster"]) password_responses = iter(["llm-secret", ""]) text_responses = iter(["http://localhost:3000"]) synced_env_values: list[dict[str, str]] = [] synced_secrets: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_dagster_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Dagster ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) monkeypatch.setattr( _integration_configurators, "sync_env_values", lambda values, **_kwargs: synced_env_values.append(values) or (tmp_path / ".env"), ) monkeypatch.setattr( _integration_configurators, "sync_env_secret", lambda key, value: synced_secrets.append((key, value)), ) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda *_args, **_kwargs: None ) exit_code = flow.run_wizard() assert exit_code == 0 assert synced_env_values == [{"DAGSTER_ENDPOINT": "http://localhost:3000"}] assert synced_secrets == [] # OSS path: no token def test_run_wizard_configures_slack_persists_webhook(monkeypatch, tmp_path) -> None: """Regression: the wizard must upsert the Slack webhook to the store. Previously `_configure_slack` validated the webhook and reported "Slack" in the success summary but never called `upsert_integration`, silently discarding the webhook (the catalog reads Slack store-first, so nothing was readable afterwards). The webhook is a secret, so it belongs in the store, not `.env` — `sync_env_values` is called with an empty mapping. """ select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "slack"]) # webhook_url is prompted with secret=True, so it comes from the password mock. password_responses = iter(["llm-secret", "https://hooks.slack.com/services/T0/B0/XXXXX"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): # Slack has no plain-text prompt; guard against an unexpected one. m = MagicMock() m.ask.return_value = "" return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_slack_webhook", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="Slack ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "slack", {"credentials": {"webhook_url": "https://hooks.slack.com/services/T0/B0/XXXXX"}}, ) ] # Webhook is a secret: it goes to the store, not `.env`. assert synced_env_values == [{}] def test_run_wizard_dagster_retries_on_validation_failure(monkeypatch, tmp_path) -> None: """Two consecutive Dagster validation failures, then success. Proves the retry loop recovers from N consecutive failures (not just one), and that only the final successful attempt reaches the persistence layer. """ select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "dagster"]) # Two wrong tokens, then the correct one. password_responses = iter(["llm-secret", "bad_token_1", "bad_token_2", "dag_good"]) # endpoint is prompted on each retry (three attempts total). text_responses = iter( ["http://localhost:3000", "http://localhost:3000", "http://localhost:3000"] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_secrets: list[tuple[str, str]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate_dagster(**_kwargs): nonlocal validation_call_count validation_call_count += 1 if validation_call_count < 3: return flow.IntegrationHealthResult( ok=False, detail="Dagster GraphQL probe failed: HTTP 401" ) return flow.IntegrationHealthResult(ok=True, detail="Dagster ok") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_dagster_integration", _validate_dagster ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "sync_env_secret", lambda key, value: synced_secrets.append((key, value)), ) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert validation_call_count == 3 # two failures, then success # Only the successful attempt should reach the persistence layer: assert saved_integrations == [ ( "dagster", { "credentials": { "endpoint": "http://localhost:3000", "api_token": "dag_good", } }, ) ] assert synced_env_values == [{"DAGSTER_ENDPOINT": "http://localhost:3000"}] assert synced_secrets == [("DAGSTER_API_TOKEN", "dag_good")] def test_run_wizard_configures_github_mcp_and_sentry(monkeypatch, tmp_path, capsys) -> None: select_responses = iter( [ "quickstart", "anthropic", "api_key", "claude-opus-4-7", "github", flow.DEFAULT_GITHUB_MCP_MODE, "token", "auto", "any", "summary", ] ) text_responses = iter( [ flow.DEFAULT_GITHUB_MCP_URL, "repos,issues,pull_requests,actions,search", ] ) password_responses = iter( [ "llm-secret", "ghp_test", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_github_mcp_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="GitHub MCP ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr( _integration_configurators, "sync_env_values", _sync_env_values, ) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "github", { "credentials": { "url": flow.DEFAULT_GITHUB_MCP_URL, "mode": flow.DEFAULT_GITHUB_MCP_MODE, "auth_token": "ghp_test", "command": "", "args": [], "toolsets": ["repos", "issues", "pull_requests", "actions", "search"], } }, ), ] assert synced_env_values == [ { "GITHUB_MCP_URL": flow.DEFAULT_GITHUB_MCP_URL, "GITHUB_MCP_MODE": flow.DEFAULT_GITHUB_MCP_MODE, "GITHUB_MCP_COMMAND": "", "GITHUB_MCP_ARGS": "", "GITHUB_MCP_TOOLSETS": "repos,issues,pull_requests,actions,search", }, ] output = capsys.readouterr().out assert "GitHub MCP" in output def test_run_wizard_reuses_saved_defaults_when_user_keeps_provider(monkeypatch, tmp_path) -> None: """When provider is already set and user declines to change, saved values are reused.""" saved: dict[str, object] = {} saved_llm_keys: list[tuple[str, str]] = [] def _mock_select(*_args, choices=None, default=None, **_kwargs): m = MagicMock() prompt = str(_args[0]) if _args else "" selected_value = "skip" if "integration" in prompt.lower() else default if choices is not None: for choice in choices: if getattr(choice, "title", None) == selected_value: selected_value = choice.value break m.ask.return_value = selected_value return m def _mock_confirm(*_args, **_kwargs): m = MagicMock() # "Change provider?" -> No (keep saved openai) m.ask.return_value = False return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "confirm", _mock_confirm) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr( _ui, "load_local_config", lambda _path: { "wizard": {"mode": "quickstart"}, "targets": { "local": { "provider": "openai", "model": "gpt-5.4-mini", "api_key_env": "OPENAI_API_KEY", "api_key": "saved-secret", } }, }, ) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(_ui, "has_llm_api_key", lambda _env: False) def _save_local_config(**kwargs): saved.update(kwargs) return tmp_path / "opensre.json" monkeypatch.setattr(flow, "save_local_config", _save_local_config) monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved["wizard_mode"] == "quickstart" assert saved["provider"] == "openai" assert saved["model"] == "gpt-5.4-mini" assert "api_key" not in saved assert saved_llm_keys == [("openai", "saved-secret")] def test_run_wizard_changes_model_when_user_keeps_provider(monkeypatch, tmp_path) -> None: """When provider is kept but user opts to change model, the new choice is saved.""" saved: dict[str, object] = {} confirm_prompts: list[str] = [] def _mock_select(*_args, choices=None, default=None, **_kwargs): m = MagicMock() prompt = str(_args[0]) if _args else "" if "Choose your LLM provider" in prompt: m.ask.return_value = "openai" elif "Choose OpenAI model" in prompt: m.ask.return_value = "gpt-5.4-mini" else: m.ask.return_value = default return m def _mock_confirm(*_args, **_kwargs): m = MagicMock() prompt = str(_args[0]) if _args else "" confirm_prompts.append(prompt) if "Change provider?" in prompt: m.ask.return_value = False elif "Change model?" in prompt: m.ask.return_value = True else: m.ask.return_value = False return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "confirm", _mock_confirm) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr( _ui, "load_local_config", lambda _path: { "wizard": {"mode": "quickstart"}, "targets": { "local": { "provider": "openai", "model": "gpt-5.4-mini", "api_key_env": "OPENAI_API_KEY", "api_key": "saved-secret", } }, }, ) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(_ui, "has_llm_api_key", lambda _env: True) def _save_local_config(**kwargs): saved.update(kwargs) return tmp_path / "opensre.json" monkeypatch.setattr(flow, "save_local_config", _save_local_config) monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") exit_code = flow.run_wizard() assert exit_code == 0 assert saved["provider"] == "openai" assert saved["model"] == "gpt-5.4-mini" assert "Change model?" in confirm_prompts def test_run_wizard_persists_matching_local_config_and_env(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "openai", "api_key", "gpt-5.4-mini", "skip"]) saved_llm_keys: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = "openai-secret" return m store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["wizard"]["mode"] == "quickstart" assert payload["targets"]["local"]["provider"] == "openai" assert payload["targets"]["local"]["api_key_env"] == "OPENAI_API_KEY" assert payload["targets"]["local"]["model_env"] == "OPENAI_REASONING_MODEL" assert "api_key" not in payload["targets"]["local"] assert "LLM_PROVIDER=openai\n" in env_values assert "OPENAI_API_KEY=" not in env_values assert saved_llm_keys == [("openai", "openai-secret")] def test_run_wizard_codex_skips_api_key_and_runs_cli_onboarding(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "codex", "", "skip"]) saved_llm_keys: list[tuple[str, str]] = [] cli_onboarding_providers: list[str] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _cli_onboarding(provider, **_kwargs): cli_onboarding_providers.append(provider.value) return "ok" store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "_run_cli_llm_onboarding", _cli_onboarding) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert cli_onboarding_providers == ["codex"] assert saved_llm_keys == [] payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "codex" assert payload["targets"]["local"]["api_key_env"] == "" assert payload["targets"]["local"]["model_env"] == "CODEX_MODEL" assert "LLM_PROVIDER=codex\n" in env_values assert "CODEX_MODEL=\n" in env_values assert "LLM_AUTH_METHOD=oauth\n" in env_values def test_run_wizard_openai_oauth_is_onboarding_auth_method(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "openai", "oauth", "gpt-5.5", "skip"]) saved_llm_keys: list[tuple[str, str]] = [] cli_onboarding_providers: list[str] = [] saved_summary: dict[str, object] = {} def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _cli_onboarding(provider, **kwargs): cli_onboarding_providers.append(provider.value) assert kwargs["display_label"] == "OpenAI OAuth" return "ok" store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "_run_cli_llm_onboarding", _cli_onboarding) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) monkeypatch.setattr( flow, "_render_saved_summary", lambda **kwargs: saved_summary.update(kwargs) ) exit_code = flow.run_wizard() assert exit_code == 0 assert cli_onboarding_providers == ["codex"] assert saved_llm_keys == [] payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "openai" assert payload["targets"]["local"]["auth_method"] == "oauth" assert payload["targets"]["local"]["api_key_env"] == "OPENAI_API_KEY" assert payload["targets"]["local"]["model_env"] == "CODEX_MODEL" assert payload["targets"]["local"]["model"] == "gpt-5.5" assert "LLM_PROVIDER=openai\n" in env_values assert "LLM_AUTH_METHOD=oauth\n" in env_values assert "CODEX_MODEL=gpt-5.5\n" in env_values assert "OPENAI_API_KEY=" not in env_values assert saved_summary["provider_label"] == "OpenAI OAuth" assert saved_summary["credential_line"] == "OpenAI OAuth tokens (Codex CLI)" def test_run_wizard_anthropic_oauth_is_onboarding_auth_method(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "oauth", "", "skip"]) cli_onboarding_providers: list[str] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _cli_onboarding(provider, **kwargs): cli_onboarding_providers.append(provider.value) assert kwargs["display_label"] == "Anthropic OAuth" return "ok" store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "_run_cli_llm_onboarding", _cli_onboarding) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) exit_code = flow.run_wizard() assert exit_code == 0 assert cli_onboarding_providers == ["claude-code"] payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "anthropic" assert payload["targets"]["local"]["auth_method"] == "oauth" assert payload["targets"]["local"]["api_key_env"] == "ANTHROPIC_API_KEY" assert payload["targets"]["local"]["model_env"] == "CLAUDE_CODE_MODEL" assert "LLM_PROVIDER=anthropic\n" in env_values assert "LLM_AUTH_METHOD=oauth\n" in env_values assert "CLAUDE_CODE_MODEL=\n" in env_values def test_run_wizard_claude_code_skips_api_key_and_runs_cli_onboarding( monkeypatch, tmp_path ) -> None: select_responses = iter(["quickstart", "claude-code", "", "skip"]) saved_llm_keys: list[tuple[str, str]] = [] cli_onboarding_providers: list[str] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _cli_onboarding(provider, **_kwargs): cli_onboarding_providers.append(provider.value) return "ok" store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "_run_cli_llm_onboarding", _cli_onboarding) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert cli_onboarding_providers == ["claude-code"] assert saved_llm_keys == [] payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "claude-code" assert payload["targets"]["local"]["api_key_env"] == "" assert payload["targets"]["local"]["model_env"] == "CLAUDE_CODE_MODEL" assert "LLM_PROVIDER=claude-code\n" in env_values assert "CLAUDE_CODE_MODEL=\n" in env_values def test_run_wizard_gemini_cli_skips_api_key_and_runs_cli_onboarding(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "gemini-cli", "", "skip"]) saved_llm_keys: list[tuple[str, str]] = [] cli_onboarding_providers: list[str] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _cli_onboarding(provider, **_kwargs): cli_onboarding_providers.append(provider.value) return "ok" store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(flow, "_run_cli_llm_onboarding", _cli_onboarding) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert cli_onboarding_providers == ["gemini-cli"] assert saved_llm_keys == [] payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "gemini-cli" assert payload["targets"]["local"]["api_key_env"] == "" assert payload["targets"]["local"]["model_env"] == "GEMINI_CLI_MODEL" assert "LLM_PROVIDER=gemini-cli\n" in env_values assert "GEMINI_CLI_MODEL=\n" in env_values def test_run_cli_llm_onboarding_ok_when_logged_in(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=True, logged_in=True, detail="Logged in.", ) provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter result = flow._run_cli_llm_onboarding(provider) assert result == "ok" adapter.detect.assert_called_once() def test_run_cli_llm_onboarding_repick_when_not_logged_in(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=True, logged_in=False, detail="Not logged in.", ) provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "repick") result = flow._run_cli_llm_onboarding(provider) assert result == "repick" def test_run_cli_llm_onboarding_ok_after_login_retry(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" detect_calls: list[object] = [] def _detect(): detect_calls.append(None) if len(detect_calls) == 1: return MagicMock( installed=True, logged_in=False, detail="Not logged in.", ) return MagicMock(installed=True, logged_in=True, detail="Logged in.") adapter.detect = _detect provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "retry") result = flow._run_cli_llm_onboarding(provider) assert result == "ok" assert len(detect_calls) == 2 def test_run_cli_llm_onboarding_launches_managed_codex_oauth(monkeypatch, tmp_path) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" detect_calls: list[object] = [] def _detect(): detect_calls.append(None) if len(detect_calls) == 1: return MagicMock( installed=True, logged_in=False, bin_path="/usr/local/bin/codex", detail="Not logged in.", ) return MagicMock(installed=True, logged_in=True, detail="Logged in.") adapter.detect = _detect provider = MagicMock() provider.value = "codex" provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter oauth_calls: list[object] = [] def _fake_codex_oauth_login() -> CodexOAuthResult: oauth_calls.append(None) return CodexOAuthResult( account_id="account-123", auth_path=tmp_path / "codex-home" / "auth.json", detail="OpenAI OAuth tokens stored for Codex.", ) monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "login") monkeypatch.setattr(flow, "run_codex_oauth_login", _fake_codex_oauth_login) monkeypatch.setenv("OPENSRE_LLM_AUTH_METADATA_PATH", str(tmp_path / "llm-auth.json")) result = flow._run_cli_llm_onboarding(provider) assert result == "ok" assert oauth_calls == [None] assert len(detect_calls) == 1 def test_run_cli_llm_onboarding_surfaces_managed_codex_oauth_error(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=True, logged_in=None, bin_path="/opt/homebrew/bin/codex", detail="Auth status unknown.", ) provider = MagicMock() provider.value = "codex" provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter choices: list[str] = ["login", "repick"] def _choose(*_args, **_kwargs): return choices.pop(0) monkeypatch.setattr(flow, "_choose", _choose) monkeypatch.setattr( flow, "run_codex_oauth_login", MagicMock(side_effect=flow.CodexOAuthError("Could not bind localhost:1455.")), ) monkeypatch.setattr(flow, "_run_interactive_login_process", MagicMock()) result = flow._run_cli_llm_onboarding(provider, display_label="OpenAI OAuth") assert result == "repick" flow._run_interactive_login_process.assert_not_called() def test_run_cli_llm_onboarding_launches_claude_browser_login(monkeypatch) -> None: adapter = MagicMock() adapter.name = "claude-code" adapter.binary_env_key = "CLAUDE_CODE_BIN" adapter.install_hint = "npm i -g @anthropic-ai/claude-code" adapter.auth_hint = "Run: claude auth login" detect_calls: list[object] = [] def _detect(): detect_calls.append(None) if len(detect_calls) == 1: return MagicMock( installed=True, logged_in=False, bin_path="/opt/homebrew/bin/claude", detail="Not authenticated.", ) return MagicMock(installed=True, logged_in=True, detail="Authenticated.") adapter.detect = _detect provider = MagicMock() provider.value = "claude-code" provider.label = "Anthropic Claude Code CLI" provider.adapter_factory = lambda: adapter login_commands: list[list[str]] = [] def _fake_login(command: list[str]) -> flow._LoginProcessResult: login_commands.append(command) return flow._LoginProcessResult(returncode=0) monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "login") monkeypatch.setattr(flow, "_run_interactive_login_process", _fake_login) result = flow._run_cli_llm_onboarding(provider) assert result == "ok" assert login_commands == [["/opt/homebrew/bin/claude", "auth", "login"]] assert len(detect_calls) == 1 def test_run_cli_llm_onboarding_repick_when_auth_status_unclear(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=True, logged_in=None, detail="Auth status unknown.", ) provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "repick") result = flow._run_cli_llm_onboarding(provider) assert result == "repick" def test_run_cli_llm_onboarding_ok_after_unclear_auth_retry(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" detect_calls: list[object] = [] def _detect(): detect_calls.append(None) if len(detect_calls) == 1: return MagicMock( installed=True, logged_in=None, detail="Auth status unknown.", ) return MagicMock(installed=True, logged_in=True, detail="Logged in.") adapter.detect = _detect provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "retry") result = flow._run_cli_llm_onboarding(provider) assert result == "ok" assert len(detect_calls) == 2 def test_run_cli_llm_onboarding_repick_when_user_chooses_repick(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=False, logged_in=None, detail="Not found.", ) provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "repick") result = flow._run_cli_llm_onboarding(provider) assert result == "repick" def test_run_cli_llm_onboarding_path_override_then_ok(monkeypatch, tmp_path) -> None: fake_bin = write_fake_runnable_cli_bin(tmp_path, "codex") adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" detect_calls: list[object] = [] def _detect(): detect_calls.append(None) if len(detect_calls) == 1: return MagicMock(installed=False, logged_in=None, detail="Not found.") return MagicMock(installed=True, logged_in=True, detail="Logged in.") adapter.detect = _detect provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "path") monkeypatch.setattr(flow, "_prompt_value", lambda *_args, **_kwargs: str(fake_bin)) monkeypatch.setattr(flow, "sync_env_values", lambda *_args, **_kwargs: None) original_codex_bin = os.environ.get("CODEX_BIN") try: result = flow._run_cli_llm_onboarding(provider) assert result == "ok" assert len(detect_calls) == 2 # os.environ must be updated in-process so the next detect() call in the # retry loop resolves the new binary without a process restart. assert os.environ.get("CODEX_BIN") == str(fake_bin) finally: if original_codex_bin is None: os.environ.pop("CODEX_BIN", None) else: os.environ["CODEX_BIN"] = original_codex_bin def test_run_cli_llm_onboarding_abort_after_max_retries(monkeypatch) -> None: adapter = MagicMock() adapter.name = "codex" adapter.binary_env_key = "CODEX_BIN" adapter.install_hint = "npm i -g @openai/codex" adapter.auth_hint = "Run: codex login" adapter.detect.return_value = MagicMock( installed=False, logged_in=None, detail="Not found.", ) provider = MagicMock() provider.label = "OpenAI Codex CLI" provider.adapter_factory = lambda: adapter monkeypatch.setattr(flow, "_choose", lambda *_args, **_kwargs: "retry") result = flow._run_cli_llm_onboarding(provider) assert result == "abort" assert adapter.detect.call_count == 10 def test_credential_line_for_saved_summary_cli_codex() -> None: from surfaces.cli.wizard import config as wizard_config codex = next(p for p in wizard_config.SUPPORTED_PROVIDERS if p.value == "codex") assert flow._credential_line_for_saved_summary(codex) == "OpenAI Codex CLI (Run: codex login)" def test_credential_line_for_saved_summary_cli_claude_code() -> None: from surfaces.cli.wizard import config as wizard_config claude_code = next(p for p in wizard_config.SUPPORTED_PROVIDERS if p.value == "claude-code") assert flow._credential_line_for_saved_summary(claude_code) == ( "Anthropic Claude Code CLI (Run: claude auth login or set ANTHROPIC_API_KEY)" ) def test_credential_line_for_saved_summary_cli_gemini_cli() -> None: from surfaces.cli.wizard import config as wizard_config gemini_cli = next(p for p in wizard_config.SUPPORTED_PROVIDERS if p.value == "gemini-cli") assert flow._credential_line_for_saved_summary(gemini_cli) == ( "Google Gemini CLI (Run: gemini (interactive login) or set GEMINI_API_KEY)" ) def test_credential_line_for_saved_summary_cli_copilot() -> None: from surfaces.cli.wizard import config as wizard_config copilot = next(p for p in wizard_config.SUPPORTED_PROVIDERS if p.value == "copilot") line = flow._credential_line_for_saved_summary(copilot) # PR #1533: hint surfaces both CLI paths (`copilot login`, `gh auth login`) # before the env-var bypass, matching the new CLI-first probe order. assert line.startswith("GitHub Copilot CLI (Run `copilot login`") assert "gh auth login" in line assert "COPILOT_GITHUB_TOKEN" in line def test_credential_line_for_saved_summary_anthropic() -> None: from surfaces.cli.wizard import config as wizard_config anthropic = next(p for p in wizard_config.SUPPORTED_PROVIDERS if p.value == "anthropic") assert flow._credential_line_for_saved_summary(anthropic) == "system keychain" def test_credential_line_for_saved_summary_cli_without_factory() -> None: from surfaces.cli.wizard.config import ModelOption, ProviderOption p = ProviderOption( value="codex", label="Fake CLI", group="Local CLI providers", api_key_env="", model_env="CODEX_MODEL", default_model="", models=(ModelOption(value="", label="default"),), credential_kind="cli", adapter_factory=None, allow_custom_models=True, ) assert flow._credential_line_for_saved_summary(p) == "Fake CLI (CLI)" def test_run_wizard_configures_gitlab(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "gitlab"]) password_responses = iter(["llm-secret", "glpat_test"]) text_responses = iter(["https://gitlab.example.com/api/v4"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_env_secrets: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_gitlab_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="GitLab ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr(_integration_configurators, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "gitlab", { "credentials": { "base_url": "https://gitlab.example.com/api/v4", "auth_token": "glpat_test", } }, ) ] assert synced_env_secrets == [("GITLAB_ACCESS_TOKEN", "glpat_test")] assert synced_env_values == [ { "GITLAB_BASE_URL": "https://gitlab.example.com/api/v4", } ] def test_run_wizard_gitlab_retries_on_validation_failure(monkeypatch, tmp_path) -> None: """When GitLab validation fails the first time, the wizard retries and succeeds.""" select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "gitlab"]) # First attempt: wrong token; second attempt: correct token password_responses = iter(["llm-secret", "bad_token", "glpat_good"]) # base_url is prompted on each retry text_responses = iter( [ "https://gitlab.com/api/v4", "https://gitlab.com/api/v4", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_env_secrets: list[tuple[str, str]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate_gitlab(**_kwargs): nonlocal validation_call_count validation_call_count += 1 if validation_call_count == 1: return flow.IntegrationHealthResult(ok=False, detail="Unauthorized") return flow.IntegrationHealthResult(ok=True, detail="GitLab ok") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(_integration_configurators, "validate_gitlab_integration", _validate_gitlab) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr(_integration_configurators, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert validation_call_count == 2 assert saved_integrations == [ ( "gitlab", { "credentials": { "base_url": "https://gitlab.com/api/v4", "auth_token": "glpat_good", } }, ) ] assert synced_env_secrets == [("GITLAB_ACCESS_TOKEN", "glpat_good")] assert synced_env_values == [ { "GITLAB_BASE_URL": "https://gitlab.com/api/v4", } ] def test_run_wizard_switches_provider_and_keeps_store_and_env_in_sync( monkeypatch, tmp_path ) -> None: # Saved: anthropic. User says yes to "Change provider?" and picks openai. select_responses = iter(["quickstart", "openai", "api_key", "gpt-5.4-mini", "skip"]) confirm_responses = iter([True]) # "Change provider?" -> Yes saved_llm_keys: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_confirm(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(confirm_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = "fresh-openai-key" return m store_path = tmp_path / "opensre.json" env_path = tmp_path / ".env" wizard_store.save_local_config( wizard_mode="quickstart", provider="anthropic", model="claude-opus-4-5", api_key_env="ANTHROPIC_API_KEY", model_env="ANTHROPIC_MODEL", probes={ "local": {"target": "local", "reachable": True, "detail": "ok"}, "remote": {"target": "remote", "reachable": False, "detail": "down"}, }, path=store_path, ) env_path.write_text( "LLM_PROVIDER=anthropic\n" "ANTHROPIC_API_KEY=saved-anthropic-key\n" "ANTHROPIC_MODEL=claude-opus-4-5\n", encoding="utf-8", ) monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "confirm", _mock_confirm) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(_ui, "get_store_path", lambda: store_path) monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( flow, "save_local_config", lambda **kwargs: wizard_store.save_local_config(path=store_path, **kwargs), ) monkeypatch.setattr( flow, "sync_provider_env", lambda **kwargs: sync_provider_env(env_path=env_path, **kwargs), ) monkeypatch.setattr( _ui, "save_api_key", lambda provider, value, **_kwargs: saved_llm_keys.append((provider, value)), ) exit_code = flow.run_wizard() assert exit_code == 0 payload = json.loads(store_path.read_text(encoding="utf-8")) env_values = env_path.read_text(encoding="utf-8") assert payload["targets"]["local"]["provider"] == "openai" assert payload["targets"]["local"]["api_key_env"] == "OPENAI_API_KEY" assert payload["targets"]["local"]["model_env"] == "OPENAI_REASONING_MODEL" assert "api_key" not in payload["targets"]["local"] assert "LLM_PROVIDER=openai\n" in env_values assert "OPENAI_API_KEY=" not in env_values assert "ANTHROPIC_API_KEY=" not in env_values assert "OPENAI_REASONING_MODEL=" in env_values assert saved_llm_keys == [("openai", "fresh-openai-key")] def test_run_wizard_configures_opensearch(monkeypatch, tmp_path) -> None: """Happy path: user picks opensearch, enters URL + basic auth, all gets persisted.""" select_responses = iter( ["quickstart", "anthropic", "api_key", "claude-opus-4-7", "opensearch", "basic"] ) password_responses = iter(["llm-secret", "secret-pass"]) text_responses = iter(["https://my-cluster.example.com", "admin"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_env_secrets: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_opensearch_integration", lambda **_kwargs: flow.IntegrationHealthResult(ok=True, detail="OpenSearch ok"), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr(_integration_configurators, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) monkeypatch.setattr( flow, "build_demo_action_response", lambda: {"success": True, "topics": [], "guidance": []}, ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "opensearch", { "credentials": { "url": "https://my-cluster.example.com", "username": "admin", "password": "secret-pass", } }, ) ] assert synced_env_secrets == [("OPENSEARCH_PASSWORD", "secret-pass")] assert synced_env_values == [ { "OPENSEARCH_URL": "https://my-cluster.example.com", "OPENSEARCH_USERNAME": "admin", } ] def test_run_wizard_opensearch_retries_on_validation_failure(monkeypatch, tmp_path) -> None: """When OpenSearch validation fails the first time, the wizard retries and succeeds.""" select_responses = iter( ["quickstart", "anthropic", "api_key", "claude-opus-4-7", "opensearch", "basic", "basic"] ) password_responses = iter(["llm-secret", "wrong-pass", "correct-pass"]) text_responses = iter( [ "https://my-cluster.example.com", "admin", "https://my-cluster.example.com", "admin", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate_opensearch(**_kwargs): nonlocal validation_call_count validation_call_count += 1 if validation_call_count == 1: return flow.IntegrationHealthResult(ok=False, detail="HTTP 401: unauthorized") return flow.IntegrationHealthResult(ok=True, detail="OpenSearch ok") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_opensearch_integration", _validate_opensearch ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) monkeypatch.setattr( flow, "build_demo_action_response", lambda: {"success": True, "topics": [], "guidance": []}, ) exit_code = flow.run_wizard() assert exit_code == 0 assert validation_call_count == 2 assert saved_integrations == [ ( "opensearch", { "credentials": { "url": "https://my-cluster.example.com", "username": "admin", "password": "correct-pass", } }, ) ] def test_run_wizard_opensearch_rejects_empty_api_key(monkeypatch, tmp_path) -> None: """When user picks api_key auth but enters an empty key, the wizard rejects it. Regression for the silent-credential-drop bug: on a cluster with security disabled the validator probe would return 200, result.ok would be True, and the integration would persist as URL-only — discarding the user's chosen auth method without any visible error. The wizard now guards against empty api_key explicitly (before the probe runs) and re-prompts with a clear error message. Verified by checking that validation is only called once the user supplies a non-empty key. """ # User picks: opensearch -> api_key auth -> (rejected, empty) -> api_key auth retry select_responses = iter( [ "quickstart", "anthropic", "api_key", "claude-opus-4-7", "opensearch", "api_key", "api_key", ] ) # First api_key prompt: empty (rejected). Second: valid key. password_responses = iter(["llm-secret", "", "valid-api-key"]) text_responses = iter( [ "https://my-cluster.example.com", "https://my-cluster.example.com", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate_opensearch(**_kwargs): nonlocal validation_call_count validation_call_count += 1 return flow.IntegrationHealthResult(ok=True, detail="OpenSearch ok") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_opensearch_integration", _validate_opensearch ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) monkeypatch.setattr( flow, "build_demo_action_response", lambda: {"success": True, "topics": [], "guidance": []}, ) exit_code = flow.run_wizard() assert exit_code == 0 # Validator should only be called once — after the user supplies a real key. # The empty-key attempt must be blocked before reaching the probe. assert validation_call_count == 1 assert saved_integrations == [ ( "opensearch", { "credentials": { "url": "https://my-cluster.example.com", "api_key": "valid-api-key", } }, ) ] def test_run_wizard_opensearch_rejects_empty_basic_password(monkeypatch, tmp_path) -> None: """When user picks basic auth but leaves password empty, the wizard rejects it. Companion regression for the half-credential bug: ElasticsearchConfig.headers silently drops the Authorization header when either half of Basic Auth is empty, so the agent would send unauthenticated requests against a security-enabled cluster and fail with a confusing 401. The wizard now guards against half-populated Basic Auth before the probe runs and re-prompts with a clear error message. Verified by checking that validation is only called once the user supplies both halves. """ # User picks: opensearch -> basic auth -> (rejected, empty pass) -> basic auth retry select_responses = iter( ["quickstart", "anthropic", "api_key", "claude-opus-4-7", "opensearch", "basic", "basic"] ) # First password prompt: empty (rejected). Second attempt: valid password. password_responses = iter(["llm-secret", "", "real-pass"]) text_responses = iter( [ "https://my-cluster.example.com", "admin", "https://my-cluster.example.com", "admin", ] ) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate_opensearch(**_kwargs): nonlocal validation_call_count validation_call_count += 1 return flow.IntegrationHealthResult(ok=True, detail="OpenSearch ok") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_opensearch_integration", _validate_opensearch ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) monkeypatch.setattr( flow, "build_demo_action_response", lambda: {"success": True, "topics": [], "guidance": []}, ) exit_code = flow.run_wizard() assert exit_code == 0 # Validator should only be called once — after the user supplies both halves. # The empty-password attempt must be blocked before reaching the probe. assert validation_call_count == 1 assert saved_integrations == [ ( "opensearch", { "credentials": { "url": "https://my-cluster.example.com", "username": "admin", "password": "real-pass", } }, ) ] def test_run_wizard_configures_telegram(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "telegram"]) password_responses = iter(["llm-secret", "123:ABC"]) text_responses = iter(["-1001234567890"]) saved_integrations: list[tuple[str, dict]] = [] synced_env_values: list[dict[str, str]] = [] synced_env_secrets: list[tuple[str, str]] = [] def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr( _integration_configurators, "validate_telegram_bot", lambda **_kwargs: flow.IntegrationHealthResult( ok=True, detail="Connected to Telegram bot @opensre_bot." ), ) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) def _sync_env_values(values: dict[str, str], **_kwargs): synced_env_values.append(values) return tmp_path / ".env" def _sync_env_secret(key: str, value: str) -> None: synced_env_secrets.append((key, value)) monkeypatch.setattr(_integration_configurators, "sync_env_values", _sync_env_values) monkeypatch.setattr(_integration_configurators, "sync_env_secret", _sync_env_secret) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert saved_integrations == [ ( "telegram", { "credentials": { "bot_token": "123:ABC", "default_chat_id": "-1001234567890", } }, ) ] assert synced_env_secrets == [("TELEGRAM_BOT_TOKEN", "123:ABC")] assert synced_env_values == [{"TELEGRAM_DEFAULT_CHAT_ID": "-1001234567890"}] def test_run_wizard_telegram_retries_on_validation_failure(monkeypatch, tmp_path) -> None: select_responses = iter(["quickstart", "anthropic", "api_key", "claude-opus-4-7", "telegram"]) password_responses = iter(["llm-secret", "bad-token", "123:GOOD"]) text_responses = iter(["-1001", "-1001"]) saved_integrations: list[tuple[str, dict]] = [] validation_call_count = 0 def _mock_select(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(select_responses) return m def _mock_password(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(password_responses) return m def _mock_text(*_args, **_kwargs): m = MagicMock() m.ask.return_value = next(text_responses) return m def _validate(**_kwargs): nonlocal validation_call_count validation_call_count += 1 if validation_call_count == 1: return flow.IntegrationHealthResult(ok=False, detail="Telegram API check failed.") return flow.IntegrationHealthResult(ok=True, detail="Connected to Telegram bot @bot.") monkeypatch.setattr(_ui, "select_prompt", _mock_select) monkeypatch.setattr(flow.questionary, "password", _mock_password) monkeypatch.setattr(flow.questionary, "text", _mock_text) monkeypatch.setattr(_ui, "get_store_path", lambda: tmp_path / "opensre.json") monkeypatch.setattr(flow, "probe_local_target", lambda _path: ProbeResult("local", True, "ok")) monkeypatch.setattr(_integration_configurators, "validate_telegram_bot", _validate) monkeypatch.setattr(flow, "save_local_config", lambda **_kwargs: tmp_path / "opensre.json") monkeypatch.setattr(flow, "sync_provider_env", lambda **_kwargs: tmp_path / ".env") monkeypatch.setattr(_ui, "save_llm_api_key", lambda *_args, **_kwargs: None) monkeypatch.setattr( _integration_configurators, "sync_env_values", lambda *_a, **_kw: tmp_path / ".env" ) monkeypatch.setattr(_integration_configurators, "sync_env_secret", lambda *_a, **_kw: None) monkeypatch.setattr( _integration_configurators, "upsert_integration", lambda service, payload: saved_integrations.append((service, payload)), ) exit_code = flow.run_wizard() assert exit_code == 0 assert validation_call_count == 2 assert saved_integrations[0][0] == "telegram" assert saved_integrations[0][1]["credentials"]["bot_token"] == "123:GOOD"