hermes(hux): HUX-04 artifact workspace and HUX-08 research backends
Immutable content-addressed artifact versions with If-Match, lineage that must resolve under the caller, unified diffs, promotion; sources/passages/citations with server-side hashing and dedupe, citation integrity checks and revisioned research notebooks. Cross-tenant access is 404 and audited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
This commit is contained in:
parent
fc8ec28db3
commit
ada74ce0d4
393
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
393
dockerfiles/hermes-hux-foundation/hux/artifacts.py
Normal file
@ -0,0 +1,393 @@
|
||||
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
|
||||
|
||||
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
|
||||
subtree. Content lives in the content-addressed blob store; the document only
|
||||
carries ``content_ref`` hashes, so a version can never be rewritten once it
|
||||
has been appended (SO-30). Blobs are served inside a JSON envelope with
|
||||
``nosniff`` and never as an executable response type (SO-32). Every
|
||||
referenced id is resolved under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
|
||||
this increment: ``access.mode`` is always ``owner`` (SO-34).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from hux import diffs
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
FAMILY = "artifacts"
|
||||
MAX_VERSION_BYTES = 25 * 1024 * 1024
|
||||
MAX_VERSIONS = 200
|
||||
MAX_ARTIFACTS = 2000
|
||||
PAGE_SIZE = 50
|
||||
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
|
||||
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
# -- helpers shared with other lanes -------------------------------------------
|
||||
|
||||
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
|
||||
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
|
||||
try:
|
||||
return store.exists(FAMILY, artifact_id)
|
||||
except Invalid:
|
||||
return False
|
||||
|
||||
|
||||
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
|
||||
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
|
||||
titles: dict[str, str] = {}
|
||||
for artifact_id in ids:
|
||||
if artifact_exists(store, artifact_id):
|
||||
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
|
||||
return titles
|
||||
|
||||
|
||||
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
|
||||
"""Record an activity event through the events lane when it is present.
|
||||
|
||||
Imported lazily so module import order never matters; a missing events
|
||||
module (earlier build) is not an error because the artifact write itself
|
||||
is the source of truth.
|
||||
"""
|
||||
conversation_id = artifact.get("conversation_id")
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
version = artifact["current_version"]
|
||||
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
|
||||
|
||||
|
||||
def replay(store: TenantStore, family: str, key: str) -> str | None:
|
||||
"""Record id previously created under an Idempotency-Key, if any."""
|
||||
if not key:
|
||||
return None
|
||||
for row in store.read(family, "idempotency"):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
|
||||
"""Persist an Idempotency-Key to record id mapping."""
|
||||
if key:
|
||||
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
|
||||
|
||||
|
||||
# -- record shaping ------------------------------------------------------------
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
||||
"""Decode the submitted content and return ``(bytes, mime)``."""
|
||||
text, encoded = body.get("content"), body.get("content_base64")
|
||||
if isinstance(text, str) and encoded is None:
|
||||
data = text.encode("utf-8")
|
||||
elif isinstance(encoded, str) and text is None:
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise Invalid("content_base64 is not valid base64") from error
|
||||
else:
|
||||
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
|
||||
if len(data) > MAX_VERSION_BYTES:
|
||||
raise TooLarge("version exceeds 25 MiB")
|
||||
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
|
||||
return data, mime
|
||||
|
||||
|
||||
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
|
||||
data, mime = _content(body)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
claimed = body.get("hash")
|
||||
if claimed is not None and claimed != f"sha256:{digest}":
|
||||
raise Invalid("hash does not match the submitted content")
|
||||
store.put_blob(digest, data)
|
||||
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
|
||||
|
||||
|
||||
def _optional_id(body: dict[str, Any], key: str) -> str | None:
|
||||
value = body.get(key)
|
||||
return None if value is None else check_id(value)
|
||||
|
||||
|
||||
def _load_owned(request: Request) -> dict[str, Any]:
|
||||
"""The artifact named in the path; unknown, malformed or foreign is 404."""
|
||||
try:
|
||||
artifact = request.store.get(FAMILY, request.params["id"])
|
||||
except Invalid as error:
|
||||
raise NotFound("artifact not found") from error
|
||||
if artifact.get("owner") != request.identity.subject:
|
||||
raise NotFound("artifact not found")
|
||||
return artifact
|
||||
|
||||
|
||||
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
|
||||
if not isinstance(number, int) or isinstance(number, bool):
|
||||
raise Invalid("version must be an integer")
|
||||
for entry in artifact["versions"]:
|
||||
if entry["version"] == number:
|
||||
return entry
|
||||
raise NotFound(f"version {number} not found")
|
||||
|
||||
|
||||
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
|
||||
lineage = body.get("lineage")
|
||||
if lineage is None:
|
||||
return None
|
||||
if not isinstance(lineage, dict):
|
||||
raise Invalid("lineage must be an object")
|
||||
try:
|
||||
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound("lineage artifact not found") from error
|
||||
if parent.get("owner") != request.identity.subject:
|
||||
raise NotFound("lineage artifact not found")
|
||||
version = _version(parent, lineage.get("version"))
|
||||
return {"artifact_id": parent["id"], "version": version["version"]}
|
||||
|
||||
|
||||
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
|
||||
"""Append an immutable version entry and persist the document.
|
||||
|
||||
The version number must be exactly ``current_version + 1``; any attempt to
|
||||
write an existing number is a Conflict so history is never rewritten.
|
||||
"""
|
||||
if any(v["version"] == entry["version"] for v in artifact["versions"]):
|
||||
raise Conflict(f"version {entry['version']} already exists")
|
||||
if entry["version"] != artifact["current_version"] + 1:
|
||||
raise Conflict("versions are appended in order")
|
||||
if len(artifact["versions"]) >= MAX_VERSIONS:
|
||||
raise TooLarge("artifact has reached 200 versions")
|
||||
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
|
||||
_check(updated)
|
||||
return store.put(FAMILY, updated, expected_revision=expected_revision)
|
||||
|
||||
|
||||
def _check(artifact: dict[str, Any]) -> None:
|
||||
problems = validate_record(artifact, _schemas())
|
||||
if problems:
|
||||
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
|
||||
|
||||
|
||||
# -- handlers ------------------------------------------------------------------
|
||||
|
||||
def create(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("artifacts.create", existing, reason="idempotent_replay")
|
||||
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
|
||||
raise TooLarge("tenant has reached 2000 artifacts")
|
||||
artifact_type = _string(body, "type", 40)
|
||||
if artifact_type not in ARTIFACT_TYPES:
|
||||
raise Invalid("unknown artifact type")
|
||||
sensitivity = body.get("sensitivity", "personal")
|
||||
if sensitivity not in SENSITIVITIES:
|
||||
raise Invalid("unknown sensitivity")
|
||||
stamp = now_iso()
|
||||
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
version[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
version["lineage"] = lineage
|
||||
artifact: dict[str, Any] = {
|
||||
"schema": "hux.artifact.v1",
|
||||
"id": new_id("art"),
|
||||
"owner": request.identity.subject,
|
||||
"type": artifact_type,
|
||||
"title": _string(body, "title", 200),
|
||||
"current_version": 1,
|
||||
"versions": [version],
|
||||
"sensitivity": sensitivity,
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
"revision": 1,
|
||||
"access": {"mode": "owner"},
|
||||
}
|
||||
for field in ("conversation_id", "project_id"):
|
||||
if _optional_id(body, field) is not None:
|
||||
artifact[field] = body[field]
|
||||
if _string(body, "language", 40, required=False) is not None:
|
||||
artifact["language"] = body["language"]
|
||||
_check(artifact)
|
||||
stored = request.store.put(FAMILY, artifact)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("artifacts.create", stored["id"])
|
||||
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def list_artifacts(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
|
||||
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
|
||||
cursor = request.query.get("cursor", "0")
|
||||
if not cursor.isdigit():
|
||||
raise Invalid("cursor must be a non-negative integer")
|
||||
items = [
|
||||
artifact
|
||||
for artifact in request.store.scan(FAMILY)
|
||||
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
|
||||
]
|
||||
start = int(cursor)
|
||||
window = items[start : start + PAGE_SIZE]
|
||||
request.audit("artifacts.list", "artifacts")
|
||||
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
|
||||
|
||||
|
||||
def get(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
|
||||
artifact = _load_owned(request)
|
||||
request.audit("artifacts.get", artifact["id"])
|
||||
return Response(200, artifact, {"ETag": str(artifact["revision"])})
|
||||
|
||||
|
||||
def add_version(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required to append a version")
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None and existing.split("@")[0] == artifact["id"]:
|
||||
request.audit("artifacts.version", existing, reason="idempotent_replay")
|
||||
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
|
||||
if expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
number = artifact["current_version"] + 1
|
||||
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
||||
if body.get("diff_from") is not None:
|
||||
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
||||
for field, limit in (("message_id", 120), ("note", 200)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
entry[field] = body[field]
|
||||
lineage = _lineage(request, body)
|
||||
if lineage is not None:
|
||||
entry["lineage"] = lineage
|
||||
stored = append_version(request.store, artifact, entry, expected)
|
||||
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
||||
request.audit("artifacts.version", f"{stored['id']}@{number}")
|
||||
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
||||
return Response(201, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def _version_number(request: Request) -> int:
|
||||
raw = request.params["n"]
|
||||
if not raw.isdigit() or int(raw) < 1:
|
||||
raise Invalid("version must be a positive integer")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def get_version(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
|
||||
artifact = _load_owned(request)
|
||||
entry = _version(artifact, _version_number(request))
|
||||
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
|
||||
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
|
||||
if text is None:
|
||||
body["content_base64"] = base64.b64encode(data).decode("ascii")
|
||||
else:
|
||||
body["content"] = text
|
||||
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
|
||||
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
|
||||
return Response(200, body, headers)
|
||||
|
||||
|
||||
def diff(request: Request) -> Response:
|
||||
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
|
||||
artifact = _load_owned(request)
|
||||
to_entry = _version(artifact, _version_number(request))
|
||||
raw_from = request.query.get("from", "")
|
||||
if raw_from and not raw_from.isdigit():
|
||||
raise Invalid("from must be a version number")
|
||||
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
|
||||
from_entry = _version(artifact, from_number)
|
||||
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
|
||||
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
|
||||
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
|
||||
|
||||
|
||||
def promote(request: Request) -> Response:
|
||||
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
|
||||
body = _body(request)
|
||||
project_id = check_id(body.get("project_id"))
|
||||
expected = request.if_match()
|
||||
with request.store.lock(FAMILY):
|
||||
artifact = _load_owned(request)
|
||||
if expected is not None and expected != artifact["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
||||
version = artifact["current_version"]
|
||||
if body.get("version") is not None:
|
||||
version = _version(artifact, body["version"])["version"]
|
||||
stamp = now_iso()
|
||||
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
|
||||
_check(updated)
|
||||
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
|
||||
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
|
||||
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-04 routes."""
|
||||
card = "HUX-04"
|
||||
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create)
|
||||
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
|
||||
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
|
||||
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)
|
||||
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
50
dockerfiles/hermes-hux-foundation/hux/diffs.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Version-to-version comparison for artifacts (HUX-04).
|
||||
|
||||
Text artifacts get a unified diff from ``difflib``; anything that is not
|
||||
valid UTF-8, or whose artifact type is binary, is described by sizes and
|
||||
hashes only so the UI never tries to render bytes as text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
TEXT_TYPES = frozenset({"markdown", "code", "html", "svg", "json", "csv"})
|
||||
|
||||
|
||||
def is_text_type(artifact_type: str) -> bool:
|
||||
"""True for artifact types whose content is served as UTF-8 text."""
|
||||
return artifact_type in TEXT_TYPES
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str | None:
|
||||
"""UTF-8 decode, or None when the bytes are not text."""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def unified(artifact_type: str, older: bytes, newer: bytes, from_n: int, to_n: int) -> dict[str, Any]:
|
||||
"""Diff record ``{"from", "to", "unified"|"binary"}`` for two versions."""
|
||||
record: dict[str, Any] = {"from": from_n, "to": to_n}
|
||||
old_text = decode_text(older) if is_text_type(artifact_type) else None
|
||||
new_text = decode_text(newer) if is_text_type(artifact_type) else None
|
||||
if old_text is None or new_text is None:
|
||||
record["binary"] = {
|
||||
"from_bytes": len(older),
|
||||
"to_bytes": len(newer),
|
||||
"from_hash": "sha256:" + hashlib.sha256(older).hexdigest(),
|
||||
"to_hash": "sha256:" + hashlib.sha256(newer).hexdigest(),
|
||||
}
|
||||
return record
|
||||
lines = difflib.unified_diff(
|
||||
old_text.splitlines(keepends=True),
|
||||
new_text.splitlines(keepends=True),
|
||||
fromfile=f"v{from_n}",
|
||||
tofile=f"v{to_n}",
|
||||
)
|
||||
record["unified"] = "".join(lines)
|
||||
return record
|
||||
378
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
378
dockerfiles/hermes-hux-foundation/hux/research.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""HUX-08 research model: sources, passages, citations and notebooks.
|
||||
|
||||
A source says where evidence came from, a passage is the exact excerpt, a
|
||||
citation ties a claim in a message to passages with a support verdict, and a
|
||||
notebook collects them per research question. The service never fetches a
|
||||
``uri`` (SO-19, SO-29): URIs are stored metadata under an allowlisted scheme.
|
||||
Every referenced id resolves under the caller's own subtree (SO-33); a foreign
|
||||
or unknown id is ``404`` so ids cannot be probed. Duplicates are merged on a
|
||||
server-computed ``dedupe_key`` and the original record is returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from hux.artifacts import remember, replay
|
||||
from hux.contracts import load_all, validate_record
|
||||
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
||||
from hux.http import Request, Response, Router, page
|
||||
from hux.identity import Identity
|
||||
from hux.store import TenantStore, check_id, new_id, now_iso
|
||||
|
||||
FAMILY = "research"
|
||||
SOURCES, PASSAGES, CITATIONS, NOTEBOOKS = "sources", "passages", "citations", "notebooks"
|
||||
SCHEMES = frozenset({"http", "https", "artifact", "memory", "file"})
|
||||
SOURCE_KINDS = ("web", "document", "artifact", "memory", "tool_output", "dataset")
|
||||
CLASSIFICATIONS = ("primary", "secondary", "unknown")
|
||||
SUPPORT = ("supports", "partially_supports", "contradicts", "unverified")
|
||||
NOTEBOOK_TRANSITIONS = {"open": frozenset({"answered", "abandoned"}), "answered": frozenset(), "abandoned": frozenset()}
|
||||
MAX_SOURCES, MAX_PASSAGES = 10000, 20000
|
||||
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schemas() -> dict[str, dict[str, Any]]:
|
||||
if not _SCHEMAS:
|
||||
_SCHEMAS.update(load_all())
|
||||
return _SCHEMAS
|
||||
|
||||
|
||||
def _check(record: dict[str, Any]) -> None:
|
||||
problems = validate_record(record, _schemas())
|
||||
if problems:
|
||||
raise Invalid(f"{record.get('schema')} record is not valid", problems)
|
||||
|
||||
|
||||
def _body(request: Request) -> dict[str, Any]:
|
||||
if not isinstance(request.body, dict):
|
||||
raise Invalid("body must be a JSON object")
|
||||
return request.body
|
||||
|
||||
|
||||
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
||||
value = body.get(key)
|
||||
if value is None:
|
||||
if required:
|
||||
raise Invalid(f"{key} is required")
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _sha(*parts: str) -> str:
|
||||
return "sha256:" + hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _lookup(store: TenantStore, ledger: str, key: str) -> str | None:
|
||||
for row in store.read(FAMILY, ledger):
|
||||
if row.get("key") == key:
|
||||
return row["id"]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve(store: TenantStore, family: str, record_id: Any) -> dict[str, Any]:
|
||||
"""Load a record under the caller's subtree; malformed or missing is 404 (SO-33).
|
||||
|
||||
Sources, passages and citations are immutable, so the store's ``revision``
|
||||
bookkeeping is stripped before they are served; notebooks keep theirs.
|
||||
"""
|
||||
try:
|
||||
record = store.get(family, check_id(record_id))
|
||||
except (Invalid, NotFound) as error:
|
||||
raise NotFound(f"{family[:-1]} not found") from error
|
||||
return record if family == NOTEBOOKS else {k: v for k, v in record.items() if k != "revision"}
|
||||
|
||||
|
||||
def _id_list(body: dict[str, Any], key: str, maximum: int = 32) -> list[str]:
|
||||
values = body.get(key, [])
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
||||
raise Invalid(f"{key} must be a list of ids")
|
||||
unique = list(dict.fromkeys(values))
|
||||
if len(unique) > maximum:
|
||||
raise TooLarge(f"{key} exceeds {maximum} ids")
|
||||
return unique
|
||||
|
||||
|
||||
def _emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, detail: dict[str, Any], evidence: list[dict[str, Any]]) -> None:
|
||||
if conversation_id is None:
|
||||
return
|
||||
try:
|
||||
from hux.events import emit
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence)
|
||||
|
||||
|
||||
# -- sources ---------------------------------------------------------------------
|
||||
|
||||
def normalise_uri(uri: str) -> str:
|
||||
"""Canonical form used for dedupe: lowercase scheme and host, no fragment, no trailing slash."""
|
||||
parts = urlsplit(uri.strip())
|
||||
if parts.scheme.lower() not in SCHEMES:
|
||||
raise Invalid("uri scheme must be one of http, https, artifact, memory, file")
|
||||
path = parts.path.rstrip("/") or ("/" if parts.netloc else "")
|
||||
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
|
||||
|
||||
|
||||
def create_source(request: Request) -> Response:
|
||||
"""``POST /hux/v1/sources``: record where evidence came from; duplicates return the original."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
kind = _string(body, "kind", 40)
|
||||
if kind not in SOURCE_KINDS:
|
||||
raise Invalid("unknown source kind")
|
||||
classification = body.get("classification", "unknown")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
raise Invalid("unknown classification")
|
||||
title = _string(body, "title", 300)
|
||||
uri = _string(body, "uri", 2000, required=False)
|
||||
dedupe = _sha("uri", normalise_uri(uri)) if uri is not None else _sha("title", kind, title.strip().lower())
|
||||
stamp = now_iso()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "source_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.source_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, SOURCES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(SOURCES) >= MAX_SOURCES:
|
||||
raise TooLarge("tenant has reached 10000 sources")
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.source.v1", "id": new_id("src"), "kind": kind, "title": title,
|
||||
"classification": classification, "retrieved_at": stamp, "dedupe_key": dedupe,
|
||||
"provenance": {"surface": request.identity.surface, "actor": _actor(request.identity), "recorded_at": stamp},
|
||||
}
|
||||
if uri is not None:
|
||||
record["uri"] = uri
|
||||
for field, limit in (("publisher", 200), ("published_at", 40), ("content_hash", 71)):
|
||||
if _string(body, field, limit, required=False) is not None:
|
||||
record[field] = body[field]
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None:
|
||||
record["provenance"]["conversation_id"] = check_id(conversation_id)
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(SOURCES, record)
|
||||
request.store.append(FAMILY, "source_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.source_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def _actor(identity: Identity) -> dict[str, str]:
|
||||
if identity.trust == "worker":
|
||||
return {"type": "system", "id": "worker"}
|
||||
return {"type": "user", "id": identity.subject}
|
||||
|
||||
|
||||
def get_source(request: Request) -> Response:
|
||||
"""``GET /hux/v1/sources/{id}``: one source record."""
|
||||
record = _resolve(request.store, SOURCES, request.params["id"])
|
||||
request.audit("research.source_get", record["id"])
|
||||
return Response(200, record)
|
||||
|
||||
|
||||
# -- passages --------------------------------------------------------------------
|
||||
|
||||
def create_passage(request: Request) -> Response:
|
||||
"""``POST /hux/v1/passages``: an exact excerpt of a source; hash and dedupe key are server-computed."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
source = _resolve(request.store, SOURCES, body.get("source_id"))
|
||||
text = _string(body, "text", 4000)
|
||||
text_hash = "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
dedupe = _sha("passage", source["id"], text_hash)
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "passage_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.passage_create", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, PASSAGES, existing), {"HUX-Replayed": "true"})
|
||||
if request.store.count(PASSAGES) >= MAX_PASSAGES:
|
||||
raise TooLarge("tenant has reached 20000 passages")
|
||||
record: dict[str, Any] = {"schema": "hux.passage.v1", "id": new_id("psg"), "source_id": source["id"], "text": text, "hash": text_hash, "dedupe_key": dedupe}
|
||||
if body.get("locator") is not None:
|
||||
record["locator"] = body["locator"]
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(PASSAGES, record)
|
||||
request.store.append(FAMILY, "passage_keys", {"key": dedupe, "id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.passage_create", stored["id"])
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
# -- citations -------------------------------------------------------------------
|
||||
|
||||
def _message_ledger(message_id: str) -> str:
|
||||
return "msg_" + hashlib.sha256(message_id.encode("utf-8")).hexdigest()[:40]
|
||||
|
||||
|
||||
def attach_citation(request: Request) -> Response:
|
||||
"""``POST /hux/v1/messages/{id}/citations``: tie a claim to passages with a support verdict."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
message_id = request.params["id"]
|
||||
if len(message_id) > 120:
|
||||
raise Invalid("message id is too long")
|
||||
claim = _string(body, "claim", 1000)
|
||||
support = body.get("support", "unverified")
|
||||
if support not in SUPPORT:
|
||||
raise Invalid("unknown support verdict")
|
||||
passage_ids = _id_list(body, "passage_ids")
|
||||
if not passage_ids:
|
||||
raise Invalid("at least one passage_id is required")
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in passage_ids]
|
||||
dedupe = _sha("citation", message_id, claim, *sorted(passage_ids))
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "citation_keys", dedupe)
|
||||
if existing is not None:
|
||||
request.audit("research.citation_attach", existing, reason="deduplicated")
|
||||
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
|
||||
if _string(body, "note", 500, required=False) is not None:
|
||||
record["note"] = body["note"]
|
||||
_check(record)
|
||||
stored = record | {}
|
||||
request.store.put(CITATIONS, record)
|
||||
request.store.append(FAMILY, "citation_keys", {"key": dedupe, "id": stored["id"]})
|
||||
request.store.append(FAMILY, _message_ledger(message_id), {"id": stored["id"]})
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.citation_attach", stored["id"])
|
||||
evidence = [{"kind": "passage", "id": p["id"], "hash": p["hash"]} for p in passages]
|
||||
conversation_id = body.get("conversation_id")
|
||||
_emit(request.store, request.identity, None if conversation_id is None else check_id(conversation_id), "citation.attached", f"Citation attached ({support})", {"message_id": message_id, "citation_id": stored["id"], "support": support}, evidence)
|
||||
return Response(201, stored)
|
||||
|
||||
|
||||
def list_citations(request: Request) -> Response:
|
||||
"""``GET /hux/v1/messages/{id}/citations``: citations with passages and sources embedded."""
|
||||
items = []
|
||||
for row in request.store.read(FAMILY, _message_ledger(request.params["id"])):
|
||||
citation = _resolve(request.store, CITATIONS, row["id"])
|
||||
passages = [_resolve(request.store, PASSAGES, pid) for pid in citation["passage_ids"]]
|
||||
sources = {p["source_id"]: _resolve(request.store, SOURCES, p["source_id"]) for p in passages}
|
||||
items.append({"citation": citation, "passages": passages, "sources": list(sources.values())})
|
||||
request.audit("research.citation_list", request.params["id"])
|
||||
return page(items)
|
||||
|
||||
|
||||
def validate_citations(store: TenantStore, citation_ids: list[str]) -> list[str]:
|
||||
"""Integrity problems for a set of citations: dangling passages, passage/source mismatch, contradictions without a note."""
|
||||
problems: list[str] = []
|
||||
for citation_id in citation_ids:
|
||||
try:
|
||||
citation = _resolve(store, CITATIONS, citation_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: citation does not resolve")
|
||||
continue
|
||||
if citation["support"] == "contradicts" and not citation.get("note"):
|
||||
problems.append(f"{citation_id}: contradicts without a note")
|
||||
for passage_id in citation["passage_ids"]:
|
||||
try:
|
||||
passage = _resolve(store, PASSAGES, passage_id)
|
||||
except NotFound:
|
||||
problems.append(f"{citation_id}: passage {passage_id} does not resolve")
|
||||
continue
|
||||
if not store.exists(SOURCES, passage["source_id"]):
|
||||
problems.append(f"{citation_id}: passage {passage_id} names missing source {passage['source_id']}")
|
||||
return problems
|
||||
|
||||
|
||||
# -- notebooks -------------------------------------------------------------------
|
||||
|
||||
def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
|
||||
values = body.get(key)
|
||||
if values is None:
|
||||
return current
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
|
||||
raise Invalid(f"{key} must be a list of non-empty strings")
|
||||
return values
|
||||
|
||||
|
||||
def create_notebook(request: Request) -> Response:
|
||||
"""``POST /hux/v1/notebooks``: open a research question for a conversation."""
|
||||
body = _body(request)
|
||||
key = request.idempotency_key()
|
||||
with request.store.lock(FAMILY):
|
||||
existing = replay(request.store, FAMILY, key)
|
||||
if existing is not None:
|
||||
request.audit("research.notebook_create", existing, reason="idempotent_replay")
|
||||
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
|
||||
record: dict[str, Any] = {
|
||||
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
|
||||
"question": _string(body, "question", 1000), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
|
||||
"assumptions": _text_list(body, "assumptions", []), "unresolved_questions": _text_list(body, "unresolved_questions", []),
|
||||
"updated_at": now_iso(), "notes": [], "revision": 1,
|
||||
}
|
||||
_check(record)
|
||||
stored = request.store.put(NOTEBOOKS, record)
|
||||
remember(request.store, FAMILY, key, stored["id"])
|
||||
request.audit("research.notebook_create", stored["id"])
|
||||
return Response(201, stored, {"ETag": "1"})
|
||||
|
||||
|
||||
def get_notebook(request: Request) -> Response:
|
||||
"""``GET /hux/v1/notebooks/{id}``: one notebook."""
|
||||
record = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
request.audit("research.notebook_get", record["id"])
|
||||
return Response(200, record, {"ETag": str(record["revision"])})
|
||||
|
||||
|
||||
def _merged_ids(store: TenantStore, family: str, current: list[str], body: dict[str, Any], key: str) -> list[str]:
|
||||
added = _id_list(body, key, maximum=500)
|
||||
for record_id in added:
|
||||
_resolve(store, family, record_id)
|
||||
return list(dict.fromkeys([*current, *added]))
|
||||
|
||||
|
||||
def patch_notebook(request: Request) -> Response:
|
||||
"""``PATCH /hux/v1/notebooks/{id}``: add references and notes, replace assumptions, move status (If-Match required)."""
|
||||
body = _body(request)
|
||||
expected = request.if_match()
|
||||
if expected is None:
|
||||
raise Invalid("If-Match is required")
|
||||
with request.store.lock(FAMILY):
|
||||
notebook = _resolve(request.store, NOTEBOOKS, request.params["id"])
|
||||
if expected != notebook["revision"]:
|
||||
raise Conflict(f"revision {expected} does not match current revision {notebook['revision']}", [str(notebook["revision"])])
|
||||
stamp = now_iso()
|
||||
updated = {
|
||||
**notebook,
|
||||
"source_ids": _merged_ids(request.store, SOURCES, notebook["source_ids"], body, "add_source_ids"),
|
||||
"passage_ids": _merged_ids(request.store, PASSAGES, notebook["passage_ids"], body, "add_passage_ids"),
|
||||
"citation_ids": _merged_ids(request.store, CITATIONS, notebook["citation_ids"], body, "add_citation_ids"),
|
||||
"assumptions": _text_list(body, "assumptions", notebook["assumptions"]),
|
||||
"unresolved_questions": _text_list(body, "unresolved_questions", notebook["unresolved_questions"]),
|
||||
"updated_at": stamp,
|
||||
}
|
||||
notes = body.get("add_notes", [])
|
||||
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
|
||||
raise Invalid("add_notes must be a list of objects")
|
||||
for note in notes:
|
||||
entry = {"at": stamp, "text": _string(note, "text", 2000)}
|
||||
if note.get("source_id") is not None:
|
||||
entry["source_id"] = _resolve(request.store, SOURCES, note["source_id"])["id"]
|
||||
updated["notes"] = [*updated["notes"], entry]
|
||||
status = body.get("status")
|
||||
if status is not None and status != notebook["status"]:
|
||||
if status not in NOTEBOOK_TRANSITIONS or status not in NOTEBOOK_TRANSITIONS[notebook["status"]]:
|
||||
raise Conflict(f"notebook cannot move from {notebook['status']} to {status}")
|
||||
updated["status"] = status
|
||||
_check(updated)
|
||||
stored = request.store.put(NOTEBOOKS, updated, expected_revision=expected)
|
||||
request.audit("research.notebook_patch", stored["id"])
|
||||
return Response(200, stored, {"ETag": str(stored["revision"])})
|
||||
|
||||
|
||||
def register(router: Router) -> None:
|
||||
"""Attach HUX-08 routes."""
|
||||
card = "HUX-08"
|
||||
router.add("POST", "/hux/v1/sources", card, "research.source_create", create_source)
|
||||
router.add("GET", "/hux/v1/sources/{id}", card, "research.source_get", get_source)
|
||||
router.add("POST", "/hux/v1/passages", card, "research.passage_create", create_passage)
|
||||
router.add("POST", "/hux/v1/messages/{id}/citations", card, "research.citation_attach", attach_citation)
|
||||
router.add("GET", "/hux/v1/messages/{id}/citations", card, "research.citation_list", list_citations)
|
||||
router.add("POST", "/hux/v1/notebooks", card, "research.notebook_create", create_notebook)
|
||||
router.add("GET", "/hux/v1/notebooks/{id}", card, "research.notebook_get", get_notebook)
|
||||
router.add("PATCH", "/hux/v1/notebooks/{id}", card, "research.notebook_patch", patch_notebook)
|
||||
130
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
130
testing/tests/test_hermes_hux_artifact_auth.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""HUX-04 artifacts: authorization boundaries and tenant isolation.
|
||||
|
||||
Security obligations exercised: a second subject sees 404, never 403, for
|
||||
every artifact route (SO-33, SO-34); malformed ids never reach a path (SO-43);
|
||||
a forged owner field inside the tenant directory is still refused; every
|
||||
denial leaves an audit outcome; the card serves nothing while its flag is off.
|
||||
"""
|
||||
|
||||
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 artifacts, audit, contracts, errors, 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=OWNER):
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
response = router.dispatch(method, path, headers, payload)
|
||||
return response.status, response.body
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifact(router):
|
||||
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Mine", "content": "secret\n", "conversation_id": "conv_0001abcd"})
|
||||
assert status == 201
|
||||
return record
|
||||
|
||||
|
||||
def test_second_subject_gets_404_everywhere(router, artifact):
|
||||
art = artifact["id"]
|
||||
probes = [
|
||||
("GET", f"/hux/v1/artifacts/{art}", None),
|
||||
("GET", f"/hux/v1/artifacts/{art}/versions/1", None),
|
||||
("GET", f"/hux/v1/artifacts/{art}/versions/1/diff", None),
|
||||
("POST", f"/hux/v1/artifacts/{art}/versions", {"content": "x"}),
|
||||
("POST", f"/hux/v1/artifacts/{art}/promote", {"project_id": "prj_0001aaaa"}),
|
||||
]
|
||||
for method, path, body in probes:
|
||||
status, error = call(router, method, path, body, {**OTHER, "If-Match": "1"})
|
||||
assert (status, error["code"]) == (404, "not_found"), path
|
||||
assert contracts.validate_record(error, SCHEMAS) == []
|
||||
status, body = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd", headers=OTHER)
|
||||
assert status == 200 and body["items"] == []
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
|
||||
assert [r["outcome"] for r in rows if r["action"].startswith("artifacts.")].count("not_found") == 5
|
||||
|
||||
|
||||
def test_cross_tenant_lineage_forgery_is_404(router, artifact):
|
||||
body = {"type": "markdown", "title": "Derived", "content": "copy", "lineage": {"artifact_id": artifact["id"], "version": 1}}
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", body, OTHER)
|
||||
assert (status, error["code"]) == (404, "not_found")
|
||||
status, own = call(router, "POST", "/hux/v1/artifacts", {**body, "lineage": None}, OTHER)
|
||||
assert status == 201
|
||||
status, error = call(router, "POST", f"/hux/v1/artifacts/{own['id']}/versions", {"content": "v2", "lineage": {"artifact_id": artifact["id"], "version": 1}}, {**OTHER, "If-Match": "1"})
|
||||
assert status == 404
|
||||
assert call(router, "GET", f"/hux/v1/artifacts/{own['id']}", headers=OTHER)[1]["current_version"] == 1
|
||||
|
||||
|
||||
def test_malformed_ids_never_reach_the_filesystem(router):
|
||||
for path in ("/hux/v1/artifacts/..", "/hux/v1/artifacts/art_x", "/hux/v1/artifacts/ART_0000000000"):
|
||||
status, error = call(router, "GET", path)
|
||||
assert status == 404 and error["code"] == "not_found", path
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
assert not (tenant.root / "artifacts" / "...json").exists()
|
||||
|
||||
|
||||
def test_forged_owner_inside_tenant_directory_is_refused(router, artifact):
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
forged = {**tenant.get("artifacts", artifact["id"]), "id": "art_forged000001", "owner": "usr_fedcba9876543210"}
|
||||
tenant.put("artifacts", forged)
|
||||
assert call(router, "GET", "/hux/v1/artifacts/art_forged000001")[0] == 404
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": "art_forged000001", "version": 1}})
|
||||
assert status == 404
|
||||
status, body = call(router, "GET", "/hux/v1/artifacts")
|
||||
assert [a["id"] for a in body["items"]] == [artifact["id"]]
|
||||
assert artifacts.artifact_exists(tenant, "art_forged000001")
|
||||
|
||||
|
||||
def test_contract_validation_guards_every_write(router, artifact):
|
||||
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
|
||||
current = tenant.get("artifacts", artifact["id"])
|
||||
with pytest.raises(errors.Invalid) as raised:
|
||||
artifacts.append_version(tenant, current, {"version": 2, "created_at": "now", "created_by": {"type": "user", "id": "x"}, "content_ref": {"hash": "sha256:" + "a" * 64, "bytes": 1, "mime": "x"}}, 1)
|
||||
assert raised.value.details and tenant.get("artifacts", artifact["id"])["current_version"] == 1
|
||||
|
||||
|
||||
def test_flag_off_hides_the_card(tmp_path, artifact):
|
||||
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
|
||||
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
|
||||
assert (status, error["code"]) == (404, "flag_off")
|
||||
status, error = call(router, "GET", "/hux/v1/artifacts")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_idempotency_replay_is_scoped_to_the_caller(router):
|
||||
body = {"type": "code", "title": "t", "content": "c"}
|
||||
status, mine = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-01"})
|
||||
status, theirs = call(router, "POST", "/hux/v1/artifacts", body, {**OTHER, "Idempotency-Key": "shared-key-01"})
|
||||
assert status == 201 and theirs["id"] != mine["id"] and theirs["owner"] == "usr_fedcba9876543210"
|
||||
status, again = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-02"})
|
||||
assert status == 201 and again["id"] != mine["id"]
|
||||
status, error = call(router, "POST", f"/hux/v1/artifacts/{mine['id']}/versions", {"content": "v"}, {**OWNER, "If-Match": "1", "Idempotency-Key": "shared-key-02"})
|
||||
assert status == 201
|
||||
|
||||
|
||||
def test_audit_rows_name_family_verbs(router, artifact):
|
||||
call(router, "GET", f"/hux/v1/artifacts/{artifact['id']}")
|
||||
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
|
||||
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
|
||||
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
|
||||
319
testing/tests/test_hermes_hux_artifact_versions.py
Normal file
319
testing/tests/test_hermes_hux_artifact_versions.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""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)
|
||||
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": "prj_0001aaaa"}, {"If-Match": "1"})
|
||||
assert status == 409
|
||||
status, promoted, headers = call(router, "POST", path, {"project_id": "prj_0001aaaa"}, {"If-Match": "2"})
|
||||
assert status == 200 and valid(promoted) and headers["ETag"] == "3"
|
||||
assert promoted["promotion"]["project_id"] == "prj_0001aaaa" and promoted["promotion"]["version"] == 2
|
||||
assert promoted["project_id"] == "prj_0001aaaa"
|
||||
assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == "prj_0001aaaa"
|
||||
status, promoted, _ = call(router, "POST", path, {"project_id": "prj_0002aaaa", "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_0002aaaa", "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
|
||||
238
testing/tests/test_hermes_hux_research_citations.py
Normal file
238
testing/tests/test_hermes_hux_research_citations.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""HUX-08 research: sources, passages and citations.
|
||||
|
||||
Security obligations exercised: the service stores but never dereferences a
|
||||
source ``uri`` and only allowlisted schemes are accepted (SO-19); passage and
|
||||
citation references resolve under the caller's subtree and foreign ids are
|
||||
404 (SO-33); hashes and dedupe keys are server-computed (SO-30 by analogy);
|
||||
every served record satisfies citation.schema.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
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 audit, contracts, identity, research, 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"])
|
||||
|
||||
|
||||
def tenant(router, subject="usr_0123456789abcdef") -> store.TenantStore:
|
||||
return store.TenantStore(router.data_root, identity.Identity("slot-3", subject, "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, {**OWNER, **(headers or {})}, payload)
|
||||
return response.status, response.body, response.headers
|
||||
|
||||
|
||||
def valid(record) -> bool:
|
||||
return contracts.validate_record(record, SCHEMAS) == []
|
||||
|
||||
|
||||
def source(router, uri="https://Example.com/cabinets/#lead", **extra):
|
||||
status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": uri, "title": "Cabinet pricing", "classification": "primary", **extra})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
def passage(router, source_id, text="Standard base cabinets ship in 3-4 weeks."):
|
||||
status, record, _ = call(router, "POST", "/hux/v1/passages", {"source_id": source_id, "text": text})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
def cite(router, passage_ids, message="msg-44", claim="Base cabinets take three to four weeks.", **extra):
|
||||
status, record, _ = call(router, "POST", f"/hux/v1/messages/{message}/citations", {"claim": claim, "passage_ids": passage_ids, "support": "supports", **extra})
|
||||
assert status == 201, record
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def events(monkeypatch):
|
||||
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, detail, evidence))
|
||||
monkeypatch.setitem(sys.modules, "hux.events", module)
|
||||
return calls
|
||||
|
||||
|
||||
# --- sources -----------------------------------------------------------------------
|
||||
|
||||
def test_source_is_valid_and_deduped_on_normalised_uri(router):
|
||||
first = source(router, publisher="Example Co", published_at="2026-01-01T00:00:00Z", content_hash="sha256:" + "a" * 64, conversation_id="conv_0001abcd")
|
||||
assert valid(first) and first["provenance"]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
|
||||
assert first["provenance"]["conversation_id"] == "conv_0001abcd" and first["publisher"] == "Example Co"
|
||||
expected = "sha256:" + hashlib.sha256(b"uri\nhttps://example.com/cabinets").hexdigest()
|
||||
assert first["dedupe_key"] == expected
|
||||
status, again, headers = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "HTTPS://example.com/cabinets", "title": "Different title"})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true")
|
||||
assert tenant(router).count("sources") == 1
|
||||
status, record, _ = call(router, "GET", f"/hux/v1/sources/{first['id']}")
|
||||
assert status == 200 and record == first
|
||||
assert call(router, "GET", "/hux/v1/sources/src_missing00001")[0] == 404
|
||||
assert call(router, "GET", "/hux/v1/sources/..")[0] == 404
|
||||
|
||||
|
||||
def test_source_without_uri_dedupes_on_kind_and_title(router):
|
||||
status, memory, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": " Remembered fact "})
|
||||
status, again, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": "remembered fact"})
|
||||
assert status == 200 and again["id"] == memory["id"] and "uri" not in memory and memory["classification"] == "unknown"
|
||||
assert research.normalise_uri("artifact://art_0001aaaa@2/") == "artifact://art_0001aaaa@2/"
|
||||
assert research.normalise_uri("file:///opt/data/x/") == "file:///opt/data/x"
|
||||
assert research.normalise_uri("https://h.example/") == "https://h.example/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"uri": "javascript:alert(1)"}, {"uri": "ftp://x"}, {"uri": "/relative"}, {"kind": "rumour"}, {"kind": None},
|
||||
{"classification": "gospel"}, {"title": ""}, {"conversation_id": "x"}, {"content_hash": "md5:abc"}, {"published_at": "yesterday"},
|
||||
])
|
||||
def test_source_rejects_bad_bodies(router, bad):
|
||||
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://ok.example/", "title": "t", **bad})
|
||||
assert status == 400 and valid(error), bad
|
||||
assert call(router, "POST", "/hux/v1/sources", raw=b'"str"')[0] == 400
|
||||
|
||||
|
||||
def test_source_idempotency_and_cap(router, monkeypatch):
|
||||
status, one, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/1", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
|
||||
status, two, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/2", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
|
||||
assert status == 200 and two["id"] == one["id"]
|
||||
monkeypatch.setattr(research, "MAX_SOURCES", 1)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/3", "title": "t"})
|
||||
assert (status, error["code"]) == (413, "too_large")
|
||||
|
||||
|
||||
def test_service_has_no_outbound_client():
|
||||
import hux.artifacts
|
||||
import hux.research
|
||||
for name in ("urllib.request", "http.client", "socket"):
|
||||
assert name not in sys.modules or not any(name in getattr(m, "__dict__", {}) for m in (hux.research, hux.artifacts))
|
||||
text = (FOUNDATION / "hux" / "research.py").read_text()
|
||||
assert "urlopen" not in text and "http.client" not in text
|
||||
|
||||
|
||||
# --- passages ----------------------------------------------------------------------
|
||||
|
||||
def test_passage_hash_is_server_computed_and_deduped(router, monkeypatch):
|
||||
src = source(router)
|
||||
first = passage(router, src["id"])
|
||||
assert valid(first) and first["hash"] == "sha256:" + hashlib.sha256(b"Standard base cabinets ship in 3-4 weeks.").hexdigest()
|
||||
status, again, headers = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Standard base cabinets ship in 3-4 weeks.", "hash": "sha256:" + "0" * 64})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true") and again["hash"] == first["hash"]
|
||||
status, located, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Other", "locator": {"page": 2, "selector": "#x"}}, {"Idempotency-Key": "psg-key-00001"})
|
||||
assert status == 201 and valid(located) and located["locator"] == {"page": 2, "selector": "#x"}
|
||||
status, replay, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Ignored"}, {"Idempotency-Key": "psg-key-00001"})
|
||||
assert status == 200 and replay["id"] == located["id"]
|
||||
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Bad", "locator": {"page": 0}})
|
||||
assert status == 400 and error["details"]
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "x" * 4001})[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": "src_missing00001", "text": "x"})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/passages", {"text": "x"})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/passages", raw=b"[]")[0] == 400
|
||||
monkeypatch.setattr(research, "MAX_PASSAGES", 2)
|
||||
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "third"})[0] == 413
|
||||
|
||||
|
||||
def test_passage_cannot_name_another_subjects_source(router):
|
||||
src = source(router)
|
||||
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "steal"}, OTHER)
|
||||
assert (status, error["code"]) == (404, "not_found")
|
||||
assert call(router, "GET", f"/hux/v1/sources/{src['id']}", headers=OTHER)[0] == 404
|
||||
rows = audit.recent(tenant(router, "usr_fedcba9876543210"))
|
||||
assert [r["outcome"] for r in rows] == ["not_found", "not_found"]
|
||||
|
||||
|
||||
# --- citations ---------------------------------------------------------------------
|
||||
|
||||
def test_citation_attach_dedupes_and_lists_with_passages_and_sources(router, events):
|
||||
src = source(router)
|
||||
one = passage(router, src["id"])
|
||||
two = passage(router, src["id"], "Delivery excludes installation.")
|
||||
cit = cite(router, [two["id"], one["id"]], conversation_id="conv_0001abcd", note="checked")
|
||||
assert valid(cit) and cit["passage_ids"] == [two["id"], one["id"]] and cit["note"] == "checked"
|
||||
assert cit["dedupe_key"] == "sha256:" + hashlib.sha256(("\n".join(["citation", "msg-44", "Base cabinets take three to four weeks.", *sorted([one["id"], two["id"]])])).encode()).hexdigest()
|
||||
assert events == [("conv_0001abcd", "citation.attached", {"message_id": "msg-44", "citation_id": cit["id"], "support": "supports"}, [{"kind": "passage", "id": two["id"], "hash": two["hash"]}, {"kind": "passage", "id": one["id"], "hash": one["hash"]}])]
|
||||
status, again, headers = call(router, "POST", "/hux/v1/messages/msg-44/citations", {"claim": "Base cabinets take three to four weeks.", "passage_ids": [one["id"], two["id"], one["id"]], "support": "contradicts"})
|
||||
assert (status, again["id"], headers["HUX-Replayed"]) == (200, cit["id"], "true") and again["support"] == "supports"
|
||||
second = cite(router, [one["id"]], claim="Second claim")
|
||||
status, body, _ = call(router, "GET", "/hux/v1/messages/msg-44/citations")
|
||||
assert status == 200 and [i["citation"]["id"] for i in body["items"]] == [cit["id"], second["id"]]
|
||||
strip = body["items"][0]
|
||||
assert [p["id"] for p in strip["passages"]] == [two["id"], one["id"]] and strip["sources"] == [src]
|
||||
assert all(valid(r) for item in body["items"] for r in (item["citation"], *item["passages"], *item["sources"]))
|
||||
assert call(router, "GET", "/hux/v1/messages/msg-none/citations")[1]["items"] == []
|
||||
assert len(events) == 1 # the second citation carried no conversation_id, so nothing to emit against
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"claim": ""}, {"support": "maybe"}, {"passage_ids": "psg_x"}, {"passage_ids": []}, {"note": "n" * 501}, {"conversation_id": "nope"},
|
||||
])
|
||||
def test_citation_rejects_bad_bodies(router, bad):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]], "support": "supports", **bad})
|
||||
assert status == 400 and valid(error), bad
|
||||
|
||||
|
||||
def test_citation_reference_rules(router):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": ["psg_missing00001"]})[0] == 404
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, OTHER)[0] == 404
|
||||
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [f"psg_{i:012d}" for i in range(33)]})
|
||||
assert status == 413
|
||||
assert call(router, "POST", f"/hux/v1/messages/{'m' * 121}/citations", {"claim": "c", "passage_ids": [psg["id"]]})[0] == 400
|
||||
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", raw=b"null")[0] == 400
|
||||
status, cit, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
|
||||
status, replay, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "other", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
|
||||
assert status == 200 and replay["id"] == cit["id"] and replay["support"] == "unverified"
|
||||
assert call(router, "GET", "/hux/v1/messages/msg-1/citations", headers=OTHER)[1]["items"] == []
|
||||
|
||||
|
||||
def test_validate_citations_reports_integrity_problems(router, monkeypatch):
|
||||
src = source(router)
|
||||
psg = passage(router, src["id"])
|
||||
good = cite(router, [psg["id"]])
|
||||
contradiction = cite(router, [psg["id"]], claim="Never", support="contradicts")
|
||||
noted = cite(router, [psg["id"]], claim="Never again", support="contradicts", note="supplier changed policy")
|
||||
tenant_store = tenant(router)
|
||||
assert research.validate_citations(tenant_store, [good["id"], noted["id"]]) == []
|
||||
assert research.validate_citations(tenant_store, [contradiction["id"]]) == [f"{contradiction['id']}: contradicts without a note"]
|
||||
tenant_store.delete("sources", src["id"])
|
||||
assert research.validate_citations(tenant_store, [good["id"]]) == [f"{good['id']}: passage {psg['id']} names missing source {src['id']}"]
|
||||
tenant_store.delete("passages", psg["id"])
|
||||
problems = research.validate_citations(tenant_store, [good["id"], "cit_missing00001", "bad id"])
|
||||
assert problems == [f"{good['id']}: passage {psg['id']} does not resolve", "cit_missing00001: citation does not resolve", "bad id: citation does not resolve"]
|
||||
monkeypatch.setitem(sys.modules, "hux.events", None)
|
||||
assert cite(router, [passage(router, source(router, "https://b.example/")["id"])["id"]], claim="silent", conversation_id="conv_0001abcd")["support"] == "supports"
|
||||
|
||||
|
||||
def test_research_modules_stay_under_500_lines():
|
||||
assert len((FOUNDATION / "hux" / "research.py").read_text().splitlines()) <= 500
|
||||
|
||||
|
||||
def test_worker_trust_records_a_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/sources", {"kind": "tool_output", "title": "grep"}, headers)
|
||||
assert status == 201 and record["provenance"] == {"surface": "worker", "actor": {"type": "system", "id": "worker"}, "recorded_at": record["retrieved_at"]}
|
||||
162
testing/tests/test_hermes_hux_research_notebook.py
Normal file
162
testing/tests/test_hermes_hux_research_notebook.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""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
|
||||
Loading…
x
Reference in New Issue
Block a user