2026-08-24 00:21:51 -03:00
|
|
|
"""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()
|
2026-08-24 01:24:14 -03:00
|
|
|
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"}
|
2026-08-24 00:21:51 -03:00
|
|
|
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):
|
2026-08-24 01:24:14 -03:00
|
|
|
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"})
|
2026-08-24 00:21:51 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-24 01:24:14 -03:00
|
|
|
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"]
|
2026-08-24 00:21:51 -03:00
|
|
|
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"])
|
2026-08-24 01:24:14 -03:00
|
|
|
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
|
|
|
|
|
theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {"HUX_ROUTER_KEY": "rk"}))
|
2026-08-24 00:21:51 -03:00
|
|
|
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)
|
2026-08-24 01:24:14 -03:00
|
|
|
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
|
2026-08-24 00:21:51 -03:00
|
|
|
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"] == []
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 00:46:34 -03:00
|
|
|
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."""
|
2026-08-24 00:21:51 -03:00
|
|
|
conversation = make_conversation(router, title="Plain")
|
2026-08-24 00:46:34 -03:00
|
|
|
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
|
2026-08-24 01:24:14 -03:00
|
|
|
s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"}))
|
2026-08-24 00:46:34 -03:00
|
|
|
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"]])
|
2026-08-24 00:21:51 -03:00
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 00:46:34 -03:00
|
|
|
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 == []
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 00:21:51 -03:00
|
|
|
def test_flag_off_hides_the_card(tmp_path):
|
2026-08-24 01:24:14 -03:00
|
|
|
off = build_router(tmp_path, {"HUX_FLAGS": "", "HUX_ROUTER_KEY": "rk"})
|
2026-08-24 00:21:51 -03:00
|
|
|
status, body, _ = call(off, "GET", "/hux/v1/projects")
|
|
|
|
|
assert (status, body["code"]) == (404, "flag_off")
|