Files
aiturk-hermes-ide/tests/hermes_cli/test_web_profile_soul_writes.py
T

215 lines
8.4 KiB
Python

"""``PUT /api/profiles/{name}/soul`` must not destroy an existing SOUL.md.
The dashboard persona editor replaces the whole document on every Save. A bare
``write_text()`` truncates SOUL.md before the new body lands, and the paired
``GET`` reports an unreadable file as ``{"content": "", "exists": False}`` — so
an interrupted save presents as "your persona was never set" and the editor's
next Save persists that empty document over the original.
Lives in its own module rather than ``test_web_server.py`` to keep the harness
small and focused on this one endpoint pair.
"""
from __future__ import annotations
import asyncio
import os
import stat
import sys
from pathlib import Path
import pytest
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
SOUL = "# Persona\n\nYou are a careful, terse assistant.\n"
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "soul-test-token")
from hermes_cli import web_server
with TestClient(web_server.app, raise_server_exceptions=False) as c:
# web_server resolves _SESSION_TOKEN once, at import. Read it back from
# the module instead of assuming the env var above won the race — any
# test file that imports web_server earlier in the session fixes the
# token before this fixture runs.
c.headers["Authorization"] = f"Bearer {web_server._SESSION_TOKEN}"
yield c
@pytest.fixture()
def profile_dir(tmp_path, monkeypatch) -> Path:
"""Create a real profile directory under the test HERMES_HOME."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli import profiles as profiles_mod
d = profiles_mod.get_profile_dir("demo")
d.mkdir(parents=True, exist_ok=True)
return d
class TestSoulWriteDurability:
def test_put_replaces_soul(self, client, profile_dir: Path):
"""Happy path: the editor's Save still works."""
(profile_dir / "SOUL.md").write_text(SOUL, encoding="utf-8")
r = client.put("/api/profiles/demo/soul", json={"content": "# New\n"})
assert r.status_code == 200, r.text
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == "# New\n"
def test_put_creates_soul_when_absent(self, client, profile_dir: Path):
"""A first save has no prior file to preserve permissions from."""
assert not (profile_dir / "SOUL.md").exists()
r = client.put("/api/profiles/demo/soul", json={"content": SOUL})
assert r.status_code == 200, r.text
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == SOUL
def test_existing_soul_survives_an_interrupted_save(
self, client, profile_dir: Path
):
soul = profile_dir / "SOUL.md"
soul.write_text(SOUL, encoding="utf-8")
original = soul.read_bytes()
def boom(fd):
raise OSError("simulated crash mid-write")
# Scoped context so restoring os.fsync doesn't also undo the
# HERMES_HOME patch the client/profile_dir fixtures installed.
with pytest.MonkeyPatch.context() as mp:
mp.setattr(os, "fsync", boom)
r = client.put(
"/api/profiles/demo/soul", json={"content": "# clobbered\n"}
)
assert r.status_code == 500
# The persona the user already had must survive verbatim...
assert soul.read_bytes() == original
# ...and the paired GET must not report it as never-set, which is what
# would make the next Save persist an empty document.
g = client.get("/api/profiles/demo/soul")
assert g.status_code == 200, g.text
assert g.json()["exists"] is True
assert g.json()["content"] == SOUL
# No temp file left behind in the profile directory.
assert list(profile_dir.glob("*.tmp")) == []
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_existing_file_mode_is_preserved(self, client, profile_dir: Path):
"""Profile SOUL.md is created 0644 and never run through
``_secure_file``; saving from the dashboard must not change that."""
soul = profile_dir / "SOUL.md"
soul.write_text(SOUL, encoding="utf-8")
os.chmod(soul, 0o644)
r = client.put("/api/profiles/demo/soul", json={"content": "# New\n"})
assert r.status_code == 200, r.text
mode = stat.S_IMODE(soul.stat().st_mode)
assert mode == 0o644, f"mode changed to {oct(mode)}"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_created_file_mode_is_not_tightened(self, client, profile_dir: Path):
"""The first-ever Save must not leave SOUL.md owner-only.
There is no prior file to copy permissions from, and
``atomic_write_text`` swaps in a ``tempfile.mkstemp`` file (0600).
Profile creation seeds SOUL.md with a plain ``write_text()`` and
chmods only ``.env`` to 0600, so routing this endpoint through the
atomic writer must not tighten the persona document as a side effect.
"""
soul = profile_dir / "SOUL.md"
assert not soul.exists()
r = client.put("/api/profiles/demo/soul", json={"content": SOUL})
assert r.status_code == 200, r.text
mode = stat.S_IMODE(soul.stat().st_mode)
assert mode == 0o644, f"first save created SOUL.md as {oct(mode)}"
class TestSoulIoIsOffTheEventLoop:
"""Neither half of the persona editor may run its I/O on the ASGI loop.
The durability the tests above buy comes from ``atomic_write_text``, which
fsyncs before replacing — so the save blocks for as long as the filesystem
takes to commit. These handlers sit in the same router as the profile
delete and describe-auto paths; the rest of that sweep is covered by
``tests/hermes_cli/test_web_profiles_off_loop.py``.
"""
@staticmethod
def _probe(seen, tag):
"""Record whether the caller's thread is running an event loop."""
try:
asyncio.get_running_loop()
seen.append((tag, True))
except RuntimeError:
seen.append((tag, False))
def test_get_soul_reads_off_loop(self, client, profile_dir: Path, monkeypatch):
(profile_dir / "SOUL.md").write_text(SOUL, encoding="utf-8")
seen: list[tuple[str, bool]] = []
real_read_text = Path.read_text
def probing_read_text(self, *args, **kwargs):
if self.name == "SOUL.md":
TestSoulIoIsOffTheEventLoop._probe(seen, "read")
return real_read_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", probing_read_text)
r = client.get("/api/profiles/demo/soul")
assert r.status_code == 200, r.text
assert r.json()["content"] == SOUL
assert ("read", False) in seen, (
f"SOUL.md must be read off the event loop; proof: {seen}"
)
def test_put_soul_writes_off_loop(self, client, profile_dir: Path, monkeypatch):
seen: list[tuple[str, bool]] = []
import utils
real_write = utils.atomic_write_text
def probing_write(*args, **kwargs):
TestSoulIoIsOffTheEventLoop._probe(seen, "write")
return real_write(*args, **kwargs)
monkeypatch.setattr(utils, "atomic_write_text", probing_write)
r = client.put("/api/profiles/demo/soul", json={"content": SOUL})
assert r.status_code == 200, r.text
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == SOUL
assert ("write", False) in seen, (
f"SOUL.md must be written off the event loop; proof: {seen}"
)
def test_missing_soul_is_still_reported_absent(self, client, profile_dir: Path):
"""The offloaded reader must keep distinguishing "no file" from
"empty file" — the whole point of the durability tests above."""
assert not (profile_dir / "SOUL.md").exists()
r = client.get("/api/profiles/demo/soul")
assert r.status_code == 200, r.text
assert r.json() == {"content": "", "exists": False}
def test_empty_soul_is_still_reported_present(self, client, profile_dir: Path):
(profile_dir / "SOUL.md").write_text("", encoding="utf-8")
r = client.get("/api/profiles/demo/soul")
assert r.status_code == 200, r.text
assert r.json() == {"content": "", "exists": True}