test: use-case coverage + end-to-end smoke test
- tests/test_coven.py: capacity cap (5th rejected, configurable), duplicate username, roster frame contents, slot/username freed on disconnect. - tests/conftest.py: set app.ctx.max_users (fixes fixture vs new server code). - hh/smoke.sh: one-command e2e — rust unit tests, SRP self-test, boot server, rust handshake round-trip, cross-language python decrypt of a rust message. - hh: drop unused Session.user_id (clean build). pytest: 85 passed. smoke: PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
651e7210b2
commit
d8acadd68b
77
hh/smoke.sh
Executable file
77
hh/smoke.sh
Executable file
|
|
@ -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 ⛧"
|
||||||
|
|
@ -17,7 +17,6 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||||
type Ws = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
type Ws = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub user_id: String,
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub room: Arc<fernet::Fernet>,
|
pub room: Arc<fernet::Fernet>,
|
||||||
pub ws_url: String,
|
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}");
|
format!("{ws_scheme}://{ip}:{port}/ws/chat?user_id={user_id}&ws_token={ws_token}");
|
||||||
|
|
||||||
Ok(Session {
|
Ok(Session {
|
||||||
user_id,
|
|
||||||
username: user.to_string(),
|
username: user.to_string(),
|
||||||
room: Arc::new(fernet),
|
room: Arc::new(fernet),
|
||||||
ws_url,
|
ws_url,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ def app():
|
||||||
app.ctx.admin_token = "test-admin-token"
|
app.ctx.admin_token = "test-admin-token"
|
||||||
from cmd_chat.server.helpers import RateLimiter
|
from cmd_chat.server.helpers import RateLimiter
|
||||||
app.ctx.rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
|
app.ctx.rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
|
||||||
|
app.ctx.max_users = 4
|
||||||
app.ctx.cleanup_task = None
|
app.ctx.cleanup_task = None
|
||||||
|
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
|
|
|
||||||
90
tests/test_coven.py
Normal file
90
tests/test_coven.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user