Files
hermes-agent/tests/test_atomic_json_writers_unified.py
ethernet 071ccfbcdf test: restore platforms() host gating that merges reverted to the legacy trio
Four origin/main merges brought back `linux_only` / `macos_only` /
`windows_only` marks in 41 test files, along with the pre-platforms()
versions of scripts/ci/list_os_marked_tests.py and check_os_marker_fakes.py.
Because the legacy names are no longer registered, pytest treated them as
unknown marks — a warning — so every Windows- or macOS-only test RAN on
Linux (test_local_runtime_recovery.py tripped the live-system kill guard).

Rewrite the marks, restore the platforms()-aware CI scripts (keeping main's
os.walk fix for vanishing __pycache__ dirs), drop the stale _BASELINE entries,
and make the conftest reject the retired marks outright so the next merge
cannot resurrect them silently.
2026-09-18 19:06:52 -04:00

135 lines
5.1 KiB
Python

"""Invariant for the non-secret atomic JSON writers that used to inline ``utils._atomic_write``.
Contract under test for ``gateway/session_persistence``, ``cron/suggestions`` and
``agent/shell_hooks``: a failed replace leaves the previous file byte-identical AND leaves no temp
file behind (the interrupt-safe cleanup only the canonical helper guarantees). A hand-rolled copy
that skips the cleanup, or writes through the target instead of a sibling temp, fails this.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
def _failing_replace(tmp, target):
raise OSError("simulated disk full")
def _leftovers(directory: Path, keep: str) -> list[str]:
return sorted(p.name for p in directory.iterdir() if p.name != keep)
@pytest.fixture
def broken_replace(monkeypatch):
import utils
monkeypatch.setattr(utils, "atomic_replace", _failing_replace)
def test_sessions_json_failed_replace_keeps_old_bytes_and_no_temp(tmp_path, monkeypatch, broken_replace):
from gateway.session_persistence import SessionPersistenceMixin
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
target = sessions_dir / "sessions.json"
target.write_text('{"old": true}', encoding="utf-8")
store = SessionPersistenceMixin()
store.sessions_dir = sessions_dir
with pytest.raises(OSError):
store._save_sessions_json({"k": "v"})
assert target.read_text(encoding="utf-8") == '{"old": true}'
assert _leftovers(sessions_dir, "sessions.json") == []
def test_suggestions_failed_replace_keeps_old_bytes_and_no_temp(tmp_path, monkeypatch, broken_replace):
from cron import suggestions
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
target = suggestions._current_suggestions_file()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('{"old": true}', encoding="utf-8")
with pytest.raises(OSError):
suggestions._save_raw([{"id": 1}])
assert target.read_text(encoding="utf-8") == '{"old": true}'
assert _leftovers(target.parent, target.name) == []
def test_shell_hooks_allowlist_survives_failed_replace_without_temp(tmp_path, monkeypatch, broken_replace):
from agent import shell_hooks
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
target = shell_hooks.allowlist_path()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps({"approvals": []}), encoding="utf-8")
shell_hooks.save_allowlist({"approvals": [{"event": "a", "command": "x"}]}) # logs, never raises
assert json.loads(target.read_text(encoding="utf-8")) == {"approvals": []}
assert _leftovers(target.parent, target.name) == []
def test_surrogate_escaped_strings_round_trip_through_atomic_json_write(tmp_path):
"""A non-UTF-8 cwd/argv (``os.fsdecode`` → lone surrogate) must be persisted, not raise.
Breadcrumbs, the shell-hook allowlist and the active-sessions ledger all persist paths and
guard only ``OSError``; a ``UnicodeEncodeError`` (a ValueError) escaping the canonical writer
silently stopped those writes.
"""
from utils import atomic_json_write
payload = {"cwd": "a\udcffb", "plain": "caf\u00e9"}
target = tmp_path / "crumbs" / "crumb.json"
atomic_json_write(target, payload)
assert json.loads(target.read_bytes()) == payload
assert _leftovers(target.parent, target.name) == []
@pytest.mark.platforms("linux")
def test_new_non_secret_file_follows_umask_while_secret_and_existing_modes_hold(tmp_path):
"""The writers this helper replaced created files at process umask; only ``mode=`` tightens."""
import os
import stat
from utils import atomic_json_write
old_umask = os.umask(0o022)
try:
fresh = tmp_path / "cache.json"
atomic_json_write(fresh, {"a": 1})
assert stat.S_IMODE(fresh.stat().st_mode) == 0o644, "new non-secret file must not inherit mkstemp's 0600"
secret = tmp_path / "creds.json"
atomic_json_write(secret, {"token": "x"}, mode=0o600)
assert stat.S_IMODE(secret.stat().st_mode) == 0o600
existing = tmp_path / "state.json"
existing.write_text("{}", encoding="utf-8")
os.chmod(existing, 0o640)
atomic_json_write(existing, {"b": 2})
assert stat.S_IMODE(existing.stat().st_mode) == 0o640
finally:
os.umask(old_umask)
@pytest.mark.platforms("linux")
def test_mkstemp_heritage_writers_keep_new_files_owner_only(tmp_path):
"""Writers that published through mkstemp on main created NEW files at 0600 regardless of umask
(bot mailboxes, turn markers); folding them into utils must not loosen that to umask."""
import os
import stat
from tools.bot_relay import _atomic_write_json
from tui_gateway.turn_marker import _store
old_umask = os.umask(0o022)
try:
relay_target = tmp_path / "relay" / "inbox.json"
_atomic_write_json(relay_target, {"k": 1})
marker = tmp_path / "turn-marker.json"
_store(marker, {"sess": {"started_at": 1.0}})
for path in (relay_target, marker):
assert stat.S_IMODE(path.stat().st_mode) == 0o600, path
finally:
os.umask(old_umask)