forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""Tests for the dashboard-managed file browser API."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
def _client_with_app_state():
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = None
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
return client, prev_auth_required, prev_bound_host
|
||||
|
||||
|
||||
def _restore_app_state(prev_auth_required, prev_bound_host):
|
||||
if prev_auth_required is None:
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
else:
|
||||
web_server.app.state.auth_required = prev_auth_required
|
||||
if prev_bound_host is None:
|
||||
if hasattr(web_server.app.state, "bound_host"):
|
||||
delattr(web_server.app.state, "bound_host")
|
||||
else:
|
||||
web_server.app.state.bound_host = prev_bound_host
|
||||
|
||||
|
||||
def _close_client(client):
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forced_files_client(monkeypatch, tmp_path):
|
||||
root = tmp_path / "data"
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, root
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_files_client(monkeypatch, tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, home
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = root / "out" / "hello.txt"
|
||||
|
||||
created = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,aGVsbG8=",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["entry"]["path"] == str(file_path)
|
||||
assert created.json()["locked_root"] == str(root)
|
||||
assert created.json()["can_change_path"] is False
|
||||
assert file_path.read_text() == "hello"
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(root / "out")})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["path"] == str(root / "out")
|
||||
assert listing.json()["parent"] == str(root)
|
||||
assert listing.json()["entries"] == [
|
||||
{
|
||||
"name": "hello.txt",
|
||||
"path": str(file_path),
|
||||
"is_directory": False,
|
||||
"size": 5,
|
||||
"mtime": pytest.approx(file_path.stat().st_mtime),
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
]
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(file_path)},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not file_path.exists()
|
||||
|
||||
|
||||
def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
runs_path = root / "runs"
|
||||
checkpoints_path = runs_path / "checkpoints"
|
||||
|
||||
created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)})
|
||||
assert created.status_code == 200
|
||||
assert checkpoints_path.is_dir()
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(runs_path)})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["entries"][0]["path"] == str(checkpoints_path)
|
||||
assert listing.json()["entries"][0]["is_directory"] is True
|
||||
|
||||
non_recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": False},
|
||||
)
|
||||
assert non_recursive.status_code == 409
|
||||
|
||||
recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": True},
|
||||
)
|
||||
assert recursive.status_code == 200
|
||||
assert not runs_path.exists()
|
||||
|
||||
|
||||
def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path):
|
||||
client, root = forced_files_client
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("do not leak")
|
||||
|
||||
traversal = client.get("/api/files", params={"path": "../outside"})
|
||||
assert traversal.status_code == 400
|
||||
|
||||
outside_absolute = client.get("/api/files", params={"path": str(outside)})
|
||||
assert outside_absolute.status_code == 403
|
||||
|
||||
root_delete = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(root), "recursive": True},
|
||||
)
|
||||
assert root_delete.status_code == 400
|
||||
|
||||
root.mkdir(exist_ok=True)
|
||||
link = root / "escape"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("filesystem does not allow directory symlinks")
|
||||
|
||||
escaped = client.get("/api/files", params={"path": str(link)})
|
||||
assert escaped.status_code == 403
|
||||
|
||||
|
||||
def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path):
|
||||
client, home = local_files_client
|
||||
(home / "home.txt").write_text("home")
|
||||
|
||||
default_listing = client.get("/api/files")
|
||||
assert default_listing.status_code == 200
|
||||
assert default_listing.json()["path"] == str(home)
|
||||
assert default_listing.json()["locked_root"] is None
|
||||
assert default_listing.json()["can_change_path"] is True
|
||||
assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt")
|
||||
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "other.txt").write_text("other")
|
||||
|
||||
other_listing = client.get("/api/files", params={"path": str(other)})
|
||||
assert other_listing.status_code == 200
|
||||
assert other_listing.json()["path"] == str(other)
|
||||
assert other_listing.json()["parent"] == str(tmp_path)
|
||||
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
|
||||
|
||||
|
||||
def test_gated_local_mode_still_defaults_to_home(monkeypatch, tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.delenv("HERMES_MANAGED", raising=False)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home / ".hermes"))
|
||||
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = True
|
||||
web_server.app.state.bound_host = "0.0.0.0"
|
||||
try:
|
||||
request = SimpleNamespace(
|
||||
app=web_server.app,
|
||||
client=SimpleNamespace(host="10.0.0.2"),
|
||||
url=SimpleNamespace(hostname="example.com"),
|
||||
)
|
||||
policy = web_server._managed_files_policy(request, create_root=False)
|
||||
finally:
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
assert policy.default_path == home.resolve()
|
||||
assert policy.locked_root is None
|
||||
assert policy.can_change_path is True
|
||||
|
||||
|
||||
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
|
||||
client, home = local_files_client
|
||||
folder = home / "workspace"
|
||||
file_path = folder / "note.txt"
|
||||
|
||||
created_folder = client.post("/api/files/mkdir", json={"path": str(folder)})
|
||||
assert created_folder.status_code == 200
|
||||
assert created_folder.json()["locked_root"] is None
|
||||
assert created_folder.json()["can_change_path"] is True
|
||||
assert folder.is_dir()
|
||||
|
||||
uploaded = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,bG9jYWw=",
|
||||
},
|
||||
)
|
||||
assert uploaded.status_code == 200
|
||||
assert file_path.read_text() == "local"
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(folder), "recursive": True},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not folder.exists()
|
||||
|
||||
|
||||
def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", "/opt/data")
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
request = SimpleNamespace(
|
||||
app=web_server.app,
|
||||
client=SimpleNamespace(host="127.0.0.1"),
|
||||
url=SimpleNamespace(hostname="127.0.0.1"),
|
||||
)
|
||||
policy = web_server._managed_files_policy(request, create_root=False)
|
||||
finally:
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
client.close()
|
||||
|
||||
assert str(policy.locked_root) == "/opt/data"
|
||||
assert policy.can_change_path is False
|
||||
Reference in New Issue
Block a user