fix: install.sh loop abort, ACME poll sudo gate, /static/ sub-paths
- install.sh: the traversal-chmod loop assigned _d but looped over the never-set $d; under set -u every fresh install aborted with "d: unbound variable" at that line. Loop over $_d. - acme collector: the self-heal normalize (sudo chmod g+rwX) now runs only when a no-sudo group-read-bit probe detects a lost bit — acme.sh re-hardens the tree 600 on every run, so the steady-state poll makes no sudo call. The group bit (not daemon readability) is what the two-user model keeps for the WebUI user. - lib.acme: new get_acme_home() accessor (ACME_HOME env, default data/acme), reused by _run_acme; _summarize_acme_output preserves a "Permission denied" line even when it is not among the final two, so the collector's actionable-error matcher keeps firing. - nginx template: emit location /static/ for any is_management path (not only '/'); the SPA references /static/... at the domain root regardless of the management backend path. - tests: probe, summarizer, and nginx-subpath cases in test_state.py, test_acme.py, test_nginx.py.
This commit is contained in:
+78
-2
@@ -1,6 +1,7 @@
|
||||
"""Tests for lib/state.py — state store and collect functions."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import daemon.collectors.acme
|
||||
@@ -267,6 +268,9 @@ class TestAcmeCollectNonFatal:
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch(
|
||||
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home"),
|
||||
patch(
|
||||
"lib.acme.list_certs",
|
||||
@@ -290,6 +294,9 @@ class TestAcmeCollectNonFatal:
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch(
|
||||
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home"),
|
||||
patch("lib.acme.list_certs", return_value=[]),
|
||||
patch.object(
|
||||
@@ -316,6 +323,9 @@ class TestAcmeCollectNonFatal:
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch(
|
||||
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
|
||||
patch("lib.acme.list_certs", side_effect=_list),
|
||||
patch.object(
|
||||
@@ -324,12 +334,78 @@ class TestAcmeCollectNonFatal:
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
# The poll must normalize ACME_HOME perms before listing, so a
|
||||
# mid-lifetime ownership flip self-heals without a restart.
|
||||
# The poll must normalize ACME_HOME perms before listing when the
|
||||
# probe detects a lost group-read bit, so a mid-lifetime ownership
|
||||
# flip self-heals without a restart.
|
||||
assert order == ["normalize", "list"]
|
||||
assert result["certs"] == [{"domain": "example.com"}]
|
||||
assert result["status"] == {"error": None}
|
||||
|
||||
def test_no_normalize_when_probe_clean(self):
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
order: list[str] = []
|
||||
|
||||
def _norm():
|
||||
order.append("normalize")
|
||||
|
||||
def _list():
|
||||
order.append("list")
|
||||
return []
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch(
|
||||
"daemon.collectors.acme._acme_home_needs_normalize", return_value=False
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
|
||||
patch("lib.acme.list_certs", side_effect=_list),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
# Steady state: the probe sees group-read bits intact, so the poll
|
||||
# must not pay for a sudo normalize.
|
||||
assert order == ["list"]
|
||||
assert result["certs"] == []
|
||||
assert result["status"] == {"error": None}
|
||||
|
||||
|
||||
class TestAcmeHomeProbe:
|
||||
"""_acme_home_needs_normalize probes the group-read bit without sudo."""
|
||||
|
||||
def test_flags_file_without_group_read(self, tmp_path, monkeypatch):
|
||||
from daemon.collectors.acme import _acme_home_needs_normalize
|
||||
|
||||
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
||||
(tmp_path / "account.conf").write_text("x")
|
||||
os.chmod(tmp_path / "account.conf", 0o600)
|
||||
assert _acme_home_needs_normalize() is True
|
||||
|
||||
def test_clean_when_group_read_set(self, tmp_path, monkeypatch):
|
||||
from daemon.collectors.acme import _acme_home_needs_normalize
|
||||
|
||||
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
||||
(tmp_path / "account.conf").write_text("x")
|
||||
os.chmod(tmp_path / "account.conf", 0o640)
|
||||
assert _acme_home_needs_normalize() is False
|
||||
|
||||
def test_clean_on_empty_home(self, tmp_path, monkeypatch):
|
||||
from daemon.collectors.acme import _acme_home_needs_normalize
|
||||
|
||||
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
||||
assert _acme_home_needs_normalize() is False
|
||||
|
||||
def test_clean_on_missing_home(self, tmp_path, monkeypatch):
|
||||
from daemon.collectors.acme import _acme_home_needs_normalize
|
||||
|
||||
monkeypatch.setenv("ACME_HOME", str(tmp_path / "does-not-exist"))
|
||||
assert _acme_home_needs_normalize() is False
|
||||
|
||||
def test_permission_error_is_actionable(self):
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
|
||||
Reference in New Issue
Block a user