"""In-app settings overlay (the dashboard 'gear') + swap-lock routing regression. Covers app_settings (the /data overlay backing the gear): first-run seeding from env (the migration path), known-key filtering, apply() validation, secret masking — and, end-to-end via TestClient, that POST /api/settings reloads the shared Settings instance live, and that GET /api/swap/lock is no longer shadowed by /api/swap/{job_id}. """ import json import pytest from app import app_settings @pytest.fixture def overlay_file(tmp_path, monkeypatch): p = tmp_path / "app_settings.json" monkeypatch.setenv("APP_SETTINGS_FILE", str(p)) return p # ---- overlay store ---- def test_seed_from_env_filters_unknown_and_blank(overlay_file): # An existing install upgrading in: values previously set via the StartOS # action arrive as env; only known, non-empty keys migrate into the overlay. app_settings.seed_from_env({ "VLLM_PORT": "8000", "QDRANT_COLLECTION": "", # blank → skipped "TOTALLY_UNKNOWN": "x", # not a gear key → skipped "PARAKEET_PORT": "8010", }) expected = {"VLLM_PORT": "8000", "PARAKEET_PORT": "8010"} assert app_settings.load_overlay() == expected assert json.loads(overlay_file.read_text()) == expected def test_seed_is_a_one_time_noop_when_file_present(overlay_file): overlay_file.write_text(json.dumps({"VLLM_PORT": "8000", "BOGUS": "y", "NGC_API_KEY": ""})) app_settings.seed_from_env({"VLLM_PORT": "9999"}) # file exists ⇒ no-op # unknown + blank keys dropped on read; existing value untouched by the seed. assert app_settings.load_overlay() == {"VLLM_PORT": "8000"} def test_no_file_is_empty_and_seed_of_blank_env_writes_nothing(overlay_file): assert app_settings.load_overlay() == {} app_settings.seed_from_env({"VLLM_PORT": "", "QDRANT_COLLECTION": ""}) assert not overlay_file.exists() # nothing worth seeding ⇒ no file assert app_settings.load_overlay() == {} def test_apply_set_then_blank_deletes(overlay_file): app_settings.apply({"VLLM_PORT": "8000"}) assert app_settings.load_overlay()["VLLM_PORT"] == "8000" app_settings.apply({"VLLM_PORT": ""}) # blank non-secret ⇒ revert to default assert "VLLM_PORT" not in app_settings.load_overlay() def test_apply_rejects_unknown_key(overlay_file): with pytest.raises(app_settings.SettingsError): app_settings.apply({"NOT_A_KNOB": "x"}) def test_apply_rejects_non_numeric_port(overlay_file): with pytest.raises(app_settings.SettingsError): app_settings.apply({"PARAKEET_PORT": "80x0"}) def test_apply_rejects_control_chars(overlay_file): with pytest.raises(app_settings.SettingsError): app_settings.apply({"QDRANT_COLLECTION": "a\nb"}) def test_secret_blank_keeps_existing(overlay_file): app_settings.apply({"NGC_API_KEY": "nvapi-abc"}) app_settings.apply({"NGC_API_KEY": ""}) # blank secret ⇒ leave it in place assert app_settings.load_overlay()["NGC_API_KEY"] == "nvapi-abc" def test_apply_rejects_out_of_range_port(overlay_file): for bad in ("0", "99999", "65536"): with pytest.raises(app_settings.SettingsError): app_settings.apply({"VLLM_PORT": bad}) def test_apply_accepts_port_bounds(overlay_file): app_settings.apply({"VLLM_PORT": "1", "PARAKEET_PORT": "65535"}) o = app_settings.load_overlay() assert o["VLLM_PORT"] == "1" and o["PARAKEET_PORT"] == "65535" def test_secret_clear_sentinel_removes(overlay_file): app_settings.apply({"NGC_API_KEY": "nvapi-abc"}) app_settings.apply({"NGC_API_KEY": app_settings.CLEAR_SENTINEL}) assert "NGC_API_KEY" not in app_settings.load_overlay() def test_seed_skips_invalid_and_strips(overlay_file): app_settings.seed_from_env({ "VLLM_PORT": "8000\n", # trailing newline → stripped "PARAKEET_PORT": "99999", # out of range → skipped, not written "QDRANT_COLLECTION": "crm", }) o = app_settings.load_overlay() assert o["VLLM_PORT"] == "8000" assert "PARAKEET_PORT" not in o assert o["QDRANT_COLLECTION"] == "crm" def test_public_view_exposes_clear_sentinel(overlay_file): assert app_settings.public_view()["clear_sentinel"] == app_settings.CLEAR_SENTINEL def test_public_view_masks_secrets_and_groups(overlay_file): app_settings.apply({"NGC_API_KEY": "nvapi-abc", "VLLM_PORT": "8000"}) view = app_settings.public_view() fields = {f["key"]: f for g in view["groups"] for f in g["fields"]} # Secret: value never echoed to the browser, only a set flag. assert "value" not in fields["NGC_API_KEY"] assert fields["NGC_API_KEY"]["set"] is True # Non-secret: current value present for prefill. assert fields["VLLM_PORT"]["value"] == "8000" assert {g["name"] for g in view["groups"]} >= {"vLLM (Spark 1)", "Integrations"} # The previously-missing support-service ports are now exposed. assert {"PARAKEET_PORT", "KOKORO_PORT", "EMBED_PORT", "QDRANT_PORT"} <= set(fields) # ---- end-to-end (TestClient): live reload + route order ---- # TestClient is created without the `with` context manager so app startup events # (the deep-health poll loop) don't run — these stay fully offline. def _client(monkeypatch, tmp_path): monkeypatch.setenv("APP_SETTINGS_FILE", str(tmp_path / "live.json")) from fastapi.testclient import TestClient from app import server return TestClient(server.app) def test_swap_lock_get_is_not_shadowed(monkeypatch, tmp_path): client = _client(monkeypatch, tmp_path) r = client.get("/api/swap/lock") # Regression: must hit get_swap_lock (200, {"held": False}), NOT the # /api/swap/{job_id} catch-all that returns 404 "no such job". assert r.status_code == 200 assert r.json() == {"held": False} def test_settings_apply_is_live_without_restart(monkeypatch, tmp_path): client = _client(monkeypatch, tmp_path) r = client.post("/api/settings", json={"values": {"VLLM_PORT": "8123"}}) assert r.status_code == 200 # Settings reloaded in place ⇒ /api/config reflects it immediately. assert client.get("/api/config").json()["vllm_port"] == 8123 # And clearing it reverts to the default, still live. client.post("/api/settings", json={"values": {"VLLM_PORT": ""}}) assert client.get("/api/config").json()["vllm_port"] == 8888 def test_settings_post_rejects_bad_value(monkeypatch, tmp_path): client = _client(monkeypatch, tmp_path) r = client.post("/api/settings", json={"values": {"PARAKEET_PORT": "nope"}}) assert r.status_code == 422 def test_webhook_notifier_repoints_live(monkeypatch, tmp_path): # WebhookNotifier snapshots url/secret, so reload() alone can't reach it; # post_settings must re-point it. Regression for that P1. client = _client(monkeypatch, tmp_path) from app import server client.post("/api/settings", json={"values": {"SWAP_WEBHOOK_URL": "https://example.test/hook"}}) assert server.swap_webhook.url == "https://example.test/hook" assert server.swap_webhook.enabled client.post("/api/settings", json={"values": {"SWAP_WEBHOOK_URL": ""}}) assert server.swap_webhook.url == ""