Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.
- rename seats -> max_members, seats_used -> members_used,
seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
stream control message -> `members`, and POST /channels/{id}/seats ->
POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
is optional for logged-in accounts, no seat row is written for them,
and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
online_devices per account; presence is tracked in memory from active
WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""对 NativeAOT 产物做跨平台冒烟(只用标准库,不含 WebSocket,那部分在 smoke.ps1 与集成测试里)。
|
|
|
|
用法: python3 scripts/smoke.py out/linux-x64/webdrop
|
|
"""
|
|
import base64, hashlib, json, os, shutil, struct, subprocess, sys, tempfile, time, urllib.error, urllib.request
|
|
|
|
exe = sys.argv[1]
|
|
port = int(sys.argv[2]) if len(sys.argv) > 2 else 5199
|
|
base = f"http://127.0.0.1:{port}"
|
|
data_dir = tempfile.mkdtemp(prefix="webdrop-smoke-")
|
|
|
|
|
|
def b64(b: bytes) -> str:
|
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
|
|
|
|
def sha(b: bytes) -> bytes:
|
|
return hashlib.sha256(b).digest()
|
|
|
|
|
|
def check(cond: bool, msg: str):
|
|
if not cond:
|
|
raise SystemExit(f"冒烟失败: {msg}")
|
|
print(f" ok {msg}")
|
|
|
|
|
|
def call(method, path, headers=None, body=None):
|
|
req = urllib.request.Request(base + path, data=body, method=method, headers=headers or {})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=15) as r:
|
|
return r.status, r.read()
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, e.read()
|
|
|
|
|
|
def hdr(role, token, secret):
|
|
h = {"X-WD-Role": role, "X-WD-Endpoint": b64(secret)}
|
|
if token:
|
|
h["Authorization"] = "Bearer " + b64(token)
|
|
return h
|
|
|
|
|
|
def frame(item: bytes, index: int, flags: int, payload: int, fill: int) -> bytes:
|
|
return bytes([1, flags]) + item + struct.pack(">II", index, payload) + bytes([fill]) * payload
|
|
|
|
|
|
env = dict(os.environ, WebDrop__DataDir=data_dir, WebDrop__Captcha__Provider="none")
|
|
proc = subprocess.Popen([exe, "--urls", base], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
for _ in range(50):
|
|
try:
|
|
call("GET", "/api/v1/info")
|
|
break
|
|
except Exception:
|
|
time.sleep(0.2)
|
|
|
|
status, body = call("GET", "/api/v1/info")
|
|
check(status == 200 and json.loads(body)["protocol"] == 1, "info 可访问")
|
|
|
|
cid = os.urandom(16)
|
|
read_tok, write_tok, owner_tok = os.urandom(32), os.urandom(32), os.urandom(32)
|
|
owner_secret, member_secret = os.urandom(32), os.urandom(32)
|
|
owner_h, member_h = hdr("owner", owner_tok, owner_secret), hdr("write", write_tok, member_secret)
|
|
|
|
create = json.dumps({
|
|
"id": b64(cid), "mode": "enc",
|
|
"read_hash": b64(sha(read_tok)), "write_hash": b64(sha(write_tok)), "owner_hash": b64(sha(owner_tok)),
|
|
}).encode()
|
|
status, body = call("POST", "/api/v1/channels", {**owner_h, "Content-Type": "application/json"}, create)
|
|
check(status == 201, "创建频道")
|
|
ch = b64(cid)
|
|
|
|
status, body = call("POST", f"/api/v1/channels/{ch}/join", member_h)
|
|
check(status == 200 and json.loads(body)["members_used"] == 2, "成员加入")
|
|
|
|
item = os.urandom(16)
|
|
f = frame(item, 0, 9, 1040, 0x42)
|
|
oct_h = {**owner_h, "Content-Type": "application/octet-stream"}
|
|
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f)
|
|
check(status == 201, "追加帧 201")
|
|
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f)
|
|
check(status == 200, "幂等重发 200")
|
|
status, body = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, frame(item, 0, 9, 1040, 0x43))
|
|
check(status == 409 and json.loads(body)["error"] == "conflict", "内容不同的重复帧 409")
|
|
|
|
status, body = call("GET", f"/api/v1/channels/{ch}/log", member_h)
|
|
check(status == 200 and body[9:] == f and body[8] == 1, "日志返回完整帧记录")
|
|
|
|
big = os.urandom(16)
|
|
for i in range(3):
|
|
flags = 13 if i == 2 else 12
|
|
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", {**oct_h, "X-WD-Max-Downloads": "1"},
|
|
frame(big, i, flags, 1040, i + 1))
|
|
check(status == 201, f"GATED 第 {i} 帧上传")
|
|
dl = f"/api/v1/channels/{ch}/items/{b64(big)}/frames"
|
|
status, body = call("GET", dl, member_h)
|
|
check(status == 200 and len(body) == 3 * (26 + 1040), "GATED 下载得到三帧")
|
|
status, body = call("GET", dl, member_h)
|
|
check(status == 410 and json.loads(body)["error"] == "gone", "下载上限生效 410")
|
|
|
|
status, body = call("GET", f"/api/v1/channels/{ch}", hdr("owner", os.urandom(32), owner_secret))
|
|
check(status == 401 and json.loads(body)["error"] == "unauthorized", "错误令牌 401")
|
|
|
|
status, _ = call("DELETE", f"/api/v1/channels/{ch}", owner_h)
|
|
check(status == 204, "删除频道")
|
|
|
|
# 数据目录里不应出现令牌或终端密钥的原值
|
|
leaked = False
|
|
for root, _, files in os.walk(data_dir):
|
|
for name in files:
|
|
content = open(os.path.join(root, name), "rb").read()
|
|
for secret in (owner_tok, write_tok, owner_secret, member_secret):
|
|
if b64(secret).encode() in content or secret in content:
|
|
leaked = True
|
|
check(not leaked, "数据目录里没有令牌或终端密钥的原值")
|
|
print("\n冒烟通过")
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
shutil.rmtree(data_dir, ignore_errors=True)
|