Files
hermes-relay/plugin/tests/test_profile_write_endpoints.py
Bailey DixonandClaude Opus 4.7 31638780ab feat(api): PUT /api/profiles/{name}/soul and /memory/{filename}
Symmetric to the existing GET endpoints — same loopback-or-bearer auth,
same _resolve_profile_home resolver, same profile_not_found 404
shape. Wire contracts (locked per coordinator spec):

  PUT /api/profiles/{name}/soul
    Body: {"content": "..."}
    → 200 {"ok": true, "profile", "path", "bytes_written"}
    → 404 {"error": "profile_not_found", "profile": name}
    → 413 {"error": "payload_too_large", "limit_bytes": 1048576}

  PUT /api/profiles/{name}/memory/{filename}
    Body: {"content": "..."}
    → 200 {"ok": true, "profile", "filename", "path", "bytes_written"}
    → 400 {"error": "invalid_filename", "detail": "..."}
    → 404 {"error": "profile_not_found", "profile": name}
    → 413 {"error": "payload_too_large", "limit_bytes": 1048576}

Implementation:

* _atomic_write_text — writes to sibling <name>.tmp, fsyncs,
  os.replace()s into place. Preserves the existing file's POSIX
  mode when present (operator choice wins over any default we'd
  impose — SOUL files are commonly world-readable in the
  operator's home).

* _extract_write_content — shared JSON body + size validation.
  UTF-8 byte count (not char count) is the size gate; the 1 MB
  limit includes the entire encoded content string.

* _validate_memory_filename — rejects:
    - empty or missing filename
    - "/" or "\\" separators
    - ".." anywhere
    - leading "." (hidden files)
    - non-.md extensions
    - literal SOUL.md (use /soul endpoint instead)
    - anything outside [A-Za-z0-9._-]

* aiohttp Application.client_max_size bumped from default 1 MiB to
  2 MiB so phone uploads hit our structured 413 JSON body instead
  of aiohttp's plaintext short-circuit. 2 MB leaves slack for JSON
  envelope overhead above our 1 MB content cap. Voice/media routes
  unaffected (they do their own streaming).

* _notify_profiles_changed — stub hook called after each
  successful write. Real broadcast wiring lands in the follow-up
  profiles.updated commit; freezing the signature here keeps that
  patch small.

Test suite gains 24 cases in plugin/tests/test_profile_write_endpoints.py:
happy path (fresh write, overwrite, UTF-8 roundtrip), validation
(404 unknown profile, 400 missing/malformed body, 413 over 1 MB,
exactly 1 MB accepted), filename validation (path-separator,
traversal, leading-dot, non-.md, SOUL.md guard), atomicity (no
.tmp left behind), and default-profile routing (root ~/.hermes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:03:20 -04:00

316 lines
12 KiB
Python

"""Tests for PUT /api/profiles/{name}/soul and
/api/profiles/{name}/memory/{filename}.
Covers the Commit-4 write endpoints — happy path, size cap (413),
profile-not-found (404), filename validation (400), and atomic write
semantics.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.server import create_app
class ProfileWriteEndpointsBase(AioHTTPTestCase):
async def get_application(self) -> web.Application:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.hermes_dir = Path(self._tmp.name)
self.profiles_dir = self.hermes_dir / "profiles"
self.profiles_dir.mkdir(parents=True, exist_ok=True)
(self.hermes_dir / "config.yaml").write_text(
"model:\n default: test-model\n", encoding="utf-8"
)
config = RelayConfig(
hermes_config_path=str(self.hermes_dir / "config.yaml")
)
return create_app(config)
def _write_profile(self, name: str, *, soul: str | None = None) -> Path:
pdir = self.profiles_dir / name
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "config.yaml").write_text(
"model:\n default: x\n", encoding="utf-8"
)
if soul is not None:
(pdir / "SOUL.md").write_text(soul, encoding="utf-8")
return pdir
class ProfileSoulPutHappyPathTests(ProfileWriteEndpointsBase):
async def test_writes_new_soul(self) -> None:
"""A profile without SOUL.md accepts a fresh write and lands
the content on disk exactly."""
pdir = self._write_profile("fresh", soul=None)
resp = await self.client.put(
"/api/profiles/fresh/soul",
json={"content": "# Fresh soul\n\nHello.\n"},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
self.assertTrue(body["ok"])
self.assertEqual(body["profile"], "fresh")
self.assertEqual(body["bytes_written"], len("# Fresh soul\n\nHello.\n".encode("utf-8")))
self.assertTrue(body["path"].endswith("SOUL.md"))
on_disk = (pdir / "SOUL.md").read_text(encoding="utf-8")
self.assertEqual(on_disk, "# Fresh soul\n\nHello.\n")
async def test_overwrites_existing_soul(self) -> None:
pdir = self._write_profile("overwrite", soul="OLD\n")
resp = await self.client.put(
"/api/profiles/overwrite/soul",
json={"content": "NEW\n"},
)
self.assertEqual(resp.status, 200)
self.assertEqual((pdir / "SOUL.md").read_text(encoding="utf-8"), "NEW\n")
async def test_utf8_payload_roundtrips(self) -> None:
pdir = self._write_profile("unicode")
resp = await self.client.put(
"/api/profiles/unicode/soul",
json={"content": "混沌の魂 🧠\n"},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
# bytes_written reports UTF-8 byte count, not char count.
self.assertEqual(body["bytes_written"], len("混沌の魂 🧠\n".encode("utf-8")))
self.assertEqual((pdir / "SOUL.md").read_text(encoding="utf-8"), "混沌の魂 🧠\n")
class ProfileSoulPutValidationTests(ProfileWriteEndpointsBase):
async def test_unknown_profile_returns_404(self) -> None:
resp = await self.client.put(
"/api/profiles/ghost/soul",
json={"content": "x"},
)
self.assertEqual(resp.status, 404)
body = await resp.json()
self.assertEqual(body["error"], "profile_not_found")
async def test_missing_body_rejected(self) -> None:
self._write_profile("nobody")
resp = await self.client.put(
"/api/profiles/nobody/soul",
data="not json",
headers={"Content-Type": "application/json"},
)
self.assertEqual(resp.status, 400)
async def test_missing_content_field_rejected(self) -> None:
self._write_profile("nocontent")
resp = await self.client.put(
"/api/profiles/nocontent/soul",
json={},
)
self.assertEqual(resp.status, 400)
body = await resp.json()
self.assertEqual(body["error"], "invalid_body")
async def test_non_string_content_rejected(self) -> None:
self._write_profile("bad")
resp = await self.client.put(
"/api/profiles/bad/soul",
json={"content": 42},
)
self.assertEqual(resp.status, 400)
async def test_payload_over_1mb_rejected(self) -> None:
self._write_profile("big")
# 1MB + 1 byte of ASCII.
blob = "A" * (1024 * 1024 + 1)
resp = await self.client.put(
"/api/profiles/big/soul",
json={"content": blob},
)
self.assertEqual(resp.status, 413)
body = await resp.json()
self.assertEqual(body["error"], "payload_too_large")
self.assertEqual(body["limit_bytes"], 1024 * 1024)
async def test_exactly_1mb_accepted(self) -> None:
self._write_profile("edge")
blob = "B" * (1024 * 1024)
resp = await self.client.put(
"/api/profiles/edge/soul",
json={"content": blob},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
self.assertEqual(body["bytes_written"], 1024 * 1024)
class ProfileMemoryPutHappyPathTests(ProfileWriteEndpointsBase):
async def test_writes_new_memory_file(self) -> None:
pdir = self._write_profile("alice")
resp = await self.client.put(
"/api/profiles/alice/memory/MEMORY.md",
json={"content": "# Memory\n\nNotes.\n"},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
self.assertTrue(body["ok"])
self.assertEqual(body["filename"], "MEMORY.md")
self.assertEqual(body["profile"], "alice")
disk = (pdir / "memories" / "MEMORY.md").read_text(encoding="utf-8")
self.assertEqual(disk, "# Memory\n\nNotes.\n")
async def test_creates_memories_dir_if_absent(self) -> None:
pdir = self._write_profile("newmem")
self.assertFalse((pdir / "memories").exists())
resp = await self.client.put(
"/api/profiles/newmem/memory/MEMORY.md",
json={"content": "x"},
)
self.assertEqual(resp.status, 200)
self.assertTrue((pdir / "memories").is_dir())
async def test_writes_custom_filename(self) -> None:
pdir = self._write_profile("custom")
resp = await self.client.put(
"/api/profiles/custom/memory/project_notes.md",
json={"content": "Custom body.\n"},
)
self.assertEqual(resp.status, 200)
self.assertTrue((pdir / "memories" / "project_notes.md").is_file())
class ProfileMemoryPutFilenameValidationTests(ProfileWriteEndpointsBase):
"""Filenames that would allow path traversal, hidden files, or
non-markdown content must be rejected with a 400
``invalid_filename``."""
async def test_traversal_with_slash_rejected(self) -> None:
self._write_profile("trav")
# Router won't match a filename with "/" in the path segment,
# but any call that reaches our handler with a "/" in the
# decoded filename is rejected. Test the guard directly:
from plugin.relay.server import _validate_memory_filename
self.assertIsNotNone(_validate_memory_filename("../etc/passwd.md"))
self.assertIsNotNone(_validate_memory_filename("sub/note.md"))
async def test_traversal_with_backslash_rejected(self) -> None:
from plugin.relay.server import _validate_memory_filename
self.assertIsNotNone(_validate_memory_filename("..\\etc\\passwd.md"))
self.assertIsNotNone(_validate_memory_filename("sub\\note.md"))
async def test_double_dot_rejected(self) -> None:
from plugin.relay.server import _validate_memory_filename
self.assertIsNotNone(_validate_memory_filename("..md"))
self.assertIsNotNone(_validate_memory_filename("note..md"))
async def test_leading_dot_rejected(self) -> None:
self._write_profile("hidden")
resp = await self.client.put(
"/api/profiles/hidden/memory/.hidden.md",
json={"content": "x"},
)
self.assertEqual(resp.status, 400)
body = await resp.json()
self.assertEqual(body["error"], "invalid_filename")
async def test_non_md_extension_rejected(self) -> None:
self._write_profile("extcheck")
resp = await self.client.put(
"/api/profiles/extcheck/memory/MEMORY.txt",
json={"content": "x"},
)
self.assertEqual(resp.status, 400)
body = await resp.json()
self.assertEqual(body["error"], "invalid_filename")
async def test_soul_md_rejected_via_memory_path(self) -> None:
"""Writing SOUL.md via the memory endpoint is blocked — the
soul endpoint is the canonical entry point."""
self._write_profile("soulroute")
resp = await self.client.put(
"/api/profiles/soulroute/memory/SOUL.md",
json={"content": "x"},
)
self.assertEqual(resp.status, 400)
async def test_valid_filenames_pass_guard(self) -> None:
"""Guard positive cases."""
from plugin.relay.server import _validate_memory_filename
self.assertIsNone(_validate_memory_filename("MEMORY.md"))
self.assertIsNone(_validate_memory_filename("USER.md"))
self.assertIsNone(_validate_memory_filename("project_notes.md"))
self.assertIsNone(_validate_memory_filename("a-b-c.md"))
self.assertIsNone(_validate_memory_filename("file_1.md"))
class ProfileMemoryPutValidationTests(ProfileWriteEndpointsBase):
async def test_unknown_profile_returns_404(self) -> None:
resp = await self.client.put(
"/api/profiles/ghost/memory/MEMORY.md",
json={"content": "x"},
)
self.assertEqual(resp.status, 404)
body = await resp.json()
self.assertEqual(body["error"], "profile_not_found")
async def test_payload_over_1mb_rejected(self) -> None:
self._write_profile("big")
blob = "A" * (1024 * 1024 + 1)
resp = await self.client.put(
"/api/profiles/big/memory/MEMORY.md",
json={"content": blob},
)
self.assertEqual(resp.status, 413)
body = await resp.json()
self.assertEqual(body["error"], "payload_too_large")
class ProfileWriteAtomicityTests(ProfileWriteEndpointsBase):
"""The SOUL.md.tmp sibling file must be renamed, not left lying."""
async def test_no_tmp_left_after_successful_write(self) -> None:
pdir = self._write_profile("atomic")
resp = await self.client.put(
"/api/profiles/atomic/soul",
json={"content": "Content.\n"},
)
self.assertEqual(resp.status, 200)
self.assertFalse((pdir / "SOUL.md.tmp").exists())
self.assertTrue((pdir / "SOUL.md").is_file())
class ProfileDefaultWriteTests(ProfileWriteEndpointsBase):
"""The synthetic ``default`` profile should route writes to the
root ``~/.hermes`` directory — same as the GET path."""
async def test_default_soul_writes_to_root(self) -> None:
resp = await self.client.put(
"/api/profiles/default/soul",
json={"content": "# Root soul\n"},
)
self.assertEqual(resp.status, 200)
self.assertEqual(
(self.hermes_dir / "SOUL.md").read_text(encoding="utf-8"),
"# Root soul\n",
)
async def test_default_memory_writes_to_root(self) -> None:
resp = await self.client.put(
"/api/profiles/default/memory/MEMORY.md",
json={"content": "# Root memory\n"},
)
self.assertEqual(resp.status, 200)
self.assertEqual(
(self.hermes_dir / "memories" / "MEMORY.md").read_text(encoding="utf-8"),
"# Root memory\n",
)
if __name__ == "__main__":
unittest.main()