fix: align external api behavior with PRD-00008
This commit is contained in:
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
|
||||
```
|
||||
@@ -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"],
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user