diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..ef8f28f
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,111 @@
+#prjs
+*.bin
+*.build
+*.dist
+*.onefile-build
+nuitka-crash-report.xml
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
diff --git a/tests/blackbox/README.md b/tests/blackbox/README.md
new file mode 100644
index 0000000..8ff3e17
--- /dev/null
+++ b/tests/blackbox/README.md
@@ -0,0 +1,122 @@
+# 黑盒用户模拟测试
+
+micro_servers 微服务生态的黑盒测试套件。模拟真实用户操作流程,不依赖内部实现,仅通过 HTTP/TCP 公开接口验证各服务行为。
+
+## 设计原则
+
+- **零依赖**: 仅使用 Python 标准库(`http.client`、`json`、`urllib`、`socket`、`argparse`)
+- **用户视角**: 每个测试文件模拟一条完整的用户旅程,而非孤立 API 端点
+- **无测试框架**: 纯 `assert` + 计数器,输出 `[PASS]`/`[FAIL]`/`[SKIP]`
+- **独立可运行**: 每个 `test_xxx.py` 可单独执行,也可通过 `test_runner.py` 统一调度
+
+## 文件结构
+
+```
+tests/blackbox/
+├── README.md # 本文档
+├── config.py # 服务地址与端口映射
+├── helpers.py # HTTP/TCP 请求封装 + 断言工具 + 计数器
+├── test_runner.py # 统一入口,--url 自动识别目标服务
+├── test_gateway.py # 用户注册 → 登录 → Token → 代理转发
+├── test_captcha.py # 获取验证码 → 校验 → 一次性消费
+├── test_smtp.py # 发送邮箱验证码 → 校验
+├── test_dnfs.py # 创建笔记 → 查询 → 更新 → 删除(含内容审核)
+├── test_db.py # 日志写入 → 查询 → 统计 → 清理
+├── test_ai_review.py # 内容审核 → 人工反馈纠正
+├── test_static.py # 首页加载 → 文件浏览 → 视频流 → 路径遍历防护
+└── test_waf.py # 正常请求通行 → SQLi/XSS/命令注入/路径遍历/扫描器拦截
+```
+
+## 覆盖的用户旅程
+
+### 1. 注册与登录 (gateway + captcha + smtp)
+```
+用户访问 captcha:8009 获取图形验证码
+ → 提交 captcha_token + answer 校验
+ → 访问 smtp:8008 发送邮箱验证码
+ → 提交 email + code 完成邮箱验证
+ → 访问 gateway:8443 POST /register 注册账号
+ → 访问 gateway:8443 POST /login 登录获取 Token
+ → 使用 Token 通过 gateway 代理访问后端服务
+```
+
+### 2. 笔记管理 (dnfs + ai_review + db)
+```
+已登录用户携带 Bearer Token
+ → POST /add_note 创建笔记(内容经 ai_review:8007 审核)
+ → POST /get_notes 查询笔记列表
+ → POST /update_note 更新笔记(内容重新审核)
+ → POST /delete_note 删除笔记
+```
+
+### 3. 内容审核 (ai_review)
+```
+提交违规内容 → 应返回 invalid: true
+提交正常内容 → 应返回 invalid: false
+提交误判反馈 → RAG 知识库更新
+```
+
+### 4. 集中日志 (db)
+```
+各服务通过 /log/ingest 推送日志
+ → /log/query 按条件查询
+ → /log/stats 聚合统计
+ → /log/purge 按时间清理
+```
+
+### 5. 前端静态服务 (static)
+```
+访问首页 → 返回 login.html
+访问文件浏览 → /api/filelist 返回目录结构
+访问视频流 → 支持 Range 分段请求
+路径遍历攻击 → 返回 403
+```
+
+### 6. WAF 安全防护 (waf)
+```
+正常请求 → 通过代理到达 gateway
+SQL 注入载荷 → 返回 403 blocked by WAF
+XSS 跨站脚本 → 返回 403
+命令注入 → 返回 403
+路径遍历 → 返回 403
+扫描器 UA → 返回 403
+```
+
+## 运行方式
+
+```bash
+# 运行全部测试
+python3 test_runner.py
+
+# 指定服务地址(自动识别服务类型)
+python3 test_runner.py --url http://127.0.0.1:8443
+
+# 运行单个服务测试
+python3 test_gateway.py
+python3 test_captcha.py
+# ... 依此类推
+
+# 列出所有可用测试
+python3 test_runner.py --list
+```
+
+## 服务端口默认值
+
+| 服务 | 默认端口 | 绑定地址 |
+|------|---------|---------|
+| gateway | 8443 | 127.0.0.1 |
+| static | 9200 | 127.0.0.1 |
+| dnfs | 8006 | 127.0.0.1 |
+| db | 8005 | 127.0.0.1 |
+| ai_review | 8007 | 127.0.0.1 |
+| captcha | 8009 | 127.0.0.1 |
+| smtp | 8008 | 127.0.0.1 |
+| waf | 443 | 0.0.0.0 |
+
+## 注意事项
+
+- 测试依赖服务运行中,请先通过 `core/init.txt` 启动全部服务
+- 注册测试会创建真实数据库记录,测试完成后可手动清理
+- SMTP 测试需要真实的邮件服务配置,若未配置则相关测试标记为 SKIP
+- WAF 测试需要服务监听 443 端口(可能需要 sudo)
+- 所有测试设计为幂等:重复运行不会产生副作用累积
diff --git a/tests/blackbox/config.py b/tests/blackbox/config.py
new file mode 100644
index 0000000..1854538
--- /dev/null
+++ b/tests/blackbox/config.py
@@ -0,0 +1,91 @@
+"""
+micro_servers 黑盒测试 — 服务地址与端口配置。
+
+每个服务定义: name, port, description, host。
+test_runner.py 通过 --url 参数自动识别目标服务。
+"""
+
+SERVICES = {
+ "gateway": {
+ "name": "gateway",
+ "host": "127.0.0.1",
+ "port": 8443,
+ "description": "应用网关 (认证+反向代理)",
+ "test_file": "test_gateway.py",
+ },
+ "static": {
+ "name": "static",
+ "host": "127.0.0.1",
+ "port": 9200,
+ "description": "前端静态服务 (文件托管+视频流)",
+ "test_file": "test_static.py",
+ },
+ "dnfs": {
+ "name": "dnfs",
+ "host": "127.0.0.1",
+ "port": 8006,
+ "description": "树状笔记服务 (CRUD+内容审核)",
+ "test_file": "test_dnfs.py",
+ },
+ "db": {
+ "name": "db",
+ "host": "127.0.0.1",
+ "port": 8005,
+ "description": "通用数据库服务 (CRUD+集中日志)",
+ "test_file": "test_db.py",
+ },
+ "ai_review": {
+ "name": "ai_review",
+ "host": "127.0.0.1",
+ "port": 8007,
+ "description": "AI 内容审核服务 (DeepSeek+ChromaDB RAG)",
+ "test_file": "test_ai_review.py",
+ },
+ "captcha": {
+ "name": "captcha",
+ "host": "127.0.0.1",
+ "port": 8009,
+ "description": "验证码服务 (图片生成+校验)",
+ "test_file": "test_captcha.py",
+ },
+ "smtp": {
+ "name": "smtp",
+ "host": "127.0.0.1",
+ "port": 8008,
+ "description": "邮件验证服务 (发送+校验)",
+ "test_file": "test_smtp.py",
+ },
+ "waf": {
+ "name": "waf",
+ "host": "127.0.0.1",
+ "port": 443,
+ "description": "WAF 安全网关 (TLS+Coraza规则引擎)",
+ "test_file": "test_waf.py",
+ },
+}
+
+# 端口到服务的反向映射
+PORT_MAP = {v["port"]: v["name"] for v in SERVICES.values()}
+
+
+def detect_service(url: str):
+ """从 URL 中识别目标服务。返回服务名或 None。"""
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ port = parsed.port
+ if port is None:
+ # 默认端口猜测
+ if parsed.scheme == "https":
+ port = 443
+ else:
+ port = 80
+ return PORT_MAP.get(port)
+
+
+def get_url(name: str) -> str:
+ """获取服务的完整 HTTP URL。"""
+ svc = SERVICES.get(name)
+ if not svc:
+ raise ValueError(f"未知服务: {name}")
+ return f"http://{svc['host']}:{svc['port']}"
diff --git a/tests/blackbox/helpers.py b/tests/blackbox/helpers.py
new file mode 100644
index 0000000..6509873
--- /dev/null
+++ b/tests/blackbox/helpers.py
@@ -0,0 +1,313 @@
+"""
+micro_servers 黑盒测试 — 共享工具模块。
+
+提供: HTTP 请求封装、断言工具、测试计数器、pymsg 协议客户端。
+纯标准库实现,零外部依赖。
+"""
+
+import http.client
+import json
+import socket
+import struct
+import time
+import urllib.request
+import urllib.error
+import urllib.parse
+import ssl
+
+
+# ---------------------------------------------------------------------------
+# 测试计数器
+# ---------------------------------------------------------------------------
+
+class Counter:
+ """每个测试模块一个计数器实例,统计 passed/failed/skipped。"""
+ def __init__(self):
+ self.passed = 0
+ self.failed = 0
+ self.skipped = 0
+ self._failures = []
+
+ def add_pass(self):
+ self.passed += 1
+
+ def add_fail(self, name: str, reason: str = ""):
+ self.failed += 1
+ self._failures.append((name, reason))
+
+ def add_skip(self):
+ self.skipped += 1
+
+ @property
+ def total(self):
+ return self.passed + self.failed + self.skipped
+
+ def summary(self) -> str:
+ return f"{self.total} tests: {self.passed} passed, {self.failed} failed, {self.skipped} skipped"
+
+ def failures(self) -> list:
+ return self._failures
+
+
+# ---------------------------------------------------------------------------
+# 断言工具
+# ---------------------------------------------------------------------------
+
+def assert_eq(actual, expected, name: str, counter: Counter):
+ """断言 actual == expected。"""
+ if actual == expected:
+ print(f" [PASS] {name}")
+ counter.add_pass()
+ return True
+ else:
+ print(f" [FAIL] {name}: expected {expected!r}, got {actual!r}")
+ counter.add_fail(name, f"expected {expected!r}, got {actual!r}")
+ return False
+
+
+def assert_contains(haystack: str, needle: str, name: str, counter: Counter):
+ """断言 needle in haystack(不区分大小写)。"""
+ if needle.lower() in haystack.lower():
+ print(f" [PASS] {name}")
+ counter.add_pass()
+ return True
+ else:
+ snippet = haystack[:200] if len(haystack) > 200 else haystack
+ print(f" [FAIL] {name}: '{needle}' not found in response")
+ counter.add_fail(name, f"'{needle}' not found in response")
+ return False
+
+
+def assert_status(status: int, expected: int, name: str, counter: Counter):
+ """断言 HTTP 状态码。"""
+ if status == expected:
+ print(f" [PASS] {name}")
+ counter.add_pass()
+ return True
+ else:
+ print(f" [FAIL] {name}: expected status {expected}, got {status}")
+ counter.add_fail(name, f"expected status {expected}, got {status}")
+ return False
+
+
+def assert_true(condition: bool, name: str, counter: Counter, detail: str = ""):
+ """断言条件为真。"""
+ if condition:
+ print(f" [PASS] {name}")
+ counter.add_pass()
+ return True
+ else:
+ msg = f" [FAIL] {name}"
+ if detail:
+ msg += f": {detail}"
+ print(msg)
+ counter.add_fail(name, detail)
+ return False
+
+
+def assert_json_key(data: dict, key: str, name: str, counter: Counter):
+ """断言 JSON 对象包含指定 key。"""
+ if key in data:
+ print(f" [PASS] {name}")
+ counter.add_pass()
+ return True
+ else:
+ print(f" [FAIL] {name}: key '{key}' not found in {json.dumps(data)[:200]}")
+ counter.add_fail(name, f"key '{key}' not found")
+ return False
+
+
+def skip(name: str, reason: str, counter: Counter):
+ """标记测试为跳过。"""
+ print(f" [SKIP] {name}: {reason}")
+ counter.add_skip()
+
+
+# ---------------------------------------------------------------------------
+# HTTP 请求封装
+# ---------------------------------------------------------------------------
+
+def http_request(method: str, url: str, *,
+ body: dict = None,
+ headers: dict = None,
+ timeout: float = 10.0,
+ raw_body: bytes = None,
+ raw_body_content_type: str = None):
+ """发送 HTTP 请求,返回 (status, body_str, resp_headers)。
+
+ 支持:
+ - JSON body (body 参数)
+ - 原始二进制 body (raw_body + raw_body_content_type)
+ - 任意自定义 headers
+ - 超时控制
+ """
+ parsed = urllib.parse.urlparse(url)
+ data = None
+ hdrs = {}
+
+ if headers:
+ hdrs.update(headers)
+
+ if raw_body is not None:
+ data = raw_body
+ if raw_body_content_type:
+ hdrs["Content-Type"] = raw_body_content_type
+ elif body is not None:
+ data = json.dumps(body).encode("utf-8")
+ hdrs["Content-Type"] = "application/json"
+
+ ctx = ssl._create_unverified_context() if parsed.scheme == "https" else None
+
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
+ resp_body = resp.read().decode("utf-8", errors="replace")
+ resp_headers = dict(resp.headers)
+ return resp.status, resp_body, resp_headers
+ except urllib.error.HTTPError as e:
+ resp_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
+ resp_headers = dict(e.headers) if e.headers else {}
+ return e.code, resp_body, resp_headers
+ except Exception as e:
+ return 0, str(e), {}
+
+
+def http_get(url: str, headers: dict = None, timeout: float = 10.0):
+ """HTTP GET 请求。"""
+ return http_request("GET", url, headers=headers, timeout=timeout)
+
+
+def http_post(url: str, body: dict = None, headers: dict = None,
+ timeout: float = 10.0):
+ """HTTP POST 请求(JSON body)。"""
+ return http_request("POST", url, body=body, headers=headers, timeout=timeout)
+
+
+def http_post_raw(url: str, raw_body: bytes, content_type: str,
+ headers: dict = None, timeout: float = 10.0):
+ """HTTP POST 请求(原始二进制 body)。"""
+ return http_request("POST", url, raw_body=raw_body,
+ raw_body_content_type=content_type,
+ headers=headers, timeout=timeout)
+
+
+def try_parse_json(text: str):
+ """尝试解析 JSON,失败返回 None。"""
+ try:
+ return json.loads(text)
+ except (json.JSONDecodeError, ValueError):
+ return None
+
+
+# ---------------------------------------------------------------------------
+# pymsg 协议客户端
+# ---------------------------------------------------------------------------
+
+class PymsgClient:
+ """pymsg TCP 协议客户端(用于直接与 db:8005 通信)。
+
+ 协议格式: [4字节大端长度][UTF-8 JSON payload]
+ """
+
+ def __init__(self, host: str = "127.0.0.1", port: int = 8005,
+ name: str = "test_client"):
+ self.host = host
+ self.port = port
+ self.name = name
+ self._sock = None
+
+ def _ensure_connected(self):
+ if self._sock is None:
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._sock.settimeout(10)
+ self._sock.connect((self.host, self.port))
+
+ def _close(self):
+ if self._sock:
+ try:
+ self._sock.close()
+ except Exception:
+ pass
+ self._sock = None
+
+ def _send_frame(self, data: str):
+ """发送一帧 pymsg 数据。"""
+ payload = data.encode("utf-8")
+ header = struct.pack(">I", len(payload))
+ self._sock.sendall(header + payload)
+
+ def _recv_frame(self, timeout: float = 10.0) -> str:
+ """接收一帧 pymsg 数据。"""
+ self._sock.settimeout(timeout)
+ header = self._recv_exact(4, timeout)
+ if header is None:
+ raise ConnectionError("Failed to receive pymsg header")
+ length = struct.unpack(">I", header)[0]
+ payload = self._recv_exact(length, timeout)
+ if payload is None:
+ raise ConnectionError("Failed to receive pymsg payload")
+ return payload.decode("utf-8")
+
+ def _recv_exact(self, n: int, timeout: float = 10.0):
+ """精确接收 n 字节。"""
+ self._sock.settimeout(timeout)
+ data = b""
+ while len(data) < n:
+ try:
+ chunk = self._sock.recv(n - len(data))
+ except socket.timeout:
+ return None
+ if not chunk:
+ return None
+ data += chunk
+ return data
+
+ def call(self, payload: dict, timeout: float = 10.0) -> dict:
+ """发送请求并接收响应(短连接模式)。
+
+ payload 必须包含 "_route": "execute"。
+ 返回 {"success": true/false, "data": [...], "error": "..."}
+ """
+ try:
+ self._ensure_connected()
+ msg = json.dumps(payload)
+ self._send_frame(msg)
+ resp_text = self._recv_frame(timeout)
+ return json.loads(resp_text)
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+ finally:
+ self._close()
+
+
+# ---------------------------------------------------------------------------
+# 服务可用性检查
+# ---------------------------------------------------------------------------
+
+def check_service(url: str, timeout: float = 8.0) -> tuple:
+ """检查服务是否可达(HTTP 请求检测,与测试实际行为一致)。
+ 返回 (reachable: bool, detail: str)。
+ 任何 HTTP 响应(包括 200/302/401/403 等)均视为可达。
+ """
+ parsed = urllib.parse.urlparse(url)
+ ctx = ssl._create_unverified_context() if parsed.scheme == "https" else None
+ try:
+ req = urllib.request.Request(url, method="HEAD")
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
+ return True, f"HTTP {resp.status}"
+ except urllib.error.HTTPError as e:
+ # 任何 HTTP 错误码都说明服务在运行
+ return True, f"HTTP {e.code}"
+ except urllib.error.URLError as e:
+ reason = str(e.reason) if hasattr(e, 'reason') else str(e)
+ return False, reason
+ except socket.timeout:
+ return False, f"HTTP 请求超时 ({timeout}s)"
+ except ConnectionRefusedError:
+ return False, "连接被拒绝 (服务未启动或端口未监听)"
+ except socket.gaierror:
+ return False, "DNS 解析失败"
+ except OSError as e:
+ return False, f"网络错误: {e.strerror if hasattr(e, 'strerror') else str(e)}"
+ except Exception as e:
+ return False, str(e)
diff --git a/tests/blackbox/test_ai_review.py b/tests/blackbox/test_ai_review.py
new file mode 100644
index 0000000..e7b893e
--- /dev/null
+++ b/tests/blackbox/test_ai_review.py
@@ -0,0 +1,154 @@
+"""
+ai_review 黑盒用户模拟测试 — 内容审核 + 人工反馈。
+
+用户旅程:
+ 1. 提交明显违规内容 → 应返回 invalid: true
+ 2. 提交正常无害内容 → 应返回 invalid: false
+ 3. 提交空内容 → 应返回 400 错误
+ 4. 缺少 content 字段 → 应返回 400 错误
+ 5. 人工反馈纠正误判 → RAG 知识库更新
+ 6. 查看 RAG 列表
+ 7. 健康检查
+
+注意: ai_review 依赖 DeepSeek API + ChromaDB,API 调用可能因网络/配额失败。
+测试使用了通用违规关键词,实际审核结果取决于 DeepSeek 模型判断。
+"""
+
+import json
+import random
+import string
+
+from helpers import (
+ http_get, http_post, try_parse_json,
+ assert_eq, assert_status, assert_true, assert_json_key,
+ skip, Counter, check_service,
+)
+from config import get_url
+
+AI_REVIEW_URL = get_url("ai_review")
+
+
+def run(url: str = None):
+ """ai_review 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ au = url or AI_REVIEW_URL
+ c = Counter()
+
+ print(f"\n--- ai_review 内容审核流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(au)[0]:
+ skip("全部测试", "ai_review 服务不可达", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 场景 1: 提交明显违规内容 — 应标记为违规
+ # ------------------------------------------------------------------
+ violation_texts = [
+ "出售毒品冰毒海洛因,价格优惠,联系QQ123456",
+ "提供赌博网站搭建服务,包月维护,日入过万",
+ ]
+ violation_text = random.choice(violation_texts)
+
+ status, body, _ = http_post(f"{au}/review", {"content": violation_text})
+ assert_eq(status, 200, "审核-违规内容-响应 (200)", c)
+ data = try_parse_json(body)
+ if data:
+ # 注意: invalid 可能是字符串 "true"/"false" 而非布尔值
+ invalid = data.get("invalid")
+ is_invalid = invalid in (True, "true", "True")
+ assert_true(
+ is_invalid,
+ "审核-违规内容-标记为invalid", c,
+ f"invalid={invalid}, body={body[:200]}",
+ )
+ else:
+ assert_true(False, "审核-违规内容-解析失败", c, f"body={body[:200]}")
+
+ # ------------------------------------------------------------------
+ # 场景 2: 提交正常无害内容 — 应通过
+ # ------------------------------------------------------------------
+ safe_texts = [
+ "今天天气真好,适合出去散步。",
+ "Python 是一门优秀的编程语言,广泛应用于数据科学和人工智能领域。",
+ "微服务架构是一种将应用拆分为多个小型独立服务的软件设计方法。",
+ ]
+ safe_text = random.choice(safe_texts)
+
+ status, body, _ = http_post(f"{au}/review", {"content": safe_text})
+ assert_eq(status, 200, "审核-正常内容-响应 (200)", c)
+ data = try_parse_json(body)
+ if data:
+ invalid = data.get("invalid")
+ is_invalid = invalid in (True, "true", "True")
+ # 正常内容应该不违规
+ assert_true(
+ not is_invalid,
+ "审核-正常内容-标记为safe", c,
+ f"invalid={invalid}, body={body[:200]}",
+ )
+ else:
+ assert_true(False, "审核-正常内容-解析失败", c, f"body={body[:200]}")
+
+ # ------------------------------------------------------------------
+ # 场景 3: 提交空内容 — 应返回 400
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{au}/review", {"content": ""})
+ assert_eq(status, 400, "审核-空内容-拒绝 (400)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 4: 缺少 content 字段 — 应返回 400
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{au}/review", {})
+ assert_eq(status, 400, "审核-缺少content-拒绝 (400)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 5: RAG 人工反馈 — 纠正误判(标注一条违规内容)
+ # ------------------------------------------------------------------
+ feedback_text = f"test_feedback_{random.randint(10000, 99999)}: 非法内容"
+ status, body, _ = http_post(f"{au}/rag/feedback", {
+ "content": feedback_text,
+ "correct_type": "violation",
+ })
+ assert_eq(status, 200, "RAG反馈-添加violation (200)", c)
+ data = try_parse_json(body)
+ if data:
+ assert_true(
+ data.get("added") in (True, False),
+ "RAG反馈-返回added字段", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 6: RAG 反馈 — 参数错误
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{au}/rag/feedback", {
+ "content": "test",
+ "correct_type": "invalid_type",
+ })
+ assert_eq(status, 400, "RAG反馈-错误类型-拒绝 (400)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 7: RAG 列表查询
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(f"{au}/rag/list")
+ assert_eq(status, 200, "RAG列表-查询 (200)", c)
+ data = try_parse_json(body)
+ if data:
+ assert_json_key(data, "total", "RAG列表-有total字段", c)
+
+ # ------------------------------------------------------------------
+ # 场景 8: 健康检查
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(f"{au}/health")
+ assert_eq(status, 200, "健康检查 (200)", c)
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n ai_review 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_captcha.py b/tests/blackbox/test_captcha.py
new file mode 100644
index 0000000..93a5a68
--- /dev/null
+++ b/tests/blackbox/test_captcha.py
@@ -0,0 +1,147 @@
+"""
+captcha 黑盒用户模拟测试 — 获取验证码 → 校验 → 一次性消费。
+
+用户旅程:
+ 1. 请求 /captcha/image 获取验证码图片 + token
+ 2. 使用正确答案校验 → 应通过
+ 3. 使用错误答案校验 → 应拒绝
+ 4. 同一 token 重复校验 → 应拒绝 (一次性)
+ 5. 缺少 token/answer → 应报错
+ 6. 不存在的 token → 应拒绝
+ 7. 健康检查
+"""
+
+import json
+import string
+
+from helpers import (
+ http_get, http_post, http_request,
+ assert_eq, assert_status, assert_true, assert_json_key, assert_contains,
+ skip, Counter, check_service,
+)
+from config import get_url
+
+CAPTCHA_URL = get_url("captcha")
+
+
+def run(url: str = None):
+ """captcha 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ cu = url or CAPTCHA_URL
+ c = Counter()
+
+ print(f"\n--- captcha 验证码获取与校验流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(cu)[0]:
+ print(" captcha 服务不可达,跳过所有测试")
+ c.add_skip()
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 场景 1: 获取验证码图片
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{cu}/captcha/image")
+ assert_eq(status, 200, "获取验证码图片 (200)", c)
+ captcha_token = headers.get("X-Captcha-Token", headers.get("x-captcha-token", ""))
+ assert_true(
+ len(captcha_token) == 32,
+ "获取验证码-Token 32位hex", c,
+ f"token={captcha_token[:10]}...",
+ )
+ # 验证返回的是图片
+ content_type = headers.get("Content-Type", headers.get("content-type", ""))
+ assert_contains(content_type, "image", "获取验证码-图片类型", c)
+
+ # ------------------------------------------------------------------
+ # 场景 2: 缺少参数 — token 和 answer 都缺
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{cu}/captcha/verify", {})
+ data = json.loads(body) if body else {}
+ assert_true(
+ not data.get("valid", True),
+ "校验-缺少参数-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 3: 不存在的 token
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{cu}/captcha/verify", {
+ "token": "0" * 32,
+ "answer": "abcde",
+ })
+ data = json.loads(body) if body else {}
+ assert_true(
+ not data.get("valid", True),
+ "校验-不存在token-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 4: 错误答案
+ # ------------------------------------------------------------------
+ if captcha_token:
+ status, body, _ = http_post(f"{cu}/captcha/verify", {
+ "token": captcha_token,
+ "answer": "ZZZZZ",
+ })
+ data = json.loads(body) if body else {}
+ assert_true(
+ not data.get("valid", True),
+ "校验-错误答案-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 5: 验证码一次性消费 — 拿新 token 分别测正确答案和二次使用
+ # ------------------------------------------------------------------
+ # 重新获取一个 captcha(上一步的 token 已被消费)
+ status2, body2, headers2 = http_get(f"{cu}/captcha/image")
+ assert_eq(status2, 200, "获取第二个验证码 (200)", c)
+
+ # 没法从图片自动识别文字,但我们可以测试一次性消费的行为
+ # 先用错误答案消费 token
+ token2 = headers2.get("X-Captcha-Token", headers.get("x-captcha-token", ""))
+ if token2:
+ # 第一次:错误答案
+ status_a, body_a, _ = http_post(f"{cu}/captcha/verify", {
+ "token": token2,
+ "answer": "WRONG",
+ })
+ data_a = json.loads(body_a) if body_a else {}
+ assert_true(
+ not data_a.get("valid", True),
+ "一次性消费-第一次用完", c,
+ )
+
+ # 第二次:同一 token 再次使用
+ status_b, body_b, _ = http_post(f"{cu}/captcha/verify", {
+ "token": token2,
+ "answer": "WRONG",
+ })
+ data_b = json.loads(body_b) if body_b else {}
+ assert_true(
+ not data_b.get("valid", True),
+ "一次性消费-重复使用拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 6: 健康检查
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(f"{cu}/health")
+ assert_eq(status, 200, "健康检查 (200)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 7: 不存在路径 → 404
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(f"{cu}/nonexistent")
+ assert_eq(status, 404, "不存在路径-404", c)
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n captcha 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_db.py b/tests/blackbox/test_db.py
new file mode 100644
index 0000000..ca922b2
--- /dev/null
+++ b/tests/blackbox/test_db.py
@@ -0,0 +1,190 @@
+"""
+db 黑盒用户模拟测试 — 集中日志: 写入 → 查询 → 统计 → 清理。
+
+用户旅程:
+ 1. 通过 pymsg 协议执行 CRUD (select/insert)
+ 2. 通过 HTTP /log/ingest 写入日志
+ 3. 通过 HTTP /log/query 查询日志
+ 4. 通过 HTTP /log/stats 查看统计
+ 5. 通过 HTTP /log/health 健康检查
+
+注意: dbv2.py (pymsg 模式) 不挂载 Flask log_module Blueprint,
+因此 HTTP /log/* 路由可能不可用。测试会优雅降级。
+"""
+
+import json
+import random
+import string
+import time
+
+from helpers import (
+ http_get, http_post, try_parse_json,
+ assert_eq, assert_status, assert_true, assert_json_key,
+ skip, Counter, check_service, PymsgClient,
+)
+from config import get_url
+
+DB_URL = get_url("db")
+DB_HOST = "127.0.0.1"
+DB_PORT = 8005
+
+
+def run(url: str = None):
+ """db 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ du = url or DB_URL
+ c = Counter()
+
+ print(f"\n--- db 数据库与日志流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(du)[0]:
+ skip("全部测试", "db 服务不可达", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 第一部分: pymsg 协议 CRUD
+ # ------------------------------------------------------------------
+ print(" [pymsg 协议测试]")
+
+ client = PymsgClient(DB_HOST, DB_PORT, "test_blackbox")
+
+ # 场景 1: pymsg 查询 Users 表
+ result = client.call({
+ "_route": "execute",
+ "action": "select",
+ "table": "Users",
+ "limit": 5,
+ })
+ assert_true(
+ result.get("success") is True,
+ "pymsg-查询Users表", c,
+ f"error={result.get('error', '')}",
+ )
+ if result.get("success"):
+ data = result.get("data", [])
+ assert_true(
+ isinstance(data, list),
+ "pymsg-返回列表格式", c,
+ )
+
+ # 场景 2: pymsg 查询 Logs 表
+ result = client.call({
+ "_route": "execute",
+ "action": "select",
+ "table": "Logs",
+ "limit": 3,
+ "order_by": [{"column": "id", "desc": True}],
+ })
+ assert_true(
+ result.get("success") is True,
+ "pymsg-查询Logs表", c,
+ f"error={result.get('error', '')}",
+ )
+
+ # 场景 3: pymsg 使用 where 条件
+ result = client.call({
+ "_route": "execute",
+ "action": "select",
+ "table": "Users",
+ "columns": ["id", "username"],
+ "where": {"id": {"gt": 0}},
+ "limit": 1,
+ })
+ assert_true(
+ result.get("success") is True,
+ "pymsg-条件查询", c,
+ f"error={result.get('error', '')}",
+ )
+
+ # 场景 4: pymsg 无效路由
+ result = client.call({
+ "_route": "nonexistent_route",
+ "action": "select",
+ "table": "Users",
+ })
+ assert_true(
+ result.get("success") is False,
+ "pymsg-无效路由-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 第二部分: HTTP /log/* 接口 (Flask log_module)
+ # ------------------------------------------------------------------
+ print("\n [HTTP /log/* 接口测试]")
+
+ # 检查 /log/health 是否可用(Flask log_module 是否挂载)
+ status, body, _ = http_get(f"{du}/log/health")
+ if status == 0 or status >= 500:
+ skip("HTTP /log/* 测试", "log_module 未挂载 (dbv2 使用 pymsg 模式, 非 Flask)", c)
+ else:
+ # 场景 5: /log/health 健康检查
+ assert_eq(status, 200, "log-健康检查 (200)", c)
+
+ # 场景 6: /log/ingest 写入日志
+ test_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ status, body, _ = http_post(f"{du}/log/ingest", {
+ "logs": [{
+ "service": "dnfs",
+ "level": "INFO",
+ "message": f"blackbox_test_{random.randint(10000, 99999)}",
+ "ts": test_ts,
+ }],
+ })
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None and data.get("success") is True,
+ "log-写入日志", c,
+ f"status={status} body={body[:150]}",
+ )
+
+ # 场景 7: /log/ingest — 空日志列表
+ status, body, _ = http_post(f"{du}/log/ingest", {"logs": []})
+ assert_eq(status, 200, "log-空日志列表 (200)", c)
+
+ # 场景 8: /log/ingest — 缺少 logs 字段
+ status, body, _ = http_post(f"{du}/log/ingest", {})
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and not data.get("success", True),
+ "log-缺少logs字段-拒绝", c,
+ )
+
+ # 场景 9: /log/query 查询日志
+ status, body, _ = http_post(f"{du}/log/query", {
+ "limit": 5,
+ "desc": True,
+ })
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None and data.get("success") is True,
+ "log-查询日志", c,
+ f"status={status}",
+ )
+ if data and data.get("success"):
+ assert_true(
+ "logs" in data.get("data", {}),
+ "log-查询返回logs字段", c,
+ )
+
+ # 场景 10: /log/stats 统计
+ status, body, _ = http_post(f"{du}/log/stats", {
+ "group_by": "service",
+ })
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None,
+ "log-统计", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n db 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_dnfs.py b/tests/blackbox/test_dnfs.py
new file mode 100644
index 0000000..8f26a93
--- /dev/null
+++ b/tests/blackbox/test_dnfs.py
@@ -0,0 +1,229 @@
+"""
+dnfs 黑盒用户模拟测试 — 创建笔记 → 查询 → 更新 → 删除(含内容审核)。
+
+用户旅程:
+ 已登录用户携带 Bearer Token:
+ 1. 创建根节点笔记(内容经 ai_review 审核)
+ 2. 创建子节点笔记
+ 3. 查询节点列表
+ 4. 更新笔记标题和内容
+ 5. 删除叶子节点(无子节点)
+ 6. 删除非空节点(应拒绝)
+ 7. 无 Token 操作(应拒绝)
+ 8. 创建时缺少必填参数(应报错)
+
+前提: gateway 已启动,有已注册用户和有效 Token。
+"""
+
+import json
+import random
+import string
+
+from helpers import (
+ http_post, try_parse_json,
+ assert_eq, assert_status, assert_true, assert_json_key,
+ skip, Counter, check_service,
+)
+from config import get_url, SERVICES
+
+DNFS_URL = get_url("dnfs")
+GATEWAY_URL = get_url("gateway")
+
+
+def _random_test_user():
+ suffix = ''.join(random.choices(string.ascii_lowercase, k=6))
+ return f"dtest_{suffix}@test.local"
+
+
+def _register_and_login(gw_url: str, counter: Counter):
+ """通过 gateway 注册并登录,返回 (token, email)。失败返回 (None, None)。"""
+ email = _random_test_user()
+ password = "TestP@ss123"
+
+ # 注册
+ status, body, _ = http_post(f"{gw_url}/register", {
+ "username": email,
+ "password": password,
+ })
+ if status != 200:
+ return None, None
+
+ data = try_parse_json(body)
+ if not data or not data.get("success"):
+ return None, None
+
+ # 登录
+ status, body, _ = http_post(f"{gw_url}/login", {
+ "username": email,
+ "password": password,
+ })
+ if status != 200:
+ return None, None
+
+ data = try_parse_json(body)
+ if not data or not data.get("success"):
+ return None, None
+
+ token = (data.get("data") or {}).get("token", "")
+ return token, email
+
+
+def run(url: str = None):
+ """dnfs 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ du = url or DNFS_URL
+ c = Counter()
+
+ print(f"\n--- dnfs 笔记管理流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(du)[0]:
+ skip("全部测试", "dnfs 服务不可达", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 准备: 注册用户并获取 Token
+ # ------------------------------------------------------------------
+ gw_available = check_service(GATEWAY_URL)[0]
+ if not gw_available:
+ skip("全部测试", "gateway 服务不可达,无法获取 Token", c)
+ return c.passed, c.failed, c.skipped
+
+ token, email = _register_and_login(GATEWAY_URL, c)
+ if not token:
+ skip("全部测试", "无法完成注册/登录获取 Token", c)
+ return c.passed, c.failed, c.skipped
+
+ auth_headers = {"Authorization": f"Bearer {token}"}
+
+ # ------------------------------------------------------------------
+ # 场景 1: 无 Token 创建笔记 — 应拒绝
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{du}/add_note", {
+ "pid": 0,
+ "title": "test",
+ "content": "test content",
+ })
+ assert_eq(status, 401, "创建笔记-无Token-拒绝 (401)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 2: 创建笔记 — 缺少参数
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{du}/add_note", {
+ "pid": 0,
+ }, headers=auth_headers)
+ assert_eq(status, 400, "创建笔记-缺少title/content (400)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 3: 创建根节点笔记
+ # ------------------------------------------------------------------
+ note_title = f"测试笔记_{random.randint(1000, 9999)}"
+ note_content = "这是一段正常的测试内容,用于验证笔记创建功能。"
+ status, body, _ = http_post(f"{du}/add_note", {
+ "pid": 0,
+ "title": note_title,
+ "content": note_content,
+ }, headers=auth_headers)
+ # 期望 200(审核通过则成功)
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None and data.get("success") is True,
+ "创建笔记-根节点", c,
+ f"status={status} body={body[:150]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 4: 查询根节点下的笔记列表
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{du}/get_notes", {"pid": 0})
+ assert_eq(status, 200, "查询笔记-根节点 (200)", c)
+ notes = try_parse_json(body)
+ assert_true(
+ isinstance(notes, list),
+ "查询笔记-返回列表", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 5: 创建子节点笔记
+ # ------------------------------------------------------------------
+ # 先获取刚创建的笔记 ID
+ child_note_id = None
+ if notes and len(notes) > 0:
+ # 找到我们刚创建的笔记
+ for note in notes:
+ if note.get("title") == note_title:
+ child_note_id = note.get("id")
+ break
+
+ if child_note_id:
+ child_title = f"子笔记_{random.randint(1000, 9999)}"
+ child_content = "这是子节点的测试内容。"
+ status, body, _ = http_post(f"{du}/add_note", {
+ "pid": child_note_id,
+ "title": child_title,
+ "content": child_content,
+ }, headers=auth_headers)
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None,
+ "创建笔记-子节点", c,
+ f"status={status} body={body[:150]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 6: 删除非空节点 — 应拒绝(有子节点)
+ # ------------------------------------------------------------------
+ if child_note_id:
+ status, body, _ = http_post(f"{du}/delete_note", {
+ "id": child_note_id,
+ }, headers=auth_headers)
+ data = try_parse_json(body)
+ assert_true(
+ status in (400, 403) or (data and not data.get("success")),
+ "删除笔记-非空节点-拒绝", c,
+ f"status={status} body={body[:150]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 7: 更新笔记内容
+ # ------------------------------------------------------------------
+ if child_note_id:
+ updated_title = f"更新_{random.randint(1000, 9999)}"
+ updated_content = "更新后的测试内容,依然正常无害。"
+ status, body, _ = http_post(f"{du}/update_note", {
+ "id": child_note_id,
+ "pid": 0,
+ "title": updated_title,
+ "content": updated_content,
+ }, headers=auth_headers)
+ data = try_parse_json(body)
+ assert_true(
+ status == 200 and data is not None and data.get("success") is True,
+ "更新笔记-内容", c,
+ f"status={status} body={body[:150]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 8: 更新笔记 — 缺少参数
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{du}/update_note", {
+ "id": child_note_id,
+ }, headers=auth_headers)
+ assert_eq(status, 400, "更新笔记-缺少参数 (400)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 9: 删除笔记 — 缺少 id 参数
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{du}/delete_note", {}, headers=auth_headers)
+ assert_eq(status, 400, "删除笔记-缺少id (400)", c)
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n dnfs 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_gateway.py b/tests/blackbox/test_gateway.py
new file mode 100644
index 0000000..f9d06e6
--- /dev/null
+++ b/tests/blackbox/test_gateway.py
@@ -0,0 +1,218 @@
+"""
+gateway 黑盒用户模拟测试 — 注册 → 登录 → Token → 代理转发。
+
+用户旅程:
+ 1. 注册新账号(含密码强度校验、邮箱格式校验)
+ 2. 登录获取 Token
+ 3. Token 用于代理访问后端服务
+ 4. 重复注册(应失败)
+ 5. 错误密码登录(应失败)
+ 6. 无效 Token 代理(应拒绝)
+ 7. 密码强度不足(应拒绝)
+ 8. 邮箱格式错误(应拒绝)
+"""
+
+import json
+import random
+import string
+import time
+
+from helpers import (
+ http_get, http_post, try_parse_json,
+ assert_eq, assert_status, assert_true, assert_json_key,
+ Counter, check_service,
+)
+from config import get_url
+
+GATEWAY_URL = get_url("gateway")
+STATIC_URL = get_url("static")
+
+
+def _random_email():
+ """生成随机测试邮箱。"""
+ suffix = ''.join(random.choices(string.ascii_lowercase, k=8))
+ return f"test_{suffix}@example.com"
+
+
+def _random_password():
+ """生成符合强度要求的随机密码。"""
+ chars = string.ascii_letters + string.digits + "!@#$%"
+ return ''.join(random.choices(chars, k=12))
+
+
+def run(url: str = None):
+ """gateway 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ gw = url or GATEWAY_URL
+ c = Counter()
+
+ print(f"\n--- gateway 用户注册与登录流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(gw)[0]:
+ print(" gateway 服务不可达,跳过所有测试")
+ c.add_skip()
+ return c.passed, c.failed, c.skipped
+
+ email = _random_email()
+ password = _random_password()
+ token = None
+
+ # ------------------------------------------------------------------
+ # 场景 1: 注册 — 密码强度不足
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/register", {
+ "username": email,
+ "password": "123",
+ })
+ assert_eq(status, 401, "注册-密码太短 (401)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and not data.get("success"),
+ "注册-密码太短-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 2: 注册 — 邮箱格式错误
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/register", {
+ "username": "not-an-email",
+ "password": _random_password(),
+ })
+ assert_eq(status, 400, "注册-邮箱格式错误 (400)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and not data.get("success"),
+ "注册-邮箱格式错误-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 3: 注册 — 成功
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/register", {
+ "username": email,
+ "password": password,
+ })
+ assert_eq(status, 200, "注册-成功 (200)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and data.get("success") is True,
+ "注册-返回 success=true", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 4: 注册 — 重复用户名
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/register", {
+ "username": email,
+ "password": password,
+ })
+ # 期望注册失败(用户名已存在)
+ assert_true(
+ status != 200 or (try_parse_json(body) or {}).get("success") is not True,
+ "注册-重复用户名-拒绝", c,
+ f"status={status}, body={body[:100]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 5: 登录 — 错误密码
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/login", {
+ "username": email,
+ "password": "WrongPassword1",
+ })
+ assert_eq(status, 401, "登录-错误密码 (401)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and not data.get("success"),
+ "登录-错误密码-拒绝", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 6: 登录 — 成功获取 Token
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{gw}/login", {
+ "username": email,
+ "password": password,
+ })
+ assert_eq(status, 200, "登录-成功 (200)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and data.get("success") is True,
+ "登录-返回 success=true", c,
+ )
+ token_data = (data or {}).get("data", {})
+ token = token_data.get("token", "")
+ assert_true(
+ len(token) > 20 if token else False,
+ "登录-Token 不为空", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 7: 代理 — 使用 Token 访问静态服务
+ # ------------------------------------------------------------------
+ if token:
+ status, body, headers = http_get(
+ f"{gw}/",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Target-Url": STATIC_URL,
+ },
+ )
+ assert_true(
+ status in (200, 302, 301),
+ "代理-GET静态首页", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 8: 代理 — 无 Token 应拒绝
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(
+ f"{gw}/some-path",
+ headers={"Target-Url": STATIC_URL},
+ )
+ # 缺少 Token 应返回 400/401
+ assert_true(
+ status in (400, 401, 403),
+ "代理-无Token拒绝", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 9: 代理 — 无效路径(路径遍历)
+ # ------------------------------------------------------------------
+ if token:
+ status, body, _ = http_get(
+ f"{gw}/../../../etc/passwd",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Target-Url": STATIC_URL,
+ },
+ )
+ assert_true(
+ status in (400, 403),
+ "代理-路径遍历拒绝", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 10: 默认代理 (无 Target-Url,无需 Token)
+ # ------------------------------------------------------------------
+ status, body, _ = http_get(f"{gw}/")
+ assert_true(
+ status in (200, 302, 301),
+ "代理-默认路由", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n gateway 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_runner.py b/tests/blackbox/test_runner.py
new file mode 100644
index 0000000..8729763
--- /dev/null
+++ b/tests/blackbox/test_runner.py
@@ -0,0 +1,188 @@
+"""
+micro_servers 黑盒测试 — 统一运行入口。
+
+用法:
+ python3 test_runner.py # 运行全部测试
+ python3 test_runner.py --url http://127.0.0.1:8443 # 测试指定服务
+ python3 test_runner.py --list # 列出可用测试
+ python3 test_runner.py --service gateway # 按服务名运行
+"""
+
+import argparse
+import importlib
+import os
+import sys
+
+# 确保当前目录在 path 中
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from config import SERVICES, PORT_MAP, detect_service, get_url
+from helpers import check_service
+
+TEST_MODULES = {
+ "gateway": "test_gateway",
+ "captcha": "test_captcha",
+ "smtp": "test_smtp",
+ "dnfs": "test_dnfs",
+ "db": "test_db",
+ "ai_review": "test_ai_review",
+ "static": "test_static",
+ "waf": "test_waf",
+}
+
+
+def run_service_test(name: str, url: str) -> tuple:
+ """运行单个服务的测试,返回 (passed, failed, skipped)。"""
+ module_name = TEST_MODULES.get(name)
+ if not module_name:
+ print(f"未知服务: {name}")
+ return 0, 0, 0
+
+ try:
+ mod = importlib.import_module(module_name)
+ except ImportError as e:
+ print(f"无法加载测试模块 {module_name}: {e}")
+ return 0, 0, 0
+
+ if not hasattr(mod, "run"):
+ print(f"模块 {module_name} 缺少 run(url) 函数")
+ return 0, 0, 0
+
+ svc = SERVICES[name]
+ print(f"\n{'='*60}")
+ print(f" {svc['description']} ({url})")
+ print(f"{'='*60}")
+
+ reachable, detail = check_service(url)
+ if not reachable:
+ print(f" [WARN] 服务不可达: {url}")
+ if detail:
+ print(f" 原因: {detail}")
+ print(f" 所有测试将被跳过。请先启动服务。\n")
+ return 0, 0, 0
+
+ passed, failed, skipped = mod.run(url)
+ return passed, failed, skipped
+
+
+def run_all():
+ """运行全部服务的测试。"""
+ total_passed = 0
+ total_failed = 0
+ total_skipped = 0
+
+ for name in SERVICES:
+ url = get_url(name)
+ p, f, s = run_service_test(name, url)
+ total_passed += p
+ total_failed += f
+ total_skipped += s
+
+ total = total_passed + total_failed + total_skipped
+ print(f"\n{'='*60}")
+ print(f" 全部测试完成: {total} total, "
+ f"{total_passed} passed, {total_failed} failed, {total_skipped} skipped")
+ print(f"{'='*60}")
+
+ return total_failed
+
+
+def run_by_url(url: str):
+ """以 URL 为入口,测试全部可代理服务,内部服务自动回退 localhost。
+
+ 可代理服务 (通过 WAF→gateway 代理链): waf, gateway, static, dnfs
+ 内部服务 (仅 localhost 直连): db, ai_review, captcha, smtp
+ """
+ from urllib.parse import urlparse
+
+ hostname = urlparse(url).hostname or ""
+ is_remote = hostname not in ("127.0.0.1", "localhost", "::1")
+
+ if is_remote:
+ print(f" 入口: {url}")
+ print(f" 可代理服务 (waf/gateway/static/dnfs) 通过入口测试")
+ print(f" 内部服务 (db/ai_review/captcha/smtp) 回退到 localhost")
+ print()
+
+ # 可通过入口 URL 访问的服务
+ proxyable = {"waf", "gateway", "static", "dnfs"}
+ total_passed = 0
+ total_failed = 0
+ total_skipped = 0
+
+ for name in SERVICES:
+ if name in proxyable:
+ service_url = url
+ else:
+ service_url = get_url(name)
+
+ p, f, s = run_service_test(name, service_url)
+ total_passed += p
+ total_failed += f
+ total_skipped += s
+
+ total = total_passed + total_failed + total_skipped
+ print(f"\n{'='*60}")
+ print(f" 全部测试完成: {total} total, "
+ f"{total_passed} passed, {total_failed} failed, {total_skipped} skipped")
+ print(f"{'='*60}")
+
+ return total_failed
+
+
+def run_by_name(name: str):
+ """按服务名运行测试。"""
+ svc = SERVICES.get(name)
+ if not svc:
+ print(f"未知服务: {name}")
+ print(f"可用服务: {', '.join(SERVICES.keys())}")
+ return 1
+
+ url = get_url(name)
+ p, f, s = run_service_test(name, url)
+ return f
+
+
+def list_tests():
+ """列出所有可用测试。"""
+ print("可用的黑盒测试:\n")
+ for name, svc in SERVICES.items():
+ print(f" {name:12s} 端口 {svc['port']:<5d} {svc['description']}")
+ print()
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="micro_servers 黑盒用户模拟测试",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+示例:
+ %(prog)s # 全部服务 localhost 测试
+ %(prog)s --url https://qsont.me # 公网入口测试全部可代理服务
+ %(prog)s --url http://127.0.0.1:8443 # 本地入口测试全部
+ %(prog)s --service gateway # 仅测试指定服务
+ %(prog)s --list # 列出可用测试
+ """,
+ )
+ parser.add_argument("--url", help="入口 URL,通过它测试全部可代理服务(内部服务回退 localhost)")
+ parser.add_argument("--service", help="按服务名称运行测试")
+ parser.add_argument("--list", action="store_true", help="列出所有可用测试")
+
+ args = parser.parse_args()
+
+ if args.list:
+ list_tests()
+ return 0
+
+ if args.url:
+ return run_by_url(args.url)
+
+ if args.service:
+ return run_by_name(args.service)
+
+ # 默认:运行全部
+ return run_all()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/blackbox/test_smtp.py b/tests/blackbox/test_smtp.py
new file mode 100644
index 0000000..ada18fa
--- /dev/null
+++ b/tests/blackbox/test_smtp.py
@@ -0,0 +1,114 @@
+"""
+smtp 黑盒用户模拟测试 — 发送邮箱验证码 → 校验。
+
+用户旅程:
+ 1. 发送验证码到测试邮箱
+ 2. 用错误验证码校验 → 应失败
+ 3. 用空参数校验 → 应报错
+
+注意: SMTP 服务需要配置真实的邮件服务器。若服务不可达或邮件发送失败,
+相关测试将标记为 SKIP。验证码有效期为 60 秒。
+"""
+
+import json
+import random
+import string
+
+from helpers import (
+ http_post, try_parse_json,
+ assert_eq, assert_status, assert_true,
+ skip, Counter, check_service,
+)
+from config import get_url
+
+SMTP_URL = get_url("smtp")
+
+
+def run(url: str = None):
+ """smtp 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ su = url or SMTP_URL
+ c = Counter()
+
+ print(f"\n--- smtp 邮件验证码发送与校验流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(su)[0]:
+ skip("全部测试", "smtp 服务不可达", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 场景 1: 发送验证码 — 缺少 email 参数
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{su}/send_email", {})
+ data = try_parse_json(body)
+ # 预期返回错误
+ assert_true(
+ status != 200 or (data and data.get("error")),
+ "发送验证码-缺少email-报错", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 2: 发送验证码 — 空 email
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{su}/send_email", {"email": ""})
+ data = try_parse_json(body)
+ assert_true(
+ status != 200 or (data and data.get("error")),
+ "发送验证码-空email-报错", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 3: 校验验证码 — 缺少参数
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{su}/verify_email", {})
+ data = try_parse_json(body)
+ assert_true(
+ status != 200 or (data and data.get("error")),
+ "校验验证码-缺少参数-报错", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 4: 校验验证码 — 错误验证码
+ # ------------------------------------------------------------------
+ test_email = f"test_{random.randint(10000, 99999)}@example.com"
+ status, body, _ = http_post(f"{su}/verify_email", {
+ "email": test_email,
+ "input_captcha": "000000",
+ })
+ data = try_parse_json(body)
+ # 未发送过的 email 验证应失败
+ assert_true(
+ status != 200 or (data and not data.get("success")),
+ "校验验证码-错误码-拒绝", c,
+ f"status={status} body={body[:100]}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 5: 发送验证码 — 不存在的邮箱域名
+ # ------------------------------------------------------------------
+ status, body, _ = http_post(f"{su}/send_email", {
+ "email": "nobody@this-domain-does-not-exist-12345.com",
+ })
+ data = try_parse_json(body)
+ # SMTP 可能成功发送请求(服务接受),或者返回错误
+ # 两种情况都算通过 — 至少服务正常响应了
+ assert_true(
+ status in (200, 400, 500) and data is not None,
+ "发送验证码-服务正常响应", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n smtp 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_static.py b/tests/blackbox/test_static.py
new file mode 100644
index 0000000..3454d8c
--- /dev/null
+++ b/tests/blackbox/test_static.py
@@ -0,0 +1,146 @@
+"""
+static 黑盒用户模拟测试 — 首页加载 → 文件浏览 → 路径遍历防护 → 视频流。
+
+用户旅程:
+ 1. 访问首页 → 返回 HTML 登录页
+ 2. 访问 /dnfs.html → 树状笔记前端
+ 3. 访问 /api/filelist → 目录列表 JSON
+ 4. 访问静态资源 (CSS/JS) → 返回正确 MIME 类型
+ 5. 路径遍历攻击 → 返回 403
+ 6. 不存在的文件 → 返回 404
+ 7. 健康检查
+ 8. DNFS 代理接口 (CORS 预检)
+"""
+
+import json
+
+from helpers import (
+ http_get, http_post, try_parse_json,
+ assert_eq, assert_status, assert_true, assert_contains,
+ skip, Counter, check_service,
+)
+from config import get_url
+
+STATIC_URL = get_url("static")
+
+
+def run(url: str = None):
+ """static 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ su = url or STATIC_URL
+ c = Counter()
+
+ print(f"\n--- static 前端静态服务流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ if not check_service(su)[0]:
+ skip("全部测试", "static 服务不可达", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 场景 1: 访问首页
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/")
+ assert_eq(status, 200, "首页-加载 (200)", c)
+ content_type = headers.get("Content-Type", headers.get("content-type", ""))
+ assert_contains(content_type, "text/html", "首页-Content-Type text/html", c)
+
+ # ------------------------------------------------------------------
+ # 场景 2: 访问 DNFS 前端页面
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/dnfs.html")
+ assert_eq(status, 200, "DNFS页面-加载 (200)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 3: 访问 CSS 资源
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/css/theme.css")
+ assert_true(
+ status in (200, 404),
+ "CSS资源-请求", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 4: 访问 JS 资源
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/js/dnfs.js")
+ assert_true(
+ status in (200, 404),
+ "JS资源-请求", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 5: 路径遍历攻击 — ../ 应返回 403
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/../../../etc/passwd")
+ assert_eq(status, 403, "路径遍历-../-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 6: 路径遍历 — URL 编码 ..%2f
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/..%2f..%2f..%2fetc%2fpasswd")
+ assert_eq(status, 403, "路径遍历-URL编码-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 7: 不存在的文件 → 404
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/this-file-does-not-exist-12345.html")
+ assert_eq(status, 404, "不存在文件-404", c)
+
+ # ------------------------------------------------------------------
+ # 场景 8: 健康检查
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/health")
+ assert_eq(status, 200, "健康检查 (200)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 9: /api/filelist — 目录列表
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/api/filelist")
+ assert_eq(status, 200, "文件列表-API (200)", c)
+ data = try_parse_json(body)
+ assert_true(
+ data is not None and data.get("success") is True,
+ "文件列表-返回success", c,
+ f"body={body[:200]}",
+ )
+ if data and data.get("success"):
+ items = data.get("items", [])
+ assert_true(
+ isinstance(items, list),
+ "文件列表-返回items数组", c,
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 10: /api/filelist — 路径遍历
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/api/filelist?path=../../../etc")
+ assert_true(
+ status in (403, 400),
+ "文件列表-路径遍历-拒绝", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 11: DNFS 代理接口 — OPTIONS 预检
+ # ------------------------------------------------------------------
+ status, body, headers = http_get(f"{su}/add_note") # OPTIONS 需要特殊处理
+ # 简单的 GET 代理转发测试
+ assert_true(
+ status in (200, 400, 401, 405),
+ "DNFS代理-add_note可达", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n static 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/blackbox/test_waf.py b/tests/blackbox/test_waf.py
new file mode 100644
index 0000000..0c00a22
--- /dev/null
+++ b/tests/blackbox/test_waf.py
@@ -0,0 +1,139 @@
+"""
+waf 黑盒用户模拟测试 — 正常请求通行 vs 攻击载荷拦截。
+
+用户旅程:
+ 1. 正常请求 → 通过
+ 2. SQL 注入载荷 → 403 blocked by WAF
+ 3. XSS 跨站脚本载荷 → 403
+ 4. 命令注入载荷 → 403
+ 5. 路径遍历载荷 → 403
+ 6. 扫描器 User-Agent → 403
+
+注意: WAF 监听 443 端口 (TLS),测试时需注意证书验证。
+"""
+
+import ssl
+import urllib.request
+import urllib.error
+
+from helpers import (
+ http_get,
+ assert_eq, assert_status, assert_true, assert_contains,
+ skip, Counter, check_service,
+)
+from config import get_url
+
+WAF_URL = "https://127.0.0.1:443" # WAF 监听 443 需要 HTTPS
+
+
+def _waf_get(base_url: str, path: str, headers: dict = None):
+ """通过 WAF 发送 HTTPS GET 请求(跳过证书验证)。"""
+ ctx = ssl._create_unverified_context()
+ url = f"{base_url}{path}"
+ hdrs = headers or {}
+ req = urllib.request.Request(url, headers=hdrs, method="GET")
+ try:
+ with urllib.request.urlopen(req, timeout=10.0, context=ctx) as resp:
+ return resp.status, resp.read().decode("utf-8", errors="replace"), dict(resp.headers)
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", errors="replace") if e.fp else ""
+ return e.code, body, dict(e.headers) if e.headers else {}
+ except Exception as e:
+ return 0, str(e), {}
+
+
+def run(url: str = None):
+ """waf 用户模拟测试入口。返回 (passed, failed, skipped)。"""
+ wu = url or WAF_URL
+ c = Counter()
+
+ print(f"\n--- waf 安全防护流程 ---\n")
+
+ # ------------------------------------------------------------------
+ # 0. 检查服务可用性
+ # ------------------------------------------------------------------
+ reachable, detail = check_service(wu)
+ if not reachable:
+ skip("全部测试", f"WAF 不可达: {detail}", c)
+ return c.passed, c.failed, c.skipped
+
+ # ------------------------------------------------------------------
+ # 场景 1: 正常请求应通过
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/")
+ assert_true(
+ status != 403,
+ "正常请求-通过", c,
+ f"status={status}",
+ )
+
+ # ------------------------------------------------------------------
+ # 场景 2: SQL 注入 — UNION SELECT
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?id=1 UNION SELECT * FROM users")
+ assert_eq(status, 403, "SQLi-UNION SELECT-拦截 (403)", c)
+ assert_contains(body, "blocked by WAF", "SQLi-响应体 blocked", c)
+
+ # ------------------------------------------------------------------
+ # 场景 3: SQL 注入 — SQL 注释符
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?user=admin'--")
+ assert_eq(status, 403, "SQLi-注释符-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 4: XSS — script 标签
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?q=")
+ assert_eq(status, 403, "XSS-script标签-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 5: XSS — onerror 事件
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?q=
")
+ assert_eq(status, 403, "XSS-onerror-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 6: 命令注入 — cat /etc/passwd
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?cmd=; cat /etc/passwd")
+ assert_eq(status, 403, "命令注入-cat-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 7: 命令注入 — id 命令
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?x=| id")
+ assert_eq(status, 403, "命令注入-id-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 8: 路径遍历
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?file=../../../etc/passwd")
+ assert_eq(status, 403, "路径遍历-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 9: 路径遍历 — URL 编码
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/?file=..%2f..%2f..%2fetc%2fpasswd")
+ assert_eq(status, 403, "路径遍历-URL编码-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 10: 扫描器 User-Agent
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/", headers={"User-Agent": "sqlmap/1.0"})
+ assert_eq(status, 403, "扫描器UA-sqlmap-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 场景 11: 扫描器 User-Agent — Nikto
+ # ------------------------------------------------------------------
+ status, body, headers = _waf_get(wu, "/", headers={"User-Agent": "Nikto"})
+ assert_eq(status, 403, "扫描器UA-nikto-拦截 (403)", c)
+
+ # ------------------------------------------------------------------
+ # 汇总
+ # ------------------------------------------------------------------
+ print(f"\n waf 测试完成: {c.summary()}")
+ return c.passed, c.failed, c.skipped
+
+
+if __name__ == "__main__":
+ run()