fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置

- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复)
- 修复IoT子会话工具调用链路日志缺失
- 新增最终审查子会话(review_provider) 支持消息格式解析拆分
- 实现历史消息持久化(后端存储+前端分页加载)
- 前端新增动作消息(ActionMessage)类型和渲染
- 优化对话链路速度(非阻塞子会话+快速问候通道)
- JWT密钥环境变量化(无默认值启动panic)
- Token自动刷新机制(401拦截器+refresh接口)
- WebSocket指数退避重连(jitter+最大10次)
- localStorage清理一致性(cyrene_前缀+版本检查)
- IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
2026-05-21 23:10:07 +08:00
parent 8b7d4ec19a
commit a058b0ab8e
53 changed files with 5535 additions and 241 deletions
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""Round 11 API Contract Test - with rate-limit awareness"""
import urllib.request
import urllib.error
import json
import time
import sys
import os
BASE = "http://localhost:8080/api/v1"
PASS = 0
FAIL = 0
TOKEN = ""
ADMIN_TOKEN = ""
SESS_ID = ""
def req(method, path, data=None, auth=None, ct="application/json"):
url = f"{BASE}{path}"
headers = {"Content-Type": ct}
if auth:
headers["Authorization"] = f"Bearer {auth}"
body = None
if data is not None:
body = json.dumps(data).encode()
try:
r = urllib.request.Request(url, data=body, headers=headers, method=method)
resp = urllib.request.urlopen(r, timeout=10)
return resp.status, resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
except Exception as e:
return 0, str(e)
def test(name, method, path, expected, data=None, auth=None, skip_msg=None):
global PASS, FAIL
if skip_msg:
print(f" SKIP | {name}: {skip_msg}")
return None, None
code, body = req(method, path, data, auth)
status = "PASS" if code == expected else "FAIL"
if status == "PASS":
PASS += 1
else:
FAIL += 1
body_preview = body[:100].replace('\n',' ') if body else ""
print(f" {status} | {name} | expected={expected} got={code} | {body_preview}")
return code, body
print("=" * 60)
print(" Round 11 API Contract Test Suite")
print(f" Started at {time.strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
# === Part 1: Health ===
print("\n--- Part 1: Health ---")
test("GET /health", "GET", "/health", 200)
test("HEAD /health", "HEAD", "/health", 200)
# === Part 2: Auth Register ===
print("\n--- Part 2: Auth Register ---")
UN = f"testapi_{int(time.time())}"
PW = "TestPass123!"
test("Register missing username", "POST", "/auth/register", 400,
{"password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
test("Register missing password", "POST", "/auth/register", 400,
{"username":"testuser","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
test("Register username too short", "POST", "/auth/register", 400,
{"username":"ab","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
test("Register username too long", "POST", "/auth/register", 400,
{"username":"a"*33,"password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
test("Register username special chars", "POST", "/auth/register", 400,
{"username":"user@name!","password":"Test123!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
test("Register password too short", "POST", "/auth/register", 400,
{"username":"test_abc","password":"Ab1!","email":"test@test.com","nickname":"Test","verify_code":"000000"})
time.sleep(1)
# Normal registration
code, body = test("Register normal", "POST", "/auth/register", 201,
{"username":UN,"password":PW,"email":"test@test.com","nickname":"TestUser","verify_code":"000000"})
time.sleep(1)
# Duplicate
test("Register duplicate", "POST", "/auth/register", 409,
{"username":UN,"password":PW,"email":"test@test.com","nickname":"TestUser","verify_code":"000000"})
# === Part 3: Auth Login ===
print("\n--- Part 3: Auth Login ---")
time.sleep(1)
test("Login wrong username", "POST", "/auth/login", 401,
{"username":"nonexistent_user_99","password":"TestPass123!"})
time.sleep(1)
test("Login wrong password", "POST", "/auth/login", 401,
{"username":UN,"password":"WrongPass999!"})
time.sleep(1)
# Correct login
code, body = req("POST", "/auth/login", {"username":UN,"password":PW})
if code == 200:
data = json.loads(body)
TOKEN = data.get("token","")
print(f" PASS | Login correct | 200 | user_id={data.get('user_id')} token={'OK' if TOKEN else 'MISSING'}")
PASS += 1
else:
print(f" FAIL | Login correct | expected=200 got={code} | {body[:100]}")
FAIL += 1
time.sleep(1)
# Admin login
code, body = req("POST", "/auth/login", {"username":"admin","password":"admin123"})
if code == 200:
data = json.loads(body)
ADMIN_TOKEN = data.get("token","")
print(f" PASS | Admin login | 200 | token={'OK' if ADMIN_TOKEN else 'MISSING'}")
PASS += 1
else:
print(f" FAIL | Admin login | expected=200 got={code} | {body[:100]}")
FAIL += 1
# JWT validation
if TOKEN:
time.sleep(1)
test("JWT valid (refresh)", "POST", "/auth/refresh", 200, auth=TOKEN)
time.sleep(1)
test("Login missing password", "POST", "/auth/login", 400, {"username":"testuser"})
time.sleep(1)
test("Login empty body", "POST", "/auth/login", 400, {})
time.sleep(1)
test("Login username format invalid", "POST", "/auth/login", 400, {"username":"ab","password":"Test123!"})
# === Part 4: Session API ===
print("\n--- Part 4: Session API ---")
test("Sessions list no auth", "GET", "/sessions", 401)
test("Sessions create no auth", "POST", "/sessions", 401, {"title":"Test"})
if TOKEN:
time.sleep(1)
test("Sessions list with auth", "GET", "/sessions", 200, auth=TOKEN)
time.sleep(1)
code, body = test("Sessions create", "POST", "/sessions", 201, {"title":"Round 11 Test"}, auth=TOKEN)
if code == 201:
try:
SESS_ID = json.loads(body).get("id","")
except: pass
if SESS_ID:
time.sleep(1)
test("Sessions get existing", "GET", f"/sessions/{SESS_ID}", 200, auth=TOKEN)
time.sleep(1)
test("Sessions get non-existent", "GET", "/sessions/session_nonexistent123", 404, auth=TOKEN)
time.sleep(1)
test("Sessions create empty title", "POST", "/sessions", 201, {}, auth=TOKEN)
if SESS_ID:
time.sleep(1)
test("Sessions delete existing", "DELETE", f"/sessions/{SESS_ID}", 200, auth=TOKEN)
time.sleep(1)
test("Sessions delete non-existent", "DELETE", "/sessions/session_nonexistent", 200, auth=TOKEN)
time.sleep(1)
test("Messages get non-existent session", "GET", "/sessions/session_nonexistent/messages", 200, auth=TOKEN)
else:
print(" SKIP: No token available for session tests")
# === Part 5: Files ===
print("\n--- Part 5: Files API ---")
test("Files list no auth", "GET", "/files", 401)
if TOKEN:
time.sleep(1)
test("Files list with auth", "GET", "/files", 200, auth=TOKEN)
time.sleep(1)
test("Files get non-existent", "GET", "/files/file_nonexistent", 404, auth=TOKEN)
# === Part 6: Knowledge ===
print("\n--- Part 6: Knowledge API ---")
test("Knowledge bases no auth", "GET", "/knowledge/bases", 401)
if TOKEN:
time.sleep(1)
test("Knowledge bases with auth", "GET", "/knowledge/bases", 200, auth=TOKEN)
time.sleep(1)
test("Knowledge get non-existent", "GET", "/knowledge/bases/kb_nonexistent", 404, auth=TOKEN)
# === Part 7: Automation ===
print("\n--- Part 7: Automation API ---")
test("Automation rules no auth", "GET", "/automation/rules", 401)
if TOKEN:
time.sleep(1)
test("Automation rules with auth", "GET", "/automation/rules", 200, auth=TOKEN)
time.sleep(1)
test("Automation scenes with auth", "GET", "/automation/scenes", 200, auth=TOKEN)
time.sleep(1)
test("Automation get non-existent rule", "GET", "/automation/rules/rule_nonexistent", 404, auth=TOKEN)
# === Part 8: Reminders ===
print("\n--- Part 8: Reminders API ---")
test("Reminders list no auth", "GET", "/reminders", 401)
if TOKEN:
time.sleep(1)
test("Reminders list with auth (needs user_id)", "GET", "/reminders?user_id=test_user", 200, auth=TOKEN)
time.sleep(1)
test("Reminders create missing fields", "POST", "/reminders", 400, {}, auth=TOKEN)
# === Part 9: Briefings ===
print("\n--- Part 9: Briefings API ---")
test("Briefings get no auth", "GET", "/briefings", 401)
if TOKEN:
time.sleep(1)
test("Briefings get with auth", "GET", "/briefings?user_id=test_user", 200, auth=TOKEN)
time.sleep(1)
test("Briefings latest with auth", "GET", "/briefings/latest?user_id=test_user", 200, auth=TOKEN)
# === Part 10: Notifications ===
print("\n--- Part 10: Notifications API ---")
test("Notifications push no auth", "POST", "/notifications/push", 401, {"message":"test"})
if TOKEN:
time.sleep(1)
test("Notifications push empty", "POST", "/notifications/push", 400, {}, auth=TOKEN)
# === Part 11: Memories ===
print("\n--- Part 11: Memories API ---")
test("Memories search no auth", "GET", "/memory/search", 401)
if TOKEN:
time.sleep(1)
test("Memories search with auth", "GET", "/memory/search?q=test", 200, auth=TOKEN)
time.sleep(1)
test("Memories list with auth", "GET", "/memory", 200, auth=TOKEN)
time.sleep(1)
test("Memories add missing fields", "POST", "/memory", 400, {}, auth=TOKEN)
# === Part 12: Voice ===
print("\n--- Part 12: Voice API ---")
if TOKEN:
time.sleep(1)
test("Voice status with auth", "GET", "/voice/status", 200, auth=TOKEN)
# === Part 13: Admin ===
print("\n--- Part 13: Admin endpoints ---")
if ADMIN_TOKEN:
time.sleep(1)
test("Admin sessions with admin", "GET", "/admin/sessions", 200, auth=ADMIN_TOKEN)
time.sleep(1)
test("Admin sessions/active", "GET", "/admin/sessions/active", 200, auth=ADMIN_TOKEN)
if TOKEN:
time.sleep(1)
test("Admin sessions without admin", "GET", "/admin/sessions", 403, auth=TOKEN)
# === Part 14: Invalid token ===
print("\n--- Part 14: Invalid token ---")
test("Sessions invalid token", "GET", "/sessions", 401, auth="invalidtoken12345")
test("Sessions expired/malformed token", "GET", "/sessions", 401, auth="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8k")
# === Part 15: Token refresh ===
print("\n--- Part 15: Token refresh ---")
test("Refresh no auth", "POST", "/auth/refresh", 401)
test("Refresh invalid token", "POST", "/auth/refresh", 401, auth="invalidtoken")
# === Summary ===
print("\n" + "=" * 60)
print(" TEST SUMMARY")
print("=" * 60)
TOTAL = PASS + FAIL
print(f"Total: {TOTAL} | Passed: {PASS} | Failed: {FAIL}")
print(f"Success rate: {PASS/TOTAL*100:.1f}%" if TOTAL > 0 else "No tests run")
print(f"\nTest user: {UN}")
print(f"Token: {'available' if TOKEN else 'MISSING'}")
print(f"Admin token: {'available' if ADMIN_TOKEN else 'MISSING'}")
# Save env
if TOKEN:
with open("/tmp/round11_testenv", "w") as f:
f.write(f"TOKEN={TOKEN}\n")
f.write(f"ADMIN_TOKEN={ADMIN_TOKEN}\n")
f.write(f"TEST_USER={UN}\n")