#!/usr/bin/env python3 """HMS 患者建档链路端到端 API 测试""" import json import sys import time import urllib.request import urllib.error BASE_URL = "http://localhost:3000/api/v1" TOKEN = None RESULTS = [] def log(test_id, name, status, detail): """记录测试结果""" RESULTS.append({"id": test_id, "name": name, "status": status, "detail": detail}) icon = "PASS" if status == "PASS" else ("FAIL" if status == "FAIL" else "WARN") print(f" [{icon}] {test_id}: {name} -- {detail}") def api_call(method, path, data=None, token=None, expect_status=None): """发送 API 请求""" url = f"{BASE_URL}{path}" headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" body = json.dumps(data).encode("utf-8") if data else None req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: status = resp.status resp_data = json.loads(resp.read().decode("utf-8")) if expect_status and status != expect_status: return status, resp_data, f"Expected {expect_status}, got {status}" return status, resp_data, None except urllib.error.HTTPError as e: resp_body = e.read().decode("utf-8", errors="replace") try: resp_data = json.loads(resp_body) except: resp_data = {"raw": resp_body} if expect_status and e.code == expect_status: return e.code, resp_data, None return e.code, resp_data, f"HTTP {e.code}: {resp_body[:200]}" except Exception as e: return 0, None, str(e) # ============================================================ # Step 0: Login # ============================================================ print("\n" + "="*60) print("Step 0: 登录获取 Token") print("="*60) status, resp, err = api_call("POST", "/auth/login", {"username": "admin", "password": "Admin@2026"}, expect_status=200) if err: # 可能限流,等一下重试 print(f" 首次登录失败: {err}") print(" 等待 20 秒后重试...") time.sleep(20) status, resp, err = api_call("POST", "/auth/login", {"username": "admin", "password": "Admin@2026"}, expect_status=200) if err or not resp or not resp.get("success"): log("T0", "登录", "FAIL", f"登录失败: {err or resp}") sys.exit(1) TOKEN = resp["data"]["access_token"] user = resp["data"]["user"] log("T0", "登录", "PASS", f"用户: {user['display_name']}, 角色: {[r['name'] for r in user['roles']]}") # ============================================================ # Test 1.1: Patient List # ============================================================ print("\n" + "="*60) print("Test 1.1: 患者列表") print("="*60) status, resp, err = api_call("GET", "/health/patients?page=1&page_size=10", token=TOKEN) if err: log("T1.1", "患者列表", "FAIL", err) else: total = resp.get("data", {}).get("total", 0) items = resp.get("data", {}).get("items", []) log("T1.1", "患者列表", "PASS", f"success={resp['success']}, total={total}, items={len(items)}") if items: p = items[0] print(f" 首条: id={p.get('id')}, name={p.get('name')}, gender={p.get('gender')}") # ============================================================ # Test 1.2: Create Patient (Valid) # ============================================================ print("\n" + "="*60) print("Test 1.2: 创建患者 - 完整有效数据") print("="*60) import random _ts = str(int(time.time() * 1000))[-6:] patient_data = { "name": f"API测试患者_{_ts}", "gender": "male", "birth_date": "1990-05-15", "phone": f"138{random.randint(10000000, 99999999)}", "blood_type": "A", "emergency_contact_name": "紧急联系人", "emergency_contact_phone": f"139{random.randint(10000000, 99999999)}" } patient_id = None # 预定义,防止后续 NameError status, resp, err = api_call("POST", "/health/patients", patient_data, token=TOKEN) if err: log("T1.2", "创建患者(有效)", "FAIL", err) elif resp and resp.get("success"): patient = resp["data"] patient_id = patient.get("id") log("T1.2", "创建患者(有效)", "PASS", f"id={patient_id}, name={patient.get('name')}, gender={patient.get('gender')}, version={patient.get('version')}") print(f" birth_date={patient.get('birth_date')}, phone={patient.get('phone')}") print(f" blood_type={patient.get('blood_type')}, tenant_id={patient.get('tenant_id')}") else: log("T1.2", "创建患者(有效)", "FAIL", f"success={resp.get('success')}, error={resp.get('error')}") # ============================================================ # Test 1.3: Create Patient - Empty Name (should fail) # ============================================================ print("\n" + "="*60) print("Test 1.3: 创建患者 - 空名称(应失败)") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": "", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN) if status == 400 or (resp and not resp.get("success")): log("T1.3", "创建患者(空名称)", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") elif status == 201 or (resp and resp.get("success")): log("T1.3", "创建患者(空名称)", "FAIL", "空名称被接受,应该被拒绝") else: log("T1.3", "创建患者(空名称)", "FAIL", f"status={status}, resp={resp}") # ============================================================ # Test 1.4: Create Patient - Future Birth Date (should fail) # ============================================================ print("\n" + "="*60) print("Test 1.4: 创建患者 - 未来出生日期(应失败)") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": "未来患者", "gender": "male", "birth_date": "2099-01-01"}, token=TOKEN) if status == 400 or (resp and not resp.get("success")): log("T1.4", "创建患者(未来日期)", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") elif status == 201 or (resp and resp.get("success")): log("T1.4", "创建患者(未来日期)", "FAIL", "未来出生日期被接受,应该被拒绝") else: log("T1.4", "创建患者(未来日期)", "FAIL", f"status={status}, resp={resp}") # ============================================================ # Test 2.1: Patient Detail + PII Check # ============================================================ print("\n" + "="*60) print("Test 2.1: 患者详情 + PII 脱敏验证") print("="*60) if patient_id: status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN) if err: log("T2.1", "患者详情", "FAIL", err) else: p = resp.get("data", {}) log("T2.1", "患者详情", "PASS", f"success={resp['success']}, name={p.get('name')}") # PII 检查: phone 是否为明文或脱敏 phone = p.get("phone", "N/A") emergency_phone = p.get("emergency_contact_phone", "N/A") print(f" phone={phone}") print(f" emergency_contact_phone={emergency_phone}") print(f" id_card_number={p.get('id_card_number', 'N/A')}") # 检查标准字段 has_tenant_id = "tenant_id" in p has_created_at = "created_at" in p has_version = "version" in p print(f" tenant_id={'存在' if has_tenant_id else '缺失'}, " f"created_at={'存在' if has_created_at else '缺失'}, " f"version={'存在' if has_version else '缺失'}") if not (has_tenant_id and has_created_at and has_version): log("T2.1b", "标准字段检查", "WARN", "部分标准字段缺失") else: print(f" 标准字段检查: 通过") else: log("T2.1", "患者详情", "SKIP", "无 patient_id (创建患者失败)") # ============================================================ # Test 2.2: Patient Detail - Non-existent ID (should 404) # ============================================================ print("\n" + "="*60) print("Test 2.2: 患者详情 - 不存在的ID(应404)") print("="*60) status, resp, err = api_call("GET", "/health/patients/00000000-0000-0000-0000-000000000000", token=TOKEN) if status == 404: log("T2.2", "患者详情(不存在)", "PASS", f"正确返回 404") elif err: log("T2.2", "患者详情(不存在)", "WARN", f"status={status}, err={err}") else: log("T2.2", "患者详情(不存在)", "FAIL", f"status={status}, 应为 404") # ============================================================ # Test 3.1: Patient Tags - Create Tag # ============================================================ print("\n" + "="*60) print("Test 3.1: 患者标签 - 创建标签") print("="*60) tag_id = None status, resp, err = api_call("POST", "/health/patient-tags", {"name": f"API测试标签_{_ts}", "color": "#FF5500"}, token=TOKEN) if err: log("T3.1", "创建标签", "FAIL", err) elif resp and resp.get("success"): tag = resp["data"] tag_id = tag.get("id") log("T3.1", "创建标签", "PASS", f"id={tag_id}, name={tag.get('name')}, color={tag.get('color')}") else: log("T3.1", "创建标签", "FAIL", f"status={status}, error={resp}") # ============================================================ # Test 3.2: Patient Tags - List # ============================================================ print("\n" + "="*60) print("Test 3.2: 患者标签 - 列表") print("="*60) status, resp, err = api_call("GET", "/health/patient-tags", token=TOKEN) if err: log("T3.2", "标签列表", "FAIL", err) else: raw_data = resp.get("data") if isinstance(raw_data, list): total = len(raw_data) log("T3.2", "标签列表", "PASS", f"success={resp['success']}, count={total}") elif isinstance(raw_data, dict): items = raw_data.get("items", []) total = raw_data.get("total", len(items)) log("T3.2", "标签列表", "PASS", f"success={resp['success']}, total={total}") else: log("T3.2", "标签列表", "PASS", f"success={resp['success']}, data_type={type(raw_data)}") # ============================================================ # Test 3.3: Patient Tags - Assign to Patient # ============================================================ print("\n" + "="*60) print("Test 3.3: 患者标签 - 关联标签到患者") print("="*60) if patient_id and tag_id: # 尝试关联标签 - 可能的 API 路径 status, resp, err = api_call("POST", f"/health/patients/{patient_id}/tags", {"tag_ids": [tag_id]}, token=TOKEN) if err and status == 404: # 尝试替代路径 status2, resp2, err2 = api_call("POST", "/health/patient-tag-relations", {"patient_id": patient_id, "tag_id": tag_id}, token=TOKEN) if err2: log("T3.3", "关联标签", "WARN", f"标签关联路径未确认: {err[:100]}") else: log("T3.3", "关联标签", "PASS", f"通过 /patient-tag-relations: {resp2}") elif err: log("T3.3", "关联标签", "WARN", f"status={status}, err={err[:150]}") else: log("T3.3", "关联标签", "PASS", f"success={resp.get('success')}") else: log("T3.3", "关联标签", "SKIP", "缺少 patient_id 或 tag_id") # ============================================================ # Test 4.1: Patient Update - Normal # ============================================================ print("\n" + "="*60) print("Test 4.1: 患者更新 - 正常更新") print("="*60) updated_version = None if patient_id: # 先获取当前 version status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN) if not err and resp.get("success"): current_version = resp["data"].get("version") print(f" 当前 version: {current_version}") update_data = { "name": "API测试患者-已更新", "phone": "13800003333", "version": current_version } status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", update_data, token=TOKEN) if err: log("T4.1", "患者更新", "FAIL", err) elif resp and resp.get("success"): updated = resp["data"] updated_version = updated.get("version") log("T4.1", "患者更新", "PASS", f"name={updated.get('name')}, version={current_version}->{updated_version}") else: log("T4.1", "患者更新", "FAIL", f"success={resp.get('success')}, error={resp}") else: log("T4.1", "患者更新", "FAIL", f"获取患者失败: {err}") else: log("T4.1", "患者更新", "SKIP", "无 patient_id") # ============================================================ # Test 4.2: Patient Update - Optimistic Lock (should fail) # ============================================================ print("\n" + "="*60) print("Test 4.2: 患者更新 - 乐观锁冲突(应失败)") print("="*60) if patient_id: # 使用旧 version 触发乐观锁冲突 stale_update = { "name": "API测试患者-冲突更新", "version": 1 # 旧版本号 } status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", stale_update, token=TOKEN) if status == 409 or (resp and not resp.get("success")): log("T4.2", "乐观锁冲突", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") elif resp and resp.get("success"): log("T4.2", "乐观锁冲突", "FAIL", "旧版本号更新被接受,乐观锁未生效") else: log("T4.2", "乐观锁冲突", "WARN", f"status={status}, resp={str(resp)[:200]}") else: log("T4.2", "乐观锁冲突", "SKIP", "无 patient_id") # ============================================================ # Test 5.1: Security - No Auth (should 401) # ============================================================ print("\n" + "="*60) print("Test 5.1: 安全测试 - 无认证访问(应401)") print("="*60) status, resp, err = api_call("GET", "/health/patients") if status == 401: log("T5.1", "无认证访问", "PASS", "正确返回 401") elif err: log("T5.1", "无认证访问", "FAIL", f"status={status}, 应为 401") else: log("T5.1", "无认证访问", "FAIL", f"status={status}, 成功访问但应该被拒绝") # ============================================================ # Test 5.2: Security - SQL Injection Attempt # ============================================================ print("\n" + "="*60) print("Test 5.2: 安全测试 - SQL 注入尝试") print("="*60) status, resp, err = api_call("GET", "/health/patients?search=%27%3B%20DROP%20TABLE%20patients%3B%20--", token=TOKEN) if status == 500: log("T5.2", "SQL注入防护", "FAIL", "服务器返回 500,可能存在注入风险") elif resp and resp.get("success"): # 正常返回搜索结果 = 注入被参数化查询防住了 log("T5.2", "SQL注入防护", "PASS", f"注入被参数化查询拦截,正常返回数据") else: log("T5.2", "SQL注入防护", "PASS", f"status={status}, 注入未导致服务异常") # ============================================================ # Test 5.3: Security - Invalid Token (should 401) # ============================================================ print("\n" + "="*60) print("Test 5.3: 安全测试 - 无效 Token(应401)") print("="*60) status, resp, err = api_call("GET", "/health/patients", token="invalid_token_here") if status == 401: log("T5.3", "无效Token", "PASS", "正确返回 401") else: log("T5.3", "无效Token", "FAIL", f"status={status}, 应为 401") # ============================================================ # Test 6.1: Pagination # ============================================================ print("\n" + "="*60) print("Test 6.1: 分页查询") print("="*60) status, resp, err = api_call("GET", "/health/patients?page=1&page_size=2", token=TOKEN) if err: log("T6.1", "分页查询", "FAIL", err) else: total = resp.get("data", {}).get("total", 0) items = resp.get("data", {}).get("items", []) page = resp.get("data", {}).get("page", "N/A") page_size = resp.get("data", {}).get("page_size", resp.get("data", {}).get("per_page", "N/A")) log("T6.1", "分页查询", "PASS", f"total={total}, page={page}, page_size={page_size}, returned={len(items)}") # ============================================================ # Test 7.1: Create patient with minimal data # ============================================================ print("\n" + "="*60) print("Test 7.1: 创建患者 - 最小必填数据") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": f"最小数据患者_{_ts}", "gender": "female", "birth_date": "2000-01-01"}, token=TOKEN) if err: log("T7.1", "创建患者(最小数据)", "FAIL", err) elif resp and resp.get("success"): p = resp["data"] log("T7.1", "创建患者(最小数据)", "PASS", f"id={p.get('id')}, name={p.get('name')}, optional fields: phone={p.get('phone')}, blood_type={p.get('blood_type')}") else: log("T7.1", "创建患者(最小数据)", "FAIL", f"success={resp.get('success')}, error={resp}") # ============================================================ # Test 7.2: Create patient with whitespace-only name (should fail) # ============================================================ print("\n" + "="*60) print("Test 7.2: 创建患者 - 纯空格名称(应失败)") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": " ", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN) if status == 400 or (resp and not resp.get("success")): log("T7.2", "创建患者(空格名称)", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") else: log("T7.2", "创建患者(空格名称)", "FAIL", f"纯空格名称被接受: status={status}") # ============================================================ # Test 8.1: Invalid gender # ============================================================ print("\n" + "="*60) print("Test 8.1: 创建患者 - 无效性别值") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": "无效性别", "gender": "invalid_gender", "birth_date": "1990-01-01"}, token=TOKEN) if status == 400 or (resp and not resp.get("success")): log("T8.1", "创建患者(无效性别)", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") elif resp and resp.get("success"): log("T8.1", "创建患者(无效性别)", "WARN", f"无效性别被接受(可能是开放枚举): gender={resp['data'].get('gender')}") else: log("T8.1", "创建患者(无效性别)", "WARN", f"status={status}") # ============================================================ # Test 9.1: Invalid birth_date format # ============================================================ print("\n" + "="*60) print("Test 9.1: 创建患者 - 无效日期格式") print("="*60) status, resp, err = api_call("POST", "/health/patients", {"name": "无效日期", "gender": "male", "birth_date": "not-a-date"}, token=TOKEN) if status == 400 or (resp and not resp.get("success")): log("T9.1", "创建患者(无效日期格式)", "PASS", f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") else: log("T9.1", "创建患者(无效日期格式)", "FAIL", f"无效日期格式被接受: status={status}") # ============================================================ # Summary # ============================================================ print("\n" + "="*60) print("测试汇总") print("="*60) passed = sum(1 for r in RESULTS if r["status"] == "PASS") failed = sum(1 for r in RESULTS if r["status"] == "FAIL") warned = sum(1 for r in RESULTS if r["status"] == "WARN") skipped = sum(1 for r in RESULTS if r["status"] == "SKIP") total = len(RESULTS) print(f"\n 总计: {total} 项测试") print(f" PASS: {passed}") print(f" FAIL: {failed}") print(f" WARN: {warned}") print(f" SKIP: {skipped}") print(f" 通过率: {passed/total*100:.1f}%") if failed > 0: print("\n 失败项:") for r in RESULTS: if r["status"] == "FAIL": print(f" [{r['id']}] {r['name']}: {r['detail']}") if warned > 0: print("\n 警告项:") for r in RESULTS: if r["status"] == "WARN": print(f" [{r['id']}] {r['name']}: {r['detail']}") print("\n" + "="*60) if failed == 0: print("结论: 所有测试通过") elif failed <= 2: print(f"结论: 基本通过,有 {failed} 项失败需要关注") else: print(f"结论: 有 {failed} 项失败,需要修复") print("="*60)