F3 memory edits go through the same privacy shaping as proposals; F5 seq is derived from the ledger tail so a crash between append and checkpoint never duplicates; F7 transitions re-read under the lock and always write with the loaded revision; F9 secret scrub on titles, passages, claims and notebooks and forget blanks the conversation document; F13 no ghost conversations from notices, idempotency under the lock, artifact titles searchable, normalised paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
175 lines
9.9 KiB
Python
175 lines
9.9 KiB
Python
"""HUX-08 research notebooks: state machine, references and concurrency.
|
|
|
|
Security obligations exercised: every id added to a notebook resolves under
|
|
the caller's subtree, so a second subject cannot attach or read another
|
|
tenant's records (SO-33); PATCH requires If-Match and a stale revision is a
|
|
409 (SO-44); status only moves open -> answered | abandoned; every served
|
|
record satisfies citation.schema.json#/$defs/notebook.
|
|
"""
|
|
|
|
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 audit, contracts, identity, store # noqa: E402
|
|
from hux.server import build_router # noqa: E402
|
|
|
|
SCHEMAS = contracts.load_all()
|
|
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
|
|
OTHER = {**OWNER, "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})
|
|
|
|
|
|
def call(router, method, path, body=None, headers=None, raw=None):
|
|
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
|
|
response = router.dispatch(method, path, {**OWNER, **(headers or {})}, payload)
|
|
return response.status, response.body, response.headers
|
|
|
|
|
|
def valid(record) -> bool:
|
|
return contracts.validate_record(record, SCHEMAS) == []
|
|
|
|
|
|
@pytest.fixture
|
|
def evidence(router):
|
|
"""One source, passage and citation owned by the default subject."""
|
|
_, src, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://example.com/a", "title": "A"})
|
|
_, psg, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "A says B."})
|
|
_, cit, _ = call(router, "POST", "/hux/v1/messages/msg-9/citations", {"claim": "B", "passage_ids": [psg["id"]], "support": "supports"})
|
|
return src, psg, cit
|
|
|
|
|
|
def notebook(router, **extra):
|
|
status, record, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "Which supplier?", **extra})
|
|
assert status == 201, record
|
|
assert headers["ETag"] == "1"
|
|
return record
|
|
|
|
|
|
def test_create_and_get_are_contract_valid(router):
|
|
record = notebook(router, assumptions=["Budget is fixed"], unresolved_questions=["Installation included?"])
|
|
assert valid(record) and record["status"] == "open" and record["revision"] == 1
|
|
assert record["assumptions"] == ["Budget is fixed"] and record["notes"] == []
|
|
status, fetched, headers = call(router, "GET", f"/hux/v1/notebooks/{record['id']}")
|
|
assert status == 200 and fetched == record and headers["ETag"] == "1"
|
|
status, replay, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
|
|
status, same, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
|
|
assert (status, same["id"], headers["HUX-Replayed"]) == (200, replay["id"], "true")
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [
|
|
{"conversation_id": "nope"}, {"conversation_id": None}, {"question": ""}, {"question": "q" * 1001},
|
|
{"assumptions": "one"}, {"assumptions": [""]}, {"unresolved_questions": [1]}, {"assumptions": ["a"] * 65},
|
|
])
|
|
def test_create_rejects_bad_bodies(router, bad):
|
|
status, error, _ = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "q", **bad})
|
|
assert status == 400 and valid(error), bad
|
|
assert call(router, "POST", "/hux/v1/notebooks", raw=b"[]")[0] == 400
|
|
|
|
|
|
def test_patch_adds_references_notes_and_lists(router, evidence):
|
|
src, psg, cit = evidence
|
|
record = notebook(router)
|
|
path = f"/hux/v1/notebooks/{record['id']}"
|
|
patch = {
|
|
"add_source_ids": [src["id"], src["id"]], "add_passage_ids": [psg["id"]], "add_citation_ids": [cit["id"]],
|
|
"add_notes": [{"text": "Lead times exclude installation.", "source_id": src["id"]}, {"text": "Plain note"}],
|
|
"assumptions": ["Budget cap 12k"], "unresolved_questions": [],
|
|
}
|
|
status, updated, headers = call(router, "PATCH", path, patch, {"If-Match": "1"})
|
|
assert status == 200 and valid(updated) and headers["ETag"] == "2"
|
|
assert updated["source_ids"] == [src["id"]] and updated["passage_ids"] == [psg["id"]] and updated["citation_ids"] == [cit["id"]]
|
|
assert [n["text"] for n in updated["notes"]] == ["Lead times exclude installation.", "Plain note"]
|
|
assert updated["notes"][0]["source_id"] == src["id"] and "source_id" not in updated["notes"][1]
|
|
assert updated["assumptions"] == ["Budget cap 12k"] and updated["unresolved_questions"] == []
|
|
status, again, _ = call(router, "PATCH", path, {"add_source_ids": [src["id"]]}, {"If-Match": "2"})
|
|
assert again["source_ids"] == [src["id"]] and again["revision"] == 3
|
|
status, error, _ = call(router, "PATCH", path, {"add_notes": [{"text": "x", "source_id": "src_missing00001"}]}, {"If-Match": "3"})
|
|
assert status == 404
|
|
for bad in ({"add_notes": "note"}, {"add_notes": [{"text": ""}]}, {"add_source_ids": "src_x"}, {"assumptions": [""]}):
|
|
status, error, _ = call(router, "PATCH", path, bad, {"If-Match": "3"})
|
|
assert status == 400 and valid(error), bad
|
|
assert call(router, "PATCH", path, raw=b"[]", headers={"If-Match": "3"})[0] == 400
|
|
status, error, _ = call(router, "PATCH", path, {"add_source_ids": [f"src_{i:012d}" for i in range(501)]}, {"If-Match": "3"})
|
|
assert status == 413
|
|
|
|
|
|
def test_patch_requires_matching_if_match(router):
|
|
record = notebook(router)
|
|
path = f"/hux/v1/notebooks/{record['id']}"
|
|
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]})
|
|
assert (status, error["code"]) == (400, "invalid")
|
|
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "5"})
|
|
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
|
|
assert call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "abc"})[0] == 400
|
|
assert call(router, "GET", path)[1]["assumptions"] == []
|
|
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
|
assert [r["outcome"] for r in rows if r["action"] == "research.notebook_patch"] == ["deny", "conflict", "deny"]
|
|
|
|
|
|
@pytest.mark.parametrize(("target", "then", "expected"), [
|
|
("answered", "abandoned", 409), ("abandoned", "answered", 409), ("answered", "open", 409), ("abandoned", "abandoned", 200),
|
|
])
|
|
def test_status_moves_only_from_open(router, target, then, expected):
|
|
record = notebook(router)
|
|
path = f"/hux/v1/notebooks/{record['id']}"
|
|
status, moved, _ = call(router, "PATCH", path, {"status": target}, {"If-Match": "1"})
|
|
assert status == 200 and moved["status"] == target and valid(moved)
|
|
status, body, _ = call(router, "PATCH", path, {"status": then}, {"If-Match": "2"})
|
|
assert status == expected
|
|
if expected == 409:
|
|
assert body["code"] == "conflict" and call(router, "GET", path)[1]["status"] == target
|
|
assert call(router, "PATCH", path, {"status": "done"}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 409
|
|
assert call(router, "PATCH", path, {"status": target}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 200
|
|
|
|
|
|
def test_second_subject_cannot_read_patch_or_attach(router, evidence):
|
|
src, psg, cit = evidence
|
|
record = notebook(router)
|
|
path = f"/hux/v1/notebooks/{record['id']}"
|
|
assert call(router, "GET", path, headers=OTHER)[0] == 404
|
|
status, error, _ = call(router, "PATCH", path, {"status": "abandoned"}, {**OTHER, "If-Match": "1"})
|
|
assert (status, error["code"]) == (404, "not_found") and valid(error)
|
|
theirs = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "spy"}, OTHER)[1]
|
|
for key, value in (("add_source_ids", src["id"]), ("add_passage_ids", psg["id"]), ("add_citation_ids", cit["id"])):
|
|
status, error, _ = call(router, "PATCH", f"/hux/v1/notebooks/{theirs['id']}", {key: [value]}, {**OTHER, "If-Match": "1"})
|
|
assert status == 404, key
|
|
status, mine, _ = call(router, "GET", path)
|
|
assert mine["status"] == "open" and mine["revision"] == 1
|
|
other_rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
|
|
assert other_rows[-1]["outcome"] == "not_found" and other_rows[-1]["action"] == "research.notebook_patch"
|
|
|
|
|
|
def test_notebook_paths_are_tenant_scoped(router):
|
|
record = notebook(router)
|
|
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
|
assert (tenant.root / "notebooks" / f"{record['id']}.json").exists()
|
|
assert call(router, "GET", "/hux/v1/notebooks/nb_missing000001")[0] == 404
|
|
assert call(router, "GET", "/hux/v1/notebooks/NB")[0] == 404
|
|
|
|
|
|
def test_f9_notebook_free_text_is_scrubbed(router, evidence):
|
|
"""F9 / SO-07: question, assumptions, unresolved questions and notes are secret-scrubbed on create and patch."""
|
|
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
record = notebook(router, question=f"q {token}", assumptions=[f"a {token}"], unresolved_questions=[f"u {token}"])
|
|
assert record["question"] == "q [redacted:github_token]" and record["assumptions"] == ["a [redacted:github_token]"]
|
|
assert record["unresolved_questions"] == ["u [redacted:github_token]"]
|
|
status, patched, _ = call(router, "PATCH", f"/hux/v1/notebooks/{record['id']}", {"add_notes": [{"text": f"n {token}"}], "assumptions": [f"b {token}"]}, {"If-Match": "1"})
|
|
assert status == 200 and patched["notes"][0]["text"] == "n [redacted:github_token]" and patched["assumptions"] == ["b [redacted:github_token]"]
|
|
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
|
|
assert leaked == []
|