cli: add add-backend / remove-backend / list-backends commands
So nobody ever has to hand-write the OLLAMA_BACKENDS JSON again. # add a backend, probe it, print the resulting .env line: neuronetz-gateway add-backend embedded http://ollama:11434 neuronetz-gateway add-backend neuro-ollama http://neuro-ollama:11434 --token ABC # update one (e.g. rotate token): neuronetz-gateway add-backend neuro-ollama http://neuro-ollama:11434 --token XYZ --replace # remove: neuronetz-gateway remove-backend neuro-ollama # peek (tokens redacted): neuronetz-gateway list-backends # write directly to a .env file (atomic temp-file + rename): neuronetz-gateway add-backend foo http://foo:11434 --token T --write-env /app/.env # show what would change without doing it: neuronetz-gateway add-backend foo http://foo:11434 --token T --dry-run What each command does: - `add-backend NAME URL` (+ optional --token / --header / --scheme / --replace / --no-validate / --write-env / --dry-run): builds a new backend list (current list parsed from OLLAMA_BACKENDS env, or synthesized from the single-backend fallback if unset), validates the new backend by probing /api/tags with the same headers the gateway will use at runtime (`build_backend_headers`), then prints the resulting OLLAMA_BACKENDS=... line ready to paste — or writes it in place if --write-env is given. Refuses to overwrite an existing name unless --replace is passed. - `remove-backend NAME` (+ --write-env / --dry-run): mirror of add-backend for removal. - `list-backends`: shows the configured backends with tokens redacted to "***" via `redacted_dump`. Useful sanity check after editing .env. All the JSON manipulation is in a new pure-helpers module `cli/backends.py` (parse / serialize / add_or_replace / remove / update_env_file). The Typer commands in `cli/manage.py` are thin shells on top — the logic is unit-tested directly without spinning up Typer or the network. The token is unwrapped from SecretStr exactly once at the serialization boundary (`to_dict`) and never logged. New tests (16): full coverage of the helpers — round-trip serialize/parse, duplicate-name rejection, replace-in-place order preservation, remove on unknown name, redaction, atomic env-file rewrite (insert / replace / idempotent re-apply / create-when-missing). ruff (incl. the per-file ignore add for tests' S105/S106 — placeholder "tok123"-style strings are inputs, not credentials) + mypy --strict (68 source files) clean. pytest: 76 passed + 39 skipped (the 16 new tests + no regressions on the existing 60).
This commit is contained in:
155
tests/unit/test_cli_backends.py
Normal file
155
tests/unit/test_cli_backends.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for the pure helpers behind add-backend / remove-backend / list-backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from neuronetz_gateway.cli.backends import (
|
||||
add_or_replace,
|
||||
parse,
|
||||
redacted_dump,
|
||||
remove,
|
||||
serialize,
|
||||
to_dict,
|
||||
update_env_file,
|
||||
)
|
||||
from neuronetz_gateway.config import BackendSpec
|
||||
|
||||
|
||||
def _spec(
|
||||
name: str, url: str, token: str | None = None, header: str = "Authorization"
|
||||
) -> BackendSpec:
|
||||
return BackendSpec(
|
||||
name=name,
|
||||
base_url=url,
|
||||
auth_token=SecretStr(token) if token else None,
|
||||
auth_header=header,
|
||||
)
|
||||
|
||||
|
||||
def test_to_dict_omits_default_header_and_scheme() -> None:
|
||||
out = to_dict(_spec("a", "http://x"))
|
||||
assert out == {"name": "a", "base_url": "http://x"}
|
||||
|
||||
|
||||
def test_to_dict_includes_token_when_present() -> None:
|
||||
out = to_dict(_spec("a", "http://x", token="s3cret"))
|
||||
assert out["auth_token"] == "s3cret"
|
||||
|
||||
|
||||
def test_to_dict_includes_non_default_header() -> None:
|
||||
out = to_dict(_spec("a", "http://x", token="t", header="X-API-Key"))
|
||||
assert out["auth_header"] == "X-API-Key"
|
||||
|
||||
|
||||
def test_redacted_dump_hides_token() -> None:
|
||||
out = redacted_dump(_spec("a", "http://x", token="s3cret"))
|
||||
assert out["auth_token"] == "***"
|
||||
# original to_dict still shows it (this fn is the secrets boundary)
|
||||
assert to_dict(_spec("a", "http://x", token="s3cret"))["auth_token"] == "s3cret"
|
||||
|
||||
|
||||
def test_serialize_round_trips_via_parse() -> None:
|
||||
backends = [
|
||||
_spec("embedded", "http://ollama:11434"),
|
||||
_spec("public", "https://ollama.neuronetz.ai", token="tok123"),
|
||||
]
|
||||
raw = serialize(backends)
|
||||
parsed = parse(raw)
|
||||
assert [b.name for b in parsed] == ["embedded", "public"]
|
||||
assert parsed[0].auth_token is None
|
||||
assert parsed[1].auth_token is not None
|
||||
assert parsed[1].auth_token.get_secret_value() == "tok123"
|
||||
assert parsed[1].base_url == "https://ollama.neuronetz.ai"
|
||||
|
||||
|
||||
def test_parse_empty_returns_empty_list() -> None:
|
||||
assert parse("") == []
|
||||
assert parse(" ") == []
|
||||
|
||||
|
||||
def test_parse_rejects_non_array() -> None:
|
||||
with pytest.raises(ValueError, match="must be a JSON array"):
|
||||
parse('{"name":"a","base_url":"http://x"}')
|
||||
|
||||
|
||||
def test_add_appends_when_name_is_new() -> None:
|
||||
backends = [_spec("a", "http://a")]
|
||||
new = _spec("b", "http://b")
|
||||
out = add_or_replace(backends, new)
|
||||
assert [b.name for b in out] == ["a", "b"]
|
||||
|
||||
|
||||
def test_add_rejects_duplicate_without_replace() -> None:
|
||||
backends = [_spec("a", "http://a")]
|
||||
new = _spec("a", "http://a-different")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
add_or_replace(backends, new)
|
||||
|
||||
|
||||
def test_add_replaces_in_place_when_replace_true() -> None:
|
||||
backends = [_spec("a", "http://old"), _spec("b", "http://b")]
|
||||
new = _spec("a", "http://new", token="t")
|
||||
out = add_or_replace(backends, new, replace=True)
|
||||
# Order preserved — "a" stays in slot 0, "b" stays in slot 1.
|
||||
assert [b.name for b in out] == ["a", "b"]
|
||||
assert out[0].base_url == "http://new"
|
||||
assert out[0].auth_token is not None
|
||||
assert out[0].auth_token.get_secret_value() == "t"
|
||||
|
||||
|
||||
def test_remove_drops_named_backend() -> None:
|
||||
backends = [_spec("a", "http://a"), _spec("b", "http://b")]
|
||||
out = remove(backends, "a")
|
||||
assert [b.name for b in out] == ["b"]
|
||||
|
||||
|
||||
def test_remove_rejects_unknown_name() -> None:
|
||||
backends = [_spec("a", "http://a")]
|
||||
with pytest.raises(ValueError, match="no backend named 'ghost'"):
|
||||
remove(backends, "ghost")
|
||||
|
||||
|
||||
def test_update_env_file_inserts_when_key_absent(tmp_path: Path) -> None:
|
||||
env = tmp_path / ".env"
|
||||
env.write_text("FOO=1\nBAR=hello\n", encoding="utf-8")
|
||||
update_env_file(env, "[{}]")
|
||||
text = env.read_text(encoding="utf-8")
|
||||
assert "FOO=1" in text
|
||||
assert "BAR=hello" in text
|
||||
assert text.rstrip().endswith("OLLAMA_BACKENDS=[{}]")
|
||||
|
||||
|
||||
def test_update_env_file_replaces_existing_line(tmp_path: Path) -> None:
|
||||
env = tmp_path / ".env"
|
||||
env.write_text(
|
||||
"FOO=1\nOLLAMA_BACKENDS=old\nBAR=hello\n", encoding="utf-8"
|
||||
)
|
||||
update_env_file(env, "new")
|
||||
text = env.read_text(encoding="utf-8")
|
||||
# exactly one OLLAMA_BACKENDS line, with the new value
|
||||
matches = [line for line in text.splitlines() if line.startswith("OLLAMA_BACKENDS=")]
|
||||
assert matches == ["OLLAMA_BACKENDS=new"]
|
||||
# untouched lines preserved
|
||||
assert "FOO=1" in text
|
||||
assert "BAR=hello" in text
|
||||
|
||||
|
||||
def test_update_env_file_is_idempotent(tmp_path: Path) -> None:
|
||||
env = tmp_path / ".env"
|
||||
env.write_text("KEEP=this\n", encoding="utf-8")
|
||||
update_env_file(env, "v1")
|
||||
update_env_file(env, "v1") # same value twice
|
||||
text = env.read_text(encoding="utf-8")
|
||||
matches = [line for line in text.splitlines() if line.startswith("OLLAMA_BACKENDS=")]
|
||||
assert matches == ["OLLAMA_BACKENDS=v1"]
|
||||
assert "KEEP=this" in text
|
||||
|
||||
|
||||
def test_update_env_file_creates_when_missing(tmp_path: Path) -> None:
|
||||
env = tmp_path / "fresh.env" # doesn't exist yet
|
||||
update_env_file(env, "x")
|
||||
assert env.read_text(encoding="utf-8").rstrip() == "OLLAMA_BACKENDS=x"
|
||||
Reference in New Issue
Block a user