"""Configuration: env-only, no defaults that point at somebody else's machine.""" from __future__ import annotations import importlib import os from dataclasses import FrozenInstanceError, fields from typing import Any import pytest from dashboards import config from dashboards.auth import PBKDF2_ITERATIONS from dashboards.config import Settings, parse_targets def test_parses_whitespace_and_comma_separated_targets() -> None: targets, problems = parse_targets("a=http://h1:9120 b=http://h2:9120,c=http://h3:9120") assert [t.name for t in targets] == ["a", "b", "c"] assert [t.url for t in targets] == ["http://h1:9120", "http://h2:9120", "http://h3:9120"] assert problems == [] def test_trailing_slash_is_stripped_so_urls_do_not_double_up() -> None: targets, _ = parse_targets("a=http://h1:9120/") assert targets[0].url == "http://h1:9120" def test_one_bad_entry_does_not_sink_the_others() -> None: targets, problems = parse_targets("good=http://h:1 broken-no-equals another=ftp://h:1") assert [t.name for t in targets] == ["good"] assert len(problems) == 2 assert any("not name=url" in p for p in problems) assert any("http://" in p for p in problems) def test_empty_input_yields_no_targets_and_no_noise() -> None: assert parse_targets("") == ([], []) assert parse_targets(" \n ") == ([], []) def test_token_in_url_becomes_a_bearer_header_and_stays_out_of_the_display_url() -> None: targets, problems = parse_targets("driveripper=http://SEKRIT@driveripper:9120") assert problems == [] target = targets[0] assert target.headers == {"Authorization": "Bearer SEKRIT"} assert target.url == "http://driveripper:9120", "the token must not stay in the URL" assert target.display_url == "http://driveripper:9120" assert "SEKRIT" not in target.display_url @pytest.mark.parametrize("raw,shown", [ ("http://SEKRIT@host:9120", "http://host:9120"), ("https://user:pass@host:8000/metrics", "https://host:8000/metrics"), ("HTTP://SEKRIT@host:1", "HTTP://host:1"), ("http://host:9120", "http://host:9120"), # clean URLs pass through untouched ("", ""), ]) def test_strip_userinfo_is_the_one_redaction_every_url_leaves_by(raw: str, shown: str) -> None: """`strip_userinfo` is what `/api/config` and every `UpstreamError` message use, so it has to survive the shapes a URL actually comes in: both schemes, `user:pass@`, a path, and the common no-credentials case where it must change nothing (invariant 6). """ assert config.strip_userinfo(raw) == shown def test_target_names_are_restricted_because_they_are_db_keys() -> None: """Names key the history DB and appear in URLs, so they are validated, not guessed. Whitespace separates entries, which means "Bad Name=http://h" is two entries — both reported, because the operator needs to see exactly what was rejected. """ targets, problems = parse_targets("Bad=http://h:1 /=http://h:1 ok-1_2=http://h:1") assert [t.name for t in targets] == ["ok-1_2"] assert len(problems) == 2 assert any("Bad" in p for p in problems) _targets, problems = parse_targets("Bad Name=http://h:1") assert len(problems) == 2, "a space splits the entry, so both halves are rejected" def test_settings_default_to_no_sources_configured() -> None: settings = Settings() assert settings.host_targets == () assert settings.gufo_url == "" and settings.vllm_url == "" assert settings.nut_host == "" assert settings.nut_enabled is False assert settings.any_source_enabled is False def test_the_gateway_source_names_the_ingress_host_and_its_own_cadence() -> None: """Phase 29: one `HOST_TARGETS` name is the gateway, and it is polled on its own clock. `gateway_host` defaults to the fleet's real name so installing agent 1.2.0 on a host called `gateway` is the whole configuration — but the **default is not a target**: with no `HOST_TARGETS` entry of that name the source is unconfigured, `/api/gateway/*` 404s without dialling, and the page says so (invariant 3). The interval sits between the system poll and SMART because a connection count is the shape of half a minute, not of ten seconds. """ settings = Settings() assert settings.gateway_host == "gateway" assert settings.gateway_interval == 30.0 assert settings.gateway_timeout == 10.0 assert settings.gateway_enabled is False assert settings.any_source_enabled is False def test_gateway_enabled_is_true_only_for_a_name_that_is_a_target() -> None: """A name with no agent behind it is unconfigured, not broken — the `nut_enabled` shape.""" def with_targets(raw: str, name: str = "gateway") -> bool: targets, _problems = parse_targets(raw) return Settings(host_targets=tuple(targets), gateway_host=name).gateway_enabled assert with_targets("gateway=https://gw.example.net:9120") is True assert with_targets( "deskwork=https://d.example.net:9120 gateway=https://gw.example.net:9120") is True assert with_targets("deskwork=https://d.example.net:9120") is False assert with_targets("router=https://gw.example.net:9120", name="router") is True assert with_targets("Gateway=https://gw.example.net:9120") is False, ("names are lowercase") assert with_targets("") is False def test_the_gateway_settings_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: """`GATEWAY_HOST=router` is how a deployment whose ingress is called something else opts in.""" monkeypatch.setenv("GATEWAY_HOST", "router") monkeypatch.setenv("GATEWAY_INTERVAL_SECONDS", "15") monkeypatch.setenv("GATEWAY_TIMEOUT_SECONDS", "20") reloaded = importlib.reload(config) try: assert reloaded.SETTINGS.gateway_host == "router" assert reloaded.SETTINGS.gateway_interval == 15.0 assert reloaded.SETTINGS.gateway_timeout == 20.0 finally: for name in ("GATEWAY_HOST", "GATEWAY_INTERVAL_SECONDS", "GATEWAY_TIMEOUT_SECONDS"): monkeypatch.delenv(name) importlib.reload(config) def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None: """SMART is not a per-second signal (phase 09), and the fetch waits for the agent's own 20 s smartctl budget. Both are env-only, like every other interval.""" settings = Settings() assert settings.disks_interval == 300.0 assert settings.disks_timeout == 30.0 def test_the_disk_cadence_is_an_env_var_like_every_other_one(monkeypatch: pytest.MonkeyPatch) -> None: """`DISKS_INTERVAL_SECONDS=60` is how an operator watches a failing drive closely.""" monkeypatch.setenv("DISKS_INTERVAL_SECONDS", "60") monkeypatch.setenv("DISKS_TIMEOUT_SECONDS", "45") reloaded = importlib.reload(config) try: assert reloaded.SETTINGS.disks_interval == 60.0 assert reloaded.SETTINGS.disks_timeout == 45.0 finally: monkeypatch.delenv("DISKS_INTERVAL_SECONDS") monkeypatch.delenv("DISKS_TIMEOUT_SECONDS") importlib.reload(config) def test_there_is_one_window_knob_per_measuring_cadence() -> None: """Phase 23: `RATE_WINDOW_SECONDS` is the **host's** Δ window; the LLM's *is* its interval. These are the defaults `app.build_hub` spans its two estimators with — the wiring is pinned in `tests/test_api.py`, the payload labels in `tests/test_llm_source.py` and `tests/test_host_source.py`, both through `tests/rate_window_contract.py`. And there is exactly **one** `*window*` setting: the LLM window tracks `LLM_INTERVAL_SECONDS` by construction, so it cannot drift from the cadence it belongs to, while the host's cannot shrink to match it either (a 2 s span over a 10 s poll has nothing inside it to diff, which `test_the_host_rates_keep_their_own_cadence_s_window` pins as `n/a`). """ settings = Settings() assert settings.rate_window == 10.0 assert settings.llm_interval == 2.0 assert settings.host_interval == 10.0 window_knobs = {field.name for field in fields(Settings) if "window" in field.name} assert window_knobs == {"rate_window"}, ( "the LLM window must stay `LLM_INTERVAL_SECONDS`: a second knob drifts from its cadence") def test_actions_need_the_flag_and_are_dead_under_read_only() -> None: assert Settings(nut_allow_actions=True, read_only=False).actions_allowed is True assert Settings(nut_allow_actions=True, read_only=True).actions_allowed is False assert Settings(nut_allow_actions=False, read_only=False).actions_allowed is False def test_nut_host_accepts_a_nut_scheme_prefix() -> None: assert Settings(nut_host="nut://ups-box:3493").nut_enabled is True def test_load_reads_host_targets_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HOST_TARGETS", "deskwork=http://tok@deskwork.example.net:9120") settings = config.load() assert [t.name for t in settings.host_targets] == ["deskwork"] def test_env_driven_scalars_are_read_at_import(monkeypatch: pytest.MonkeyPatch) -> None: """The dataclass defaults read os.environ, so a reload must pick up a changed env.""" monkeypatch.setenv("DASH_PORT", "8123") monkeypatch.setenv("RETAIN_RAW_HOURS", "6") monkeypatch.setenv("NUT_ALLOW_ACTIONS", "1") reloaded = importlib.reload(config) try: assert reloaded.SETTINGS.port == 8123 assert reloaded.SETTINGS.raw_keep_hours == 6 assert reloaded.SETTINGS.actions_allowed is True finally: monkeypatch.delenv("DASH_PORT") monkeypatch.delenv("RETAIN_RAW_HOURS") monkeypatch.delenv("NUT_ALLOW_ACTIONS") importlib.reload(config) def test_flags_accept_the_usual_spellings(monkeypatch: pytest.MonkeyPatch) -> None: for truthy in ("1", "true", "TRUE", "yes", "on"): monkeypatch.setenv("DASH_READ_ONLY", truthy) assert config._flag("DASH_READ_ONLY") is True, truthy for falsey in ("0", "false", "", "no"): monkeypatch.setenv("DASH_READ_ONLY", falsey) assert config._flag("DASH_READ_ONLY") is False, falsey def test_numeric_env_must_be_a_number_or_the_default_wins(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("HOST_INTERVAL_SECONDS", "not-a-number") assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 10.0 monkeypatch.setenv("HOST_INTERVAL_SECONDS", "2.5") assert config._num("HOST_INTERVAL_SECONDS", 10.0) == 2.5 def test_settings_is_immutable_so_nobody_mutates_shared_state() -> None: settings: Any = Settings() with pytest.raises(FrozenInstanceError): settings.port = 1 # type: ignore[misc] # ---- the .env convenience -------------------------------------------------- # def test_no_env_file_is_the_normal_case(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: monkeypatch.setenv("DASH_ENV_FILE", str(tmp_path / "absent.env")) assert config.load_env_file() is None def test_env_file_seeds_values_a_deployed_env_does_not_set(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: env = tmp_path / ".env" env.write_text("DASH_PORT=9999\nNUT_HOST=ups.internal\n") monkeypatch.setenv("DASH_ENV_FILE", str(env)) monkeypatch.delenv("DASH_PORT", raising=False) monkeypatch.delenv("NUT_HOST", raising=False) assert config.load_env_file() == env # Settings reads its defaults when the module is imported, so the reload is what a real # process gets for free and what the test has to ask for explicitly reloaded = importlib.reload(config) assert reloaded.SETTINGS.port == 9999 assert reloaded.SETTINGS.nut_host == "ups.internal" def test_a_real_environment_variable_beats_the_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: """The container/quadlet env is the source of truth; the file is for laptops.""" env = tmp_path / ".env" env.write_text("DASH_PORT=9999\n") monkeypatch.setenv("DASH_ENV_FILE", str(env)) monkeypatch.setenv("DASH_PORT", "7777") reloaded = importlib.reload(config) assert os.environ["DASH_PORT"] == "7777" assert reloaded.SETTINGS.port == 7777 def test_env_file_expands_other_variables(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: """`.env.example` shows `DASH_DB=${HOME}/...` — the file has to mean what it shows.""" env = tmp_path / ".env" env.write_text("MY_BASE=/srv/dash\nDASH_DB=${MY_BASE}/dashboards.sqlite3\n") monkeypatch.setenv("DASH_ENV_FILE", str(env)) monkeypatch.delenv("MY_BASE", raising=False) monkeypatch.delenv("DASH_DB", raising=False) reloaded = importlib.reload(config) assert reloaded.SETTINGS.db_path == "/srv/dash/dashboards.sqlite3" def test_the_example_file_is_never_loaded(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: example = tmp_path / ".env.example" example.write_text("NUT_HOST=should-not-load\n") monkeypatch.setenv("DASH_ENV_FILE", str(example)) monkeypatch.delenv("NUT_HOST", raising=False) reloaded = importlib.reload(config) assert reloaded.load_env_file() is None assert reloaded.SETTINGS.nut_host == "" # ---- authentication settings (phase 11) ------------------------------------ # def test_the_auth_defaults_are_a_locked_door_and_a_long_session() -> None: """No default password, a 30-day session, no `Secure` flag, OWASP's cost. Each default is a decision, so each is asserted rather than inherited: * `admin_password == ""` is what makes "no password configured" detectable at all — a default of `testpass` would ship a known credential on every box that forgot the env. * 720 h (30 days) because a dashboard that logs the operator out mid-outage gets bypassed. * `cookie_secure` off: dev is `http://127.0.0.1`, and a default on would make the cookie unsendable there (the task adds the `X-Forwarded-Proto` case for the real proxy). * the cost is `auth.PBKDF2_ITERATIONS`, imported rather than repeated, so the primitive and the setting cannot drift. """ settings = Settings() assert settings.admin_password == "" assert settings.session_hours == 720.0 assert settings.cookie_secure is False assert settings.pbkdf2_iterations == PBKDF2_ITERATIONS == 600_000 def test_the_auth_settings_come_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DASH_ADMIN_PASSWORD", " spaced-but-real ") monkeypatch.setenv("DASH_SESSION_HOURS", "12") monkeypatch.setenv("DASH_COOKIE_SECURE", "1") monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "4096") reloaded = importlib.reload(config) try: assert reloaded.SETTINGS.admin_password == "spaced-but-real", "_env() strips" assert reloaded.SETTINGS.session_hours == 12.0 assert reloaded.SETTINGS.cookie_secure is True assert reloaded.SETTINGS.pbkdf2_iterations == 4096 finally: for name in ("DASH_ADMIN_PASSWORD", "DASH_SESSION_HOURS", "DASH_COOKIE_SECURE", "DASH_PBKDF2_ITERATIONS"): monkeypatch.delenv(name) importlib.reload(config) def test_an_empty_DASH_ADMIN_PASSWORD_means_unset_not_blank( monkeypatch: pytest.MonkeyPatch, ) -> None: """`DASH_ADMIN_PASSWORD=` is the operator saying "no seed", not "the password is empty". The distinction is load-bearing: `load_auth_state` treats `""` as no seed and fails closed, where a value of `""` that survived as a password would be the one credential shape an attacker tries first. """ monkeypatch.setenv("DASH_ADMIN_PASSWORD", "") reloaded = importlib.reload(config) try: assert reloaded.SETTINGS.admin_password == "" finally: monkeypatch.delenv("DASH_ADMIN_PASSWORD") importlib.reload(config) def test_the_pbkdf2_knob_is_for_tests_and_not_a_performance_tune( monkeypatch: pytest.MonkeyPatch, ) -> None: """A garbage value falls back to the OWASP figure rather than to 0 or 1. `_int`/`_num` answer a non-number with the default, so a typo in `DASH_PBKDF2_ITERATIONS` cannot produce a verifier hashed once. """ monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "not-a-cost") assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 600_000 monkeypatch.setenv("DASH_PBKDF2_ITERATIONS", "1000") assert config._int("DASH_PBKDF2_ITERATIONS", 600_000) == 1000