Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""``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}
|
||||
Reference in New Issue
Block a user