Adds receipt evidence kind, 422 unprocessable, optional revision on research records, audit_stale on the privacy policy, per-route body caps (25 MiB artifact uploads), promotion checks the project exists, memory rules skip content-free statuses. Handoff ledger covers every Wave A card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
323 lines
19 KiB
Python
323 lines
19 KiB
Python
"""HUX-04 artifacts: immutable versions, diffs, lineage, promotion and caps.
|
|
|
|
Security obligations exercised: server-side sha256 with mismatch rejection and
|
|
never-rewritten blobs (SO-30), per-version / per-artifact / per-tenant caps
|
|
(SO-31), nosniff attachment delivery (SO-32), lineage resolved under the
|
|
caller's subtree (SO-33), owner-only access (SO-34) and If-Match on every
|
|
revisioned write (SO-44).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
import threading
|
|
import types
|
|
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 artifacts, contracts, diffs, errors, identity, 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"}
|
|
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
|
|
|
|
|
|
def ident() -> identity.Identity:
|
|
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
|
|
|
|
|
|
@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, {**HEADERS, **(headers or {})}, payload)
|
|
return response.status, response.body, response.headers
|
|
|
|
|
|
def valid(record) -> bool:
|
|
return contracts.validate_record(record, SCHEMAS) == []
|
|
|
|
|
|
def make(router, text="# one\nline\n", **extra):
|
|
body = {"type": "markdown", "title": "Doc", "content": text, "mime": "text/markdown", "conversation_id": "conv_0001abcd", **extra}
|
|
status, record, _ = call(router, "POST", "/hux/v1/artifacts", body)
|
|
assert status == 201, record
|
|
return record
|
|
|
|
|
|
@pytest.fixture
|
|
def events(monkeypatch):
|
|
"""Fake events lane capturing emit() calls with the agreed signature."""
|
|
calls = []
|
|
module = types.ModuleType("hux.events")
|
|
module.emit = lambda store, identity, conversation_id, kind, summary, detail=None, evidence=None, sensitivity="personal", run_id=None, turn=None, correlation_id=None: calls.append((conversation_id, kind, summary, detail, evidence, sensitivity))
|
|
monkeypatch.setitem(sys.modules, "hux.events", module)
|
|
return calls
|
|
|
|
|
|
# --- create ------------------------------------------------------------------------
|
|
|
|
def test_create_computes_hash_and_serves_contract_valid_record(router, events):
|
|
record = make(router, language="markdown", message_id="msg-1", note="first")
|
|
assert valid(record)
|
|
digest = hashlib.sha256(b"# one\nline\n").hexdigest()
|
|
version = record["versions"][0]
|
|
assert version["content_ref"] == {"hash": f"sha256:{digest}", "bytes": 11, "mime": "text/markdown"}
|
|
assert version["created_by"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
|
assert (version["message_id"], version["note"], record["language"]) == ("msg-1", "first", "markdown")
|
|
assert record["owner"] == "usr_0123456789abcdef" and record["access"] == {"mode": "owner"}
|
|
assert record["current_version"] == 1 and record["revision"] == 1
|
|
assert events[0][:2] == ("conv_0001abcd", "artifact.created") and events[0][5] == "personal"
|
|
assert events[0][4][0]["kind"] == "artifact_version"
|
|
assert store.TenantStore(router.data_root, ident()).get_blob(digest) == b"# one\nline\n"
|
|
|
|
|
|
def test_create_accepts_base64_and_verifies_claimed_hash(router):
|
|
data = bytes(range(256))
|
|
body = {"type": "image", "title": "Pixels", "content_base64": base64.b64encode(data).decode(), "mime": "image/png", "sensitivity": "sensitive", "project_id": "prj_0001aaaa"}
|
|
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + hashlib.sha256(data).hexdigest()})
|
|
assert status == 201 and valid(record) and record["versions"][0]["content_ref"]["bytes"] == 256
|
|
assert record["sensitivity"] == "sensitive" and record["project_id"] == "prj_0001aaaa"
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + "0" * 64})
|
|
assert (status, error["code"]) == (400, "invalid") and valid(error)
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content_base64": "not*base64"})
|
|
assert status == 400
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content": "both"})
|
|
assert status == 400
|
|
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "audio", "title": "Beep", "content_base64": base64.b64encode(b"\x00\x01").decode()})
|
|
assert status == 201 and record["versions"][0]["content_ref"]["mime"] == "application/octet-stream"
|
|
|
|
|
|
def test_worker_trust_is_recorded_as_system_actor(tmp_path):
|
|
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
|
|
headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
|
|
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "gen.py", "content": "print(1)\n"}, headers)
|
|
assert status == 201 and record["versions"][0]["created_by"] == {"type": "system", "id": "worker"}
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [
|
|
{"type": "binary"}, {"type": None}, {"sensitivity": "secret"}, {"title": ""}, {"title": "x" * 201}, {"content": 5},
|
|
{"conversation_id": "../etc"}, {"lineage": "art_x"}, {"note": "n" * 201}, {"mime": ""},
|
|
])
|
|
def test_create_rejects_bad_bodies(router, bad):
|
|
body = {"type": "markdown", "title": "Doc", "content": "x", **bad}
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", body)
|
|
assert status == 400 and valid(error)
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", raw=b"[]")
|
|
assert status == 400
|
|
|
|
|
|
def test_idempotency_key_replays_the_original(router):
|
|
body = {"type": "json", "title": "Data", "content": "{}"}
|
|
status, first, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
|
|
status, again, headers = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
|
|
assert (status, headers.get("HUX-Replayed")) == (200, "true") and again["id"] == first["id"]
|
|
status, _, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "bad key"})
|
|
assert status == 400
|
|
assert store.TenantStore(router.data_root, ident()).count("artifacts") == 1
|
|
|
|
|
|
# --- list / get ----------------------------------------------------------------------
|
|
|
|
def test_list_filters_and_pages(router, monkeypatch):
|
|
monkeypatch.setattr(artifacts, "PAGE_SIZE", 2)
|
|
ids = [make(router)["id"] for _ in range(3)]
|
|
other = make(router, conversation_id="conv_0002abcd", project_id="prj_0001aaaa")["id"]
|
|
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd")
|
|
assert status == 200 and [a["id"] for a in body["items"]] == ids[:2] and body["next"] == "2"
|
|
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd&cursor=2")
|
|
assert [a["id"] for a in body["items"]] == ids[2:] and body["next"] is None
|
|
status, body, _ = call(router, "GET", "/hux/v1/artifacts?project_id=prj_0001aaaa")
|
|
assert [a["id"] for a in body["items"]] == [other] and all(valid(a) for a in body["items"])
|
|
assert call(router, "GET", "/hux/v1/artifacts?cursor=x")[0] == 400
|
|
status, record, headers = call(router, "GET", f"/hux/v1/artifacts/{other}")
|
|
assert status == 200 and record["id"] == other and headers["ETag"] == "1"
|
|
|
|
|
|
# --- versions --------------------------------------------------------------------------
|
|
|
|
def test_versions_append_immutably_with_if_match(router, events):
|
|
record = make(router)
|
|
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
|
body = {"content": "# one\nline two\n", "mime": "text/markdown", "note": "edit", "message_id": "msg-2"}
|
|
assert call(router, "POST", path, body)[0] == 400
|
|
status, error, _ = call(router, "POST", path, body, {"If-Match": "7"})
|
|
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
|
|
status, updated, headers = call(router, "POST", path, body, {"If-Match": "1"})
|
|
assert status == 201 and valid(updated) and headers["ETag"] == "2"
|
|
entry = updated["versions"][1]
|
|
assert (entry["version"], entry["diff_from"], entry["note"], entry["message_id"]) == (2, 1, "edit", "msg-2")
|
|
assert updated["current_version"] == 2 and updated["versions"][0] == record["versions"][0]
|
|
assert events[-1][1] == "artifact.version" and events[-1][3]["version"] == 2
|
|
status, third, _ = call(router, "POST", path, {"content": "third", "diff_from": 1}, {"If-Match": "2"})
|
|
assert third["versions"][2]["diff_from"] == 1
|
|
assert call(router, "POST", path, {"content": "x", "diff_from": 9}, {"If-Match": "3"})[0] == 404
|
|
assert call(router, "POST", path, {"content": "x", "diff_from": "1"}, {"If-Match": "3"})[0] == 400
|
|
status, error, _ = call(router, "POST", path, raw=b"[1]", headers={"If-Match": "3"})
|
|
assert status == 400
|
|
|
|
|
|
def test_version_replay_and_old_blob_untouched(router):
|
|
record = make(router)
|
|
tenant = store.TenantStore(router.data_root, ident())
|
|
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
|
status, second, _ = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
|
|
status, replay, headers = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
|
|
assert (status, headers["HUX-Replayed"], replay["revision"]) == (200, "true", 2)
|
|
old_digest = record["versions"][0]["content_ref"]["hash"].split(":")[1]
|
|
tenant.put_blob(old_digest, b"tampered")
|
|
assert tenant.get_blob(old_digest) == b"# one\nline\n"
|
|
with pytest.raises(errors.Conflict):
|
|
artifacts.append_version(tenant, second, dict(second["versions"][1]), 2)
|
|
with pytest.raises(errors.Conflict):
|
|
artifacts.append_version(tenant, second, {**second["versions"][1], "version": 5}, 2)
|
|
assert tenant.get("artifacts", record["id"])["versions"] == second["versions"]
|
|
|
|
|
|
def test_concurrent_writers_never_lose_a_version(router):
|
|
record = make(router)
|
|
path = f"/hux/v1/artifacts/{record['id']}/versions"
|
|
statuses = []
|
|
|
|
def worker(n: int) -> None:
|
|
for _ in range(6):
|
|
_, current, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
|
|
status, _, _ = call(router, "POST", path, {"content": f"w{n}"}, {"If-Match": str(current["revision"])})
|
|
statuses.append(status)
|
|
|
|
threads = [threading.Thread(target=worker, args=(n,)) for n in range(3)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
_, final, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
|
|
assert statuses.count(201) == final["current_version"] - 1
|
|
assert [v["version"] for v in final["versions"]] == list(range(1, final["current_version"] + 1))
|
|
assert final["revision"] == final["current_version"]
|
|
|
|
|
|
def test_lineage_resolves_only_under_the_caller(router):
|
|
parent = make(router)
|
|
child = make(router, lineage={"artifact_id": parent["id"], "version": 1})
|
|
assert child["versions"][0]["lineage"] == {"artifact_id": parent["id"], "version": 1}
|
|
path = f"/hux/v1/artifacts/{child['id']}/versions"
|
|
status, updated, _ = call(router, "POST", path, {"content": "v2", "lineage": {"artifact_id": parent["id"], "version": 1}}, {"If-Match": "1"})
|
|
assert status == 201 and valid(updated) and updated["versions"][1]["lineage"]["artifact_id"] == parent["id"]
|
|
for bad in ({"artifact_id": parent["id"], "version": 9}, {"artifact_id": "art_missing0000", "version": 1}, {"artifact_id": "../x", "version": 1}):
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": bad})
|
|
assert (status, error["code"]) == (404, "not_found"), bad
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": parent["id"], "version": "1"}})
|
|
assert status == 400
|
|
|
|
|
|
def test_caps_are_413(router, monkeypatch):
|
|
record = make(router)
|
|
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 8)
|
|
status, error, _ = call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "123456789"}, {"If-Match": "1"})
|
|
assert (status, error["code"]) == (413, "too_large")
|
|
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 25 * 1024 * 1024)
|
|
monkeypatch.setattr(artifacts, "MAX_VERSIONS", 2)
|
|
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})[0] == 201
|
|
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v3"}, {"If-Match": "2"})[0] == 413
|
|
monkeypatch.setattr(artifacts, "MAX_ARTIFACTS", 1)
|
|
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
|
|
assert (status, error["code"]) == (413, "too_large")
|
|
|
|
|
|
# --- content and diffs ------------------------------------------------------------------
|
|
|
|
def test_get_version_serves_text_or_base64_with_nosniff(router):
|
|
record = make(router)
|
|
status, body, headers = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
|
|
assert status == 200 and body["content"] == "# one\nline\n" and body["version"] == record["versions"][0]
|
|
assert headers["X-Content-Type-Options"] == "nosniff" and headers["Content-Disposition"] == "attachment"
|
|
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
|
|
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
|
|
assert base64.b64decode(body["content_base64"]) == b"\xff\xfe" and "content" not in body
|
|
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "bin", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
|
|
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
|
|
assert "content_base64" in body
|
|
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/2")[0] == 404
|
|
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/0")[0] == 400
|
|
|
|
|
|
def test_diff_unified_for_text_and_hashes_for_binary(router):
|
|
record = make(router)
|
|
path = f"/hux/v1/artifacts/{record['id']}"
|
|
call(router, "POST", f"{path}/versions", {"content": "# one\nline two\n"}, {"If-Match": "1"})
|
|
call(router, "POST", f"{path}/versions", {"content": "# one\nline three\n"}, {"If-Match": "2"})
|
|
status, body, headers = call(router, "GET", f"{path}/versions/2/diff")
|
|
assert status == 200 and body["from"] == 1 and body["to"] == 2 and headers["X-Content-Type-Options"] == "nosniff"
|
|
assert "-line\n" in body["unified"] and "+line two\n" in body["unified"] and body["unified"].startswith("--- v1")
|
|
status, body, _ = call(router, "GET", f"{path}/versions/3/diff?from=1")
|
|
assert body["from"] == 1 and "+line three\n" in body["unified"]
|
|
status, body, _ = call(router, "GET", f"{path}/versions/1/diff")
|
|
assert body == {"from": 1, "to": 1, "unified": ""}
|
|
assert call(router, "GET", f"{path}/versions/3/diff?from=x")[0] == 400
|
|
assert call(router, "GET", f"{path}/versions/3/diff?from=9")[0] == 404
|
|
blob = base64.b64encode(b"\x00\x01").decode()
|
|
status, image, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": blob})
|
|
call(router, "POST", f"/hux/v1/artifacts/{image['id']}/versions", {"content_base64": base64.b64encode(b"\x00\x01\x02").decode()}, {"If-Match": "1"})
|
|
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{image['id']}/versions/2/diff")
|
|
assert body["binary"]["from_bytes"] == 2 and body["binary"]["to_bytes"] == 3 and "unified" not in body
|
|
assert body["binary"]["to_hash"] == "sha256:" + hashlib.sha256(b"\x00\x01\x02").hexdigest()
|
|
assert diffs.unified("code", b"\xff", b"ok", 1, 2)["binary"]["from_bytes"] == 1
|
|
|
|
|
|
# --- promotion ---------------------------------------------------------------------------
|
|
|
|
def test_promote_sets_promotion_and_emits(router, events):
|
|
record = make(router)
|
|
p1 = call(router, "POST", "/hux/v1/projects", {"name": "one"})[1]["id"]
|
|
p2 = call(router, "POST", "/hux/v1/projects", {"name": "two"})[1]["id"]
|
|
call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})
|
|
path = f"/hux/v1/artifacts/{record['id']}/promote"
|
|
status, error, _ = call(router, "POST", path, {"project_id": p1}, {"If-Match": "1"})
|
|
assert status == 409
|
|
status, promoted, headers = call(router, "POST", path, {"project_id": p1}, {"If-Match": "2"})
|
|
assert status == 200 and valid(promoted) and headers["ETag"] == "3"
|
|
assert promoted["promotion"]["project_id"] == p1 and promoted["promotion"]["version"] == 2
|
|
assert promoted["project_id"] == p1
|
|
assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == p1
|
|
status, promoted, _ = call(router, "POST", path, {"project_id": p2, "version": 1})
|
|
assert status == 200 and promoted["promotion"]["version"] == 1
|
|
assert call(router, "POST", path, {"project_id": "nope"})[0] == 400
|
|
assert call(router, "POST", path, {"project_id": "prj_0009zzzz"})[0] == 404
|
|
assert call(router, "POST", path, {"project_id": p2, "version": 7})[0] == 404
|
|
assert call(router, "POST", path, raw=b"1")[0] == 400
|
|
from hux import audit
|
|
rows = [r for r in audit.recent(store.TenantStore(router.data_root, ident())) if r["action"] == "artifacts.promote" and r["outcome"] == "allow"]
|
|
assert [r.get("reason", "") for r in rows][-2:] == ["", "unconditional_write"]
|
|
|
|
|
|
# --- helpers for other lanes and hygiene ---------------------------------------------------
|
|
|
|
def test_helpers_and_event_fallbacks(router, monkeypatch):
|
|
record = make(router)
|
|
tenant = store.TenantStore(router.data_root, ident())
|
|
assert artifacts.artifact_exists(tenant, record["id"]) and not artifacts.artifact_exists(tenant, "bad id")
|
|
assert artifacts.artifact_titles(tenant, [record["id"], "art_missing0000"]) == {record["id"]: "Doc"}
|
|
monkeypatch.setitem(sys.modules, "hux.events", None)
|
|
artifacts.emit_event(tenant, ident(), record, "artifact.created", "s", {})
|
|
artifacts.emit_event(tenant, ident(), {**record, "conversation_id": None}, "artifact.created", "s", {})
|
|
status, plain, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "csv", "title": "no conv", "content": "a,b\n"})
|
|
assert status == 201 and "conversation_id" not in plain
|
|
|
|
|
|
def test_modules_stay_under_500_lines():
|
|
for name in ("artifacts.py", "diffs.py"):
|
|
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500
|