fix: gate low-confidence verification extraction results as 404 (BUG-00017)
- Add code_confidence/link_confidence fine-grained fields to extractor - Service-layer credibility gate: confidence != high -> nullify result -> 404 - Caller-directed extraction (code_regex only) gets high confidence - Narrow LINK_CONTEXT_PHRASES with object nouns (email/account) prevent false positives - 31 new tests (21 unit + 10 integration), 376 total passing - Update docs/api.md with new fields, null semantics, v1.5 gating notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
7163b3bc17
commit
09c3ab3416
5 files changed
+1179
-52
No files matched your search
+87
-43
@@ -8,7 +8,7 @@
|
||||
|
||||
- 本地化部署
|
||||
- 单实例
|
||||
- 单可信调用方
|
||||
- 多调用方受控接入
|
||||
- 内网或受控访问环境
|
||||
|
||||
当前版本不建议直接公网暴露。原因见:
|
||||
@@ -41,12 +41,12 @@
|
||||
|
||||
### 1.2 配套但不是新建开放接口的后台接口
|
||||
|
||||
以下接口用于管理员配置 `external_api_key`,属于现有后台接口扩展,不属于开放接口本身:
|
||||
以下接口用于管理员配置对外 API 鉴权模型,属于现有后台接口扩展,不属于开放接口本身:
|
||||
|
||||
| 方法 | 路径 | 登录要求 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `GET` | `/api/settings` | 需要后台登录 | 返回 `external_api_key_set`、`external_api_key_masked` |
|
||||
| `PUT` | `/api/settings` | 需要后台登录 | 保存或清空 `external_api_key` |
|
||||
| `GET` | `/api/settings` | 需要后台登录 | 返回 legacy `external_api_key_*` 字段,以及 `external_api_keys` 多 Key 列表与当日调用统计 |
|
||||
| `PUT` | `/api/settings` | 需要后台登录 | 保存或清空 legacy `external_api_key`,或整体替换 `external_api_keys` 多 Key 配置 |
|
||||
|
||||
### 1.3 当前不建议继续新增的接口
|
||||
|
||||
@@ -55,9 +55,7 @@ P0 阶段不建议再新增以下业务接口:
|
||||
- 邮件删除、标记已读、移动邮件等写接口
|
||||
- 附件下载、附件元数据开放接口
|
||||
- Webhook / 回调通知接口
|
||||
- 多调用方 API Key 管理接口
|
||||
|
||||
原因是当前目标是“受控私有接入闭环”,不是开放平台化扩张。
|
||||
原因是当前目标仍是“受控私有接入闭环”,不是开放平台化扩张。
|
||||
|
||||
### 1.4 后续阶段可能补充的能力
|
||||
|
||||
@@ -68,7 +66,7 @@ P0 阶段不建议再新增以下业务接口:
|
||||
| P1 | 公网模式、IP 白名单、限流 | 已实现(v1.1) | 安全守卫层 `external_api_guard.py` |
|
||||
| P1 | 高风险接口禁用(raw/wait-message) | 已实现(v1.1) | 设置页可动态开关 |
|
||||
| P2 | `wait-message` 异步探测解耦 | 已实现(v1.2) | `mode=async` + `/api/external/probe/<id>` |
|
||||
| P2 | 多 API Key / 范围授权 | 否 | 优先落在设置与鉴权模型,不急于新增公开路径 |
|
||||
| P2 | 多 API Key / 范围授权 / 调用方审计 | 已实现(v1.4) | 仍落在设置与鉴权模型,不新增公开路径 |
|
||||
|
||||
结论:
|
||||
|
||||
@@ -103,7 +101,19 @@ X-API-Key: your-api-key
|
||||
|
||||
- 仅支持 Header 中的 `X-API-Key`
|
||||
- 不支持 query 参数中的 `api_key`
|
||||
- 未配置 `external_api_key` 时,统一返回 `403 API_KEY_NOT_CONFIGURED`
|
||||
- 未配置 legacy `external_api_key` 且未配置任何启用中的 `external_api_keys` 时,统一返回 `403 API_KEY_NOT_CONFIGURED`
|
||||
|
||||
### 2.2.2 P2 多 API Key 与邮箱范围授权(v1.4 新增)
|
||||
|
||||
- 支持同时配置多个 `external_api_keys`
|
||||
- 每个 Key 可配置:
|
||||
- `name`
|
||||
- `enabled`
|
||||
- `allowed_emails`
|
||||
- `allowed_emails=[]` 表示该 Key 不限制邮箱范围
|
||||
- `allowed_emails` 非空时,仅允许访问名单中的邮箱
|
||||
- 使用超出范围的邮箱时,统一返回 `403 EMAIL_SCOPE_FORBIDDEN`
|
||||
- legacy `external_api_key` 仍保留兼容;未迁移前可继续使用
|
||||
|
||||
### 2.2.1 P1 公网安全层(v1.1 新增)
|
||||
|
||||
@@ -150,8 +160,8 @@ X-API-Key: your-api-key
|
||||
| `INVALID_PARAM` | `400` | 参数不合法 |
|
||||
| `ACCOUNT_NOT_FOUND` | `404` | 指定邮箱账号不存在 |
|
||||
| `MAIL_NOT_FOUND` | `404` | 未找到匹配邮件 |
|
||||
| `VERIFICATION_CODE_NOT_FOUND` | `404` | 未找到验证码 |
|
||||
| `VERIFICATION_LINK_NOT_FOUND` | `404` | 未找到验证链接 |
|
||||
| `VERIFICATION_CODE_NOT_FOUND` | `404` | 未找到可信验证码(含低置信度被拦截的场景) |
|
||||
| `VERIFICATION_LINK_NOT_FOUND` | `404` | 未找到可信验证链接(含低置信度被拦截的场景) |
|
||||
| `PROXY_ERROR` | `502` | 代理连接失败 |
|
||||
| `UPSTREAM_READ_FAILED` | `502` | Graph / IMAP 均读取失败 |
|
||||
| `INTERNAL_ERROR` | `500` | 服务内部错误 |
|
||||
@@ -376,7 +386,7 @@ curl -H "X-API-Key: your-api-key" \
|
||||
>
|
||||
> `/api/external/messages/{message_id}/raw` 返回未经过滤的邮件原始内容(完整 MIME 正文),可能包含敏感信息、附件二进制数据或恶意脚本。
|
||||
>
|
||||
> **当前版本定位**:仅面向受控私有接入场景(单实例、单可信调用方、内网部署)。
|
||||
> **当前版本定位**:仅面向受控私有接入场景(单实例、多调用方受控接入、内网部署)。
|
||||
>
|
||||
> **风险点**:
|
||||
> - 原始内容无字段级脱敏,调用方须自行处理展示安全
|
||||
@@ -400,6 +410,9 @@ curl -H "X-API-Key: your-api-key" \
|
||||
特别说明:
|
||||
|
||||
- 未显式传入 `since_minutes` 时,该接口默认只扫描最近 `10` 分钟的邮件
|
||||
- **置信度门控(v1.5)**:仅当验证码通过关键词智能提取命中(`code_confidence=high`)时才视为成功。
|
||||
若邮件中无验证码关键词上下文、仅 fallback 匹配到普通数字(`code_confidence=low`),接口将返回 `404 VERIFICATION_CODE_NOT_FOUND`,而非此前的 `200 OK`。
|
||||
调用方可通过 `from_contains`、`subject_contains`、`code_regex` 等参数缩窄匹配范围,提升命中准确率。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
@@ -410,11 +423,13 @@ curl -H "X-API-Key: your-api-key" \
|
||||
"message": "success",
|
||||
"data": {
|
||||
"verification_code": "123456",
|
||||
"verification_link": "",
|
||||
"verification_link": null,
|
||||
"links": [],
|
||||
"formatted": "123456",
|
||||
"match_source": "content",
|
||||
"confidence": "high",
|
||||
"code_confidence": "high",
|
||||
"link_confidence": "low",
|
||||
"email": "user@outlook.com",
|
||||
"matched_email_id": "msg-1",
|
||||
"from": "noreply@example.com",
|
||||
@@ -425,10 +440,20 @@ curl -H "X-API-Key: your-api-key" \
|
||||
}
|
||||
```
|
||||
|
||||
> 注:低置信度字段在成功响应中会被置为 `null`(非空字符串)。仅高置信度命中的字段会返回实际值。
|
||||
|
||||
响应字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `confidence` | 向后兼容总置信度(`code_confidence` 与 `link_confidence` 中较高者) |
|
||||
| `code_confidence` | 验证码置信度:`high`=关键词上下文命中或调用方指定 `code_regex` 精确匹配命中;`low`=仅 fallback 扫描命中或未命中 |
|
||||
| `link_confidence` | 验证链接置信度:`high`=链接 URL 含验证关键词(verify/confirm/activate/validation)或邮件正文含强验证语境短语(需带对象名词如 email/account);`low`=任意首链接且无验证语境 |
|
||||
|
||||
失败场景:
|
||||
|
||||
- 没有命中邮件:`404 MAIL_NOT_FOUND`
|
||||
- 命中邮件但未提取到验证码:`404 VERIFICATION_CODE_NOT_FOUND`
|
||||
- 命中邮件但未提取到**可信**验证码:`404 VERIFICATION_CODE_NOT_FOUND`
|
||||
|
||||
### 5.2 `GET /api/external/verification-link`
|
||||
|
||||
@@ -437,6 +462,8 @@ curl -H "X-API-Key: your-api-key" \
|
||||
特别说明:
|
||||
|
||||
- 未显式传入 `since_minutes` 时,该接口默认只扫描最近 `10` 分钟的邮件
|
||||
- **置信度门控(v1.5)**:仅当链接 URL 包含验证关键词(verify/confirm/activate/validation),或邮件正文/主题含强验证语境短语(如 "verify your email"、"confirm your account"、"activate your account"、"邮箱验证" 等带对象名词的完整短语)时才视为成功(`link_confidence=high`)。
|
||||
若邮件中仅包含普通跳转链接且无验证语境(`link_confidence=low`),接口将返回 `404 VERIFICATION_LINK_NOT_FOUND`。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
@@ -446,13 +473,15 @@ curl -H "X-API-Key: your-api-key" \
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"verification_code": "",
|
||||
"verification_code": null,
|
||||
"verification_link": "https://example.com/verify?token=abc",
|
||||
"links": [
|
||||
"https://example.com/verify?token=abc"
|
||||
],
|
||||
"formatted": "https://example.com/verify?token=abc",
|
||||
"confidence": "high",
|
||||
"code_confidence": "low",
|
||||
"link_confidence": "high",
|
||||
"email": "user@outlook.com",
|
||||
"matched_email_id": "msg-1",
|
||||
"from": "noreply@example.com",
|
||||
@@ -466,7 +495,7 @@ curl -H "X-API-Key: your-api-key" \
|
||||
失败场景:
|
||||
|
||||
- 没有命中邮件:`404 MAIL_NOT_FOUND`
|
||||
- 命中邮件但未提取到验证链接:`404 VERIFICATION_LINK_NOT_FOUND`
|
||||
- 命中邮件但未提取到**可信**验证链接:`404 VERIFICATION_LINK_NOT_FOUND`
|
||||
|
||||
### 5.3 `GET /api/external/wait-message`
|
||||
|
||||
@@ -578,15 +607,18 @@ curl -H "X-API-Key: your-api-key" \
|
||||
"service": "outlook-email-plus",
|
||||
"version": "0.1.0-draft",
|
||||
"server_time_utc": "2026-03-08T12:00:00Z",
|
||||
"database": "ok"
|
||||
"database": "ok",
|
||||
"upstream_probe_ok": true,
|
||||
"last_probe_at": "2026-03-12T12:00:00Z",
|
||||
"last_probe_error": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 该接口当前偏向“服务与数据库存活”
|
||||
- 不代表上游 Graph / IMAP 一定真实可读
|
||||
- 该接口仍以“服务与数据库存活”为主
|
||||
- 但会补充返回实例级最近一次真实上游读取探测摘要
|
||||
|
||||
### 6.2 `GET /api/external/capabilities`
|
||||
|
||||
@@ -640,7 +672,11 @@ curl -H "X-API-Key: your-api-key" \
|
||||
"status": "active",
|
||||
"last_refresh_at": null,
|
||||
"preferred_method": "graph",
|
||||
"can_read": true
|
||||
"can_read": true,
|
||||
"upstream_probe_ok": true,
|
||||
"probe_method": "Graph API",
|
||||
"last_probe_at": "2026-03-12T12:00:00Z",
|
||||
"last_probe_error": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -648,7 +684,7 @@ curl -H "X-API-Key: your-api-key" \
|
||||
注意:
|
||||
|
||||
- `can_read=true` 仅表示基础配置字段存在
|
||||
- 不等于已经完成真实拉信探测
|
||||
- `upstream_probe_ok` 才表示最近一次真实拉信探测结果
|
||||
|
||||
## 七、管理员配置接口说明
|
||||
|
||||
@@ -662,6 +698,9 @@ curl -H "X-API-Key: your-api-key" \
|
||||
|------|------|
|
||||
| `external_api_key_set` | 是否已配置开放 API Key |
|
||||
| `external_api_key_masked` | 脱敏后的 Key 展示值 |
|
||||
| `external_api_keys` | 多 API Key 列表(含 `id`、`name`、`enabled`、`allowed_emails`、`api_key_masked`、当日调用统计) |
|
||||
| `external_api_keys_count` | 多 API Key 数量 |
|
||||
| `external_api_multi_key_set` | 是否已配置多 API Key |
|
||||
|
||||
### 7.2 `PUT /api/settings`
|
||||
|
||||
@@ -672,17 +711,21 @@ curl -H "X-API-Key: your-api-key" \
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `external_api_key` | string | 设置新的开放 API Key;传空字符串表示清空 |
|
||||
| `external_api_keys` | array | 整体替换多 API Key 配置;元素支持 `id`、`name`、`api_key`、`enabled`、`allowed_emails` |
|
||||
|
||||
说明:
|
||||
|
||||
- 配置写入后不会明文回显
|
||||
- 实际存储会走现有敏感配置加密逻辑
|
||||
- `PUT /api/settings` 采用事务式提交;当请求中任一设置项校验失败时,本次所有变更都会回滚
|
||||
- `external_api_keys` 采用“全量替换”语义,删除某个 Key 时不再提交该项即可
|
||||
- 更新已有 Key 时可继续提交 `api_key_masked` 占位值,后端会保留原 Key
|
||||
|
||||
## 八、接入建议
|
||||
|
||||
推荐接入顺序:
|
||||
|
||||
1. 管理员登录后台,通过 `/api/settings` 配置 `external_api_key`
|
||||
1. 管理员登录后台,通过 `/api/settings` 配置 legacy `external_api_key` 或 `external_api_keys`
|
||||
2. 调用 `/api/external/health` 检查基础可用性
|
||||
3. 调用 `/api/external/account-status?email=...` 确认目标邮箱存在
|
||||
4. 视业务选择 `/messages`、`/verification-code`、`/verification-link`
|
||||
@@ -691,9 +734,9 @@ curl -H "X-API-Key: your-api-key" \
|
||||
## 九、已知限制
|
||||
|
||||
- 当前仅适合受控私有接入,不建议直接公网暴露
|
||||
- 当前只有单 `external_api_key`,默认具备读取本实例全部已配置邮箱的能力边界
|
||||
- `wait-message` 为同步轮询实现,不适合高并发公网场景
|
||||
- `/api/external/health` 与 `/api/external/account-status` 当前仍偏轻量,不等价于真实上游探测
|
||||
- 设置页前端已提供 `external_api_keys` 的 JSON 文本编辑入口,可直接维护多 Key、启停状态和邮箱范围授权
|
||||
|
||||
## 十、当前接口问题与处理口径
|
||||
|
||||
@@ -719,21 +762,21 @@ curl -H "X-API-Key: your-api-key" \
|
||||
- P0/P1 不为此单独新增查询接口
|
||||
- 如未来确实需要全局消息索引,再考虑在新版本中调整
|
||||
|
||||
### 10.2 `health` 与 `account-status` 当前是轻量自检,不是真实探测
|
||||
### 10.2 `health` 与 `account-status` 当前是“轻量自检 + 最近探测摘要”
|
||||
|
||||
现状:
|
||||
|
||||
- `/api/external/health` 主要反映服务和数据库是否存活
|
||||
- `/api/external/account-status` 主要反映账号是否存在、基础配置是否完整
|
||||
- `/api/external/health` 主要反映服务和数据库是否存活,并返回实例级最近探测摘要
|
||||
- `/api/external/account-status` 主要反映账号是否存在、基础配置是否完整,并返回该账号最近探测摘要
|
||||
|
||||
问题:
|
||||
|
||||
- 它们不能证明 Graph / IMAP 在当前时刻一定可读
|
||||
- 它们返回的是“最近一次探测结果”,不是持续实时健康流
|
||||
|
||||
处理口径:
|
||||
|
||||
- 当前文档明确按“轻量自检”解释这两个接口
|
||||
- P1 优先扩展字段,不优先新增同类接口
|
||||
- 当前文档明确按“轻量自检 + 最近探测摘要”解释这两个接口
|
||||
- 如需更强诊断能力,再单独演进 probe 服务
|
||||
|
||||
### 10.3 `/raw` 接口敏感度高
|
||||
|
||||
@@ -768,42 +811,44 @@ curl -H "X-API-Key: your-api-key" \
|
||||
- P1 若进入公网模式,应优先禁用或限制
|
||||
- P2 再考虑是否替换为异步模型
|
||||
|
||||
### 10.5 单 API Key 权限边界较粗
|
||||
### 10.5 多 API Key 已支持,但配额仍以审计统计为主
|
||||
|
||||
现状:
|
||||
|
||||
- 当前只有一个 `external_api_key`
|
||||
- 默认可读取本实例全部已配置邮箱
|
||||
- 当前已支持多个 `external_api_keys`
|
||||
- 每个 Key 可限制 `allowed_emails`
|
||||
- 审计日志已记录 `consumer_id` / `consumer_name`
|
||||
- `/api/settings` 已返回调用方当日聚合统计
|
||||
|
||||
问题:
|
||||
|
||||
- 不适合多调用方或复杂权限边界
|
||||
- 目前仍未实现“超阈值即拒绝”的硬性调用方限额拦截
|
||||
- 暂无独立开发者门户或专门配额管理页面
|
||||
|
||||
处理口径:
|
||||
|
||||
- 当前仅按单可信调用方场景验收
|
||||
- P2 再做多 API Key 和范围授权
|
||||
- 当前已满足多调用方受控接入与基础配额审计
|
||||
- 如需硬性 quota / 套餐化能力,再单独扩展平台层
|
||||
|
||||
## 十一、后续阶段接口文档预留
|
||||
|
||||
本节用于说明后续如果进入 P1/P2,接口文档应优先怎么演进。
|
||||
本节用于说明后续如果继续进入更深的 P1/P2,接口文档应优先怎么演进。
|
||||
|
||||
### 11.1 P1:优先扩展现有接口字段
|
||||
|
||||
#### `GET /api/external/health`
|
||||
|
||||
建议后续增加但当前不承诺的字段:
|
||||
当前已实现字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `upstream_probe_ok` | 上游读取链路最近一次探测是否成功 |
|
||||
| `last_probe_at` | 最近一次探测时间 |
|
||||
| `last_probe_error` | 最近一次探测错误摘要 |
|
||||
| `public_mode` | 当前是否处于公网模式 |
|
||||
|
||||
#### `GET /api/external/capabilities`
|
||||
|
||||
建议后续增加但当前不承诺的字段:
|
||||
当前已实现字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
@@ -813,7 +858,7 @@ curl -H "X-API-Key: your-api-key" \
|
||||
|
||||
#### `GET /api/external/account-status`
|
||||
|
||||
建议后续增加但当前不承诺的字段:
|
||||
当前已实现字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
@@ -849,8 +894,7 @@ P1 更推荐补的是控制能力,而不是新增更多邮件接口:
|
||||
|
||||
当前不提前承诺新增以下路径:
|
||||
|
||||
- 异步等待接口
|
||||
- 多调用方管理接口
|
||||
- 配额管理接口
|
||||
- 更细粒度的开发者门户接口
|
||||
- 硬性 quota 套餐化管理接口
|
||||
|
||||
如果未来必须新增,建议进入新版本或新增专门的管理域,而不是继续把所有能力堆在 `/api/external/*` 主业务路径下。
|
||||
@@ -8,7 +8,9 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from outlook_web.audit import log_audit
|
||||
from outlook_web.repositories import accounts as accounts_repo
|
||||
from outlook_web.repositories import external_api_keys as external_api_keys_repo
|
||||
from outlook_web.repositories import groups as groups_repo
|
||||
from outlook_web.security.auth import get_external_api_consumer
|
||||
from outlook_web.services import graph as graph_service
|
||||
from outlook_web.services import imap as imap_service
|
||||
from outlook_web.services.imap_generic import get_email_detail_imap_generic, get_emails_imap_generic
|
||||
@@ -67,6 +69,11 @@ class UpstreamReadFailedError(ExternalApiError):
|
||||
status = 502
|
||||
|
||||
|
||||
class EmailScopeForbiddenError(ExternalApiError):
|
||||
code = "EMAIL_SCOPE_FORBIDDEN"
|
||||
status = 403
|
||||
|
||||
|
||||
def ok(data: Any = None, *, message: str = "success") -> Dict[str, Any]:
|
||||
return {"success": True, "code": "OK", "message": message, "data": data}
|
||||
|
||||
@@ -126,6 +133,25 @@ def _extract_email_address(value: str) -> str:
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def get_current_external_api_consumer() -> Dict[str, Any]:
|
||||
return get_external_api_consumer() or {}
|
||||
|
||||
|
||||
def ensure_external_email_access(email_addr: str) -> None:
|
||||
consumer = get_current_external_api_consumer()
|
||||
allowed_emails = [str(item or "").strip().lower() for item in (consumer.get("allowed_emails") or [])]
|
||||
target_email = str(email_addr or "").strip().lower()
|
||||
if allowed_emails and target_email not in allowed_emails:
|
||||
raise EmailScopeForbiddenError(
|
||||
"当前 API Key 无权访问该邮箱",
|
||||
data={
|
||||
"email": email_addr,
|
||||
"consumer_id": consumer.get("id"),
|
||||
"consumer_name": consumer.get("name"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_message_summary(email_addr: str, item: Dict[str, Any], *, method: str) -> Dict[str, Any]:
|
||||
raw_from = item.get("from")
|
||||
if isinstance(raw_from, dict):
|
||||
@@ -185,6 +211,187 @@ def require_account(email_addr: str) -> Dict[str, Any]:
|
||||
return account
|
||||
|
||||
|
||||
def _preferred_probe_method(account: Dict[str, Any]) -> str:
|
||||
account_type = (account.get("account_type") or "outlook").strip().lower()
|
||||
return "imap_generic" if account_type == "imap" else "graph"
|
||||
|
||||
|
||||
def _account_can_read(account: Dict[str, Any]) -> bool:
|
||||
status = (account.get("status") or "active").strip().lower()
|
||||
if status == "disabled":
|
||||
return False
|
||||
account_type = (account.get("account_type") or "outlook").strip().lower()
|
||||
if account_type == "imap":
|
||||
return bool((account.get("imap_host") or "").strip()) and bool((account.get("imap_password") or "").strip())
|
||||
return bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip())
|
||||
|
||||
|
||||
def _probe_now_iso() -> str:
|
||||
return _utcnow().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _probe_summary_from_row(row: Any) -> Dict[str, Any]:
|
||||
if not row:
|
||||
return {
|
||||
"upstream_probe_ok": None,
|
||||
"probe_method": "",
|
||||
"last_probe_at": "",
|
||||
"last_probe_error": "",
|
||||
}
|
||||
return {
|
||||
"upstream_probe_ok": None if row["probe_ok"] is None else bool(row["probe_ok"]),
|
||||
"probe_method": row["probe_method"] or "",
|
||||
"last_probe_at": row["last_probe_at"] or "",
|
||||
"last_probe_error": row["last_probe_error"] or "",
|
||||
}
|
||||
|
||||
|
||||
def get_upstream_probe_summary(scope_type: str, scope_key: str) -> Dict[str, Any]:
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT scope_type, scope_key, email_addr, probe_method, probe_ok, last_probe_at, last_probe_error
|
||||
FROM external_upstream_probes
|
||||
WHERE scope_type = ? AND scope_key = ?
|
||||
""",
|
||||
(scope_type, scope_key),
|
||||
).fetchone()
|
||||
return _probe_summary_from_row(row)
|
||||
|
||||
|
||||
def _is_probe_summary_fresh(summary: Dict[str, Any], cache_ttl_seconds: int) -> bool:
|
||||
last_probe_at = summary.get("last_probe_at") or ""
|
||||
if not last_probe_at:
|
||||
return False
|
||||
probed_at = _parse_datetime(last_probe_at)
|
||||
if not probed_at:
|
||||
return False
|
||||
age_seconds = (_utcnow() - probed_at).total_seconds()
|
||||
return age_seconds <= max(0, int(cache_ttl_seconds))
|
||||
|
||||
|
||||
def record_upstream_probe_summary(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_key: str,
|
||||
email_addr: str,
|
||||
probe_ok: Optional[bool],
|
||||
probe_method: str = "",
|
||||
last_probe_error: str = "",
|
||||
last_probe_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
probe_at = last_probe_at or _probe_now_iso()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO external_upstream_probes
|
||||
(scope_type, scope_key, email_addr, probe_method, probe_ok, last_probe_at, last_probe_error, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(scope_type, scope_key)
|
||||
DO UPDATE SET
|
||||
email_addr = excluded.email_addr,
|
||||
probe_method = excluded.probe_method,
|
||||
probe_ok = excluded.probe_ok,
|
||||
last_probe_at = excluded.last_probe_at,
|
||||
last_probe_error = excluded.last_probe_error,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
scope_type,
|
||||
scope_key,
|
||||
email_addr or "",
|
||||
probe_method or "",
|
||||
None if probe_ok is None else int(bool(probe_ok)),
|
||||
probe_at,
|
||||
str(last_probe_error or "")[:500],
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"upstream_probe_ok": probe_ok,
|
||||
"probe_method": probe_method or "",
|
||||
"last_probe_at": probe_at,
|
||||
"last_probe_error": str(last_probe_error or "")[:500],
|
||||
}
|
||||
|
||||
|
||||
def _probe_error_message(exc: Exception) -> str:
|
||||
if isinstance(exc, ExternalApiError):
|
||||
return str(exc.message or exc.code or "探测失败")
|
||||
return str(exc)[:500] or type(exc).__name__
|
||||
|
||||
|
||||
def probe_account_upstream(
|
||||
account: Dict[str, Any],
|
||||
*,
|
||||
folder: str = "inbox",
|
||||
cache_ttl_seconds: int = 60,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
email_addr = str(account.get("email") or "").strip()
|
||||
preferred_method = _preferred_probe_method(account)
|
||||
cached = get_upstream_probe_summary("account", email_addr) if email_addr else {}
|
||||
if email_addr and (not force) and _is_probe_summary_fresh(cached, cache_ttl_seconds):
|
||||
return cached
|
||||
|
||||
last_probe_at = _probe_now_iso()
|
||||
try:
|
||||
_emails, method = list_messages_for_external(email_addr=email_addr, folder=folder, top=1, skip=0)
|
||||
summary = record_upstream_probe_summary(
|
||||
scope_type="account",
|
||||
scope_key=email_addr,
|
||||
email_addr=email_addr,
|
||||
probe_ok=True,
|
||||
probe_method=str(method or preferred_method),
|
||||
last_probe_error="",
|
||||
last_probe_at=last_probe_at,
|
||||
)
|
||||
except Exception as exc:
|
||||
summary = record_upstream_probe_summary(
|
||||
scope_type="account",
|
||||
scope_key=email_addr,
|
||||
email_addr=email_addr,
|
||||
probe_ok=False,
|
||||
probe_method=preferred_method,
|
||||
last_probe_error=_probe_error_message(exc),
|
||||
last_probe_at=last_probe_at,
|
||||
)
|
||||
record_upstream_probe_summary(
|
||||
scope_type="instance",
|
||||
scope_key="__instance__",
|
||||
email_addr=email_addr,
|
||||
probe_ok=summary.get("upstream_probe_ok"),
|
||||
probe_method=summary.get("probe_method") or preferred_method,
|
||||
last_probe_error=summary.get("last_probe_error") or "",
|
||||
last_probe_at=summary.get("last_probe_at") or last_probe_at,
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def _pick_instance_probe_account() -> Optional[Dict[str, Any]]:
|
||||
candidates = accounts_repo.load_accounts()
|
||||
for account in candidates:
|
||||
if _account_can_read(account):
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def probe_instance_upstream(*, cache_ttl_seconds: int = 60, force: bool = False) -> Dict[str, Any]:
|
||||
cached = get_upstream_probe_summary("instance", "__instance__")
|
||||
if (not force) and (cached.get("last_probe_at") or ""):
|
||||
return cached
|
||||
|
||||
account = _pick_instance_probe_account()
|
||||
if not account:
|
||||
return cached
|
||||
|
||||
return probe_account_upstream(account, cache_ttl_seconds=cache_ttl_seconds, force=force)
|
||||
|
||||
|
||||
def list_messages_for_external(
|
||||
*,
|
||||
email_addr: str,
|
||||
@@ -497,6 +704,16 @@ def get_verification_result(
|
||||
code_length=code_length,
|
||||
code_source=code_source,
|
||||
)
|
||||
|
||||
# ── 可信度门控:非高置信度结果不作为 external API 成功输出 ──
|
||||
if extracted.get("code_confidence") != "high":
|
||||
extracted["verification_code"] = None
|
||||
if extracted.get("link_confidence") != "high":
|
||||
extracted["verification_link"] = None
|
||||
# 重算 formatted(与清零后的值保持一致)
|
||||
parts = [v for v in (extracted.get("verification_code"), extracted.get("verification_link")) if v]
|
||||
extracted["formatted"] = " ".join(parts) if parts else None
|
||||
|
||||
extracted["email"] = email_addr
|
||||
extracted["matched_email_id"] = message_id
|
||||
extracted["from"] = detail.get("from_address") or latest_summary.get("from_address") or ""
|
||||
@@ -802,6 +1019,18 @@ def audit_external_api_access(
|
||||
details: Dict[str, Any] | None = None,
|
||||
):
|
||||
safe_details: Dict[str, Any] = {"endpoint": endpoint, "status": status}
|
||||
consumer = get_current_external_api_consumer()
|
||||
if consumer:
|
||||
safe_details.update(
|
||||
{
|
||||
"consumer_id": consumer.get("id"),
|
||||
"consumer_key": consumer.get("consumer_key"),
|
||||
"consumer_name": consumer.get("name"),
|
||||
"consumer_source": consumer.get("source"),
|
||||
}
|
||||
)
|
||||
if consumer.get("allowed_emails"):
|
||||
safe_details["consumer_allowed_emails"] = consumer.get("allowed_emails")
|
||||
if details:
|
||||
# 避免日志中输出敏感信息(如 API Key)
|
||||
safe_details.update(details)
|
||||
@@ -812,3 +1041,13 @@ def audit_external_api_access(
|
||||
details_text = str(safe_details)
|
||||
|
||||
log_audit(action, "external_api", email_addr, details_text)
|
||||
try:
|
||||
if consumer:
|
||||
external_api_keys_repo.record_external_api_consumer_usage(
|
||||
consumer_key=str(consumer.get("consumer_key") or ""),
|
||||
consumer_name=str(consumer.get("name") or ""),
|
||||
endpoint=endpoint,
|
||||
status=status,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -49,6 +49,38 @@ DEFAULT_LINK_KEYWORDS = [
|
||||
"validation",
|
||||
]
|
||||
|
||||
# 链接语境提权:仅与"验证/激活账户/确认邮箱"强相关的完整短语
|
||||
# 必须带对象名词(email/account/邮箱/账户),避免 "confirm your order" 等交易邮件误触
|
||||
LINK_CONTEXT_PHRASES = [
|
||||
"verify your email",
|
||||
"verify your account",
|
||||
"verify your address",
|
||||
"confirm your email",
|
||||
"confirm your account",
|
||||
"confirm your address",
|
||||
"activate your email",
|
||||
"activate your account",
|
||||
"email verification",
|
||||
"account verification",
|
||||
"验证您的邮箱",
|
||||
"验证你的邮箱",
|
||||
"验证您的账户",
|
||||
"验证你的账户",
|
||||
"验证您的账号",
|
||||
"验证你的账号",
|
||||
"确认您的邮箱",
|
||||
"确认你的邮箱",
|
||||
"确认您的账户",
|
||||
"确认你的账户",
|
||||
"激活您的账户",
|
||||
"激活你的账户",
|
||||
"激活您的邮箱",
|
||||
"激活你的邮箱",
|
||||
"邮箱验证",
|
||||
"账号验证",
|
||||
"账户验证",
|
||||
]
|
||||
|
||||
|
||||
class HTMLTextExtractor(HTMLParser):
|
||||
"""HTML 转纯文本提取器"""
|
||||
@@ -490,7 +522,9 @@ def extract_verification_info_with_options(
|
||||
- links
|
||||
- formatted
|
||||
- match_source
|
||||
- confidence
|
||||
- confidence (向后兼容:code/link 中较高者)
|
||||
- code_confidence (high=关键词命中或调用方指定code_regex命中, low=仅 fallback 或未命中)
|
||||
- link_confidence (high=链接含验证关键词或邮件正文含强验证语境短语, low=任意首链接且无验证语境)
|
||||
|
||||
注意:该函数主要服务外部 API,不主动抛“未找到验证码/链接”的异常,方便上层按需映射错误码。
|
||||
"""
|
||||
@@ -513,20 +547,40 @@ def extract_verification_info_with_options(
|
||||
match_source = "all"
|
||||
|
||||
code_re = _build_code_regex(code_regex=code_regex, code_length=code_length)
|
||||
# 仅 code_regex 具有判别力,code_length 只是宽度约束,不自动提权
|
||||
caller_directed_code = bool(code_regex)
|
||||
|
||||
# ── 验证码提取 & 置信度 ──
|
||||
verification_code = _smart_extract_code_by_keywords(source_text, code_re)
|
||||
confidence = "high" if verification_code else "low"
|
||||
code_confidence: str = "high" if verification_code else "low"
|
||||
if not verification_code:
|
||||
verification_code = _fallback_extract_code(source_text, code_re)
|
||||
# 调用方显式指定了 code_regex(强判别力正则)→ 提取命中即视为可信
|
||||
if verification_code and caller_directed_code:
|
||||
code_confidence = "high"
|
||||
|
||||
# ── 链接提取 & 置信度 ──
|
||||
links = extract_links(f"{subject} {content} {html_content}".strip())
|
||||
prefer_keywords = prefer_link_keywords or DEFAULT_LINK_KEYWORDS
|
||||
verification_link = _pick_preferred_link(links, prefer_keywords)
|
||||
if verification_link and confidence != "high":
|
||||
|
||||
link_confidence: str = "low"
|
||||
if verification_link:
|
||||
# 优先检查 URL 本身是否含验证关键词
|
||||
for kw in prefer_keywords:
|
||||
if kw and kw.lower() in verification_link.lower():
|
||||
confidence = "high"
|
||||
link_confidence = "high"
|
||||
break
|
||||
# URL 不含关键词时,检查邮件正文/主题是否有强验证语境短语
|
||||
if link_confidence != "high":
|
||||
full_text_lower = f"{subject} {content}".lower()
|
||||
for phrase in LINK_CONTEXT_PHRASES:
|
||||
if phrase.lower() in full_text_lower:
|
||||
link_confidence = "high"
|
||||
break
|
||||
|
||||
# 总 confidence 向后兼容:取 code / link 中较高者
|
||||
confidence = "high" if code_confidence == "high" or link_confidence == "high" else "low"
|
||||
|
||||
parts: List[str] = []
|
||||
if verification_code:
|
||||
@@ -542,4 +596,6 @@ def extract_verification_info_with_options(
|
||||
"formatted": formatted,
|
||||
"match_source": match_source,
|
||||
"confidence": confidence,
|
||||
"code_confidence": code_confidence,
|
||||
"link_confidence": link_confidence,
|
||||
}
|
||||
+534
-5
@@ -22,6 +22,10 @@ class ExternalApiBaseTest(unittest.TestCase):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM audit_logs WHERE resource_type = 'external_api'")
|
||||
db.execute("DELETE FROM accounts WHERE email LIKE '%@extapi.test'")
|
||||
db.execute("DELETE FROM external_api_keys")
|
||||
db.execute("DELETE FROM external_api_consumer_usage_daily")
|
||||
db.execute("DELETE FROM external_upstream_probes")
|
||||
db.execute("DELETE FROM external_probe_cache")
|
||||
db.commit()
|
||||
settings_repo.set_setting("external_api_key", "")
|
||||
|
||||
@@ -31,6 +35,24 @@ class ExternalApiBaseTest(unittest.TestCase):
|
||||
|
||||
settings_repo.set_setting("external_api_key", value)
|
||||
|
||||
def _create_external_api_key(
|
||||
self,
|
||||
name: str,
|
||||
api_key: str,
|
||||
*,
|
||||
allowed_emails: list[str] | None = None,
|
||||
enabled: bool = True,
|
||||
):
|
||||
with self.app.app_context():
|
||||
from outlook_web.repositories import external_api_keys as external_api_keys_repo
|
||||
|
||||
return external_api_keys_repo.create_external_api_key(
|
||||
name=name,
|
||||
api_key=api_key,
|
||||
allowed_emails=allowed_emails or [],
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
def _insert_outlook_account(self, email_addr: str | None = None) -> str:
|
||||
email_addr = email_addr or f"{uuid.uuid4().hex}@extapi.test"
|
||||
with self.app.app_context():
|
||||
@@ -127,6 +149,20 @@ class ExternalApiBaseTest(unittest.TestCase):
|
||||
""").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _external_consumer_usage_rows(self):
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT consumer_key, consumer_name, endpoint, total_count, success_count, error_count
|
||||
FROM external_api_consumer_usage_daily
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
class ExternalApiAuthTests(ExternalApiBaseTest):
|
||||
def test_external_health_requires_api_key(self):
|
||||
@@ -159,6 +195,44 @@ class ExternalApiAuthTests(ExternalApiBaseTest):
|
||||
self.assertTrue(data.get("success"))
|
||||
self.assertEqual(data.get("code"), "OK")
|
||||
|
||||
def test_external_health_accepts_valid_multi_api_key(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key("partner-a", "multi-123")
|
||||
|
||||
resp = client.get("/api/external/health", headers=self._auth_headers("multi-123"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.get_json().get("success"))
|
||||
|
||||
def test_disabled_multi_api_key_rejected(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key("partner-a", "multi-123", enabled=False)
|
||||
|
||||
resp = client.get("/api/external/health", headers=self._auth_headers("multi-123"))
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.get_json().get("code"), "API_KEY_NOT_CONFIGURED")
|
||||
|
||||
def test_disabled_multi_api_key_returns_401_when_other_enabled_key_exists(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key("partner-a", "multi-123", enabled=False)
|
||||
self._create_external_api_key("partner-b", "multi-456", enabled=True)
|
||||
|
||||
resp = client.get("/api/external/health", headers=self._auth_headers("multi-123"))
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertEqual(resp.get_json().get("code"), "UNAUTHORIZED")
|
||||
|
||||
def test_legacy_external_api_key_still_works_when_multi_keys_exist(self):
|
||||
client = self.app.test_client()
|
||||
self._create_external_api_key("partner-a", "multi-123")
|
||||
self._set_external_api_key("legacy-123")
|
||||
|
||||
resp = client.get("/api/external/health", headers=self._auth_headers("legacy-123"))
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.get_json().get("success"))
|
||||
|
||||
|
||||
class ExternalApiMessageTests(ExternalApiBaseTest):
|
||||
@patch("outlook_web.services.graph.get_emails_graph")
|
||||
@@ -210,6 +284,106 @@ class ExternalApiMessageTests(ExternalApiBaseTest):
|
||||
self.assertEqual(data.get("code"), "OK")
|
||||
self.assertEqual(len(data.get("data", {}).get("emails", [])), 1)
|
||||
|
||||
|
||||
class ExternalApiKeyScopeTests(ExternalApiBaseTest):
|
||||
@patch("outlook_web.services.graph.get_emails_graph")
|
||||
def test_external_messages_allows_email_within_key_scope(self, mock_get_emails_graph):
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._create_external_api_key("partner-a", "scope-123", allowed_emails=[email_addr])
|
||||
mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]}
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/messages?email={email_addr}",
|
||||
headers=self._auth_headers("scope-123"),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.get_json().get("success"))
|
||||
|
||||
def test_external_messages_reject_email_outside_key_scope(self):
|
||||
allowed_email = self._insert_outlook_account()
|
||||
denied_email = self._insert_outlook_account()
|
||||
self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email])
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/messages?email={denied_email}",
|
||||
headers=self._auth_headers("scope-123"),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN")
|
||||
|
||||
def test_external_account_status_reject_email_outside_key_scope(self):
|
||||
allowed_email = self._insert_outlook_account()
|
||||
denied_email = self._insert_outlook_account()
|
||||
self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email])
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/account-status?email={denied_email}",
|
||||
headers=self._auth_headers("scope-123"),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN")
|
||||
|
||||
def test_probe_status_rejects_email_outside_key_scope(self):
|
||||
allowed_email = self._insert_outlook_account()
|
||||
denied_email = self._insert_outlook_account()
|
||||
self._create_external_api_key("partner-a", "scope-123", allowed_emails=[allowed_email])
|
||||
|
||||
with self.app.app_context():
|
||||
from outlook_web.db import get_db
|
||||
|
||||
db = get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
future = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO external_probe_cache
|
||||
(id, email_addr, status, timeout_seconds, poll_interval, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', 30, 5, ?, ?, ?)
|
||||
""",
|
||||
("scope-probe-1", denied_email, future, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get("/api/external/probe/scope-probe-1", headers=self._auth_headers("scope-123"))
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertEqual(resp.get_json().get("code"), "EMAIL_SCOPE_FORBIDDEN")
|
||||
|
||||
|
||||
class ExternalApiConsumerAuditTests(ExternalApiBaseTest):
|
||||
@patch("outlook_web.services.graph.get_emails_graph")
|
||||
def test_multi_key_request_records_consumer_metadata_and_usage(self, mock_get_emails_graph):
|
||||
email_addr = self._insert_outlook_account()
|
||||
created = self._create_external_api_key("partner-a", "audit-123", allowed_emails=[email_addr])
|
||||
mock_get_emails_graph.return_value = {"success": True, "emails": [self._graph_email()]}
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/messages?email={email_addr}",
|
||||
headers=self._auth_headers("audit-123"),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
audit_logs = self._external_audit_logs()
|
||||
self.assertEqual(len(audit_logs), 1)
|
||||
self.assertIn('"consumer_name": "partner-a"', audit_logs[0]["details"])
|
||||
self.assertIn(f'"consumer_id": {created["id"]}', audit_logs[0]["details"])
|
||||
|
||||
usage_rows = self._external_consumer_usage_rows()
|
||||
self.assertEqual(len(usage_rows), 1)
|
||||
self.assertEqual(usage_rows[0]["consumer_key"], created["consumer_key"])
|
||||
self.assertEqual(usage_rows[0]["consumer_name"], "partner-a")
|
||||
self.assertEqual(usage_rows[0]["endpoint"], "/api/external/messages")
|
||||
self.assertEqual(usage_rows[0]["total_count"], 1)
|
||||
self.assertEqual(usage_rows[0]["success_count"], 1)
|
||||
|
||||
@patch("outlook_web.services.imap.get_emails_imap_with_server")
|
||||
@patch("outlook_web.services.graph.get_emails_graph")
|
||||
def test_external_messages_falls_back_to_imap_when_graph_fails(self, mock_get_emails_graph, mock_get_emails_imap):
|
||||
@@ -433,17 +607,34 @@ class ExternalApiSystemTests(ExternalApiBaseTest):
|
||||
def test_external_health_audits_access(self):
|
||||
client = self.app.test_client()
|
||||
self._set_external_api_key("abc123")
|
||||
with self.app.app_context():
|
||||
from outlook_web.services import external_api as external_api_service
|
||||
|
||||
external_api_service.record_upstream_probe_summary(
|
||||
scope_type="instance",
|
||||
scope_key="__instance__",
|
||||
email_addr="probe@extapi.test",
|
||||
probe_ok=True,
|
||||
probe_method="Graph API",
|
||||
last_probe_error="",
|
||||
last_probe_at=self._utc_iso(),
|
||||
)
|
||||
|
||||
resp = client.get("/api/external/health", headers=self._auth_headers())
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json().get("data", {})
|
||||
self.assertTrue(data.get("upstream_probe_ok"))
|
||||
self.assertTrue(data.get("last_probe_at"))
|
||||
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):
|
||||
@patch("outlook_web.services.external_api.graph_service.get_emails_graph")
|
||||
def test_external_account_status_returns_account_data_and_audits(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()]}
|
||||
client = self.app.test_client()
|
||||
|
||||
resp = client.get(
|
||||
@@ -461,11 +652,36 @@ class ExternalApiSystemTests(ExternalApiBaseTest):
|
||||
self.assertIn("preferred_method", data.get("data", {}))
|
||||
self.assertIn("last_refresh_at", data.get("data", {}))
|
||||
self.assertTrue(data.get("data", {}).get("can_read"))
|
||||
self.assertTrue(data.get("data", {}).get("upstream_probe_ok"))
|
||||
self.assertTrue(data.get("data", {}).get("last_probe_at"))
|
||||
self.assertEqual(data.get("data", {}).get("probe_method"), "Graph API")
|
||||
self.assertEqual(data.get("data", {}).get("last_probe_error"), "")
|
||||
|
||||
audit_logs = self._external_audit_logs()
|
||||
self.assertEqual(len(audit_logs), 1)
|
||||
self.assertIn("/api/external/account-status", audit_logs[0]["details"])
|
||||
|
||||
@patch("outlook_web.services.external_api.imap_service.get_emails_imap_with_server")
|
||||
@patch("outlook_web.services.external_api.graph_service.get_emails_graph")
|
||||
def test_external_account_status_probe_failure_returns_probe_summary(self, mock_get_emails_graph, mock_get_emails_imap):
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_get_emails_graph.return_value = {"success": False, "error": {"message": "token invalid"}}
|
||||
mock_get_emails_imap.return_value = {"success": False, "error": {"message": "imap fallback failed"}}
|
||||
client = self.app.test_client()
|
||||
|
||||
resp = client.get(
|
||||
f"/api/external/account-status?email={email_addr}",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json().get("data", {})
|
||||
self.assertFalse(data.get("upstream_probe_ok"))
|
||||
self.assertEqual(data.get("probe_method"), "graph")
|
||||
self.assertTrue(data.get("last_probe_at"))
|
||||
self.assertTrue(data.get("last_probe_error"))
|
||||
|
||||
|
||||
class ExternalApiRegressionTests(ExternalApiBaseTest):
|
||||
@patch("outlook_web.services.graph.get_emails_graph")
|
||||
@@ -604,7 +820,7 @@ class ExternalApiSchemaValidationTests(ExternalApiBaseTest):
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json().get("data", {})
|
||||
for key in ("status", "service", "version", "server_time_utc", "database"):
|
||||
for key in ("status", "service", "version", "server_time_utc", "database", "upstream_probe_ok", "last_probe_at", "last_probe_error"):
|
||||
self.assertIn(key, data, f"HealthData 缺少字段: {key}")
|
||||
|
||||
def test_capabilities_response_schema_has_required_fields(self):
|
||||
@@ -618,15 +834,17 @@ class ExternalApiSchemaValidationTests(ExternalApiBaseTest):
|
||||
self.assertIn(key, data, f"CapabilitiesData 缺少字段: {key}")
|
||||
self.assertIsInstance(data["features"], list)
|
||||
|
||||
def test_account_status_response_schema_has_required_fields(self):
|
||||
@patch("outlook_web.services.external_api.graph_service.get_emails_graph")
|
||||
def test_account_status_response_schema_has_required_fields(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()]}
|
||||
client = self.app.test_client()
|
||||
resp = client.get(f"/api/external/account-status?email={email_addr}", headers=self._auth_headers())
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json().get("data", {})
|
||||
for key in ("email", "exists"):
|
||||
for key in ("email", "exists", "upstream_probe_ok", "probe_method", "last_probe_at", "last_probe_error"):
|
||||
self.assertIn(key, data, f"AccountStatusData 缺少字段: {key}")
|
||||
self.assertIn("status", data, "AccountStatusData 应返回 status 字段")
|
||||
|
||||
@@ -1041,7 +1259,318 @@ class ExternalApiSystemErrorTests(ExternalApiBaseTest):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-REG-02, TC-REG-05: 旧接口回归补充
|
||||
# BUG-00017 止血:营销邮件误命中 & 低置信度拦截
|
||||
# ---------------------------------------------------------------------------
|
||||
class ExternalApiVerificationConfidenceTests(ExternalApiBaseTest):
|
||||
"""BUG-00017: 低置信度结果不再返回 200 OK"""
|
||||
|
||||
@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_marketing_email_code_returns_404(self, mock_list, mock_detail, mock_raw):
|
||||
"""营销邮件中的普通数字不应被当作成功验证码返回"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(
|
||||
subject="Runpod - 50% OFF GPU Instances",
|
||||
sender="marketing@runpod.io",
|
||||
)],
|
||||
}
|
||||
# 关键:detail 的 subject 也要与 email 列表一致(营销主题)
|
||||
marketing_detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "Runpod - 50% OFF GPU Instances",
|
||||
"from": {"emailAddress": {"address": "marketing@runpod.io"}},
|
||||
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
|
||||
"receivedDateTime": self._utc_iso(),
|
||||
"body": {"content": "Save big on 1181 new GPU instances! Order now for $2999/month.", "contentType": "text"},
|
||||
}
|
||||
mock_detail.return_value = marketing_detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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, "营销邮件数字不应返回 200 成功")
|
||||
self.assertEqual(resp.get_json().get("code"), "VERIFICATION_CODE_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_marketing_email_link_returns_404(self, mock_list, mock_detail, mock_raw):
|
||||
"""营销邮件中的普通链接不应被当作成功验证链接返回"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(
|
||||
subject="Weekly Newsletter - Check out new features",
|
||||
sender="news@example.com",
|
||||
)],
|
||||
}
|
||||
mock_detail.return_value = {
|
||||
"id": "msg-1",
|
||||
"subject": "Weekly Newsletter - Check out new features",
|
||||
"from": {"emailAddress": {"address": "news@example.com"}},
|
||||
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
|
||||
"receivedDateTime": self._utc_iso(),
|
||||
"body": {
|
||||
"content": "Read more at https://blog.example.com/latest-news and https://shop.example.com/deals",
|
||||
"contentType": "text",
|
||||
},
|
||||
}
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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, "营销链接不应返回 200 成功")
|
||||
self.assertEqual(resp.get_json().get("code"), "VERIFICATION_LINK_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_legit_verification_code_still_succeeds(self, mock_list, mock_detail, mock_raw):
|
||||
"""标准验证码邮件仍可正常成功提取(高置信度 → 200 OK)"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="Your verification code")],
|
||||
}
|
||||
mock_detail.return_value = self._graph_detail(
|
||||
body_text="Your verification code is 987654. Do not share this code.",
|
||||
)
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data.get("success"))
|
||||
self.assertEqual(data["data"]["verification_code"], "987654")
|
||||
self.assertEqual(data["data"]["code_confidence"], "high")
|
||||
|
||||
@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_legit_verification_link_still_succeeds(self, mock_list, mock_detail, mock_raw):
|
||||
"""标准验证链接邮件仍可正常成功提取(高置信度 → 200 OK)"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="Confirm your email address")],
|
||||
}
|
||||
mock_detail.return_value = self._graph_detail(
|
||||
body_text="Click https://auth.example.com/verify?token=abc to confirm your email.",
|
||||
)
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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, 200)
|
||||
data = resp.get_json()
|
||||
self.assertTrue(data.get("success"))
|
||||
self.assertIn("verify", data["data"]["verification_link"])
|
||||
self.assertEqual(data["data"]["link_confidence"], "high")
|
||||
|
||||
@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_low_confidence_code_response_includes_confidence_metadata(self, mock_list, mock_detail, mock_raw):
|
||||
"""低置信度返回 404 时,仍可从错误中辨别原因(非邮件不存在,而是无可信结果)"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="System Report")],
|
||||
}
|
||||
# 关键:detail 的 subject 也要是非验证码主题
|
||||
report_detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "System Report",
|
||||
"from": {"emailAddress": {"address": "noreply@example.com"}},
|
||||
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
|
||||
"receivedDateTime": self._utc_iso(),
|
||||
"body": {"content": "There are 445566 active users this quarter.", "contentType": "text"},
|
||||
}
|
||||
mock_detail.return_value = report_detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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"), "VERIFICATION_CODE_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_code_with_custom_regex_still_returns_high_confidence(self, mock_list, mock_detail, mock_raw):
|
||||
"""调用方传入 code_regex 精确匹配时,关键词命中仍返回 high confidence"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="Your OTP code")],
|
||||
}
|
||||
mock_detail.return_value = self._graph_detail(
|
||||
body_text="Your OTP code is AB1234. Enter it within 5 minutes.",
|
||||
)
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/verification-code?email={email_addr}&code_regex=%5Cb%5BA-Z0-9%5D%7B6%7D%5Cb",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.get_json()
|
||||
self.assertEqual(data["data"]["verification_code"], "AB1234")
|
||||
self.assertEqual(data["data"]["code_confidence"], "high")
|
||||
|
||||
@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_code_regex_without_keyword_context_still_succeeds(self, mock_list, mock_detail, mock_raw):
|
||||
"""code_regex 精确匹配,邮件无验证码关键词 → 仍应返回 200"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="Account notice")],
|
||||
}
|
||||
detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "Account notice",
|
||||
"from": {"emailAddress": {"address": "noreply@example.com"}},
|
||||
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
|
||||
"receivedDateTime": self._utc_iso(),
|
||||
"body": {"content": "Use AB1234 within 5 minutes to proceed.", "contentType": "text"},
|
||||
}
|
||||
mock_detail.return_value = detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
client = self.app.test_client()
|
||||
resp = client.get(
|
||||
f"/api/external/verification-code?email={email_addr}&code_regex=%5Cb%5BA-Z%5D%7B2%7D%5Cd%7B4%7D%5Cb",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, "code_regex 精确匹配不应被拦截")
|
||||
data = resp.get_json()
|
||||
self.assertEqual(data["data"]["verification_code"], "AB1234")
|
||||
self.assertEqual(data["data"]["code_confidence"], "high")
|
||||
|
||||
@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_opaque_verify_link_with_email_context_succeeds(self, mock_list, mock_detail, mock_raw):
|
||||
"""URL 不含验证关键词但邮件正文有验证语境 → 应返回 200"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {
|
||||
"success": True,
|
||||
"emails": [self._graph_email(subject="Please verify your account")],
|
||||
}
|
||||
detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "Please verify your account",
|
||||
"from": {"emailAddress": {"address": "noreply@example.com"}},
|
||||
"toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}],
|
||||
"receivedDateTime": self._utc_iso(),
|
||||
"body": {"content": "Click to verify your email: https://auth.example.com/t/abc123", "contentType": "text"},
|
||||
}
|
||||
mock_detail.return_value = detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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, 200, "邮件正文有验证语境时不应拦截链接")
|
||||
data = resp.get_json()
|
||||
self.assertIn("auth.example.com", data["data"]["verification_link"])
|
||||
self.assertEqual(data["data"]["link_confidence"], "high")
|
||||
|
||||
@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_discount_code_email_link_returns_404(self, mock_list, mock_detail, mock_raw):
|
||||
"""营销邮件正文含 'discount code' + 普通链接 → 不应被提权,应返回 404"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {"success": True, "emails": [self._graph_email()]}
|
||||
detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "Your exclusive discount code inside!",
|
||||
"from_address": "deals@shop.example.com",
|
||||
"content": "Use discount code SAVE20 at https://shop.example.com/checkout",
|
||||
"html_content": "",
|
||||
"received_at": "2026-03-08T12:00:00Z",
|
||||
}
|
||||
mock_detail.return_value = detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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,
|
||||
"'discount code' 语境不应让普通链接通过门控")
|
||||
|
||||
@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_confirm_your_order_link_returns_404(self, mock_list, mock_detail, mock_raw):
|
||||
"""'confirm your order' 不是验证语境 → 普通链接应返回 404"""
|
||||
email_addr = self._insert_outlook_account()
|
||||
self._set_external_api_key("abc123")
|
||||
mock_list.return_value = {"success": True, "emails": [self._graph_email()]}
|
||||
detail = {
|
||||
"id": "msg-1",
|
||||
"subject": "Please confirm your order",
|
||||
"from_address": "orders@shop.example.com",
|
||||
"content": "Click here to confirm your order: https://shop.example.com/orders/status/789",
|
||||
"html_content": "",
|
||||
"received_at": "2026-03-08T12:00:00Z",
|
||||
}
|
||||
mock_detail.return_value = detail
|
||||
mock_raw.return_value = "RAW"
|
||||
|
||||
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,
|
||||
"'confirm your order' 不应让普通链接通过门控")
|
||||
# ---------------------------------------------------------------------------
|
||||
class ExternalApiRegressionExtendedTests(ExternalApiBaseTest):
|
||||
"""TC-REG-02, TC-REG-05"""
|
||||
|
||||
@@ -218,5 +218,264 @@ class VerificationExtractorEdgeCaseTests(unittest.TestCase):
|
||||
self.assertEqual(result.get("confidence"), "low")
|
||||
|
||||
|
||||
class VerificationExtractorConfidenceTests(unittest.TestCase):
|
||||
"""BUG-00017 止血:code_confidence / link_confidence 细粒度置信度测试"""
|
||||
|
||||
def _require_new_api(self):
|
||||
func = getattr(extractor, "extract_verification_info_with_options", None)
|
||||
self.assertTrue(callable(func), "缺少 extract_verification_info_with_options()")
|
||||
return func
|
||||
|
||||
# ── code_confidence ──
|
||||
|
||||
def test_code_confidence_high_when_keyword_match(self):
|
||||
"""关键词命中验证码 → code_confidence=high"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Your verification code",
|
||||
"body": "Your verification code is 654321",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["verification_code"], "654321")
|
||||
self.assertEqual(result["code_confidence"], "high")
|
||||
|
||||
def test_code_confidence_low_when_fallback_only(self):
|
||||
"""仅 fallback 命中数字 → code_confidence=low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Weekly newsletter",
|
||||
"body": "We have 1181 new features this month",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_code"), "应命中 1181 作为 fallback 候选")
|
||||
self.assertEqual(result["code_confidence"], "low")
|
||||
|
||||
def test_code_confidence_low_for_marketing_numbers(self):
|
||||
"""营销邮件中的普通数字不应获得 high confidence"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "50% OFF - Huge Sale!",
|
||||
"body": "Save big! Use discount 2999 for GPU instances. Order #8847 confirmed.",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_code"), "应命中某个数字作为 fallback 候选")
|
||||
self.assertEqual(result["code_confidence"], "low",
|
||||
"营销邮件数字不应获得 high confidence")
|
||||
|
||||
# ── link_confidence ──
|
||||
|
||||
def test_link_confidence_high_when_verify_keyword_in_url(self):
|
||||
"""链接含 verify 关键词 → link_confidence=high"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Verify your email",
|
||||
"body": "Click https://example.com/verify?token=abc123 to activate",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["link_confidence"], "high")
|
||||
self.assertIn("verify", result["verification_link"])
|
||||
|
||||
def test_link_confidence_high_when_confirm_keyword_in_url(self):
|
||||
"""链接含 confirm 关键词 → link_confidence=high"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Confirm your account",
|
||||
"body": "Please visit https://example.com/confirm?id=xyz",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["link_confidence"], "high")
|
||||
|
||||
def test_link_confidence_low_for_generic_link(self):
|
||||
"""链接不含验证关键词 → link_confidence=low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Check out our sale",
|
||||
"body": "Visit https://shop.example.com/deals for amazing offers",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"普通链接不应获得 high link_confidence")
|
||||
|
||||
def test_link_confidence_low_for_marketing_link(self):
|
||||
"""营销邮件中的普通跳转链接 → link_confidence=low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Runpod - New GPU Instances Available",
|
||||
"body": "Check out https://www.runpod.io/pricing and https://blog.runpod.io/update",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"), "应命中某个链接作为 fallback")
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"营销链接不应获得 high link_confidence")
|
||||
|
||||
# ── confidence 总字段向后兼容 ──
|
||||
|
||||
def test_confidence_compat_high_when_code_high(self):
|
||||
"""code_confidence=high → 总 confidence=high(向后兼容)"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "OTP",
|
||||
"body": "Your OTP is 998877",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["code_confidence"], "high")
|
||||
self.assertEqual(result["confidence"], "high")
|
||||
|
||||
def test_confidence_compat_high_when_link_high(self):
|
||||
"""link_confidence=high → 总 confidence=high(向后兼容)"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Hello",
|
||||
"body": "Random text with https://example.com/verify?t=1",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["link_confidence"], "high")
|
||||
self.assertEqual(result["confidence"], "high")
|
||||
|
||||
def test_confidence_compat_low_when_both_low(self):
|
||||
"""code 和 link 均 low → 总 confidence=low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Sale alert",
|
||||
"body": "Item 5678 at https://shop.example.com/deals",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertEqual(result["code_confidence"], "low")
|
||||
self.assertEqual(result["link_confidence"], "low")
|
||||
self.assertEqual(result["confidence"], "low")
|
||||
|
||||
# ── 审查修复:补充关键场景测试 ──
|
||||
|
||||
def test_code_regex_without_keyword_gets_high_confidence(self):
|
||||
"""调用方传入 code_regex 精确匹配,即使无关键词上下文也应 high confidence"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Account notice",
|
||||
"body": "Use AB1234 within 5 minutes to proceed.",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email, code_regex=r"\b[A-Z]{2}\d{4}\b")
|
||||
self.assertEqual(result["verification_code"], "AB1234")
|
||||
self.assertEqual(result["code_confidence"], "high",
|
||||
"code_regex 精确匹配命中应视为 high confidence")
|
||||
|
||||
def test_code_length_without_keyword_stays_low_confidence(self):
|
||||
"""code_length 仅限定宽度,不具备判别力 → 无关键词上下文时 code_confidence 仍为 low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Action required",
|
||||
"body": "Enter 887766 to continue your setup process.",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email, code_length="6-6")
|
||||
self.assertEqual(result["verification_code"], "887766")
|
||||
self.assertEqual(result["code_confidence"], "low",
|
||||
"code_length 不应自动提权为 high,避免营销邮件订单号绕过门控")
|
||||
|
||||
def test_link_high_confidence_from_email_context(self):
|
||||
"""URL 不含验证关键词但邮件正文有强验证语境短语 → link_confidence=high"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Please verify your account",
|
||||
"body": "Click the link below to verify your email: https://auth.example.com/t/abc123",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "high",
|
||||
"邮件正文含强验证语境短语时,即使 URL 不含关键词也应 high")
|
||||
|
||||
# ── G3 反例:宽词语境 + 普通链接不应被提权 ──
|
||||
|
||||
def test_discount_code_email_link_stays_low(self):
|
||||
"""正文含 'discount code' 但这不是验证语境 → link_confidence 仍为 low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Your exclusive discount code inside!",
|
||||
"body": "Use discount code SAVE20 at https://shop.example.com/checkout",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'discount code' 不应触发链接验证语境提权")
|
||||
|
||||
def test_promo_code_email_link_stays_low(self):
|
||||
"""正文含 'promo code' → link_confidence 仍为 low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Special promo code for you",
|
||||
"body": "Apply promo code SPRING25 here: https://deals.example.com/apply",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'promo code' 不应触发链接验证语境提权")
|
||||
|
||||
def test_order_confirmation_link_stays_low(self):
|
||||
"""正文含 'order confirmation' 但只是交易确认 → link_confidence 仍为 low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Order confirmation #12345",
|
||||
"body": "View your order confirmation at https://store.example.com/orders/12345",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'order confirmation' 不应触发链接验证语境提权")
|
||||
|
||||
def test_confirm_your_order_link_stays_low(self):
|
||||
"""正文含 'confirm your order'(非邮箱/账户对象)→ low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Please confirm your order",
|
||||
"body": "Click here to confirm your order: https://shop.example.com/orders/status/789",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'confirm your order' 不应触发链接验证语境提权")
|
||||
|
||||
def test_activate_your_subscription_link_stays_low(self):
|
||||
"""正文含 'activate your subscription'(非邮箱/账户对象)→ low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Activate your subscription today!",
|
||||
"body": "Ready to activate your subscription? Visit https://service.example.com/subscribe/start",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'activate your subscription' 不应触发链接验证语境提权")
|
||||
|
||||
def test_verify_your_purchase_link_stays_low(self):
|
||||
"""正文含 'verify your purchase'(非邮箱/账户对象)→ low"""
|
||||
func = self._require_new_api()
|
||||
email = {
|
||||
"subject": "Verify your purchase details",
|
||||
"body": "Please verify your purchase at https://store.example.com/orders/review/456",
|
||||
"body_html": "",
|
||||
}
|
||||
result = func(email)
|
||||
self.assertIsNotNone(result.get("verification_link"))
|
||||
self.assertEqual(result["link_confidence"], "low",
|
||||
"'verify your purchase' 不应触发链接验证语境提权")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user