Files
hermes-webui/tests/test_issue3802_delete_session_journals.py
nesquena-hermes 396d0d0abd
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.326 — Release KP (#3618 + #3802 + #3762 + #3810) (#3816)
Batch: mic STT capability probe+fallback (#3618, live-drive verified), journal cleanup on delete (#3802), minimal-schema SQL guard (#3762), Help hover readability (#3810). Full suite 8265, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
2026-06-07 23:18:51 -07:00

114 lines
4.8 KiB
Python

"""Regression tests for #3802 — deleting a session must remove its journal files.
Deleting a conversation from the WebUI removed the session JSON + state.db rows
but left the turn journal (`_turn_journal/{sid}*.jsonl`, user messages in
plaintext) and the run journal (`_run_journal/{sid}/`, full request/response
payloads) on disk, so the conversation stayed recoverable. These tests pin the
two cleanup helpers: every shard/dir for the deleted session is removed, and an
unrelated session's journals are left untouched.
"""
import os
from api.run_journal import RunJournalWriter, delete_run_journal, read_run_events
from api.turn_journal import (
append_turn_journal_event,
delete_turn_journal,
read_turn_journal,
)
def _submit(sid, content, session_dir):
return append_turn_journal_event(
sid,
{"event": "submitted", "turn_id": "t1", "stream_id": "s1", "role": "user", "content": content},
session_dir=session_dir,
)
def test_delete_turn_journal_removes_pid_shard_and_legacy(tmp_path):
# pid-scoped shard (written by append) + a legacy single-file shard.
_submit("sid-del", "secret message", session_dir=tmp_path)
journal_dir = tmp_path / "_turn_journal"
legacy = journal_dir / "sid-del.jsonl"
legacy.write_text('{"event":"submitted","session_id":"sid-del","content":"old"}\n', encoding="utf-8")
pid_shard = journal_dir / f"sid-del~{os.getpid()}.jsonl"
assert pid_shard.exists()
assert legacy.exists()
removed = delete_turn_journal("sid-del", session_dir=tmp_path)
assert removed == 2
assert not pid_shard.exists()
assert not legacy.exists()
# read_turn_journal now finds nothing for the deleted session.
assert read_turn_journal("sid-del", session_dir=tmp_path)["events"] == []
def test_delete_turn_journal_leaves_other_sessions_intact(tmp_path):
_submit("sid-keep", "keep me", session_dir=tmp_path)
_submit("sid-del", "delete me", session_dir=tmp_path)
delete_turn_journal("sid-del", session_dir=tmp_path)
keep_shard = tmp_path / "_turn_journal" / f"sid-keep~{os.getpid()}.jsonl"
del_shard = tmp_path / "_turn_journal" / f"sid-del~{os.getpid()}.jsonl"
assert keep_shard.exists(), "unrelated session's journal must survive"
assert not del_shard.exists()
assert read_turn_journal("sid-keep", session_dir=tmp_path)["events"]
def test_delete_turn_journal_noop_on_missing_or_invalid(tmp_path):
# Missing directory: no error, zero removed.
assert delete_turn_journal("nope", session_dir=tmp_path) == 0
# Invalid id: no error, zero removed (and never touches the filesystem).
assert delete_turn_journal("../etc/passwd", session_dir=tmp_path) == 0
assert delete_turn_journal("", session_dir=tmp_path) == 0
def test_delete_run_journal_removes_session_directory(tmp_path):
writer = RunJournalWriter("sid-del", "run-1", session_dir=tmp_path)
writer.append_sse_event("token", {"text": "hello"})
writer.append_sse_event("done", {"session": {"session_id": "sid-del"}})
run_dir = tmp_path / "_run_journal" / "sid-del"
assert run_dir.exists()
assert delete_run_journal("sid-del", session_dir=tmp_path) is True
assert not run_dir.exists()
# No events recoverable after delete.
assert read_run_events("sid-del", "run-1", session_dir=tmp_path)["events"] == []
def test_delete_run_journal_leaves_other_sessions_intact(tmp_path):
RunJournalWriter("sid-keep", "run-k", session_dir=tmp_path).append_sse_event("token", {"text": "k"})
RunJournalWriter("sid-del", "run-d", session_dir=tmp_path).append_sse_event("token", {"text": "d"})
delete_run_journal("sid-del", session_dir=tmp_path)
assert (tmp_path / "_run_journal" / "sid-keep").exists()
assert not (tmp_path / "_run_journal" / "sid-del").exists()
def test_delete_run_journal_noop_on_missing_or_invalid(tmp_path):
assert delete_run_journal("nope", session_dir=tmp_path) is False
assert delete_run_journal("../escape", session_dir=tmp_path) is False
assert delete_run_journal("", session_dir=tmp_path) is False
def test_delete_journals_reject_dot_traversal_ids(tmp_path):
"""A bare '.'/'..' passes the dot-permitting id regex but must NOT resolve to
the journal root/parent and delete the wrong directory (no '/' to catch it).
"""
# Seed a real run + turn journal so we'd notice an over-broad delete.
writer = RunJournalWriter("keep", "run-1", session_dir=tmp_path)
writer.append_sse_event("token", {"text": "hello"})
_submit("keep", "hi", tmp_path)
run_dir = tmp_path / "_run_journal" / "keep"
assert run_dir.exists()
for bad in (".", ".."):
assert delete_run_journal(bad, session_dir=tmp_path) is False
assert delete_turn_journal(bad, session_dir=tmp_path) == 0
# The legitimate journals must still be present.
assert run_dir.exists()
assert read_turn_journal("keep", session_dir=tmp_path)