rodboev test-only #4274 (test_issue609, test_sprint3, + 2 others). Linux assertions stay at full strictness. 63 tests pass. No version stamp (tests-only). Note: #4273 was NOT included — it touches api/workspace.py runtime (home-expansion in the blocked-roots boundary), needs a separate full gate. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
parent
1bf2ef9f5e
commit
40591f146f
5 changed files with 53 additions and 26 deletions
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
### Internal
|
||||
|
||||
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266)
|
||||
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266, #4274)
|
||||
|
||||
## [v0.51.445] — 2026-06-15 — Release PF (scope gateway watcher to the active profile)
|
||||
|
||||
|
|
|
|||
|
|
@ -274,8 +274,7 @@ class TestFilePathEndpointBehaviour:
|
|||
body, status = _post("/api/file/path", {"session_id": sid, "path": "."})
|
||||
assert status == 200, body
|
||||
assert body.get("ok") is True
|
||||
# Path should be absolute (starts with /).
|
||||
assert body.get("path", "").startswith("/"), body
|
||||
assert Path(body.get("path", "")).is_absolute(), body
|
||||
|
||||
def test_does_not_404_on_missing_file(self):
|
||||
"""Copy-path on a stale-but-recently-deleted file must still
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -15,6 +16,7 @@ def _isolate_sessions(tmp_path, monkeypatch):
|
|||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent.resolve()))
|
||||
SESSIONS.clear()
|
||||
yield session_dir
|
||||
SESSIONS.clear()
|
||||
|
|
@ -32,7 +34,7 @@ def test_worktree_metadata_round_trips_through_session_file(_isolate_sessions):
|
|||
s.save()
|
||||
|
||||
raw = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert raw["worktree_path"].endswith(".worktrees/hermes-1234")
|
||||
assert Path(raw["worktree_path"]).as_posix().endswith(".worktrees/hermes-1234")
|
||||
assert raw["worktree_branch"] == "hermes/hermes-1234"
|
||||
assert raw["worktree_repo_root"].endswith("repo")
|
||||
assert raw["worktree_created_at"] == 123.5
|
||||
|
|
@ -133,7 +135,7 @@ def test_create_worktree_for_workspace_calls_agent_setup_with_repo_root(tmp_path
|
|||
info = worktrees.create_worktree_for_workspace(nested)
|
||||
|
||||
assert seen["repo_root"] == str(repo.resolve())
|
||||
assert info["path"].endswith(".worktrees/hermes-test")
|
||||
assert Path(info["path"]).as_posix().endswith(".worktrees/hermes-test")
|
||||
assert info["branch"] == "hermes/hermes-test"
|
||||
assert info["repo_root"] == str(repo.resolve())
|
||||
assert info["created_at"] >= now
|
||||
|
|
|
|||
|
|
@ -61,10 +61,13 @@ def test_path_outside_boot_default_and_home_is_rejected(monkeypatch, tmp_path):
|
|||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
outside = tmp_path / "other_mount" / "secret"
|
||||
outside.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
|
||||
with pytest.raises(ValueError, match="outside the user home"):
|
||||
resolve_trusted_workspace(str(outside))
|
||||
|
|
@ -95,10 +98,13 @@ def test_path_traversal_via_dotdot_does_not_escape_boot_default(monkeypatch, tmp
|
|||
|
||||
boot_default = tmp_path / "data" / "workspace"
|
||||
boot_default.mkdir(parents=True)
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
sibling = tmp_path / "data" / "private"
|
||||
sibling.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(ws_mod, "_BOOT_DEFAULT_WORKSPACE", str(boot_default))
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
|
||||
# `boot_default/../private` resolves to `tmp_path/data/private`, which is
|
||||
# NOT a child of boot_default and not under home — must reject.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"""Sprint 3 tests: cron API, skills API, memory API, input validation."""
|
||||
import json, uuid, urllib.request, urllib.error
|
||||
import json, pathlib, shutil, tempfile, urllib.request, urllib.error
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
|
@ -16,6 +19,15 @@ def post(path, body=None):
|
|||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
def make_outside_trusted_dir(prefix):
|
||||
home_root = pathlib.Path.home().resolve()
|
||||
temp_root = pathlib.Path(tempfile.gettempdir()).resolve()
|
||||
base_root = temp_root if temp_root != home_root and home_root not in (temp_root, *temp_root.parents) else REPO_ROOT
|
||||
outside_root = base_root / ".tmp-outside-trusted"
|
||||
outside_root.mkdir(exist_ok=True)
|
||||
return pathlib.Path(tempfile.mkdtemp(prefix=f"{prefix}-", dir=outside_root))
|
||||
|
||||
def make_session_tracked(created_list, ws=None):
|
||||
"""Create a session and register it with the cleanup fixture."""
|
||||
import pathlib as _pathlib
|
||||
|
|
@ -208,21 +220,25 @@ def test_session_update_unknown_id_returns_404():
|
|||
def test_session_update_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
outside = make_outside_trusted_dir("outside")
|
||||
try:
|
||||
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
finally:
|
||||
shutil.rmtree(outside, ignore_errors=True)
|
||||
|
||||
|
||||
def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
outside = tmp_path / "outside-chat"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
outside = make_outside_trusted_dir("outside-chat")
|
||||
try:
|
||||
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
finally:
|
||||
shutil.rmtree(outside, ignore_errors=True)
|
||||
|
||||
|
||||
def test_workspace_add_allows_external_valid_paths(tmp_path):
|
||||
|
|
@ -251,19 +267,23 @@ def test_legacy_chat_rejects_workspace_outside_trusted_root(tmp_path):
|
|||
"""Legacy /api/chat must use the same trusted workspace validation as /api/chat/start."""
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
outside = tmp_path / "outside-legacy-chat"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/chat", {"session_id": sid, "message": "hello", "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
outside = make_outside_trusted_dir("outside-legacy-chat")
|
||||
try:
|
||||
result, status = post("/api/chat", {"session_id": sid, "message": "hello", "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
finally:
|
||||
shutil.rmtree(outside, ignore_errors=True)
|
||||
|
||||
|
||||
def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
outside = tmp_path / "outside-new"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/session/new", {"workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
outside = make_outside_trusted_dir("outside-new")
|
||||
try:
|
||||
result, status = post("/api/session/new", {"workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
finally:
|
||||
shutil.rmtree(outside, ignore_errors=True)
|
||||
|
||||
|
||||
def test_session_search_returns_matches(cleanup_test_sessions):
|
||||
|
|
|
|||
Loading…
Reference in a new issue