diff --git a/hh/smoke.sh b/hh/smoke.sh new file mode 100755 index 0000000..b59d8d2 --- /dev/null +++ b/hh/smoke.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# hack-house smoke test ⛧ +# Exercises the full use-case path end to end against a live server: +# rust unit tests → SRP self-test → boot server → rust client handshake + +# round-trip → cross-language (python decrypts what the rust client sent). +# Run from anywhere: hh/smoke.sh +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" # .../hh +ROOT="$(cd "$HERE/.." && pwd)" # repo root +PY="$ROOT/.venv/bin/python" +BIN="$HERE/target/debug/hack-house" +PORT="${PORT:-4199}" +PW="${PW:-labtest}" + +fail() { echo "✖ SMOKE FAIL: $1"; exit 1; } + +echo "── 1/5 rust unit tests (srp vectors + fernet interop) ──" +( cd "$HERE" && cargo test --quiet ) || fail "cargo test" + +echo "── 2/5 build + SRP self-test (Rust SRP ≡ Python srp) ──" +( cd "$HERE" && cargo build --quiet ) || fail "cargo build" +"$BIN" selftest | grep -q "selftest passed" || fail "selftest" + +echo "── 3/5 boot server on :$PORT ──" +"$PY" "$ROOT/cmd_chat.py" serve 127.0.0.1 "$PORT" --password "$PW" --no-tls \ + >/tmp/hh-smoke-srv.log 2>&1 & +SRV=$! +trap 'kill $SRV 2>/dev/null' EXIT +for _ in $(seq 1 20); do + curl -s --max-time 2 "http://127.0.0.1:$PORT/health" 2>/dev/null | grep -q '"status":"ok"' && break + sleep 1 +done +curl -s "http://127.0.0.1:$PORT/health" 2>/dev/null | grep -q '"status":"ok"' || fail "server did not come up" + +echo "── 4/5 rust client: SRP auth + encrypted round-trip ──" +"$BIN" handshake 127.0.0.1 "$PORT" smoke-rust --password "$PW" --no-tls \ + | tee /tmp/hh-smoke-rust.log | grep -q "round-trip ✓" || fail "rust handshake/round-trip" + +echo "── 5/5 cross-language: python decrypts the rust-sent message ──" +"$PY" - "$PORT" "$PW" <<'PYEOF' || fail "python cross-language read" +import sys, asyncio, base64, json +import srp, requests, websockets +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +srp.rfc5054_enable() +port, pw = sys.argv[1], sys.argv[2].encode() +base, wsb = f"http://127.0.0.1:{port}", f"ws://127.0.0.1:{port}" + +async def main(): + usr = srp.User(b"chat", pw, hash_alg=srp.SHA256) + _, A = usr.start_authentication() + r = requests.post(f"{base}/srp/init", + json={"username": "smoke-py", "A": base64.b64encode(A).decode()}).json() + uid = r["user_id"] + B, salt, rs = (base64.b64decode(r[k]) for k in ("B", "salt", "room_salt")) + M = usr.process_challenge(salt, B) + v = requests.post(f"{base}/srp/verify", + json={"user_id": uid, "username": "smoke-py", + "M": base64.b64encode(M).decode()}).json() + room = Fernet(base64.urlsafe_b64encode( + HKDF(algorithm=hashes.SHA256(), length=32, salt=rs, + info=b"cmd-chat-room-key").derive(pw))) + async with websockets.connect(f"{wsb}/ws/chat?user_id={uid}&ws_token={v['ws_token']}") as ws: + data = json.loads(await asyncio.wait_for(ws.recv(), 5)) + msgs = [room.decrypt(m["text"].encode()).decode() + for m in data.get("messages", []) if m.get("text")] + hits = [m for m in msgs if "house is open" in m] + assert hits, f"rust-sent message not found/decryptable: {msgs!r}" + print(" ✓ python decrypted rust-sent message:", hits) + +asyncio.run(main()) +PYEOF + +echo +echo "✓ SMOKE PASS — crypto · SRP · fernet · cross-language relay all green ⛧" diff --git a/hh/src/net.rs b/hh/src/net.rs index f101076..12057d9 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -17,7 +17,6 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; type Ws = WebSocketStream>; pub struct Session { - pub user_id: String, pub username: String, pub room: Arc, pub ws_url: String, @@ -79,7 +78,6 @@ pub fn authenticate( format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}"); Ok(Session { - user_id, username: user.to_string(), room: Arc::new(fernet), ws_url, diff --git a/tests/conftest.py b/tests/conftest.py index dad0516..8d79aec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ def app(): app.ctx.admin_token = "test-admin-token" from cmd_chat.server.helpers import RateLimiter app.ctx.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + app.ctx.max_users = 4 app.ctx.cleanup_task = None register_routes(app) diff --git a/tests/test_coven.py b/tests/test_coven.py new file mode 100644 index 0000000..81775fb --- /dev/null +++ b/tests/test_coven.py @@ -0,0 +1,90 @@ +"""Use-case tests for the hack-house multi-user coven features (capacity cap, +roster, username + slot lifecycle). In-process via sanic-testing. +""" +import base64 +import json + +import srp + +from cmd_chat.server.models import UserSession +from cmd_chat.server.views import _roster_frame + + +def _add_member(app, name): + """Seat a verified member directly in the session store.""" + app.ctx.session_store.add( + UserSession(user_id=f"id-{name}", ip="127.0.0.1", username=name) + ) + + +def _init(test_client, name): + """Run /srp/init for a fresh username (just the first handshake leg).""" + usr = srp.User(b"chat", b"testpassword") + _, A = usr.start_authentication() + return test_client.post( + "/srp/init", + json={"username": name, "A": base64.b64encode(A).decode()}, + ) + + +class TestCovenCapacity: + def test_accepts_up_to_capacity(self, app, test_client): + app.ctx.max_users = 4 + for n in ("a", "b", "c"): + _add_member(app, n) + # 4th seat is still available + _, resp = _init(test_client, "fourth") + assert resp.status == 200 + + def test_rejects_when_full(self, app, test_client): + app.ctx.max_users = 4 + for n in ("a", "b", "c", "d"): + _add_member(app, n) + _, resp = _init(test_client, "fifth") + assert resp.status == 409 + assert "full" in resp.json["error"].lower() + + def test_capacity_is_configurable(self, app, test_client): + app.ctx.max_users = 2 + for n in ("a", "b"): + _add_member(app, n) + _, resp = _init(test_client, "third") + assert resp.status == 409 + + +class TestUsernames: + def test_duplicate_username_rejected(self, app, test_client): + _add_member(app, "ghost") + _, resp = _init(test_client, "ghost") + assert resp.status == 409 + assert "taken" in resp.json["error"].lower() + + +class TestRoster: + def test_roster_frame_contents(self, app): + _add_member(app, "alice") + _add_member(app, "bob") + frame = json.loads(_roster_frame(app)) + assert frame["type"] == "roster" + assert frame["capacity"] == app.ctx.max_users + assert {u["username"] for u in frame["users"]} == {"alice", "bob"} + assert all("user_id" in u for u in frame["users"]) + + +class TestSlotLifecycle: + def test_disconnect_frees_slot_and_username(self, app, test_client): + app.ctx.max_users = 1 + _add_member(app, "ghost") + assert app.ctx.session_store.username_exists("ghost") + # full room rejects a newcomer + _, resp = _init(test_client, "intruder") + assert resp.status == 409 + + # the disconnect path removes the session (frees slot + name) + app.ctx.session_store.remove("id-ghost") + assert not app.ctx.session_store.username_exists("ghost") + assert app.ctx.session_store.count() == 0 + + # name is reusable and the slot is open again + _, resp = _init(test_client, "ghost") + assert resp.status == 200