"""Bounded, privacy-enforced message-text search (HUX-03 increment).""" from __future__ import annotations import json import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) from hux import contracts, organization # noqa: E402 from hux.server import build_router # noqa: E402 HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) def call(router, method, path, body=None, headers=None): raw = b"" if body is None else json.dumps(body).encode() response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw) return response.status, response.body @pytest.fixture def router(tmp_path): return build_router( tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk", "HUX_WORKER_KEY": "wk", "HUX_WRITES_PER_MINUTE": "5000", "HUX_READS_PER_MINUTE": "5000"}, ) def conversation(router, title, project_id): status, body = call(router, "POST", "/hux/v1/conversations", {"title": title, "project_id": project_id}) assert status == 201 return body def say(router, conversation_id, summary, kind="message.user", sensitivity="personal", key=None): import hashlib slug = key or hashlib.sha256(f"{conversation_id}:{summary}".encode()).hexdigest()[:24] status, body = call( router, "POST", f"/hux/v1/conversations/{conversation_id}/events", {"kind": kind, "summary": summary, "sensitivity": sensitivity}, {**WORKER, "Idempotency-Key": f"evt.{slug}"}, ) assert status in (200, 201), body @pytest.fixture def scoped(router): status, project = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen"}) assert status == 201 return router, project["id"] def test_message_text_is_opt_in_and_scoped_explicitly(scoped): router, project_id = scoped convo = conversation(router, "Plumbing", project_id) say(router, convo["id"], "the kidneystone diagnosis was benign") status, body = call(router, "GET", "/hux/v1/search?q=kidneystone") assert status == 200 and body["items"] == [] assert body["not_indexed"] == ["message_text"] status, body = call(router, "GET", "/hux/v1/search?q=kidneystone&include=message_text") assert status == 200 assert [item["id"] for item in body["items"]] == [convo["id"]] assert "message_text" in body["indexed"] and body["not_indexed"] == [] status, body = call(router, "GET", "/hux/v1/search?q=kidneystone&include=bogus") assert status == 400 def test_privacy_and_tombstones_defeat_message_search(scoped): router, project_id = scoped plain = conversation(router, "Plain", project_id) say(router, plain["id"], "walnut cabinets arrived") hidden = conversation(router, "Hidden", project_id) say(router, hidden["id"], "walnut cabinets secret plans", sensitivity="restricted") private = conversation(router, "Private", project_id) say(router, private["id"], "walnut cabinets private note") status, _ = call( router, "PUT", f"/hux/v1/projects/{project_id}/conversations/{private['id']}/mode", {"project_id": project_id, "mode": "private"}, {"If-Match": "0", "Idempotency-Key": "mode-private-x"}, ) assert status == 200 forgotten = conversation(router, "Forgotten", project_id) say(router, forgotten["id"], "walnut cabinets to forget") status, _ = call(router, "POST", f"/hux/v1/conversations/{forgotten['id']}/forget", {}) assert status in (200, 201, 202) status, body = call(router, "GET", "/hux/v1/search?q=walnut+cabinets&include=message_text") assert status == 200 assert [item["id"] for item in body["items"]] == [plain["id"]] def test_pagination_is_deterministic(scoped): router, project_id = scoped ids = [] for index in range(organization.SEARCH_PAGE + 3): convo = conversation(router, f"Convo {index}", project_id) say(router, convo["id"], "zebra sighting logged", key=f"zebra.{index:04}") ids.append(convo["id"]) status, first = call(router, "GET", "/hux/v1/search?q=zebra&include=message_text") assert status == 200 and len(first["items"]) == organization.SEARCH_PAGE assert first["next"] == str(organization.SEARCH_PAGE) status, second = call( router, "GET", f"/hux/v1/search?q=zebra&include=message_text&cursor={first['next']}", ) assert status == 200 and len(second["items"]) == 3 and second["next"] is None assert not {i["id"] for i in first["items"]} & {i["id"] for i in second["items"]} status, again = call(router, "GET", "/hux/v1/search?q=zebra&include=message_text") assert [i["id"] for i in again["items"]] == [i["id"] for i in first["items"]] status, body = call(router, "GET", "/hux/v1/search?q=zebra&include=message_text&cursor=nope") assert status == 400 def test_scan_stays_bounded_to_recent_conversations(scoped, monkeypatch): router, project_id = scoped monkeypatch.setattr(organization, "MESSAGE_SCAN_CONVERSATIONS", 1) older = conversation(router, "Older", project_id) say(router, older["id"], "quartz counters measured") newer = conversation(router, "Newer", project_id) say(router, newer["id"], "quartz counters ordered") # Force a strictly newer updated_at (now_iso ties at clock resolution). monkeypatch.setattr(organization, "now_iso", lambda: "2099-01-01T00:00:00Z") status, _ = call( router, "PATCH", f"/hux/v1/conversations/{newer['id']}", {"pinned": True}, {"If-Match": str(newer["revision"])}, ) assert status == 200 status, body = call(router, "GET", "/hux/v1/search?q=quartz&include=message_text") assert status == 200 assert [item["id"] for item in body["items"]] == [newer["id"]] assert body["message_scan_limit"] == 1