"""Tests for the ``[web]`` / ``[web.oidc]`` config section (W3 of the web-UI plan). Contract: ``[web]`` absent means the web UI is disabled by default; secrets are NEVER read from TOML — only keyring key *names* (``client_secret_cred``, ``session_secret_cred``). """ from __future__ import annotations from pathlib import Path from ci_orchestrator.config import WebConfig, WebOidcConfig, gitea_base_url, load_config from ci_orchestrator.operations.services import MANAGED_SERVICES, ManagedService def _paths_section() -> str: return ( '[paths]\nroot = "/var/lib/ci"\ntemplates = "/var/lib/ci/templates"\n' 'build_vms = "/var/lib/ci/build-vms"\nartifacts = "/var/lib/ci/artifacts"\n' 'keys = "/var/lib/ci/keys"\n' ) def _write_toml(tmp_path: Path, extra: str = "") -> Path: toml = tmp_path / "config.toml" toml.write_text(_paths_section() + extra, encoding="utf-8") return toml # ── [web] absent ───────────────────────────────────────────────────────────── def test_web_absent_defaults_to_disabled(tmp_path: Path) -> None: cfg = load_config(config_path=_write_toml(tmp_path), env={}) assert cfg.web == WebConfig() assert cfg.web.enabled is False assert cfg.web.oidc is None def test_web_absent_when_no_toml_file_at_all(tmp_path: Path) -> None: # No config file at all -> pure defaults. cfg = load_config(config_path=tmp_path / "nope.toml", env={}) assert cfg.web.enabled is False assert cfg.web.oidc is None # ── [web] present ──────────────────────────────────────────────────────────── def test_web_section_populates_fields(tmp_path: Path) -> None: extra = ( "\n[web]\n" "enabled = true\n" 'bind_host = "192.168.1.50"\n' "bind_port = 9000\n" 'audit_log = "/var/lib/ci/logs/custom-audit.jsonl"\n' 'allowed_users = ["simone", "alice"]\n' 'session_secret_cred = "MySessionCred"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.enabled is True assert cfg.web.bind_host == "192.168.1.50" assert cfg.web.bind_port == 9000 assert cfg.web.audit_log == Path("/var/lib/ci/logs/custom-audit.jsonl") assert cfg.web.allowed_users == ["simone", "alice"] assert cfg.web.session_secret_cred == "MySessionCred" assert cfg.web.oidc is None def test_web_section_partial_uses_defaults_for_rest(tmp_path: Path) -> None: extra = "\n[web]\nenabled = true\n" cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.enabled is True assert cfg.web.bind_host == "127.0.0.1" assert cfg.web.bind_port == 8800 assert cfg.web.audit_log == Path("/var/lib/ci/logs/web-audit.jsonl") assert cfg.web.allowed_users == [] assert cfg.web.session_secret_cred == "WebSessionSecret" # ── [web.oidc] ─────────────────────────────────────────────────────────────── def test_web_oidc_section_populates_fields(tmp_path: Path) -> None: extra = ( "\n[web]\nenabled = true\n" "\n[web.oidc]\n" 'issuer = "https://gitea.lan"\n' 'client_id = "ci-web"\n' 'redirect_url = "http://192.168.1.50:8800/auth/callback"\n' 'client_secret_cred = "MyOidcCred"\n' 'scopes = ["openid", "profile", "email"]\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.oidc == WebOidcConfig( issuer="https://gitea.lan", client_id="ci-web", redirect_url="http://192.168.1.50:8800/auth/callback", client_secret_cred="MyOidcCred", scopes=["openid", "profile", "email"], ) def test_web_oidc_section_defaults_client_secret_cred_and_scopes(tmp_path: Path) -> None: extra = ( "\n[web.oidc]\n" 'issuer = "https://gitea.lan"\n' 'client_id = "ci-web"\n' 'redirect_url = "http://192.168.1.50:8800/auth/callback"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.oidc is not None assert cfg.web.oidc.client_secret_cred == "WebOidcClientSecret" assert cfg.web.oidc.scopes == ["openid", "profile"] def test_web_oidc_missing_required_key_yields_none_not_crash(tmp_path: Path) -> None: # Missing redirect_url -> oidc must degrade to None, never raise. extra = ( "\n[web.oidc]\n" 'issuer = "https://gitea.lan"\n' 'client_id = "ci-web"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.oidc is None def test_web_oidc_empty_section_yields_none(tmp_path: Path) -> None: extra = "\n[web.oidc]\n" cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.oidc is None # ── security: no secret values ever stored ────────────────────────────────── def test_no_secret_value_fields_exist_on_web_config() -> None: """Only keyring key *names* are stored -- never a secret value.""" field_names = set(WebConfig.__dataclass_fields__) assert "session_secret" not in field_names assert "session_secret_value" not in field_names assert "session_secret_cred" in field_names def test_no_secret_value_fields_exist_on_web_oidc_config() -> None: field_names = set(WebOidcConfig.__dataclass_fields__) assert "client_secret" not in field_names assert "client_secret_value" not in field_names assert "client_secret_cred" in field_names def test_toml_secret_looking_value_is_never_surfaced_as_a_secret(tmp_path: Path) -> None: """Even if a TOML author mistakenly writes a secret-shaped value under ``client_secret_cred``/``session_secret_cred``, config.py only ever treats it as a keyring key *name* -- there is no code path that reads a ``client_secret``/``session_secret`` key from TOML at all. """ extra = ( "\n[web]\nenabled = true\n" 'session_secret_cred = "sk-not-a-real-secret-just-a-name"\n' "\n[web.oidc]\n" 'issuer = "https://gitea.lan"\n' 'client_id = "ci-web"\n' 'redirect_url = "http://192.168.1.50:8800/auth/callback"\n' 'client_secret_cred = "another-cred-name"\n' # If config.py ever started reading these, this test would need to # change -- their presence here proves they are ignored. 'client_secret = "should-never-be-read"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.session_secret_cred == "sk-not-a-real-secret-just-a-name" assert cfg.web.oidc is not None assert cfg.web.oidc.client_secret_cred == "another-cred-name" # No attribute on WebOidcConfig can ever hold "should-never-be-read". for value in vars(cfg.web.oidc).values(): assert value != "should-never-be-read" # ── [web].gitea_url (Stage C2) ─────────────────────────────────────────────── def test_gitea_url_absent_from_toml_defaults_to_none() -> None: assert WebConfig().gitea_url is None def test_gitea_url_explicit_toml_value_populates_field(tmp_path: Path) -> None: extra = '\n[web]\ngitea_url = "https://gitea.example.lan"\n' cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.gitea_url == "https://gitea.example.lan" def test_gitea_base_url_explicit_override_wins_over_issuer(tmp_path: Path) -> None: extra = ( "\n[web]\n" 'gitea_url = "https://gitea.explicit.lan"\n' "\n[web.oidc]\n" 'issuer = "https://gitea.emulab.it"\n' 'client_id = "ci-web"\n' 'redirect_url = "http://192.168.1.50:8800/auth/callback"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert gitea_base_url(cfg.web) == "https://gitea.explicit.lan" def test_gitea_base_url_derives_origin_from_oidc_issuer(tmp_path: Path) -> None: extra = ( "\n[web.oidc]\n" 'issuer = "https://gitea.emulab.it"\n' 'client_id = "ci-web"\n' 'redirect_url = "http://192.168.1.50:8800/auth/callback"\n' ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.gitea_url is None # not set explicitly assert gitea_base_url(cfg.web) == "https://gitea.emulab.it" def test_gitea_base_url_strips_issuer_path_and_query() -> None: web = WebConfig( oidc=WebOidcConfig( issuer="https://gitea.emulab.it/some/path?query=1", client_id="ci-web", redirect_url="http://127.0.0.1:8800/auth/callback", ) ) assert gitea_base_url(web) == "https://gitea.emulab.it" def test_gitea_base_url_none_when_oidc_unset_and_no_explicit_url() -> None: assert gitea_base_url(WebConfig()) is None def test_gitea_base_url_none_when_issuer_unparseable() -> None: web = WebConfig( oidc=WebOidcConfig( issuer="not-a-url", client_id="ci-web", redirect_url="http://127.0.0.1:8800/auth/callback", ) ) assert gitea_base_url(web) is None # ── [web].managed_services (Stage D) ──────────────────────────────────────── def test_managed_services_absent_defaults_to_builtin_allowlist() -> None: assert WebConfig().managed_services == MANAGED_SERVICES def test_managed_services_absent_from_toml_defaults_to_builtin_allowlist(tmp_path: Path) -> None: cfg = load_config(config_path=_write_toml(tmp_path), env={}) assert cfg.web.managed_services == MANAGED_SERVICES def test_managed_services_override_parses_rows(tmp_path: Path) -> None: extra = ( "\n[[web.managed_services]]\n" 'id = "act-runner"\n' 'unit = "act-runner.service"\n' 'label = "Runner"\n' "allow_stop = true\n" "\n[[web.managed_services]]\n" 'id = "ci-web"\n' 'unit = "ci-web.service"\n' 'label = "Web UI"\n' "allow_stop = false\n" ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.managed_services == ( ManagedService("act-runner", "act-runner.service", "Runner", True), ManagedService("ci-web", "ci-web.service", "Web UI", False), ) def test_managed_services_malformed_row_is_skipped_not_raised(tmp_path: Path) -> None: extra = ( "\n[[web.managed_services]]\n" 'id = "act-runner"\n' 'unit = "act-runner.service"\n' 'label = "Runner"\n' "allow_stop = true\n" "\n[[web.managed_services]]\n" # Missing 'unit' -- this row must be skipped, not crash config loading. 'id = "broken"\n' 'label = "Broken"\n' "allow_stop = true\n" ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.managed_services == ( ManagedService("act-runner", "act-runner.service", "Runner", True), ) def test_managed_services_all_rows_malformed_falls_back_to_default(tmp_path: Path) -> None: extra = ( "\n[[web.managed_services]]\n" 'id = "broken"\n' "allow_stop = true\n" ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.managed_services == MANAGED_SERVICES def test_managed_services_wrong_type_for_allow_stop_is_skipped(tmp_path: Path) -> None: extra = ( "\n[[web.managed_services]]\n" 'id = "act-runner"\n' 'unit = "act-runner.service"\n' 'label = "Runner"\n' 'allow_stop = "yes"\n' # string, not bool -- must be rejected ) cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.managed_services == MANAGED_SERVICES def test_managed_services_empty_list_falls_back_to_default(tmp_path: Path) -> None: extra = "\n[web]\nmanaged_services = []\n" cfg = load_config(config_path=_write_toml(tmp_path, extra), env={}) assert cfg.web.managed_services == MANAGED_SERVICES