From 40591f146ff0d792376c0a0dccf2d244178c3fb3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 15 Jun 2026 15:57:18 -0700 Subject: [PATCH] test(#3664): Windows pytest-harness batch #3 (#4274) (#4276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: rodboev --- CHANGELOG.md | 2 +- tests/test_1764_context_menu_essentials.py | 3 +- tests/test_issue1955_worktree_sessions.py | 6 ++- tests/test_issue609.py | 6 +++ tests/test_sprint3.py | 62 ++++++++++++++-------- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b46d01..1db79465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/tests/test_1764_context_menu_essentials.py b/tests/test_1764_context_menu_essentials.py index 04a5e12a..fd2791b1 100644 --- a/tests/test_1764_context_menu_essentials.py +++ b/tests/test_1764_context_menu_essentials.py @@ -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 diff --git a/tests/test_issue1955_worktree_sessions.py b/tests/test_issue1955_worktree_sessions.py index c1c623e7..b6b80042 100644 --- a/tests/test_issue1955_worktree_sessions.py +++ b/tests/test_issue1955_worktree_sessions.py @@ -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 diff --git a/tests/test_issue609.py b/tests/test_issue609.py index 5716e3f0..f9b22d98 100644 --- a/tests/test_issue609.py +++ b/tests/test_issue609.py @@ -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. diff --git a/tests/test_sprint3.py b/tests/test_sprint3.py index b536d821..1569a522 100644 --- a/tests/test_sprint3.py +++ b/tests/test_sprint3.py @@ -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):