You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

271 lines
9.7 KiB

#!/usr/bin/env python3
"""
chat_invite_test.py — headless test of the inviter-side invite flow.
Сценарий: A (owner) говорит B (серверу) «добавь себе мою группу»
(CS_MSG_CHANNEL_INVITE), B авто-принимает (join_policy=0) и join+member_sync
завершается. Проверяем, что канал и мемберы появились на обеих сторонах.
Usage:
python3 tools/chat_invite_test.py
"""
import asyncio
import json
import os
import socket
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(__file__))
from chat_client import ChatClient, ChatClientError
UTUN_BIN = os.path.join(os.path.dirname(__file__), "..", "src", "utun")
READY_TIMEOUT = 3.0 # max wait for utun ready
SYNC_TIMEOUT = 10.0 # max wait for join + sync
REQUEST_TIMEOUT = 5.0 # per-request timeout
TOTAL_TIMEOUT = 30.0 # overall test timeout
def find_free_port():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def write_config(path, etcp_port, ctrl_port, db_subdir, join_policy=None):
chat_section = ""
if join_policy is not None:
chat_section = f"\n[chat]\njoin_policy={join_policy}\n"
content = f"""[global]
db_path={db_subdir}
[server: srv]
addr=127.0.0.1:{etcp_port}
type=public
[chatserver]
db_path={db_subdir}
headless_control_bind=127.0.0.1:{ctrl_port}
{chat_section}
[allowed_keys]
allow_all=1
"""
with open(path, "w") as f:
f.write(content)
def read_config_key(path, key):
try:
with open(path, "r") as f:
for line in f:
line = line.strip()
if line.startswith(key + "="):
return line[len(key) + 1:]
except OSError:
return None
return None
async def wait_config_key(path, key, timeout=READY_TIMEOUT):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
val = read_config_key(path, key)
if val:
return val
await asyncio.sleep(0.1)
return None
async def kill_proc(proc, label):
if proc is None or proc.returncode is not None:
return
try:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=3.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass
async def wait_ready(cli, timeout=READY_TIMEOUT):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
await cli.ping()
return True
except ChatClientError:
await asyncio.sleep(0.1)
return False
def check(name, expr, detail=""):
if not expr:
detail = f" ({detail})" if detail else ""
raise AssertionError(f"FAIL: {name}{detail}")
print(f" OK: {name}")
async def main():
etcp_a = find_free_port()
etcp_b = find_free_port()
ctrl_a = find_free_port()
ctrl_b = find_free_port()
print(f"ports: etcp={etcp_a},{etcp_b} ctrl={ctrl_a},{ctrl_b}")
tmpdir = tempfile.mkdtemp(prefix="utun_invite_test_")
db_a = os.path.join(tmpdir, "db_a")
db_b = os.path.join(tmpdir, "db_b")
os.makedirs(db_a, exist_ok=True)
os.makedirs(db_b, exist_ok=True)
config_a = os.path.join(tmpdir, "a.conf")
config_b = os.path.join(tmpdir, "b.conf")
write_config(config_a, etcp_a, ctrl_a, db_a) # A: owner
write_config(config_b, etcp_b, ctrl_b, db_b, join_policy=0) # B: server, autojoin
proc_a = None
proc_b = None
try:
print("\n--- Starting utun ---")
log_a = os.path.join(tmpdir, "utun_a.log")
log_b = os.path.join(tmpdir, "utun_b.log")
pid_a = os.path.join(tmpdir, "utun_a.pid")
pid_b = os.path.join(tmpdir, "utun_b.pid")
proc_a = await asyncio.create_subprocess_exec(
UTUN_BIN, "-f", "-p", pid_a, "-l", log_a, "-c", config_a,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
proc_b = await asyncio.create_subprocess_exec(
UTUN_BIN, "-f", "-p", pid_b, "-l", log_b, "-c", config_b,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
print(f" proc_a pid={proc_a.pid} proc_b pid={proc_b.pid}")
await asyncio.sleep(0.5)
print("\n--- Connecting to headless control ---")
async with ChatClient(port=ctrl_a, timeout=REQUEST_TIMEOUT) as cli_a:
if not await wait_ready(cli_a):
raise RuntimeError(f"node A not ready after {READY_TIMEOUT}s")
print(f" node A ready")
async with ChatClient(port=ctrl_b, timeout=REQUEST_TIMEOUT) as cli_b:
if not await wait_ready(cli_b):
raise RuntimeError(f"node B not ready after {READY_TIMEOUT}s")
print(f" node B ready")
# ── Create channel on A ──
print("\n--- Create channel ---")
await cli_a.create_channel("TestGroup")
channels = await cli_a.channels()
check("channel created", isinstance(channels, list) and len(channels) == 1,
f"channels={json.dumps(channels)}")
ch_id = str(channels[0]["id"])
print(f" channel_id={ch_id} name={channels[0]['name']}")
# ── Read B's node_id + pubkey from config (written back by utun) ──
print("\n--- Read B identity ---")
b_node_id = await wait_config_key(config_b, "my_node_id")
b_pubkey = await wait_config_key(config_b, "my_public_key")
if not b_node_id or not b_pubkey:
raise RuntimeError("failed to read B's node_id/pubkey from config")
b_node_id = "0x" + b_node_id
print(f" B node_id={b_node_id} pubkey={b_pubkey[:16]}...")
# ── A invites B to add the channel (inviter side) ──
print("\n--- Invite B (inviter → CHANNEL_INVITE) ---")
resp = await cli_a.invite_to(ch_id, b_node_id, b_pubkey, f"127.0.0.1:{etcp_b}", proto=1)
check("invite_to accepted", isinstance(resp, dict) and resp.get("inviting"),
f"resp={json.dumps(resp)}")
# ── Wait for B to join + member_sync ──
print(f"\n--- Wait join + sync (max {SYNC_TIMEOUT}s) ---")
deadline = time.monotonic() + SYNC_TIMEOUT
member_count_b = 0
members_b = []
while time.monotonic() < deadline:
try:
members_b = await cli_b.members(ch_id)
member_count_b = len(members_b) if isinstance(members_b, list) else 0
if member_count_b >= 2:
break
except ChatClientError:
pass
await asyncio.sleep(0.15)
print(f"\n--- Members on B ({member_count_b}) ---")
for m in (members_b if isinstance(members_b, list) else []):
print(f" {m['node_id']} name={m.get('name','?')} online={m.get('online')} connected={m.get('connected')}")
check("2 members on B (B joined A)", member_count_b >= 2,
f"got {member_count_b} after {SYNC_TIMEOUT}s")
await asyncio.sleep(0.5)
members_a = await cli_a.members(ch_id)
print(f"\n--- Members on A ({len(members_a) if isinstance(members_a, list) else '?'}) ---")
for m in members_a:
print(f" {m['node_id']} name={m.get('name','?')} online={m.get('online')} connected={m.get('connected')}")
check("2 members on A (B visible to A)", isinstance(members_a, list) and len(members_a) >= 2,
f"got {len(members_a) if isinstance(members_a, list) else '?'}")
channels_b = await cli_b.channels()
check("channel present on B", isinstance(channels_b, list) and len(channels_b) >= 1,
f"channels_b={json.dumps(channels_b)}")
print("\n=== TEST PASSED ===")
return 0
except Exception as e:
print(f"\n=== TEST FAILED: {e} ===", file=sys.stderr)
import traceback
traceback.print_exc()
print(f"\n--- logs ---", file=sys.stderr)
for lp in ["utun_a.log", "utun_b.log"]:
lp = os.path.join(tmpdir, lp)
if os.path.exists(lp):
print(f"\n===== {lp} (tail) =====", file=sys.stderr)
try:
with open(lp, "r") as f:
lines = f.readlines()
for ln in lines[-80:]:
sys.stderr.write(ln)
except OSError:
pass
return 1
finally:
print("\n--- Cleanup ---")
await kill_proc(proc_a, "proc_a")
await kill_proc(proc_b, "proc_b")
for f in [config_a, config_b]:
try: os.unlink(f)
except OSError: pass
try: os.rmdir(db_a)
except OSError: pass
try: os.rmdir(db_b)
except OSError: pass
try: os.rmdir(tmpdir)
except OSError: pass
print(f" temp dir cleaned: {tmpdir}")
if __name__ == "__main__":
async def _run():
try:
return await asyncio.wait_for(main(), timeout=TOTAL_TIMEOUT)
except asyncio.TimeoutError:
print(f"\n=== TEST FAILED: total timeout {TOTAL_TIMEOUT}s ===", file=sys.stderr)
return 1
sys.exit(asyncio.run(_run()))