* test-infra: hard-isolate test state from production + robust teardown Two production-safety fixes to tests/conftest.py + tests/_pytest_port.py: 1. TEST_STATE_DIR now defaults under the OS temp dir (/tmp/hermes-webui-tests/) instead of HERMES_HOME/webui-test-<hash> — which had been writing test state INTO the real ~/.hermes/profiles/webui/ (144 leaked webui-test-* dirs found). Added a hard-stop guard: refuse to run if the resolved test state dir is inside the production Hermes home, so a misconfigured env can never let tests wipe/clobber production profiles/sessions/credentials. 2. _rmtree_retry is now resilient on Linux (was a bare shutil.rmtree): retries transient OSError [Errno 39] Directory-not-empty races (a background daemon writing into the tree during teardown) + a final ignore_errors sweep, and warns instead of raising so a benign cleanup race can't spuriously ERROR a test (the failure mode seen near #4283 context-reconciliation tests). * test-infra: anchor production-proximity guard on literal ~/.hermes (not $HERMES_HOME) The first guard compared TEST_STATE_DIR against $HERMES_HOME, which is overridden to TEST_STATE_DIR itself during a run -> false-tripped collection (6 modules) with 'test dir X is inside production home X'. Now anchor on the literal user-home ~/.hermes and explicitly allow any dir under the OS temp root, so the temp default never trips while a real ~/.hermes target still hard-stops. * test-infra: close TMPDIR-under-~/.hermes guard hole (Codex) Codex flagged: the 'temp is always allowed' exception could be exploited if TMPDIR itself is set under ~/.hermes — then a 'temp' path is a production path and _under_temp would wrongly suppress the guard. Now the temp exception only holds when the temp root is itself OUTSIDE the production home. Verified: normal /tmp default allowed; TMPDIR=~/.hermes/tmp now trips the guard. * test-infra: mirror production-proximity guard into _pytest_port.py (Opus defense-in-depth) * test-infra: remove ~/.hermes production-path fallbacks from 4 test files (Codex) Codex's kickback found the real gap: test_sprint5, test_sprint45, test_onboarding_existing_config, test_regressions each hardcoded a ~/.hermes/webui-mvp-test fallback for their state dir (used when running standalone or if the env var is unset). Repointed all to the shared isolated TEST_STATE_DIR from _pytest_port (temp-rooted). Also refreshed stale ~/.hermes/webui-mvp-test references in test_gateway_sync comment + ARCHITECTURE.md. Now ZERO production-path fallbacks remain in tests. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""
|
|
Sprint 45 Tests: v0.50.36 upstream sync with minimal local patch retention.
|
|
|
|
Covers:
|
|
- First password enablement via POST /api/settings keeps the current browser logged in
|
|
- The returned auth metadata is present and onboarding can continue with the issued cookie
|
|
- Legacy assistant_language is no longer exposed and is removed on the next save
|
|
- The local reply-language UI/runtime enhancement is gone from the synced codebase
|
|
"""
|
|
import json
|
|
import pathlib
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
import os
|
|
|
|
from tests._pytest_port import BASE, TEST_STATE_DIR
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
# Use HERMES_WEBUI_TEST_STATE_DIR if available (set by conftest for the test process),
|
|
# falling back to the shared isolated TEST_STATE_DIR (temp-rooted, never ~/.hermes).
|
|
def _get_settings_file() -> pathlib.Path:
|
|
"""Resolve SETTINGS_FILE at call time (env var set by conftest after module import)."""
|
|
state_dir = pathlib.Path(
|
|
os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(TEST_STATE_DIR))
|
|
)
|
|
return state_dir / "settings.json"
|
|
|
|
|
|
def get(path, headers=None):
|
|
req = urllib.request.Request(BASE + path, headers=headers or {})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read()), r.status, dict(r.headers)
|
|
except urllib.error.HTTPError as e:
|
|
return json.loads(e.read()), e.code, dict(e.headers)
|
|
|
|
|
|
def post(path, body=None, headers=None):
|
|
req = urllib.request.Request(
|
|
BASE + path,
|
|
data=json.dumps(body or {}).encode(),
|
|
headers={"Content-Type": "application/json", **(headers or {})},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read()), r.status, dict(r.headers)
|
|
except urllib.error.HTTPError as e:
|
|
return json.loads(e.read()), e.code, dict(e.headers)
|
|
|
|
|
|
def read(path):
|
|
return (REPO / path).read_text(encoding="utf-8")
|
|
|
|
|
|
def _snapshot_settings_file():
|
|
if _get_settings_file().exists():
|
|
return _get_settings_file().read_text(encoding="utf-8")
|
|
return None
|
|
|
|
|
|
def _restore_settings_file(original_text):
|
|
if original_text is None:
|
|
_get_settings_file().unlink(missing_ok=True)
|
|
return
|
|
_get_settings_file().write_text(original_text, encoding="utf-8")
|
|
|
|
|
|
def test_first_password_enablement_returns_cookie_and_keeps_browser_logged_in():
|
|
original_settings = _snapshot_settings_file()
|
|
cookie_header = None # captured for teardown use
|
|
try:
|
|
saved, status, headers = post("/api/settings", {"_set_password": "sprint45-secret"})
|
|
assert status == 200
|
|
assert saved["auth_enabled"] is True
|
|
assert saved["logged_in"] is True
|
|
assert saved["auth_just_enabled"] is True
|
|
|
|
set_cookie = headers.get("Set-Cookie", "")
|
|
assert "hermes_session=" in set_cookie
|
|
cookie_header = set_cookie.split(";", 1)[0]
|
|
|
|
auth, auth_status, _ = get("/api/auth/status", headers={"Cookie": cookie_header})
|
|
assert auth_status == 200
|
|
assert auth["auth_enabled"] is True
|
|
assert auth["logged_in"] is True
|
|
|
|
done, done_status, _ = post(
|
|
"/api/onboarding/complete",
|
|
{},
|
|
headers={"Cookie": cookie_header},
|
|
)
|
|
assert done_status == 200
|
|
assert done["completed"] is True
|
|
finally:
|
|
# First: write a clean settings file (no password_hash) directly to disk
|
|
try:
|
|
import json as _json
|
|
clean = _json.loads(original_settings) if original_settings else {}
|
|
clean.pop("password_hash", None)
|
|
_get_settings_file().parent.mkdir(parents=True, exist_ok=True)
|
|
_get_settings_file().write_text(_json.dumps(clean, indent=2), encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
# Then: tell the server to clear auth via API (must use the session cookie)
|
|
try:
|
|
_headers = {"Cookie": cookie_header} if cookie_header else {}
|
|
post("/api/settings", {"_clear_password": True}, headers=_headers)
|
|
except Exception:
|
|
pass
|
|
_restore_settings_file(original_settings)
|
|
|
|
|
|
def test_legacy_assistant_language_is_hidden_and_removed_on_next_save():
|
|
original_settings = _snapshot_settings_file()
|
|
try:
|
|
_get_settings_file().parent.mkdir(parents=True, exist_ok=True)
|
|
_get_settings_file().write_text(
|
|
json.dumps(
|
|
{
|
|
"assistant_language": "zh",
|
|
"send_key": "enter",
|
|
"onboarding_completed": False,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
loaded, status, _ = get("/api/settings")
|
|
assert status == 200
|
|
assert "assistant_language" not in loaded
|
|
|
|
saved, save_status, _ = post("/api/settings", {"send_key": "ctrl+enter"})
|
|
assert save_status == 200
|
|
assert "assistant_language" not in saved
|
|
assert saved["send_key"] == "ctrl+enter"
|
|
|
|
persisted = json.loads(_get_settings_file().read_text(encoding="utf-8"))
|
|
assert "assistant_language" not in persisted
|
|
finally:
|
|
_restore_settings_file(original_settings)
|
|
|
|
|
|
def test_reply_language_customization_ui_and_runtime_are_removed():
|
|
index_html = read("static/index.html")
|
|
panels_js = read("static/panels.js")
|
|
streaming_py = read("api/streaming.py")
|
|
|
|
assert "settingsAssistantLanguage" not in index_html
|
|
assert "assistant_language" not in panels_js
|
|
assert "settingsAssistantLanguage" not in panels_js
|
|
assert "assistant_language" not in streaming_py
|
|
assert "Default reply language:" not in streaming_py
|
|
|
|
|