atlas-iac/testing/tests/test_hermes_hux_contract_organization.py

255 lines
16 KiB
Python

"""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")