"""HUX-03 projects, conversations, branch lineage and search. Security obligations exercised: SO-44 (If-Match on PATCH, 409 on mismatch, unconditional writes audited) plus the storage rules every family shares: ids and provenance are server-set, records the caller does not own are 404, and every served record validates against ``project.schema.json``. """ 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)) import hux # noqa: E402 from hux import audit, contracts, identity, organization, store # noqa: E402 from hux.server import build_router # noqa: E402 SCHEMAS = contracts.load_all() HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) @pytest.fixture def router(tmp_path): return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) def call(router, method, path, body=None, headers=HEADERS): raw = b"" if body is None else json.dumps(body).encode() response = router.dispatch(method, path, headers, raw) return response.status, response.body, response.headers def valid(record): problems = contracts.validate_record(record, SCHEMAS) assert problems == [], problems return record def make_project(router, **fields): status, body, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen", **fields}) assert status == 201, body return valid(body) def make_conversation(router, **fields): status, body, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", **fields}) assert status == 201, body return valid(body) def test_module_stays_under_the_line_budget(): assert len((FOUNDATION / "hux" / "organization.py").read_text().splitlines()) <= 500 # --- projects ------------------------------------------------------------------- def test_project_create_read_list_and_server_set_fields(router): body = make_project(router, tags=["home"], pinned=True, default_mode="research", id="prj_evil", owner="usr_ffffffffffffffff", revision=9) assert body["id"].startswith("prj_") and body["id"] != "prj_evil" and body["owner"] == HEADERS["X-Hux-Subject"] and body["revision"] == 1 assert body["created_at"] == body["updated_at"] and body["archived"] is False status, got, headers = call(router, "GET", f"/hux/v1/projects/{body['id']}") assert (status, got, headers["ETag"]) == (200, body, "1") second = make_project(router, name="Garden") status, listing, _ = call(router, "GET", "/hux/v1/projects") assert [p["id"] for p in listing["items"]] == [body["id"], second["id"]], "pinned first" assert call(router, "GET", "/hux/v1/projects?archived=true")[1]["items"] == [] assert call(router, "GET", "/hux/v1/projects?archived=false")[1]["items"] == listing["items"] def test_project_patch_needs_if_match_and_audits_unconditional_writes(router, tmp_path): project = make_project(router) path = f"/hux/v1/projects/{project['id']}" status, body, headers = call(router, "PATCH", path, {"name": "Kitchen v2", "archived": True, "owner": "usr_ffffffffffffffff"}, {**HEADERS, "If-Match": "1"}) assert status == 200 and valid(body)["name"] == "Kitchen v2" and body["archived"] is True and body["revision"] == 2 and headers["ETag"] == "2" assert body["owner"] == project["owner"] and body["updated_at"] >= project["updated_at"] status, error, _ = call(router, "PATCH", path, {"name": "stale"}, {**HEADERS, "If-Match": "1"}) assert (status, error["code"]) == (409, "conflict") and valid(error) status, body, _ = call(router, "PATCH", path, {"description": "no If-Match"}) assert status == 200 and body["revision"] == 3 assert call(router, "PATCH", path, {"name": ""}, {**HEADERS, "If-Match": "3"})[0] == 400 assert call(router, "PATCH", path, {"default_mode": "turbo"}, {**HEADERS, "If-Match": "3"})[0] == 400 assert call(router, "PATCH", path, [], {**HEADERS, "If-Match": "3"})[0] == 400 rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))) if r["action"] == "projects.update"] assert rows[0] == ("projects.update", "allow", "") and rows[1][1] == "conflict" and rows[2] == ("projects.update", "allow", "unconditional_write") def test_project_idempotency_and_cap(router, monkeypatch): key = {**HEADERS, "Idempotency-Key": "chat:project:0001"} status, first, _ = call(router, "POST", "/hux/v1/projects", {"name": "Once"}, key) status, again, _ = call(router, "POST", "/hux/v1/projects", {"name": "Twice"}, key) assert (status, again) == (200, first) status, other, _ = call(router, "POST", "/hux/v1/projects", {"name": "Other key"}, {**HEADERS, "Idempotency-Key": "chat:project:0002"}) assert status == 201 and other["id"] != first["id"] assert call(router, "POST", "/hux/v1/projects", {"name": "x" * 121})[0] == 400 assert call(router, "POST", "/hux/v1/projects", None)[0] == 400 monkeypatch.setattr(organization, "MAX_PROJECTS", 1) status, error, _ = call(router, "POST", "/hux/v1/projects", {"name": "Too many"}) assert (status, error["code"]) == (409, "conflict") assert call(router, "POST", "/hux/v1/projects", {"name": "Replays still work"}, key)[0] == 200 def test_projects_are_invisible_to_a_second_subject(router): project = make_project(router) assert call(router, "GET", f"/hux/v1/projects/{project['id']}", headers=OTHER)[0] == 404 assert call(router, "PATCH", f"/hux/v1/projects/{project['id']}", {"name": "mine now"}, {**OTHER, "If-Match": "1"})[0] == 404 assert call(router, "GET", "/hux/v1/projects", headers=OTHER)[1]["items"] == [] assert call(router, "GET", "/hux/v1/projects/prj_missing0001")[0] == 404 assert call(router, "GET", "/hux/v1/projects/bad")[0] == 400 # --- conversations ------------------------------------------------------------- def test_conversation_create_list_filters_and_patch(router): project = make_project(router) first = make_conversation(router, project_id=project["id"], tags=["suppliers"], mode="research") second = make_conversation(router, title="Loose", pinned=True) assert first["project_id"] == project["id"] and first["artifact_ids"] == [] and second.get("project_id") is None assert call(router, "POST", "/hux/v1/conversations", {"title": "Orphan", "project_id": "prj_missing0001"})[0] == 404 assert call(router, "POST", "/hux/v1/conversations", {"title": "", "tags": ["Bad Tag"]})[0] == 400 items = lambda query: [c["id"] for c in call(router, "GET", f"/hux/v1/conversations{query}")[1]["items"]] # noqa: E731 assert items("") == [second["id"], first["id"]] assert items(f"?project_id={project['id']}") == [first["id"]] assert items("?tag=suppliers") == [first["id"]] and items("?tag=nope") == [] assert items("?pinned=true") == [second["id"]] and items("?archived=true") == [] path = f"/hux/v1/conversations/{first['id']}" status, body, _ = call(router, "PATCH", path, {"title": "Cabinet suppliers", "archived": True, "project_id": None}, {**HEADERS, "If-Match": "1"}) assert status == 200 and valid(body)["title"] == "Cabinet suppliers" and "project_id" not in body and body["revision"] == 2 assert items("?archived=true") == [first["id"]] assert call(router, "PATCH", path, {"project_id": "prj_missing0001"}, {**HEADERS, "If-Match": "2"})[0] == 404 assert call(router, "PATCH", path, {"title": "stale"}, {**HEADERS, "If-Match": "1"})[0] == 409 status, body, headers = call(router, "GET", path) assert status == 200 and headers["ETag"] == "2" and valid(body) assert call(router, "GET", path, headers=OTHER)[0] == 404 assert call(router, "PATCH", path, {"title": "x"}, {**OTHER, "If-Match": "2"})[0] == 404 def test_helpers_for_other_lanes(router, tmp_path): project = make_project(router) conversation = make_conversation(router, project_id=project["id"]) mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {"HUX_ROUTER_KEY": "rk"})) assert organization.project_exists(mine, project["id"]) and not organization.project_exists(theirs, project["id"]) assert organization.conversation_exists(mine, conversation["id"]) and not organization.conversation_exists(theirs, conversation["id"]) assert not organization.project_exists(mine, "../escape") and not organization.conversation_exists(mine, None) assert organization.project_of(mine, conversation["id"]) == project["id"] assert organization.project_of(mine, None) is None and organization.project_of(mine, "conv_missing0001") is None # --- branches and lineage --------------------------------------------------------- def test_branch_copies_project_tags_and_mode_and_lineage_walks_both_ways(router): project = make_project(router) root = make_conversation(router, project_id=project["id"], tags=["a", "b"], mode="create") status, child, _ = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-12"}) assert status == 201 and valid(child)["branch"] == {"parent_conversation_id": root["id"], "branch_point_message_id": "msg-12"} assert child["project_id"] == project["id"] and child["tags"] == ["a", "b"] and child["mode"] == "create" and child["title"] == "Cabinets (branch)" status, grandchild, _ = call(router, "POST", f"/hux/v1/conversations/{child['id']}/branch", {"branch_point_message_id": "msg-3", "title": "Deeper"}) assert grandchild["title"] == "Deeper" sibling = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-1"})[1] status, body, _ = call(router, "GET", f"/hux/v1/conversations/{grandchild['id']}/lineage") assert status == 200 and body["conversation"] == grandchild assert [a["id"] for a in body["ancestors"]] == [root["id"], child["id"]] and body["children"] == [] body = call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage")[1] assert body["ancestors"] == [] and {c["id"] for c in body["children"]} == {child["id"], sibling["id"]} for record in body["children"]: valid(record) assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {})[0] == 400 assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "m"}, OTHER)[0] == 404 assert call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage", headers=OTHER)[0] == 404 def test_lineage_survives_a_missing_or_cyclic_parent(router, tmp_path): orphan = make_conversation(router) mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) mine.put("conversations", {**orphan, "branch": {"parent_conversation_id": "conv_gone00000001", "branch_point_message_id": "m"}}) assert call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]["ancestors"] == [] mine.put("conversations", {**mine.get("conversations", orphan["id"]), "branch": {"parent_conversation_id": orphan["id"], "branch_point_message_id": "m"}}) body = call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1] assert body["ancestors"] == [] and [c["id"] for c in body["children"]] == [orphan["id"]] # --- search ----------------------------------------------------------------------- def test_search_ranks_title_over_tag_over_project_and_says_what_is_indexed(router): kitchen = make_project(router, name="Kitchen renovation") by_title = make_conversation(router, title="Cabinet suppliers compared", project_id=kitchen["id"]) by_tag = make_conversation(router, title="Budget", tags=["cabinet"]) by_project = make_conversation(router, title="Flooring", project_id=kitchen["id"]) make_conversation(router, title="Unrelated") status, body, _ = call(router, "GET", "/hux/v1/search?q=cabinet") assert status == 200 and [c["id"] for c in body["items"]] == [by_title["id"], by_tag["id"]] assert body["indexed"] == ["title", "tags", "project_name", "artifact_titles"] and body["not_indexed"] == ["message_text"] assert body["scores"][by_title["id"]] > body["scores"][by_tag["id"]] and body["next"] is None for record in body["items"]: valid(record) assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"]] == [by_title["id"], by_project["id"]] assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=cabinet+kitchen")[1]["items"]] == [by_title["id"]], "terms are ANDed" assert call(router, "GET", f"/hux/v1/search?q=kitchen&project_id={kitchen['id']}")[1]["items"] == call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"] assert call(router, "GET", "/hux/v1/search?q=kitchen&project_id=prj_other0000001")[1]["items"] == [] assert call(router, "GET", "/hux/v1/search?q=%21%21")[0] == 400 assert call(router, "GET", "/hux/v1/search")[0] == 400 assert call(router, "GET", "/hux/v1/search?q=cabinet", headers=OTHER)[1]["items"] == [] def test_f13c_search_finds_conversations_by_artifact_title(router, monkeypatch): """F13c: an artifact filed under a conversation makes that conversation searchable by the artifact title; the lane may be absent.""" conversation = make_conversation(router, title="Plain") other = make_conversation(router, title="Other") status, artifact, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Supplier comparison sheet", "content": "x", "conversation_id": conversation["id"]}) assert status == 201, artifact status, body, _ = call(router, "GET", "/hux/v1/search?q=supplier") assert [c["id"] for c in body["items"]] == [conversation["id"]] and body["scores"][conversation["id"]] == 1 s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) assert organization.artifact_titles(s, conversation) == ["Supplier comparison sheet"] and organization.artifact_titles(s, other) == [] # The conversation's own artifact_ids list is indexed as well, without duplicates. s.put(organization.CONVERSATIONS, {**s.get(organization.CONVERSATIONS, other["id"]), "artifact_ids": [artifact["id"], "art_missing0001"]}, 1) assert organization.artifact_titles(s, other) == ["Supplier comparison sheet"] assert sorted(c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]) == sorted([conversation["id"], other["id"]]) monkeypatch.setitem(sys.modules, "hux.artifacts", None) monkeypatch.delattr(hux, "artifacts", raising=False) assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == [] assert call(router, "GET", "/hux/v1/search?q=plain")[1]["items"] == [conversation] def test_f9_titles_tags_names_and_descriptions_are_secret_scrubbed(router): """F9 / SO-07: a token pasted into a title, tag, project name or description is scrubbed before it is stored or indexed.""" token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" project = make_project(router, name=f"proj {token}", description="password: hunter2") assert token not in project["name"] and project["description"] == "[redacted:password]" # A tag can only be [a-z0-9-], so a scrubbed tag fails the contract instead of being stored. assert call(router, "POST", "/hux/v1/conversations", {"title": "t", "tags": [token]})[0] == 400 conversation = make_conversation(router, title=f"token {token}", tags=["ok"]) assert conversation["title"] == "token [redacted:github_token]" and conversation["tags"] == ["ok"] status, patched, _ = call(router, "PATCH", f"/hux/v1/conversations/{conversation['id']}", {"title": f"again {token}"}, {**HEADERS, "If-Match": "1"}) assert status == 200 and token not in patched["title"] assert call(router, "GET", f"/hux/v1/search?q={token}")[1]["items"] == [] leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()] assert leaked == [] def test_flag_off_hides_the_card(tmp_path): off = build_router(tmp_path, {"HUX_FLAGS": "", "HUX_ROUTER_KEY": "rk"}) status, body, _ = call(off, "GET", "/hux/v1/projects") assert (status, body["code"]) == (404, "flag_off")