From 7ab37a049807cb535267e091ec9accd400095451 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 22 Oct 2026 17:29:35 -0700 Subject: [PATCH 1/3] test(crypto,client,ipfs,learning,release): pin envelope, list, invite, transport and release-gate guards at their entry points (red) The manifest check fails on main: MANIFEST.in includes requirements.txt, which no longer exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4 --- tests/test_gap_guard_pins.py | 211 ++++++++++++++++++++++++++++ tests/test_release_artifact_pins.py | 181 ++++++++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 tests/test_gap_guard_pins.py create mode 100644 tests/test_release_artifact_pins.py diff --git a/tests/test_gap_guard_pins.py b/tests/test_gap_guard_pins.py new file mode 100644 index 0000000..417a556 --- /dev/null +++ b/tests/test_gap_guard_pins.py @@ -0,0 +1,211 @@ +"""Pins for guards whose removal the rest of the suite did not notice. + +Each test drives the public entry point (``decrypt_data``, ``list_models``, +``verify_model_integrity``, ``ClassroomManager.create``, the client +constructors, ``IPFSClient``) and fails on an assertion if the guard it names +is removed. No network: RPC and HTTP are replaced in-process, and the keys +are fixed throwaway test scalars. +""" +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from citrate_sdk import crypto as crypto_mod +from citrate_sdk._url_security import InsecureTransportError +from citrate_sdk.client import CitrateClient +from citrate_sdk.crypto import KeyManager, verify_model_integrity +from citrate_sdk.errors import CitrateError +from citrate_sdk.ipfs import IPFSClient +from citrate_sdk.learning import ClassroomManager + +SENDER_PK = "0x" + "11" * 32 +RECIPIENT_PK = "0x" + "22" * 32 + + +def _envelope() -> tuple[KeyManager, str]: + sender = KeyManager(SENDER_PK) + recipient = KeyManager(RECIPIENT_PK) + env = sender.encrypt_data("payload", recipient.get_public_key()) + return recipient, env + + +# ── envelope scheme and salt checks in decrypt_data ───────────────────────── + +def test_positive_control_v2_envelope_decrypts() -> None: + recipient, env = _envelope() + assert recipient.decrypt_data(env) == "payload" + + +@pytest.mark.parametrize("scheme", ["ecdh-secp256k1-aesgcm-v1", None, "", "ECDH-SECP256K1-AESGCM-V2 "]) +def test_a_well_formed_envelope_under_another_scheme_label_is_refused(scheme: Any) -> None: + """Every other field is a valid V2 envelope, so only the scheme check can + stop this decrypting (an older-scheme or unlabelled envelope must not be read).""" + recipient, env = _envelope() + pkg = json.loads(env) + if scheme is None: + del pkg["scheme"] + else: + pkg["scheme"] = scheme + with pytest.raises(CitrateError, match="unsupported envelope scheme"): + recipient.decrypt_data(json.dumps(pkg)) + + +def test_an_envelope_without_its_kdf_salt_is_refused_by_name() -> None: + recipient, env = _envelope() + pkg = json.loads(env) + del pkg["kdf_salt"] + with pytest.raises(CitrateError, match="missing kdf_salt"): + recipient.decrypt_data(json.dumps(pkg)) + + +# ── verify_model_integrity uses a constant-time compare ───────────────────── + +def test_verify_model_integrity_compares_with_compare_digest(monkeypatch: pytest.MonkeyPatch) -> None: + import hashlib + import hmac + + seen: list[tuple[Any, Any]] = [] + real = hmac.compare_digest + + def spy(a: Any, b: Any) -> bool: + seen.append((a, b)) + return real(a, b) + + monkeypatch.setattr(crypto_mod.hmac, "compare_digest", spy) + data = b"model-bytes" + good = hashlib.sha256(data).hexdigest() + assert verify_model_integrity(data, good) is True + assert verify_model_integrity(data, "0" * 64) is False + assert [b for _, b in seen] == [good, "0" * 64] + + +# ── list_models unwraps the node's {"models": [...]} answer ───────────────── + +def test_list_models_unwraps_the_node_shape(monkeypatch: pytest.MonkeyPatch) -> None: + client = CitrateClient("http://localhost:8545") + rows = [{"model_id": "m1"}, {"model_id": "m2"}] + monkeypatch.setattr(client, "_rpc_call", lambda method, params=None: {"models": rows}) + got = client.list_models(limit=2) + assert got == rows + assert [m["model_id"] for m in got] == ["m1", "m2"] + + +def test_list_models_of_an_empty_node_answer_is_an_empty_list(monkeypatch: pytest.MonkeyPatch) -> None: + client = CitrateClient("http://localhost:8545") + monkeypatch.setattr(client, "_rpc_call", lambda method, params=None: {}) + assert client.list_models() == [] + + +# ── ClassroomManager.create default invite secret is random, not clock-derived ── + +def _classroom() -> ClassroomManager: + def rpc(method: str, params: Any) -> Any: + if method == "eth_chainId": + return hex(40204) + if method == "eth_sendTransaction": + return "0xhash" + raise AssertionError(method) + + return ClassroomManager(rpc, default_account="0x" + "01" * 20, classroom_address="0x" + "02" * 20) + + +def test_default_invite_secret_does_not_depend_on_the_clock(monkeypatch: pytest.MonkeyPatch) -> None: + import time as time_mod + + monkeypatch.setattr(time_mod, "time", lambda: 1_700_000_000.123) + mgr = _classroom() + mgr.create("A", 10) + first = mgr.last_invite_code + mgr.create("B", 10) + second = mgr.last_invite_code + assert first and second and first != second + + +def test_default_invite_secret_comes_from_the_os_csprng(monkeypatch: pytest.MonkeyPatch) -> None: + import citrate_sdk.learning as learning + + monkeypatch.setattr(learning.secrets, "token_bytes", lambda n: b"\x5a" * n) + mgr = _classroom() + mgr.create("A", 10) + assert mgr.last_invite_code == "0x" + "5a" * 32 + + +# ── transport gate: only https/http are accepted, wherever the URL enters ─── + +@pytest.mark.parametrize("url", [ + "ftp://localhost:8545", + "ws://127.0.0.1:8545", + "gopher://localhost:8545", + "file://localhost/etc/passwd", +]) +def test_client_refuses_a_non_http_scheme_even_on_loopback(url: str) -> None: + with pytest.raises(InsecureTransportError, match="is not https or http"): + CitrateClient(url) + + +@pytest.mark.parametrize("url", ["ftp://ipfs.example.com:5001", "wss://ipfs.example.com:5001"]) +def test_insecure_opt_in_does_not_admit_other_schemes(url: str) -> None: + with pytest.raises(InsecureTransportError, match="is not https or http"): + IPFSClient(url, allow_insecure_http=True) + + +# ── every IPFS request carries the client's timeout ───────────────────────── + +class _Resp: + def __init__(self, body: dict[str, Any] | None = None, chunks: list[bytes] | None = None) -> None: + self.status_code = 200 + self._body = body or {} + self._chunks = chunks or [] + self.text = json.dumps(self._body) + + def json(self) -> dict[str, Any]: + return self._body + + def iter_content(self, chunk_size: int = 0) -> Any: + return iter(self._chunks) + + def close(self) -> None: + pass + + +def _recording_client(calls: list[tuple[str, dict[str, Any]]], data: bytes = b"x") -> IPFSClient: + c = IPFSClient("http://localhost:5001", timeout=7.5) + + def post(url: str, **kw: Any) -> _Resp: + calls.append((url.rsplit("/api/v0/", 1)[-1], kw)) + if url.endswith("/add"): + return _Resp({"Hash": "QmTest", "Size": "1"}) + if url.endswith("/files/stat") or url.endswith("/object/stat"): + return _Resp({"Hash": "QmTest", "Size": 1, "CumulativeSize": 1, "Type": "file"}) + if url.endswith("/cat"): + return _Resp(chunks=[data]) + return _Resp({}) + + c.session.post = post # type: ignore[method-assign] + return c + + +def test_upload_and_its_verification_carry_the_timeout() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + c = _recording_client(calls) + c.upload_bytes(b"model") + assert calls, "upload made no request" + assert calls[0][0] == "add" + for endpoint, kw in calls: + assert kw.get("timeout") == 7.5, f"{endpoint} request sent without the client timeout" + + +def test_download_and_pin_carry_the_timeout() -> None: + import hashlib + + calls: list[tuple[str, dict[str, Any]]] = [] + c = _recording_client(calls, data=b"abc") + c.download_bytes("QmTest", expected_sha256=hashlib.sha256(b"abc").hexdigest()) + c.pin_file("QmTest") + seen = {e for e, _ in calls} + assert {"cat", "pin/add"} <= seen, seen + for endpoint, kw in calls: + assert kw.get("timeout") == 7.5, f"{endpoint} request sent without the client timeout" diff --git a/tests/test_release_artifact_pins.py b/tests/test_release_artifact_pins.py new file mode 100644 index 0000000..436d6c2 --- /dev/null +++ b/tests/test_release_artifact_pins.py @@ -0,0 +1,181 @@ +"""Pins on the non-source artifacts that ship with, or gate, this package. + +Each check reads a file that nothing in the runtime suite otherwise reads: the +shipped examples, the publish workflow, the lockfile and the sdist manifest. +They are text/AST checks on purpose (no network, no YAML dependency), so they +run in the same frozen environment as the rest of the suite. +""" +from __future__ import annotations + +import ast +import pathlib +import re +import sys + +import pytest +from packaging.requirements import Requirement +from packaging.version import Version + +if sys.version_info >= (3, 11): + import tomllib +else: # Python 3.10 (requires-python floor) + import tomli as tomllib + +ROOT = pathlib.Path(__file__).resolve().parent.parent +EXAMPLES = sorted((ROOT / "examples").glob("*.py")) +WORKFLOWS = ROOT / ".github" / "workflows" + + +# --------------------------------------------------------------------------- +# Shipped examples: the code integrators copy. +# --------------------------------------------------------------------------- + +def _calls(path: pathlib.Path) -> list[ast.Call]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + return [n for n in ast.walk(tree) if isinstance(n, ast.Call)] + + +def _attr_name(call: ast.Call) -> str | None: + f = call.func + if isinstance(f, ast.Attribute): + return f.attr + if isinstance(f, ast.Name): + return f.id + return None + + +def test_examples_are_present_and_parse() -> None: + assert EXAMPLES, "examples/ is empty; the pins below would pass vacuously" + for path in EXAMPLES: + ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) +def test_examples_never_call_the_disabled_purchase_path(path: pathlib.Path) -> None: + names = [_attr_name(c) for c in _calls(path)] + assert "purchase_model_access" not in names, ( + f"{path.name} calls purchase_model_access, which has no on-chain route and refuses" + ) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) +def test_example_encrypted_calls_name_a_recipient_key(path: pathlib.Path) -> None: + for call in _calls(path): + name = _attr_name(call) + kw = {k.arg: k.value for k in call.keywords if k.arg} + if name == "inference": + enc = kw.get("encrypted") + if isinstance(enc, ast.Constant) and enc.value is True: + assert "recipient_public_key" in kw, ( + f"{path.name}:{call.lineno} encrypted inference without recipient_public_key" + ) + if name == "encrypt_data": + assert len(call.args) >= 2 or "recipient_public_key" in kw, ( + f"{path.name}:{call.lineno} encrypt_data without a recipient key" + ) + + +@pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) +def test_examples_do_not_hand_a_generated_key_string_to_a_client(path: pathlib.Path) -> None: + """`CitrateClient(private_key=KeyManager().get_private_key())` is the + round-trip the examples must not model; keys come from the environment.""" + for call in _calls(path): + if _attr_name(call) != "CitrateClient": + continue + for k in call.keywords: + if k.arg == "private_key" and isinstance(k.value, ast.Call): + assert _attr_name(k.value) != "get_private_key", ( + f"{path.name}:{call.lineno} builds a client from get_private_key()" + ) + + +# --------------------------------------------------------------------------- +# Publish workflow: the release gate. +# --------------------------------------------------------------------------- + +def _jobs(text: str) -> dict[str, str]: + """Split a workflow's `jobs:` block into {job_name: body} by indentation.""" + body = text.split("\njobs:\n", 1)[1] + out: dict[str, str] = {} + cur: str | None = None + for line in body.splitlines(): + m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if m: + cur = m.group(1) + out[cur] = "" + elif cur is not None and (line.startswith(" ") or not line.strip()): + out[cur] += line + "\n" + elif line.strip(): + break + return out + + +@pytest.fixture(scope="module") +def publish() -> str: + return (WORKFLOWS / "publish.yml").read_text(encoding="utf-8") + + +def test_publish_workflow_chains_test_build_publish(publish: str) -> None: + jobs = _jobs(publish) + assert {"test", "build", "publish"} <= set(jobs), sorted(jobs) + assert re.search(r"^ needs: test\s*$", jobs["build"], re.M), "build must need test" + assert re.search(r"^ needs: build\s*$", jobs["publish"], re.M), "publish must need build" + + +def test_publish_test_job_runs_the_suite_blocking(publish: str) -> None: + test_job = _jobs(publish)["test"] + assert re.search(r"run: uv run --frozen pytest\b", test_job), "test job must run pytest" + assert "continue-on-error" not in publish + assert "|| true" not in publish + + +def test_publish_uses_trusted_publishing_not_a_token(publish: str) -> None: + job = _jobs(publish)["publish"] + assert re.search(r"id-token: write", job) + assert "pypa/gh-action-pypi-publish@" in job + assert not re.search(r"^\s*(password|api[-_]token)\s*:", publish, re.M | re.I) + assert "secrets.PYPI" not in publish + + +def test_manual_publish_is_main_only_and_version_checked(publish: str) -> None: + job = _jobs(publish)["publish"] + assert "github.ref == 'refs/heads/main'" in job + assert "inputs.publish_version != ''" in job + assert "Built version matches the requested version" in job + + +@pytest.mark.parametrize("wf", sorted(WORKFLOWS.glob("*.yml")), ids=lambda p: p.name) +def test_every_action_reference_is_pinned_to_a_commit(wf: pathlib.Path) -> None: + refs = re.findall(r"^\s*-?\s*uses:\s*(\S+)", wf.read_text(encoding="utf-8"), re.M) + assert refs, f"{wf.name} has no uses: lines" + for ref in refs: + if ref.startswith("./"): + continue + assert re.search(r"@[0-9a-f]{40}$", ref), f"{wf.name}: {ref} is not pinned to a commit SHA" + + +# --------------------------------------------------------------------------- +# Lockfile and sdist manifest. +# --------------------------------------------------------------------------- + +def _locked_version(name: str) -> Version: + text = (ROOT / "uv.lock").read_text(encoding="utf-8") + m = re.search(r'^\[\[package\]\]\nname = "' + re.escape(name) + r'"\nversion = "([^"]+)"', text, re.M) + assert m, f"{name} not in uv.lock" + return Version(m.group(1)) + + +def test_locked_cryptography_satisfies_the_declared_range() -> None: + with (ROOT / "pyproject.toml").open("rb") as fh: + deps = tomllib.load(fh)["project"]["dependencies"] + req = next(Requirement(d) for d in deps if Requirement(d).name == "cryptography") + locked = _locked_version("cryptography") + assert req.specifier.contains(locked), f"uv.lock pins cryptography {locked}, pyproject wants {req.specifier}" + + +def test_manifest_includes_only_files_that_exist() -> None: + for line in (ROOT / "MANIFEST.in").read_text(encoding="utf-8").splitlines(): + parts = line.split() + if parts and parts[0] == "include": + for name in parts[1:]: + assert (ROOT / name).exists(), f"MANIFEST.in includes missing file {name}" From caa45260ebde7c661b87921ee1a097cc5fe5dd29 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 22 Oct 2026 17:29:35 -0700 Subject: [PATCH 2/3] chore(sdist): drop the MANIFEST.in include of the removed requirements.txt pyproject.toml is the single dependency declaration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4 --- MANIFEST.in | 1 - 1 file changed, 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 93abf6e..b603db1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ include README.md include LICENSE include CHANGELOG.md -include requirements.txt include pyproject.toml recursive-include citrate_sdk *.py *.pyi recursive-include citrate_sdk py.typed From 07f8636b40133726f221adcf65acc76c638041e6 Mon Sep 17 00:00:00 2001 From: Larry Klosowski Date: Thu, 22 Oct 2026 17:49:35 -0700 Subject: [PATCH 3/3] test: widen the type-ignore on the recorded session.post (mypy) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGMeatV8UjtCB4CTbWuAv4 --- tests/test_gap_guard_pins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gap_guard_pins.py b/tests/test_gap_guard_pins.py index 417a556..9c97673 100644 --- a/tests/test_gap_guard_pins.py +++ b/tests/test_gap_guard_pins.py @@ -184,7 +184,7 @@ def post(url: str, **kw: Any) -> _Resp: return _Resp(chunks=[data]) return _Resp({}) - c.session.post = post # type: ignore[method-assign] + c.session.post = post # type: ignore[method-assign,assignment] return c