Files
compose-farm/tests/web/test_helpers.py
Bas Nijholt 1e3b1d71ed Drop CF_LOCAL_HOST; limit web-stack inference to containers (#163)
* config: Add local_host and web_stack options

Allow configuring local_host and web_stack in compose-farm.yaml instead
of requiring environment variables. This makes it easier to deploy the
web UI with just a config file mount.

- local_host: specifies which host is "local" for Glances connectivity
- web_stack: identifies the web UI stack for self-update detection

Environment variables (CF_LOCAL_HOST, CF_WEB_STACK) still work as
fallback for backwards compatibility.

Closes #152

* docs: Clarify glances_stack is used by CLI and web UI

* config: Env vars override config, add docs

- Change precedence: environment variables now override config values
  (follows 12-factor app pattern)
- Document all CF_* environment variables in configuration.md
- Update example-config.yaml to mention env var overrides

* config: Consolidate env vars, prefer config options

- Update docker-compose.yml to comment out CF_WEB_STACK and CF_LOCAL_HOST
  (now prefer setting in compose-farm.yaml)
- Update init-env to comment out CF_LOCAL_HOST (can be set in config)
- Update docker-deployment.md with new "Config option" column
- Simplify troubleshooting to prefer config over env vars

* config: Generate CF_LOCAL_HOST with config alternative note

Instead of commenting out CF_LOCAL_HOST, generate it normally but add
a note in the comment that it can also be set as 'local_host' in config.

* config: Extend local_host to all web UI operations

When running the web UI in a Docker container, is_local() can't detect
which host the container is on due to different network namespaces.

Previously local_host/CF_LOCAL_HOST only affected Glances connectivity.
Now it also affects:
- Container exec/shell (runs locally instead of via SSH)
- File editing (uses local filesystem instead of SSH)

Added is_local_host() helper that checks CF_LOCAL_HOST/config.local_host
first, then falls back to is_local() detection.

* refactor: DRY get_web_stack helper, add tests

- Move get_web_stack to deps.py to avoid duplication in streaming.py
  and actions.py
- Add tests for config.local_host and config.web_stack parsing
- Add tests for is_local_host, get_web_stack, and get_local_host helpers
- Tests verify env var precedence over config values

* glances: rely on CF_WEB_STACK for container mode

Restore docker-compose env defaults and document local_host scope.

* web: ignore local_host outside container

Document container-only behavior and adjust tests.

* web: infer local host from web_stack

Drop local_host config option and update docs/tests.

* Remove CF_LOCAL_HOST override

* refactor: move web_stack helpers to Config class

- Add get_web_stack() and get_local_host_from_web_stack() as Config methods
- Remove duplicate _get_local_host_from_web_stack() from glances.py and deps.py
- Update deps.py get_web_stack() to delegate to Config method
- Add comprehensive tests for the new Config methods

* config: remove web_stack config option

The web_stack config option was redundant since:
- In Docker, CF_WEB_STACK env var is always set
- Outside Docker, the container-specific behavior is disabled anyway

Simplify by only using the CF_WEB_STACK environment variable.

* refactor: remove get_web_stack wrapper from deps

Callers now use config.get_web_stack() directly instead of
going through a pointless wrapper function.

* prompts: add rule to identify pointless wrapper functions
2026-01-10 10:48:35 +01:00

249 lines
8.9 KiB
Python

"""Tests for web API helper functions."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
if TYPE_CHECKING:
from compose_farm.config import Config
class TestExtractConfigError:
"""Tests for extract_config_error helper."""
def test_validation_error_with_location(self) -> None:
from compose_farm.config import Config, Host
from compose_farm.web.deps import extract_config_error
# Trigger a validation error with an extra field
with pytest.raises(ValidationError) as exc_info:
Config(
hosts={"server": Host(address="192.168.1.1")},
stacks={"app": "server"},
unknown_field="bad", # type: ignore[call-arg]
)
msg = extract_config_error(exc_info.value)
assert "unknown_field" in msg
assert "Extra inputs are not permitted" in msg
def test_validation_error_nested_location(self) -> None:
from compose_farm.config import Host
from compose_farm.web.deps import extract_config_error
# Trigger a validation error with a nested extra field
with pytest.raises(ValidationError) as exc_info:
Host(address="192.168.1.1", bad_key="value") # type: ignore[call-arg]
msg = extract_config_error(exc_info.value)
assert "bad_key" in msg
assert "Extra inputs are not permitted" in msg
def test_regular_exception(self) -> None:
from compose_farm.web.deps import extract_config_error
exc = ValueError("Something went wrong")
msg = extract_config_error(exc)
assert msg == "Something went wrong"
def test_file_not_found_exception(self) -> None:
from compose_farm.web.deps import extract_config_error
exc = FileNotFoundError("Config file not found")
msg = extract_config_error(exc)
assert msg == "Config file not found"
class TestValidateYaml:
"""Tests for _validate_yaml helper."""
def test_valid_yaml(self) -> None:
from compose_farm.web.routes.api import _validate_yaml
# Should not raise
_validate_yaml("key: value")
_validate_yaml("list:\n - item1\n - item2")
_validate_yaml("")
def test_invalid_yaml(self) -> None:
from compose_farm.web.routes.api import _validate_yaml
with pytest.raises(HTTPException) as exc_info:
_validate_yaml("key: [unclosed")
assert exc_info.value.status_code == 400
assert "Invalid YAML" in exc_info.value.detail
class TestGetStackComposePath:
"""Tests for _get_stack_compose_path helper."""
def test_stack_found(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _get_stack_compose_path
path = _get_stack_compose_path("plex")
assert isinstance(path, Path)
assert path.name == "compose.yaml"
assert path.parent.name == "plex"
def test_stack_not_found(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _get_stack_compose_path
with pytest.raises(HTTPException) as exc_info:
_get_stack_compose_path("nonexistent")
assert exc_info.value.status_code == 404
assert "not found" in exc_info.value.detail
class TestIsLocalHost:
"""Tests for is_local_host helper."""
def test_returns_true_when_web_stack_host_matches(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""is_local_host returns True when host matches web stack host."""
from compose_farm.config import Config, Host
from compose_farm.web.deps import is_local_host
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")},
stacks={"compose-farm": "nas"},
)
host = config.hosts["nas"]
assert is_local_host("nas", host, config) is True
def test_returns_false_when_web_stack_host_differs(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""is_local_host returns False when host does not match web stack host."""
from compose_farm.config import Config, Host
from compose_farm.web.deps import is_local_host
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")},
stacks={"compose-farm": "nas"},
)
host = config.hosts["nuc"]
# nuc is not local, and not matching the web stack host
assert is_local_host("nuc", host, config) is False
class TestGetLocalHost:
"""Tests for get_local_host helper."""
def test_returns_web_stack_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_local_host returns the web stack host when in container."""
from compose_farm.config import Config, Host
from compose_farm.web.deps import get_local_host
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")},
stacks={"compose-farm": "nas"},
)
assert get_local_host(config) == "nas"
def test_ignores_unknown_web_stack(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_local_host ignores web stack if it's not in stacks."""
from compose_farm.config import Config, Host
from compose_farm.web.deps import get_local_host
monkeypatch.setenv("CF_WEB_STACK", "unknown-stack")
# Use address that won't match local machine to avoid is_local() fallback
config = Config(
hosts={"nas": Host(address="10.99.99.1")},
stacks={"test": "nas"},
)
# Should fall back to auto-detection (which won't match anything here)
assert get_local_host(config) is None
def test_returns_none_outside_container(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_local_host returns None when CF_WEB_STACK not set."""
from compose_farm.config import Config, Host
from compose_farm.web.deps import get_local_host
monkeypatch.delenv("CF_WEB_STACK", raising=False)
config = Config(
hosts={"nas": Host(address="10.99.99.1")},
stacks={"compose-farm": "nas"},
)
assert get_local_host(config) is None
class TestRenderContainers:
"""Tests for container template rendering."""
def test_render_running_container(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "running"}]
html = _render_containers("plex", "server-1", containers)
assert "badge-success" in html
assert "plex" in html
assert "initExecTerminal" in html
def test_render_unknown_state(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "unknown"}]
html = _render_containers("plex", "server-1", containers)
assert "loading-spinner" in html
def test_render_exited_success(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "exited", "ExitCode": 0}]
html = _render_containers("plex", "server-1", containers)
assert "badge-neutral" in html
assert "exited (0)" in html
def test_render_exited_error(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "exited", "ExitCode": 1}]
html = _render_containers("plex", "server-1", containers)
assert "badge-error" in html
assert "exited (1)" in html
def test_render_other_state(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "restarting"}]
html = _render_containers("plex", "server-1", containers)
assert "badge-warning" in html
assert "restarting" in html
def test_render_with_header(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "running"}]
html = _render_containers("plex", "server-1", containers, show_header=True)
assert "server-1" in html
assert "font-semibold" in html
def test_render_multiple_containers(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [
{"Name": "app-web-1", "State": "running"},
{"Name": "app-db-1", "State": "running"},
]
html = _render_containers("app", "server-1", containers)
assert "app-web-1" in html
assert "app-db-1" in html