567 lines
20 KiB
Python
567 lines
20 KiB
Python
"""Input validation and stored-value seeding in the interactive wizard.
|
|
|
|
These tests drive the questionary seams with a validate-aware fake prompt:
|
|
like the real library, a canned input is only returned once the prompt's
|
|
``validate=`` hook accepts it, so garbage input re-prompts instead of leaking
|
|
into ``int()``/pydantic and crashing the wizard mid-section.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.channel_specs import ChannelSetupField
|
|
from opensquilla.onboarding.errors import UserCancelledError
|
|
from opensquilla.onboarding.flow import OnboardOptions
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS
|
|
|
|
|
|
class _ValidatedPrompt:
|
|
"""Fake prompt honouring ``validate=`` the way questionary does.
|
|
|
|
Rejected input is re-asked (the next canned value is tried), so a
|
|
garbage value can only be returned when the prompt carries no validator
|
|
— which is exactly the regression each test pins.
|
|
"""
|
|
|
|
def __init__(self, values: list[Any], validate: Any) -> None:
|
|
self._values = list(values)
|
|
self._validate = validate
|
|
|
|
def ask(self) -> Any:
|
|
while self._values:
|
|
candidate = self._values.pop(0)
|
|
if candidate is None:
|
|
return None # cancel short-circuits validation
|
|
if self._validate is None or self._validate(candidate) is True:
|
|
return candidate
|
|
raise AssertionError("prompt inputs exhausted without an accepted value")
|
|
|
|
|
|
class _RecordingConsole:
|
|
def __init__(self) -> None:
|
|
self.messages: list[str] = []
|
|
|
|
def print(self, message: str = "", *_a, **_kw) -> None:
|
|
self.messages.append(str(message))
|
|
|
|
def joined(self) -> str:
|
|
return "\n".join(self.messages)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1-5 — numeric prompts re-prompt on garbage instead of raising ValueError.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _duckduckgo_search_questionary(max_results_inputs: list[Any]):
|
|
class _Answer:
|
|
def __init__(self, value: Any) -> None:
|
|
self.value = value
|
|
|
|
def ask(self) -> Any:
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
if message == "Max search results":
|
|
return _ValidatedPrompt(max_results_inputs, kwargs.get("validate"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs: Any):
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs: Any):
|
|
return _Answer(False)
|
|
|
|
def password(self, message: str, **_kwargs: Any):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
return _Questionary()
|
|
|
|
|
|
def test_search_max_results_garbage_reprompts_instead_of_crashing():
|
|
"""Typing a non-number used to raise a raw ``ValueError`` that aborted
|
|
every remaining onboarding section and discarded the just-entered
|
|
search credentials."""
|
|
|
|
answers = flow._ask_search_fields(
|
|
_duckduckgo_search_questionary(["banana", "7"]),
|
|
get_search_provider_setup_spec("duckduckgo"),
|
|
)
|
|
|
|
assert answers["max_results"] == 7
|
|
|
|
|
|
def test_search_max_results_blank_keeps_default():
|
|
answers = flow._ask_search_fields(
|
|
_duckduckgo_search_questionary([""]),
|
|
get_search_provider_setup_spec("duckduckgo"),
|
|
)
|
|
|
|
assert answers["max_results"] == DEFAULT_SEARCH_MAX_RESULTS
|
|
|
|
|
|
def test_search_max_results_validator_enforces_minimum_of_one():
|
|
"""The write side requires an integer >= 1; the prompt must reject the
|
|
same range up front instead of failing at persist time."""
|
|
|
|
answers = flow._ask_search_fields(
|
|
_duckduckgo_search_questionary(["0", "-3", "2"]),
|
|
get_search_provider_setup_spec("duckduckgo"),
|
|
)
|
|
|
|
assert answers["max_results"] == 2
|
|
|
|
|
|
def test_channel_int_field_garbage_reprompts_instead_of_crashing():
|
|
field = ChannelSetupField(
|
|
name="poll_interval",
|
|
label="Poll interval",
|
|
field_type="int",
|
|
required=True,
|
|
default=3,
|
|
)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Poll interval"
|
|
return _ValidatedPrompt(["lots", "5"], kwargs.get("validate"))
|
|
|
|
assert flow._ask_channel_field(_Questionary(), field, field.default) == 5
|
|
|
|
|
|
def test_channel_float_field_garbage_reprompts_instead_of_crashing():
|
|
field = ChannelSetupField(
|
|
name="timeout",
|
|
label="Timeout seconds",
|
|
field_type="float",
|
|
required=True,
|
|
default=1.5,
|
|
)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Timeout seconds"
|
|
return _ValidatedPrompt(["fast", "2.5"], kwargs.get("validate"))
|
|
|
|
assert flow._ask_channel_field(_Questionary(), field, field.default) == 2.5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1-6 — required base URL: validated input, Esc cancels.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_required_base_url_rejects_blank_and_non_http_schemes():
|
|
"""Plain Enter used to coerce the answer to '' and kill the wizard with
|
|
a raw ``ValueError`` at persist time."""
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Base URL"
|
|
captured["validate"] = kwargs.get("validate")
|
|
return _ValidatedPrompt(
|
|
["", "ftp://intranet.test", "http://localhost:8000/v1"],
|
|
kwargs.get("validate"),
|
|
)
|
|
|
|
answers = flow._ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("vllm"),
|
|
OnboardOptions(model="stub-model"),
|
|
)
|
|
|
|
assert answers["base_url"] == "http://localhost:8000/v1"
|
|
validate = captured["validate"]
|
|
assert validate is not None
|
|
assert validate("") is not True
|
|
assert validate(" ") is not True
|
|
assert validate("ftp://intranet.test") is not True
|
|
assert validate("https://example.test/v1") is True
|
|
|
|
|
|
def test_required_base_url_cancel_raises_user_cancelled():
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Base URL"
|
|
return _ValidatedPrompt([None], kwargs.get("validate"))
|
|
|
|
with pytest.raises(UserCancelledError) as exc_info:
|
|
flow._ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("vllm"),
|
|
OnboardOptions(model="stub-model"),
|
|
)
|
|
assert exc_info.value.section == "provider"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1-7 — wizard re-run seeds prompt defaults from the stored provider entry.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stored_config(provider: str, *, base_url: str, proxy: str) -> Any:
|
|
return types.SimpleNamespace(
|
|
llm=types.SimpleNamespace(provider=provider, base_url=base_url, proxy=proxy)
|
|
)
|
|
|
|
|
|
def test_provider_rerun_seeds_base_url_prompt_and_keeps_proxy():
|
|
"""Re-running the wizard for an already-stored provider must default the
|
|
Base URL prompt to the stored endpoint and keep the stored proxy —
|
|
accepting the defaults used to silently resave the spec defaults."""
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Base URL"
|
|
captured["default"] = kwargs.get("default")
|
|
return _ValidatedPrompt([kwargs.get("default")], kwargs.get("validate"))
|
|
|
|
answers = flow._ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("vllm"),
|
|
OnboardOptions(model="stub-model"),
|
|
config=_stored_config(
|
|
"vllm",
|
|
base_url="http://intranet.test:8000/v1",
|
|
proxy="http://proxy.test:3128",
|
|
),
|
|
)
|
|
|
|
assert captured["default"] == "http://intranet.test:8000/v1"
|
|
assert answers["base_url"] == "http://intranet.test:8000/v1"
|
|
assert answers["proxy"] == "http://proxy.test:3128"
|
|
|
|
|
|
def test_provider_rerun_keeps_stored_custom_base_url_without_prompt(monkeypatch):
|
|
"""Providers that do not prompt for a base URL must still keep a stored
|
|
custom endpoint and proxy on re-run instead of resaving spec defaults."""
|
|
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **_kwargs: Any):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = flow._ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(api_key_env="OPENROUTER_API_KEY"),
|
|
config=_stored_config(
|
|
"openrouter",
|
|
base_url="https://relay.internal.test/v1",
|
|
proxy="http://proxy.test:3128",
|
|
),
|
|
)
|
|
|
|
assert answers["base_url"] == "https://relay.internal.test/v1"
|
|
assert answers["proxy"] == "http://proxy.test:3128"
|
|
|
|
|
|
def test_provider_switch_ignores_other_providers_stored_endpoint(monkeypatch):
|
|
"""Stored values belong to the stored provider only — selecting a
|
|
different provider must fall back to that provider's spec defaults."""
|
|
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **_kwargs: Any):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
spec = get_provider_setup_spec("openrouter")
|
|
answers = flow._ask_provider_fields(
|
|
_Questionary(),
|
|
spec,
|
|
OnboardOptions(api_key_env="OPENROUTER_API_KEY"),
|
|
config=_stored_config(
|
|
"groq",
|
|
base_url="https://groq-relay.internal.test/v1",
|
|
proxy="http://proxy.test:3128",
|
|
),
|
|
)
|
|
|
|
assert answers["base_url"] == spec.default_base_url
|
|
assert answers["proxy"] == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1-8 — channel edit: blank keeps the stored secret.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_channel_secret_with_stored_value_allows_blank_and_hints(monkeypatch):
|
|
recorder = _RecordingConsole()
|
|
monkeypatch.setattr(flow, "console", recorder)
|
|
|
|
field = ChannelSetupField(
|
|
name="token",
|
|
label="Bot token",
|
|
field_type="password",
|
|
required=True,
|
|
secret=True,
|
|
)
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Questionary:
|
|
def password(self, message: str, **kwargs: Any):
|
|
assert message == "Bot token"
|
|
captured["validate"] = kwargs.get("validate")
|
|
return _ValidatedPrompt([""], kwargs.get("validate"))
|
|
|
|
result = flow._ask_channel_field(_Questionary(), field, "xoxb-stored")
|
|
|
|
# Blank resolves to the stored value at the prompt itself (not via the
|
|
# mutation's by-name merge), so the keep promise also holds when the
|
|
# operator renames the entry in the same edit.
|
|
assert result == "xoxb-stored"
|
|
validate = captured["validate"]
|
|
assert validate is not None
|
|
assert validate("") is True
|
|
# Broken terminal pastes are still rejected even in keep-current mode.
|
|
assert validate("\x1b[200~xoxb-new\x1b[201~") is not True
|
|
assert "leave blank to keep the stored value" in recorder.joined()
|
|
|
|
|
|
def test_channel_secret_without_stored_value_still_requires_input():
|
|
field = ChannelSetupField(
|
|
name="token",
|
|
label="Bot token",
|
|
field_type="password",
|
|
required=True,
|
|
secret=True,
|
|
)
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Questionary:
|
|
def password(self, message: str, **kwargs: Any):
|
|
captured["validate"] = kwargs.get("validate")
|
|
return _ValidatedPrompt(["", "xoxb-new"], kwargs.get("validate"))
|
|
|
|
result = flow._ask_channel_field(_Questionary(), field, None)
|
|
|
|
assert result == "xoxb-new"
|
|
assert captured["validate"]("") is not True
|
|
|
|
|
|
def test_channel_edit_blank_secrets_keep_stored_values(tmp_path, monkeypatch):
|
|
"""End-to-end edit: leaving both secret prompts blank must round-trip the
|
|
stored credentials through the mutation layer's keep-current merge."""
|
|
|
|
target = tmp_path / "c.toml"
|
|
from opensquilla.onboarding.setup_engine import SetupEngine
|
|
|
|
engine = SetupEngine(path=target)
|
|
engine.apply(
|
|
"channels",
|
|
{
|
|
"type": "slack",
|
|
"name": "slack-main",
|
|
"token": "xoxb-stored",
|
|
"signing_secret": "ss-stored",
|
|
},
|
|
)
|
|
engine.persist()
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "console", _RecordingConsole())
|
|
|
|
passwords: list[str] = []
|
|
|
|
class _Answer:
|
|
def __init__(self, value: Any) -> None:
|
|
self.value = value
|
|
|
|
def ask(self) -> Any:
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def select(self, message: str, **kwargs: Any):
|
|
if message == "Channel to edit":
|
|
return _Answer("slack-main")
|
|
return _Answer(kwargs.get("default") or list(kwargs.get("choices") or [])[0])
|
|
|
|
def text(self, message: str, **kwargs: Any):
|
|
return _ValidatedPrompt([kwargs.get("default", "")], kwargs.get("validate"))
|
|
|
|
def password(self, message: str, **kwargs: Any):
|
|
passwords.append(message)
|
|
return _ValidatedPrompt([""], kwargs.get("validate"))
|
|
|
|
def confirm(self, message: str, **kwargs: Any):
|
|
return _Answer(bool(kwargs.get("default")))
|
|
|
|
def checkbox(self, message: str, **_kwargs: Any):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_channel_edit(None, config_path=target)
|
|
|
|
assert passwords, "the edit flow should have prompted for at least one secret"
|
|
data = target.read_text()
|
|
assert 'token = "xoxb-stored"' in data
|
|
assert 'signing_secret = "ss-stored"' in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# C1-9 — free-text model prompt must reject a blank submit (R9).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_free_text_model_prompt_rejects_blank_then_accepts_model():
|
|
"""Providers without a derivable default model raise ValueError('model
|
|
is required') deep in the mutation after the operator already typed the
|
|
API key — the model prompt must re-ask on a blank submit instead."""
|
|
captured: dict[str, Any] = {}
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
assert message == "Model id"
|
|
captured["validate"] = kwargs.get("validate")
|
|
return _ValidatedPrompt(["", " ", "my-model"], kwargs.get("validate"))
|
|
|
|
result = flow._prompt_free_text_model(_Questionary())
|
|
|
|
assert result == "my-model"
|
|
validate = captured["validate"]
|
|
assert validate is not None
|
|
assert validate("") is not True
|
|
assert validate(" ") is not True
|
|
assert validate("my-model") is True
|
|
|
|
|
|
def test_free_text_model_prompt_cancel_raises_user_cancelled():
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
return _ValidatedPrompt([None], kwargs.get("validate"))
|
|
|
|
with pytest.raises(UserCancelledError) as exc_info:
|
|
flow._prompt_free_text_model(_Questionary())
|
|
assert exc_info.value.section == "provider"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wizard numeric channel fields share the headless --field coercer (F32).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_channel_int_field_blank_keeps_the_displayed_default():
|
|
"""Clearing the pre-filled default must keep it — the wizard twin of the
|
|
headless contract where omitting --field keeps the stored/spec value.
|
|
The old hand-rolled coercion silently turned a blank answer into 0."""
|
|
|
|
field = ChannelSetupField(
|
|
name="poll_timeout_s",
|
|
label="Polling timeout (s)",
|
|
field_type="int",
|
|
required=False,
|
|
default=30,
|
|
)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
return _ValidatedPrompt([""], kwargs.get("validate"))
|
|
|
|
assert flow._ask_channel_field(_Questionary(), field, field.default) == 30
|
|
|
|
|
|
def test_channel_int_field_agrees_with_headless_coercion_semantics():
|
|
"""The wizard validator and the headless --field parser must accept and
|
|
reject exactly the same spellings for the same spec field."""
|
|
|
|
from opensquilla.cli.channel_fields import coerce_channel_field_value
|
|
|
|
field = ChannelSetupField(
|
|
name="intents",
|
|
label="Intents bitmask",
|
|
field_type="int",
|
|
required=False,
|
|
default=0,
|
|
)
|
|
validate = flow._channel_number_validator(field)
|
|
|
|
import typer
|
|
|
|
for raw in ("5", "-3", " 12 "):
|
|
assert validate(raw) is True
|
|
expected = coerce_channel_field_value("int", raw.strip(), field_name="intents")
|
|
value, error = flow._coerce_channel_prompt_value(field, raw.strip())
|
|
assert error is None
|
|
assert value == expected
|
|
|
|
for raw in ("abc", "7.5", "0x10"):
|
|
wizard_verdict = validate(raw)
|
|
assert wizard_verdict is not True
|
|
with pytest.raises(typer.BadParameter) as exc_info:
|
|
coerce_channel_field_value("int", raw, field_name="intents")
|
|
# Same wording too, with the flag swapped for the field label the
|
|
# wizard user actually sees.
|
|
assert wizard_verdict == str(exc_info.value.message).replace(
|
|
"--field intents", "Intents bitmask"
|
|
)
|
|
|
|
|
|
def test_channel_int_field_error_names_the_label_not_the_flag():
|
|
"""The wizard user never typed --field: the re-prompt message must carry
|
|
the field label while keeping the shared coercer's wording."""
|
|
|
|
field = ChannelSetupField(
|
|
name="intents",
|
|
label="Intents bitmask",
|
|
field_type="int",
|
|
required=False,
|
|
default=0,
|
|
)
|
|
verdict = flow._channel_number_validator(field)("banana")
|
|
|
|
assert isinstance(verdict, str)
|
|
assert "Intents bitmask" in verdict
|
|
assert "--field" not in verdict
|
|
assert "expects an integer" in verdict
|
|
|
|
|
|
def test_channel_float_field_agrees_with_headless_coercion_semantics():
|
|
import typer
|
|
|
|
from opensquilla.cli.channel_fields import coerce_channel_field_value
|
|
|
|
field = ChannelSetupField(
|
|
name="poll_idle_sleep_s",
|
|
label="Poll idle sleep (s)",
|
|
field_type="float",
|
|
required=False,
|
|
default=1.5,
|
|
)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **kwargs: Any):
|
|
return _ValidatedPrompt(["not-a-number", "2.5"], kwargs.get("validate"))
|
|
|
|
value = flow._ask_channel_field(_Questionary(), field, field.default)
|
|
|
|
assert value == coerce_channel_field_value("float", "2.5", field_name=field.name)
|
|
assert isinstance(value, float)
|
|
|
|
# Rejection wording stays in lockstep with the headless parser as well.
|
|
wizard_verdict = flow._channel_number_validator(field)("not-a-number")
|
|
with pytest.raises(typer.BadParameter) as exc_info:
|
|
coerce_channel_field_value("float", "not-a-number", field_name=field.name)
|
|
assert wizard_verdict == str(exc_info.value.message).replace(
|
|
f"--field {field.name}", field.label
|
|
)
|