Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions tests/test_hub_relay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Tests for the hub sealed-envelope relay (cross-user collab A3)."""
from __future__ import annotations

import json
import os
import pytest

from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
)

from tinyagentos.hub.relay import (
canonicalize,
seal,
unseal,
build_envelope,
MAX_ENVELOPE_SIZE,
_b64,
_b64_decode,
)

# ---------------------------------------------------------------------------
# Round-trip: seal then unseal
# ---------------------------------------------------------------------------


def _make_x25519_keypair() -> tuple[str, str]:
"""Return (priv_hex, pub_hex) for a fresh X25519 keypair."""
priv = X25519PrivateKey.generate()
pub = priv.public_key()
return (
priv.private_bytes_raw().hex(),
pub.public_bytes_raw().hex(),
)


class TestRoundTrip:
"""A sealed envelope must survive seal→unseal across a real keypair."""

def test_small_payload(self):
priv_hex, pub_hex = _make_x25519_keypair()
plaintext = b"hello sealed world"
envelope = seal(plaintext=plaintext,
recipient_x25519_pub_hex=pub_hex)
assert "sender_ephemeral_pub" in envelope
assert "nonce" in envelope
assert "ciphertext" in envelope
result = unseal(envelope=envelope,
recipient_x25519_priv_hex=priv_hex)
assert result == plaintext

def test_larger_payload(self):
priv_hex, pub_hex = _make_x25519_keypair()
plaintext = os.urandom(4096)
envelope = seal(plaintext=plaintext,
recipient_x25519_pub_hex=pub_hex)
result = unseal(envelope=envelope,
recipient_x25519_priv_hex=priv_hex)
assert result == plaintext

def test_wrong_recipient_fails(self):
alice_priv, alice_pub = _make_x25519_keypair()
_, bob_pub = _make_x25519_keypair()
envelope = seal(plaintext=b"secret",
recipient_x25519_pub_hex=alice_pub)
# Bob tries to decrypt: should return None.
bob_priv, _ = _make_x25519_keypair()
result = unseal(envelope=envelope,
recipient_x25519_priv_hex=bob_priv)
assert result is None

def test_tampered_ciphertext_fails(self):
priv_hex, pub_hex = _make_x25519_keypair()
envelope = seal(plaintext=b"tamper me",
recipient_x25519_pub_hex=pub_hex)
# Flip a byte in the ciphertext.
ct = bytearray(_b64_decode(envelope["ciphertext"]))
ct[0] ^= 0xFF
envelope["ciphertext"] = _b64(bytes(ct))
result = unseal(envelope=envelope,
recipient_x25519_priv_hex=priv_hex)
assert result is None

def test_unicode_plaintext(self):
priv_hex, pub_hex = _make_x25519_keypair()
plaintext = "héllo wörld 🔐".encode("utf-8")
envelope = seal(plaintext=plaintext,
recipient_x25519_pub_hex=pub_hex)
result = unseal(envelope=envelope,
recipient_x25519_priv_hex=priv_hex)
assert result == plaintext


# ---------------------------------------------------------------------------
# build_envelope
# ---------------------------------------------------------------------------

class TestBuildEnvelope:
def test_includes_recipient(self):
_, pub_hex = _make_x25519_keypair()
outer = build_envelope(
recipient="hub:alice",
recipient_x25519_pub_hex=pub_hex,
inner_payload=b'{"type":"test"}',
)
assert outer["recipient"] == "hub:alice"
assert "sender_ephemeral_pub" in outer
assert "nonce" in outer
assert "ciphertext" in outer
assert "created_at" in outer

def test_enforces_size_limit(self):
_, pub_hex = _make_x25519_keypair()
# Build a payload just over the limit (after base64 overhead).
large = b"x" * (MAX_ENVELOPE_SIZE + 1)
with pytest.raises(ValueError, match="exceeds"):
build_envelope(
recipient="hub:alice",
recipient_x25519_pub_hex=pub_hex,
inner_payload=large,
)

def test_inner_json_preserved_after_roundtrip(self):
priv_hex, pub_hex = _make_x25519_keypair()
inner = json.dumps(
{"from": "hub:alice", "to": "hub:bob", "kind": "chat",
"body": "hello"}
).encode("utf-8")
outer = build_envelope(
recipient="hub:bob",
recipient_x25519_pub_hex=pub_hex,
inner_payload=inner,
)
result = unseal(envelope=outer,
recipient_x25519_priv_hex=priv_hex)
assert json.loads(result) == json.loads(inner)


# ---------------------------------------------------------------------------
# canonicalize
# ---------------------------------------------------------------------------

class TestCanonicalize:
def test_strips_sig(self):
payload = {"from": "a", "to": "b", "sig": "deadbeef"}
c = canonicalize(payload)
decoded = json.loads(c)
assert "sig" not in decoded
assert decoded["from"] == "a"

def test_sorted_keys(self):
payload = {"z": 1, "a": 2, "m": 3}
c = canonicalize(payload)
# canonical JSON: sorted keys, compact
assert c == b'{"a":2,"m":3,"z":1}'

def test_ensure_ascii_false_preserves_unicode(self):
payload = {"text": "café"}
c = canonicalize(payload)
assert "café".encode("utf-8") in c


# ---------------------------------------------------------------------------
# base64 round-trip
# ---------------------------------------------------------------------------

class TestBase64:
def test_roundtrip(self):
data = os.urandom(32)
assert _b64_decode(_b64(data)) == data

def test_url_safe(self):
# All-zero bytes produce '/' in standard base64 but '_' in urlsafe.
data = bytes([0xFF, 0xFF])
enc = _b64(data)
assert "/" not in enc
assert "+" not in enc
assert "=" not in enc # padding stripped
112 changes: 112 additions & 0 deletions tests/test_routes_account_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,115 @@ async def handler(method, url, **kw):
assert r.status_code == 200
assert "taos_session" not in captured.get("cookie", "")
assert captured.get("cookie", "") == ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The PR description notes these 3 new account-proxy tests "require full app fixture (pre-existing conftest hang on this machine)" and were not actually run. Confirm they execute in CI before merge — the inline monkeypatch/_FakeResp/_patch_upstream fixtures must be available in this file's scope, and a hang or skip here would leave the new relay routes effectively untested in the pipeline.


# --- Hub sealed-envelope relay (cross-user collab A3) ---


@pytest.mark.asyncio
async def test_hub_relay_drop_forwards_envelope(client, monkeypatch):
"""POST /api/account/hub/relay/drop forwards to
{base}/api/hub/relay/drop with the sealed envelope body."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
captured: dict[str, str] = {}

async def handler(method, url, **kw):
captured["method"] = method
captured["url"] = url
captured["body"] = kw.get("content", b"").decode("utf-8")
return _FakeResp(
content=b'{"status":"queued","count":1}',
headers={"content-type": "application/json"},
)

_patch_upstream(monkeypatch, handler)
r = await client.post(
"/api/account/hub/relay/drop",
json={
"recipient": "hub:bob",
"sender_ephemeral_pub": "aa",
"nonce": "bb",
"ciphertext": "cc",
},
)
assert r.status_code == 200
assert r.json()["status"] == "queued"
assert captured["url"] == "https://taos.my/api/hub/relay/drop"
assert captured["method"] == "POST"
assert "hub:bob" in captured["body"]


@pytest.mark.asyncio
async def test_hub_relay_poll_forwards_recipient(client, monkeypatch):
"""GET /api/account/hub/relay/poll?recipient=hub:alice forwards to
{base}/api/hub/relay/poll?recipient=hub:alice."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
captured: dict[str, str] = {}

async def handler(method, url, **kw):
captured["method"] = method
captured["url"] = url
return _FakeResp(
content=b'{"envelopes":[],"count":0}',
headers={"content-type": "application/json"},
)

_patch_upstream(monkeypatch, handler)
r = await client.get("/api/account/hub/relay/poll?recipient=hub:alice")
assert r.status_code == 200
assert r.json()["count"] == 0
assert captured["url"] == "https://taos.my/api/hub/relay/poll?recipient=hub:alice"
assert captured["method"] == "GET"


@pytest.mark.asyncio
async def test_hub_relay_drop_rejects_invalid_recipient(client, monkeypatch):
"""An invalid recipient in the body is rejected 400 before forwarding."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")

async def handler(method, url, **kw):
pytest.fail("must not be called for invalid recipient")

_patch_upstream(monkeypatch, handler)
r = await client.post(
"/api/account/hub/relay/drop",
json={
"recipient": "../admin",
"sender_ephemeral_pub": "aa",
"nonce": "bb",
"ciphertext": "cc",
},
)
assert r.status_code == 400
assert "invalid recipient" in r.json()["error"].lower()


@pytest.mark.asyncio
async def test_hub_relay_drop_rejects_non_dict_body(client, monkeypatch):
"""A JSON array (or any non-dict) body is rejected 400 before forwarding."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")

async def handler(method, url, **kw):
pytest.fail("must not be called for non-dict body")

_patch_upstream(monkeypatch, handler)
r = await client.post(
"/api/account/hub/relay/drop",
json=[1, 2, 3],
)
assert r.status_code == 400
assert "invalid body" in r.json()["error"].lower()


@pytest.mark.asyncio
async def test_hub_relay_poll_rejects_invalid_recipient(client, monkeypatch):
"""An invalid recipient (path injection) is rejected 400 before forwarding."""
monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")

async def handler(method, url, **kw):
pytest.fail("must not be called for invalid recipient")

_patch_upstream(monkeypatch, handler)
r = await client.get("/api/account/hub/relay/poll?recipient=../admin")
assert r.status_code == 400
assert "invalid recipient" in r.json()["error"].lower()
Loading
Loading