Files
hermes-webui/tests/test_sprint5.py
T
nesquena-hermesandnesquena-hermes ee7fda019f test-infra: hard-isolate test state from production + resilient teardown (#4289)
* 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>
2026-06-15 19:04:16 -07:00

252 lines
11 KiB
Python

"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving."""
import json, pathlib, uuid, urllib.request, urllib.error, urllib.parse
import os
import pytest
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def get_raw(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read(), r.headers.get("Content-Type",""), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_session_tracked(created_list, ws=None):
"""Create a session and register it with the cleanup fixture."""
import pathlib as _pathlib
body = {}
if ws: body["workspace"] = str(ws)
d, _ = post("/api/session/new", body)
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, _pathlib.Path(d["session"]["workspace"])
def make_workspace_child(base: pathlib.Path, name: str) -> pathlib.Path:
target = base / name
target.mkdir(parents=True, exist_ok=True)
return target
def test_server_running_from_new_location():
data, status = get("/health")
assert status == 200 and data["status"] == "ok"
def test_app_js_served():
"""Sprint 9: app.js replaced by modules. Verify ui.js (contains renderMd) is served."""
raw, ct, status = get_raw("/static/ui.js")
assert status == 200 and "javascript" in ct and b"renderMd" in raw
def test_workspaces_list():
data, status = get("/api/workspaces")
assert status == 200 and "workspaces" in data and "last" in data
def test_workspace_add_valid(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-add-{uuid.uuid4().hex[:6]}")
post("/api/workspaces/remove", {"path": str(child)})
result, status = post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
assert status == 200 and any(w["path"] == str(child) for w in result["workspaces"])
post("/api/workspaces/remove", {"path": str(child)})
def test_workspace_add_validates_existence():
result, status = post("/api/workspaces/add", {"path": "/tmp/does_not_exist_xyz_999"})
assert status == 400
def test_workspace_add_validates_is_dir():
result, status = post("/api/workspaces/add", {"path": "/etc/hostname"})
assert status == 400
def test_workspace_add_no_duplicate(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-dup-{uuid.uuid4().hex[:6]}")
post("/api/workspaces/remove", {"path": str(child)})
post("/api/workspaces/add", {"path": str(child)})
result, status = post("/api/workspaces/add", {"path": str(child)})
assert status == 400 and "already" in result.get("error","").lower()
post("/api/workspaces/remove", {"path": str(child)})
def test_workspace_add_requires_path():
result, status = post("/api/workspaces/add", {})
assert status == 400
def test_workspace_suggest_returns_trusted_directories(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-suggest-{uuid.uuid4().hex[:6]}")
nested = make_workspace_child(child, "nested")
prefix = str(child.parent / child.name[:12])
data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(prefix)}")
assert status == 200
assert str(child) in data["suggestions"]
assert all(not pathlib.Path(p).name.startswith('.') for p in data["suggestions"])
def test_workspace_suggest_hides_untrusted_system_prefix():
data, status = get("/api/workspaces/suggest?prefix=/etc")
assert status == 200
assert data["suggestions"] == []
def test_workspace_suggest_hidden_dirs_only_when_requested(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
hidden = make_workspace_child(ws, ".workspace-hidden")
visible = make_workspace_child(ws, "workspace-visible")
base = str(ws) + "/"
data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base)}")
assert status == 200
assert str(visible) in data["suggestions"]
assert str(hidden) not in data["suggestions"]
data2, status2 = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base + '.w')}")
assert status2 == 200
assert str(hidden) in data2["suggestions"]
def test_workspace_suggest_preserves_tilde_prefix(monkeypatch, tmp_path):
from api import workspace
home = tmp_path / "home"
child = home / "Projects"
child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/")
assert "~/Projects" in suggestions
assert str(child.resolve()) not in suggestions
def test_workspace_suggest_preserves_tilde_prefix_for_partial_child(monkeypatch, tmp_path):
from api import workspace
home = tmp_path / "home"
child = home / "Projects"
child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/Pro")
assert suggestions == ["~/Projects"]
def test_workspace_suggest_expands_tilde_when_home_is_symlink(monkeypatch, tmp_path):
from api import workspace
actual_home = tmp_path / "actual-home"
child = actual_home / "Documents"
child.mkdir(parents=True)
link_home = tmp_path / "link-home"
try:
link_home.symlink_to(actual_home, target_is_directory=True)
except (NotImplementedError, OSError) as exc:
pytest.skip(f"symlinks are not available in this environment: {exc}")
monkeypatch.setenv("HOME", str(link_home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [actual_home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/Doc")
assert suggestions == ["~/Documents"]
def test_workspace_suggest_keeps_absolute_prefix_absolute(monkeypatch, tmp_path):
from api import workspace
home = tmp_path / "home"
child = home / "Projects"
child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()])
suggestions = workspace.list_workspace_suggestions(str(home) + "/Pro")
assert suggestions == [str(child.resolve())]
def test_workspace_remove(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-remove-{uuid.uuid4().hex[:6]}")
post("/api/workspaces/remove", {"path": str(child)})
post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
result, status = post("/api/workspaces/remove", {"path": str(child)})
assert status == 200 and str(child) not in [w["path"] for w in result["workspaces"]]
def test_workspace_rename(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-rename-{uuid.uuid4().hex[:6]}")
post("/api/workspaces/remove", {"path": str(child)})
post("/api/workspaces/add", {"path": str(child), "name": "Temp"})
result, status = post("/api/workspaces/rename", {"path": str(child), "name": "My Temp"})
assert status == 200
assert {w["path"]: w["name"] for w in result["workspaces"]}.get(str(child)) == "My Temp"
post("/api/workspaces/remove", {"path": str(child)})
def test_workspace_rename_unknown():
result, status = post("/api/workspaces/rename", {"path": "/no/such/path", "name": "X"})
assert status == 404
def test_last_workspace_updates_on_session_update(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-last-{uuid.uuid4().hex[:6]}")
post("/api/session/update", {"session_id": sid, "workspace": str(child), "model": "openai/gpt-5.4-mini"})
data, _ = get("/api/workspaces")
assert data["last"] == str(child)
def test_file_save(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
fname = f"save_{uuid.uuid4().hex[:6]}.txt"
(ws / fname).write_text("original content")
result, status = post("/api/file/save", {"session_id": sid, "path": fname, "content": "updated"})
assert status == 200 and (ws / fname).read_text() == "updated"
def test_file_save_requires_fields(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid})
assert status == 400
def test_file_save_nonexistent_returns_404(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid, "path": "no_such.txt", "content": ""})
assert status == 404
def test_file_save_path_traversal_blocked(cleanup_test_sessions):
sid, _ = make_session_tracked(cleanup_test_sessions)
result, status = post("/api/file/save", {"session_id": sid, "path": "../../etc/passwd", "content": ""})
assert status in (400, 500)
def test_session_index_created_after_save(cleanup_test_sessions):
# Index is created in the TEST state dir, not the production dir.
# Use the shared isolated TEST_STATE_DIR (temp-rooted, never ~/.hermes).
from tests._pytest_port import TEST_STATE_DIR as test_state_dir
index_path = test_state_dir / "sessions" / "_index.json"
make_session_tracked(cleanup_test_sessions)
# Index may not exist yet if cleanup already wiped it -- just check the endpoint works
data, status = get("/api/sessions")
assert status == 200
assert isinstance(data["sessions"], list)
def test_sessions_endpoint_returns_sorted():
data, status = get("/api/sessions")
assert status == 200
sessions = data["sessions"]
if len(sessions) >= 2:
assert sessions[0]["updated_at"] >= sessions[1]["updated_at"]
def test_new_session_inherits_last_workspace(cleanup_test_sessions):
sid, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-inherit-{uuid.uuid4().hex[:6]}")
post("/api/session/update", {"session_id": sid, "workspace": str(child), "model": "openai/gpt-5.4-mini"})
sid2, _ = make_session_tracked(cleanup_test_sessions)
d, _ = get(f"/api/session?session_id={sid2}")
assert d["session"]["workspace"] == str(child)