Files
hermes-webui/tests/test_session_public_share.py
T
nesquena-hermesandMicoRobot. 75153f5b79 fix(share): always-on redaction boundary + rebase/UX fixes (gate findings)
Adversarial security re-gate on the rebased head found 4 issues; fixed:

1. [CORE] api/shares.py public snapshot could leak credentials/paths/tool
   payloads. The sanitizer relied on redact_session_data (gated on the
   user-toggleable api_redact_enabled) — disabling it exposed API keys, a dict
   assistant content was stringified as raw tool data, and session.workspace
   survived in transcript text. Now the per-message sanitizer applies ALWAYS-ON
   credential redaction (_redact_fn_cached, force=True, ignores the setting) +
   scrubs known session/workspace/home paths, and refuses to stringify non-text
   structured content. Title is force-redacted too. Regression test proves the
   boundary holds with api_redact_enabled=False.
2. [SILENT] api/models.py: my rebase wrongly kept anchor_activity_scenes in
   METADATA_FIELDS, moving 250-480KB scene bodies before messages and undoing
   master's metadata fast-path (#5854). Removed; 17 #5854 tests pass again.
3. [SILENT] static/index.html: the Share/StopSharing/ExportHTML button triplet
   was double-applied by the rebase (duplicate IDs → only the first wired).
   Deduped to one each + a count assertion test.
4. tests: fixed the stale Session.load-after-/api/session/new setup (master
   keeps new sessions memory-only until first message).

Also (UX, found in the visual flow sweep): a clipboard-copy failure after a
SUCCESSFUL share showed a red 'Share failed' toast and skipped opening the page.
The copy is now isolated — the share succeeds, the page opens, and the toast
shows the link if the clipboard write fails, never a false failure.

Co-authored-by: MicoRobot.
2026-07-11 02:06:34 +00:00

229 lines
8.2 KiB
Python

import json
import urllib.error
import urllib.request
from tests._pytest_port import BASE
def get(path):
try:
with urllib.request.urlopen(BASE + path, timeout=10) as r:
data = r.read()
content_type = r.headers.get("Content-Type", "")
if "application/json" in content_type:
return json.loads(data), r.status, dict(r.headers)
return data.decode("utf-8"), r.status, dict(r.headers)
except urllib.error.HTTPError as e:
data = e.read()
content_type = e.headers.get("Content-Type", "")
if "application/json" in content_type:
return json.loads(data), e.code, dict(e.headers)
return data.decode("utf-8"), e.code, dict(e.headers)
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _make_session_with_messages():
created, _ = post("/api/session/new", {})
sid = created["session"]["session_id"]
from api.models import Session
# Current master keeps a freshly /api/session/new session memory-only until
# its first message is persisted, so Session.load(sid) can return None here.
# Construct + persist the session directly so the share path has a real file.
session = Session.load(sid) or Session(session_id=sid)
session.title = "Shared Test"
session.messages = [
{"role": "system", "content": "internal system instructions should stay private"},
{"role": "user", "content": "Please summarize this."},
{
"role": "assistant",
"content": "Here is a concise summary.",
"provider_details": "HTTP 401: expired upstream token",
"provider_details_label": "Provider details",
},
{"role": "tool", "content": "raw tool output should not be public"},
]
session.workspace = "/very/private/workspace"
session.profile = None
session.save()
return sid
def test_share_create_returns_public_url_and_persists_session_fields():
sid = _make_session_with_messages()
try:
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
assert payload["ok"] is True
share = payload["share"]
assert share["token"]
assert share["url"].startswith("/share/")
assert payload["session"]["share_token"] == share["token"]
assert payload["session"]["share_created_at"]
finally:
post("/api/session/delete", {"session_id": sid})
def test_public_share_payload_is_sanitized_and_read_only():
sid = _make_session_with_messages()
try:
created, _ = post("/api/share/create", {"session_id": sid})
token = created["share"]["token"]
payload, status, headers = get(f"/api/share/{token}")
assert status == 200
assert headers.get("X-Robots-Tag") == "noindex, nofollow"
share = payload["share"]
assert share["title"] == "Shared Test"
assert "workspace" not in share
assert "profile" not in share
assert "source_session_id" not in share
assert "token" not in share
assert "revoked_at" not in share
assert share["message_count"] == 2
assert [m["role"] for m in share["messages"]] == ["user", "assistant"]
assert all("system" != m["role"] for m in share["messages"])
assert all("tool" != m["role"] for m in share["messages"])
assert "provider_details" not in share["messages"][1]
assert "provider_details_label" not in share["messages"][1]
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_revoke_makes_link_unavailable():
sid = _make_session_with_messages()
try:
created, _ = post("/api/share/create", {"session_id": sid})
token = created["share"]["token"]
revoked, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert revoked["ok"] is True
missing, status, _ = get(f"/api/share/{token}")
assert status == 404
assert missing["error"] == "Shared conversation not found"
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_revoke_endpoint_hides_share_token_from_session():
sid = _make_session_with_messages()
try:
post("/api/share/create", {"session_id": sid})
payload, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert payload["session"]["share_token"] is None
assert payload["session"]["share_created_at"] is None
finally:
post("/api/session/delete", {"session_id": sid})
def test_share_page_serves_public_html():
body, status, _ = get("/share/example-token")
assert status == 200
assert "Hermes Shared Conversation" in body
assert "static/share.js" in body
def test_share_create_supports_raw_messaging_session_without_webui_sidecar():
from tests.test_gateway_sync import _ensure_state_db, _insert_gateway_session, _remove_test_sessions
conn = _ensure_state_db()
sid = "share_tg_external_001"
try:
_insert_gateway_session(
conn,
session_id=sid,
source="telegram",
title="Telegram Share",
)
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
assert payload["ok"] is True
token = payload["share"]["token"]
assert token
assert payload["session"]["share_token"] == token
assert payload["session"]["session_source"] == "messaging"
assert payload["session"]["raw_source"] == "telegram"
assert [m["role"] for m in payload["session"]["messages"]] == ["user", "assistant"]
shared, status, _ = get(f"/api/share/{token}")
assert status == 200
assert shared["share"]["title"] == "Telegram Share"
assert [m["content"] for m in shared["share"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
revoked, status = post("/api/share/revoke", {"session_id": sid})
assert status == 200
assert revoked["session"]["share_token"] is None
assert [m["role"] for m in revoked["session"]["messages"]] == ["user", "assistant"]
finally:
try:
post("/api/session/delete", {"session_id": sid})
except Exception:
pass
_remove_test_sessions(conn, sid)
conn.close()
def test_share_create_uses_messaging_display_transcript_when_sidecar_has_no_messages():
from api.models import Session
from tests.test_gateway_sync import _ensure_state_db, _insert_gateway_session, _remove_test_sessions
conn = _ensure_state_db()
sid = "share_discord_imported_001"
try:
_insert_gateway_session(
conn,
session_id=sid,
source="discord",
title="Discord Share",
)
local = Session(
session_id=sid,
title="Discord Share",
messages=[],
model="openai/gpt-5",
created_at=1.0,
updated_at=2.0,
)
local.is_cli_session = True
local.session_source = "messaging"
local.raw_source = "discord"
local.source_tag = "discord"
local.source_label = "Discord"
local.save(touch_updated_at=False)
payload, status = post("/api/share/create", {"session_id": sid})
assert status == 200
token = payload["share"]["token"]
assert token
assert [m["content"] for m in payload["session"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
shared, status, _ = get(f"/api/share/{token}")
assert status == 200
assert [m["content"] for m in shared["share"]["messages"]] == [
"Hello from Telegram",
"Hi there!",
]
finally:
post("/api/session/delete", {"session_id": sid})
_remove_test_sessions(conn, sid)
conn.close()