fix: align external api behavior with PRD-00008

This commit is contained in:
胡绍康 committed 2026-03-10 21:24:55 +08:00
1 parent 2c4ca1f0cb
commit 1d4d848702
7 files changed
+330 -33

No files matched your search

@@ -118,3 +118,33 @@ python -m flask --app web_outlook_app run --host 127.0.0.1 --port 5055
- 写入对外 API Key:`PUT /api/settings {"external_api_key":"abc123"}` → 200
- 读取设置脱敏:`GET /api/settings` → `external_api_key_set=true` 且 `external_api_key_masked` 非明文
- 外部健康检查:`GET /api/external/health`(Header `X-API-Key: abc123`)→ 200 `code=OK`
## 执行记录(2026-03-10)
### 8) 对齐 PRD 语义偏差并补强回归用例
- 修复 `verification-code` / `verification-link`
- 未显式传入 `since_minutes` 时,默认按 PRD 使用最近 `10` 分钟窗口
- 避免命中过期历史验证码/历史验证链接
- 修复 `wait-message`
- 由“存在匹配邮件即返回”改为“仅当调用开始后出现的新邮件才返回”
- 保留超时上限 `120` 秒与同步轮询实现
- 修复 Graph 链路 RAW 内容
- 新增 Graph MIME 读取函数,优先使用 `/me/messages/{id}/$value`
- `messages/{id}` 与 `messages/{id}/raw` 在 Graph 成功时返回真实 MIME 内容,而非仅正文
- 补齐审计日志闭环
- 新增 `/api/external/messages/{id}/raw`
- 新增 `/api/external/health`
- 新增 `/api/external/capabilities`
- 新增 `/api/external/account-status`
- 补充回归测试
- `messages/latest` 筛选与最新命中
- `verification-*` 默认 10 分钟窗口
- `wait-message` 仅返回新邮件
- Graph RAW 内容与外部审计日志写入
验证:
- `python -m unittest tests.test_external_api -v` ✅
- `python -m unittest tests.test_settings_external_api_key -v` ✅
- `python -m unittest tests.test_verification_extractor_options -v` ✅
- `python -m unittest discover -s tests -v` ✅(256 tests)
@@ -764,3 +764,23 @@ curl -H "X-API-Key: your-api-key" "http://localhost:5000/api/external/verificati
3. 鉴权与配置链路闭环
4. 核心错误码与 OpenAPI 一致
5. 手动联调可完成“配置 → 健康检查 → 查邮件 → 取验证码/链接”全流程
---
## 15. 2026-03-10 增补回归重点
本轮修复后,PRD-00008 相关回归必须额外覆盖以下场景:
1. `verification-code` / `verification-link` 在未传 `since_minutes` 时,默认仅扫描最近 `10` 分钟邮件。
2. `wait-message` 只能返回调用开始后到达的新邮件,历史旧邮件不能立即命中。
3. Graph 成功链路下,`/api/external/messages/{message_id}` 与 `/raw` 返回的 `raw_content` 应优先为 MIME RAW,而不是正文字符串。
4. `/api/external/messages/{message_id}/raw`、`/health`、`/capabilities`、`/account-status` 都必须写入 `audit_logs(resource_type='external_api')`。
建议执行顺序:
```bash
python -m unittest tests.test_external_api -v
python -m unittest tests.test_settings_external_api_key -v
python -m unittest tests.test_verification_extractor_options -v
python -m unittest discover -s tests -v
```
+26 -5
View File
@@ -581,7 +581,7 @@ def api_extract_verification(email_addr: str) -> Any:
# ==================== External Emails API ====================
def _parse_external_common_args() -> dict:
def _parse_external_common_args(*, default_since_minutes: int | None = None) -> dict:
"""解析 external API 通用 query 参数(按 TDD-00008 做基础校验)。"""
email_addr = (request.args.get("email") or "").strip()
if not email_addr or "@" not in email_addr:
@@ -608,7 +608,7 @@ def _parse_external_common_args() -> dict:
raise external_api_service.InvalidParamError("top 参数无效")
since_minutes_raw = request.args.get("since_minutes", None)
since_minutes = None
since_minutes = default_since_minutes
if since_minutes_raw not in (None, ""):
try:
since_minutes = int(since_minutes_raw)
@@ -763,6 +763,13 @@ def api_external_get_message_raw(message_id: str) -> Any:
message_id=message_id,
folder=args["folder"],
)
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=args["email"] or "",
endpoint="/api/external/messages/{message_id}/raw",
status="ok",
details={"method": detail.get("method")},
)
return jsonify(
external_api_service.ok(
{
@@ -774,15 +781,29 @@ def api_external_get_message_raw(message_id: str) -> Any:
)
)
except external_api_service.ExternalApiError as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=(request.args.get("email") or "").strip(),
endpoint="/api/external/messages/{message_id}/raw",
status="error",
details={"code": exc.code},
)
return _external_error_response(exc)
except Exception:
except Exception as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=(request.args.get("email") or "").strip(),
endpoint="/api/external/messages/{message_id}/raw",
status="error",
details={"code": "INTERNAL_ERROR", "err": type(exc).__name__},
)
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500
@api_key_required
def api_external_get_verification_code() -> Any:
try:
args = _parse_external_common_args()
args = _parse_external_common_args(default_since_minutes=10)
code_length = (request.args.get("code_length") or "").strip() or None
code_regex = (request.args.get("code_regex") or "").strip() or None
code_source = (request.args.get("code_source") or "all").strip().lower()
@@ -828,7 +849,7 @@ def api_external_get_verification_code() -> Any:
@api_key_required
def api_external_get_verification_link() -> Any:
try:
args = _parse_external_common_args()
args = _parse_external_common_args(default_since_minutes=10)
result = external_api_service.get_verification_result(
email_addr=args["email"],
folder=args["folder"],
+55 -14
View File
@@ -272,7 +272,23 @@ def api_external_health() -> Any:
"server_time_utc": utcnow().isoformat() + "Z",
"database": "ok" if db_ok else "error",
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/health",
status="ok",
details={"database": data["database"]},
)
return jsonify(external_api_service.ok(data))
except Exception as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/health",
status="error",
details={"code": "INTERNAL_ERROR", "err": type(exc).__name__},
)
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500
finally:
conn.close()
@@ -292,6 +308,13 @@ def api_external_capabilities() -> Any:
"wait_message",
],
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr="",
endpoint="/api/external/capabilities",
status="ok",
details={"feature_count": len(data["features"])},
)
return jsonify(external_api_service.ok(data))
@@ -300,10 +323,24 @@ def api_external_account_status() -> Any:
"""对外账号状态检查"""
email_addr = (request.args.get("email") or "").strip()
if not email_addr or "@" not in email_addr:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="error",
details={"code": "INVALID_PARAM"},
)
return jsonify(external_api_service.fail("INVALID_PARAM", "email 参数不合法")), 400
account = accounts_repo.get_account_by_email(email_addr)
if not account:
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="error",
details={"code": "ACCOUNT_NOT_FOUND"},
)
return jsonify(external_api_service.fail("ACCOUNT_NOT_FOUND", "账号不存在", data={"email": email_addr})), 404
account_type = (account.get("account_type") or "outlook").strip().lower()
@@ -317,18 +354,22 @@ def api_external_account_status() -> Any:
else:
can_read = bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip())
return jsonify(
external_api_service.ok(
{
"email": email_addr,
"exists": True,
"account_type": account_type,
"provider": provider,
"group_id": account.get("group_id"),
"status": account.get("status"),
"last_refresh_at": account.get("last_refresh_at"),
"preferred_method": preferred_method,
"can_read": can_read,
}
)
data = {
"email": email_addr,
"exists": True,
"account_type": account_type,
"provider": provider,
"group_id": account.get("group_id"),
"status": account.get("status"),
"last_refresh_at": account.get("last_refresh_at"),
"preferred_method": preferred_method,
"can_read": can_read,
}
external_api_service.audit_external_api_access(
action="external_api_access",
email_addr=email_addr,
endpoint="/api/external/account-status",
status="ok",
details={"preferred_method": preferred_method, "can_read": can_read},
)
return jsonify(external_api_service.ok(data))
+13 -2
View File
@@ -384,6 +384,14 @@ def get_message_detail_for_external(
proxy_url,
)
method_label = "Graph API"
graph_raw_content = None
if detail:
graph_raw_content = graph_service.get_email_raw_graph(
account.get("client_id") or "",
account.get("refresh_token") or "",
message_id,
proxy_url,
)
if not detail:
detail = imap_service.get_email_detail_imap_with_server(
email_addr,
@@ -420,7 +428,7 @@ def get_message_detail_for_external(
html_content = body_content if body_type == "html" else ""
content = body_content if body_type == "text" else extract_email_text({"body_html": html_content})
raw_content = body_content
raw_content = str(graph_raw_content or body_content)
from_address = (detail.get("from") or {}).get("emailAddress", {}).get("address", "")
to_address = ",".join([r.get("emailAddress", {}).get("address", "") for r in (detail.get("toRecipients") or [])])
@@ -519,17 +527,20 @@ def wait_for_message(
if poll_interval <= 0 or poll_interval > timeout_seconds:
raise InvalidParamError("poll_interval 参数无效")
baseline_timestamp = int(time.time())
start = time.time()
last_error: Optional[ExternalApiError] = None
while True:
try:
return get_latest_message_for_external(
latest_message = get_latest_message_for_external(
email_addr=email_addr,
folder=folder,
from_contains=from_contains,
subject_contains=subject_contains,
since_minutes=since_minutes,
)
if int(latest_message.get("timestamp") or 0) >= baseline_timestamp:
return latest_message
except MailNotFoundError as exc:
last_error = exc
+29
View File
@@ -183,6 +183,35 @@ def get_email_detail_graph(
return None
def get_email_raw_graph(
client_id: str,
refresh_token: str,
message_id: str,
proxy_url: str = None,
) -> Optional[str]:
"""使用 Graph API 获取邮件 MIME RAW 内容。"""
access_token = get_access_token_graph(client_id, refresh_token, proxy_url)
if not access_token:
return None
try:
url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
headers = {
"Authorization": f"Bearer {access_token}",
}
proxies = build_proxies(proxy_url)
res = requests.get(url, headers=headers, timeout=30, proxies=proxies)
if res.status_code != 200:
return None
res.encoding = res.encoding or "utf-8"
return res.text
except Exception:
return None
def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str | None]:
"""测试 refresh token 是否有效,返回 (是否成功, 错误信息)"""
ok, err, _new_refresh_token = test_refresh_token_with_rotation(client_id, refresh_token, proxy_url)
+157 -12
View File
@@ -1,6 +1,7 @@
import json
import unittest
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from tests._import_app import clear_login_attempts, import_web_app_module
@@ -74,30 +75,60 @@ class ExternalApiBaseTest(unittest.TestCase):
self.assertTrue(resp.get_json().get("success"))
@staticmethod
def _graph_email(message_id: str = "msg-1", subject: str = "Your verification code", sender: str = "noreply@example.com"):
def _utc_iso(minutes_delta: int = 0) -> str:
dt = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=minutes_delta)
return dt.isoformat().replace("+00:00", "Z")
@classmethod
def _graph_email(
cls,
message_id: str = "msg-1",
subject: str = "Your verification code",
sender: str = "noreply@example.com",
received_at: str | None = None,
):
return {
"id": message_id,
"subject": subject,
"from": {"emailAddress": {"address": sender}},
"receivedDateTime": "2026-03-08T12:00:00Z",
"receivedDateTime": received_at or cls._utc_iso(),
"isRead": False,
"hasAttachments": False,
"bodyPreview": "Your code is 123456",
}
@staticmethod
@classmethod
def _graph_detail(
message_id: str = "msg-1", body_text: str = "Your code is 123456", html_text: str = "<p>Your code is 123456</p>"
cls,
message_id: str = "msg-1",
body_text: str = "Your code is 123456",
html_text: str = "<p>Your code is 123456</p>",
received_at: str | None = None,
):
return {
"id": message_id,
"subject": "Your verification code",
"from": {"emailAddress": {"address": "noreply@example.com"}},
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
"receivedDateTime": "2026-03-08T12:00:00Z",
"receivedDateTime": received_at or cls._utc_iso(),
"body": {"content": body_text if body_text else html_text, "contentType": "text" if body_text else "html"},
}
def _external_audit_logs(self):
with self.app.app_context():
from outlook_web.db import get_db
db = get_db()
rows = db.execute(
"""
SELECT action, resource_id, details
FROM audit_logs
WHERE resource_type = 'external_api'
ORDER BY id ASC
"""
).fetchall()
return [dict(row) for row in rows]
class ExternalApiAuthTests(ExternalApiBaseTest):
def test_external_health_requires_api_key(self):
@@ -132,6 +163,25 @@ class ExternalApiAuthTests(ExternalApiBaseTest):
class ExternalApiMessageTests(ExternalApiBaseTest):
@patch("outlook_web.services.graph.get_emails_graph")
def test_external_latest_message_returns_filtered_latest_email(self, mock_get_emails_graph):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
newer = self._graph_email(message_id="msg-new", subject="Target mail", received_at=self._utc_iso())
older = self._graph_email(message_id="msg-old", subject="Ignore mail", received_at=self._utc_iso(minutes_delta=-2))
mock_get_emails_graph.return_value = {"success": True, "emails": [older, newer]}
client = self.app.test_client()
resp = client.get(
f"/api/external/messages/latest?email={email_addr}&subject_contains=Target",
headers=self._auth_headers(),
)
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data.get("success"))
self.assertEqual(data.get("data", {}).get("id"), "msg-new")
def test_external_messages_returns_account_not_found(self):
client = self.app.test_client()
self._set_external_api_key("abc123")
@@ -194,11 +244,13 @@ class ExternalApiMessageTests(ExternalApiBaseTest):
self.assertTrue(data.get("success"))
self.assertEqual(len(data.get("data", {}).get("emails", [])), 1)
@patch("outlook_web.services.graph.get_email_raw_graph")
@patch("outlook_web.services.graph.get_email_detail_graph")
def test_external_message_detail_returns_message_content(self, mock_get_email_detail_graph):
def test_external_message_detail_returns_message_content(self, mock_get_email_detail_graph, mock_get_email_raw_graph):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_email_detail_graph.return_value = self._graph_detail()
mock_get_email_raw_graph.return_value = "RAW MIME CONTENT"
client = self.app.test_client()
resp = client.get(
@@ -211,12 +263,15 @@ class ExternalApiMessageTests(ExternalApiBaseTest):
self.assertTrue(data.get("success"))
self.assertIn("content", data.get("data", {}))
self.assertIn("raw_content", data.get("data", {}))
self.assertEqual(data.get("data", {}).get("raw_content"), "RAW MIME CONTENT")
@patch("outlook_web.services.graph.get_email_raw_graph")
@patch("outlook_web.services.graph.get_email_detail_graph")
def test_external_message_raw_returns_raw_content(self, mock_get_email_detail_graph):
def test_external_message_raw_returns_raw_content_and_audits(self, mock_get_email_detail_graph, mock_get_email_raw_graph):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_email_detail_graph.return_value = self._graph_detail(body_text="raw test")
mock_get_email_raw_graph.return_value = "MIME-Version: 1.0\r\nraw test"
client = self.app.test_client()
resp = client.get(
@@ -227,17 +282,28 @@ class ExternalApiMessageTests(ExternalApiBaseTest):
self.assertEqual(resp.status_code, 200)
data = resp.get_json()
self.assertTrue(data.get("success"))
self.assertIn("raw_content", data.get("data", {}))
self.assertEqual(data.get("data", {}).get("raw_content"), "MIME-Version: 1.0\r\nraw test")
audit_logs = self._external_audit_logs()
self.assertEqual(len(audit_logs), 1)
self.assertIn("/api/external/messages/{message_id}/raw", audit_logs[0]["details"])
class ExternalApiVerificationTests(ExternalApiBaseTest):
@patch("outlook_web.services.graph.get_email_raw_graph")
@patch("outlook_web.services.graph.get_email_detail_graph")
@patch("outlook_web.services.graph.get_emails_graph")
def test_external_verification_code_returns_code(self, mock_get_emails_graph, mock_get_email_detail_graph):
def test_external_verification_code_returns_code(
self,
mock_get_emails_graph,
mock_get_email_detail_graph,
mock_get_email_raw_graph,
):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]}
mock_get_email_detail_graph.return_value = self._graph_detail(body_text="Your code is 123456")
mock_get_email_raw_graph.return_value = "RAW MIME CONTENT"
client = self.app.test_client()
resp = client.get(
@@ -250,9 +316,33 @@ class ExternalApiVerificationTests(ExternalApiBaseTest):
self.assertTrue(data.get("success"))
self.assertEqual(data.get("data", {}).get("verification_code"), "123456")
@patch("outlook_web.services.graph.get_emails_graph")
def test_external_verification_code_defaults_to_recent_10_minutes(self, mock_get_emails_graph):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_emails_graph.return_value = {
"success": True,
"emails": [self._graph_email(received_at=self._utc_iso(minutes_delta=-20))],
}
client = self.app.test_client()
resp = client.get(
f"/api/external/verification-code?email={email_addr}",
headers=self._auth_headers(),
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.get_json().get("code"), "MAIL_NOT_FOUND")
@patch("outlook_web.services.graph.get_email_raw_graph")
@patch("outlook_web.services.graph.get_email_detail_graph")
@patch("outlook_web.services.graph.get_emails_graph")
def test_external_verification_link_returns_preferred_link(self, mock_get_emails_graph, mock_get_email_detail_graph):
def test_external_verification_link_returns_preferred_link(
self,
mock_get_emails_graph,
mock_get_email_detail_graph,
mock_get_email_raw_graph,
):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_emails_graph.return_value = {
@@ -262,6 +352,7 @@ class ExternalApiVerificationTests(ExternalApiBaseTest):
mock_get_email_detail_graph.return_value = self._graph_detail(
body_text="Click https://example.com/verify?token=abc to continue",
)
mock_get_email_raw_graph.return_value = "RAW MIME CONTENT"
client = self.app.test_client()
resp = client.get(
@@ -274,6 +365,41 @@ class ExternalApiVerificationTests(ExternalApiBaseTest):
self.assertTrue(data.get("success"))
self.assertIn("verify", data.get("data", {}).get("verification_link", ""))
@patch("outlook_web.services.graph.get_emails_graph")
def test_external_verification_link_defaults_to_recent_10_minutes(self, mock_get_emails_graph):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
mock_get_emails_graph.return_value = {
"success": True,
"emails": [self._graph_email(subject="Please verify your email", received_at=self._utc_iso(minutes_delta=-30))],
}
client = self.app.test_client()
resp = client.get(
f"/api/external/verification-link?email={email_addr}",
headers=self._auth_headers(),
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.get_json().get("code"), "MAIL_NOT_FOUND")
@patch("outlook_web.services.external_api.time.sleep")
@patch("outlook_web.services.external_api.time.time")
@patch("outlook_web.services.external_api.get_latest_message_for_external")
def test_wait_for_message_only_returns_new_messages(self, mock_get_latest_message, mock_time, mock_sleep):
from outlook_web.services import external_api as external_api_service
mock_time.side_effect = [100, 100, 100]
mock_get_latest_message.side_effect = [
{"id": "old", "timestamp": 99, "method": "Graph API"},
{"id": "new", "timestamp": 101, "method": "Graph API"},
]
result = external_api_service.wait_for_message(email_addr="user@example.com", timeout_seconds=30, poll_interval=5)
self.assertEqual(result.get("id"), "new")
mock_sleep.assert_called_once_with(5)
def test_external_wait_message_rejects_too_large_timeout(self):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
@@ -289,7 +415,7 @@ class ExternalApiVerificationTests(ExternalApiBaseTest):
class ExternalApiSystemTests(ExternalApiBaseTest):
def test_external_capabilities_returns_feature_list(self):
def test_external_capabilities_returns_feature_list_and_audits(self):
client = self.app.test_client()
self._set_external_api_key("abc123")
@@ -302,7 +428,22 @@ class ExternalApiSystemTests(ExternalApiBaseTest):
self.assertIn("version", data.get("data", {}))
self.assertIn("features", data.get("data", {}))
def test_external_account_status_returns_account_data(self):
audit_logs = self._external_audit_logs()
self.assertEqual(len(audit_logs), 1)
self.assertIn("/api/external/capabilities", audit_logs[0]["details"])
def test_external_health_audits_access(self):
client = self.app.test_client()
self._set_external_api_key("abc123")
resp = client.get("/api/external/health", headers=self._auth_headers())
self.assertEqual(resp.status_code, 200)
audit_logs = self._external_audit_logs()
self.assertEqual(len(audit_logs), 1)
self.assertIn("/api/external/health", audit_logs[0]["details"])
def test_external_account_status_returns_account_data_and_audits(self):
email_addr = self._insert_outlook_account()
self._set_external_api_key("abc123")
client = self.app.test_client()
@@ -323,6 +464,10 @@ class ExternalApiSystemTests(ExternalApiBaseTest):
self.assertIn("last_refresh_at", data.get("data", {}))
self.assertTrue(data.get("data", {}).get("can_read"))
audit_logs = self._external_audit_logs()
self.assertEqual(len(audit_logs), 1)
self.assertIn("/api/external/account-status", audit_logs[0]["details"])
class ExternalApiRegressionTests(ExternalApiBaseTest):
@patch("outlook_web.services.graph.get_emails_graph")