062e11b1ff
CI / ai-gates (push) Successful in 39s
按 openapi-internal.yaml 落地两个端点与鉴权,全部离线可测。
**鉴权(app/api/auth.py)**是这一层的安全边界,三条纪律各有测试:
- **强制校验 aud 与 iss**(交给 pyjwt 强制,不解出来自己比——自己比容易漏掉
「claim 不存在」那一路)。这是独立密钥对方案唯一的意义所在:Java 侧现有的
JwtVerifier 只验签名、不校验 aud,正因如此才必须独立密钥;若本服务也只验签名,
那对独立密钥就白搭了。
- **额外强制 typ=service**,作为「有人误把用户密钥配到这里」的第二道防线。
- **缺公钥时全拒而非全过**。配置缺失必须立刻暴露,而不是变成一扇敞开的门;
与 Java 侧 InternalAuthFilter 未配置 internal-token 时全拒 /internal/** 一致。
- 失败一律返回同一个笼统原因,**不告诉调用方是签名错/过期/aud 不符**——对方是
我们自己的服务,真正原因在日志里;细节回给调用方只会在将来某天变成提示。
**三类失败分开**(契约核心)。第三类「任务失败」走 200 + state=failed + failure,
因为那种情况下 HTTP 调用是成功的;做成非 2xx 会让「查不到任务」与「任务失败了」
难以区分。另外两处刻意选择:
- 服务商 429 **不透传**,一律 502 + provider 信封。429 既可能是「等几秒」也可能是
「等到明天」,状态码表达不了这个区别,retry 才是权威信号。
- 超出模型限制的请求归 service/invalid_request 而非 provider——请求**根本没发出去**,
归到 provider 会让调用方以为是上游问题而去退避重试,而重试同样的参数永远不会变好。
- 服务商 404(含超出其 7 天保留期)归 service/task_not_found:调用方要做的是
**停止轮询**,不是退避重试。
- 接管 FastAPI 默认的 {"detail": [...]} 校验错误,否则调用方要处理三种形状,
而第三种还是框架实现细节。
**契约漂移守卫(tests/api/test_contract_drift.py,14 项)**:本项目开工审计已证明
「没有门禁的契约必然漂移」——四模块快照锁实际不存在,白名单 42/41 之误四份文档
一路不可见整整一个迭代。内部契约同样需要门禁。比对路径/方法/字段集/枚举/必填项,
**不比对字节**(永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。
写这个守卫时它立刻抓到一处问题,但**问题在我的内省方式而不在应用**:本版本
FastAPI 的 include_router 会包一层内部 wrapper,路由并不平铺在 app.routes 上。
已改用 app.openapi()——它是「究竟暴露了什么」的权威视图,且不依赖框架内部结构,
否则这个门禁会在升级框架时莫名失效,而一个悄悄失效的门禁比没有门禁更糟。
**新增 pyproject.toml**:`Depends()` 放在参数默认值里正是 FastAPI 的标准写法,
ruff 的 B008 在此是误报。按 ruff 正规方式声明这些调用可安全求值,而不是在每个
端点上撒 noqa——后者迟早被用来压制真正的问题。
schemas 用 extra="forbid":拼错的字段应当立刻报错而非被静默忽略,后者表现为
「我传了 durationSec 却没生效」这类极难查的问题。测试覆盖了 camelCase 与
user_id(契约刻意不含它)两种被拒情形。
新增依赖 fastapi / uvicorn / pyjwt[crypto];测试 127 → 166。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""手写契约 `openapi-internal.yaml` 与实际实现的一致性守卫。
|
|
|
|
**为什么需要它**:契约是手写的,实现是另一份代码。本项目开工审计已经证明「没有
|
|
门禁的契约必然漂移」——四模块快照锁实际不存在,结果白名单 42/41 之误四份文档一路
|
|
不可见,整整一个迭代没人发现。内部契约同样是契约,同样需要门禁。
|
|
|
|
**不做的事**:不比对 FastAPI 自动生成的 `/openapi.json` 与手写 yaml 的字节一致性。
|
|
两者永远不会字节相同(描述、示例、`$ref` 组织方式都不同),逐字比对只会制造一个
|
|
天天红的门禁,然后被人加 skip 绕过。这里比对的是**有实质意义的东西**:路径、方法、
|
|
字段集、枚举取值。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
|
|
import yaml
|
|
|
|
from app.api.errors import ServiceErrorCode
|
|
from app.api.main import create_app
|
|
from app.api.schemas import FailureModel, SubmitRequest, SubmitResponse, TaskResponse
|
|
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
|
|
from app.domain.generation import Retry, TaskState, UserFacing
|
|
|
|
CONTRACT = yaml.safe_load(
|
|
(
|
|
pathlib.Path(__file__).resolve().parent.parent.parent / "openapi-internal.yaml"
|
|
).read_text(encoding="utf-8")
|
|
)
|
|
|
|
|
|
def schema_properties(name: str) -> set[str]:
|
|
return set(CONTRACT["components"]["schemas"][name]["properties"])
|
|
|
|
|
|
def app_routes() -> set[tuple[str, str]]:
|
|
"""实际对外暴露的 `/internal` 端点集合。
|
|
|
|
取自 `app.openapi()` 而非遍历 `app.routes`:后者在不同 FastAPI 版本里结构不同
|
|
(本版本的 `include_router` 会包一层内部 wrapper,路由并不平铺在 `app.routes`
|
|
上),依赖它会让这个守卫在升级框架时莫名失效——而一个悄悄失效的门禁比没有
|
|
门禁更糟。`openapi()` 是「究竟暴露了什么」的权威视图。
|
|
"""
|
|
app = create_app(
|
|
Settings(
|
|
volcengine=VolcengineSettings(api_key="dummy-api-key-for-tests"),
|
|
service_auth=ServiceAuthSettings(public_key=""),
|
|
)
|
|
)
|
|
return {
|
|
(method.upper(), path)
|
|
for path, operations in app.openapi()["paths"].items()
|
|
if path.startswith("/internal")
|
|
for method in operations
|
|
}
|
|
|
|
|
|
class TestPathsMatch:
|
|
def test_every_contract_path_is_implemented(self):
|
|
implemented = app_routes()
|
|
for path, operations in CONTRACT["paths"].items():
|
|
for method in operations:
|
|
assert (method.upper(), path) in implemented, (
|
|
f"契约声明了 {method.upper()} {path} 但实现里没有"
|
|
)
|
|
|
|
def test_no_undeclared_internal_route_exists(self):
|
|
"""实现里不得有契约之外的 /internal 端点。
|
|
|
|
未声明的内部端点是最容易被遗忘的表面——它不在契约里,评审看不到,
|
|
但它确实对外开放。
|
|
"""
|
|
declared = {
|
|
(method.upper(), path)
|
|
for path, operations in CONTRACT["paths"].items()
|
|
for method in operations
|
|
}
|
|
assert app_routes() <= declared
|
|
|
|
|
|
class TestFieldSetsMatch:
|
|
"""字段集漂移是最隐蔽的一种:多一个字段无人察觉,少一个字段调用方拿到 None。"""
|
|
|
|
def test_submit_request(self):
|
|
assert schema_properties("SubmitRequest") == set(SubmitRequest.model_fields)
|
|
|
|
def test_submit_response(self):
|
|
assert schema_properties("SubmitResponse") == set(SubmitResponse.model_fields)
|
|
|
|
def test_task_response(self):
|
|
assert schema_properties("TaskResponse") == set(TaskResponse.model_fields)
|
|
|
|
def test_failure(self):
|
|
assert schema_properties("Failure") == set(FailureModel.model_fields)
|
|
|
|
def test_all_field_names_are_snake_case(self):
|
|
"""契约定为 snake_case(与对客户端的 camelCase 契约不同,见契约说明)。
|
|
|
|
混入一个 camelCase 字段就会让「JSON 与 dataclass 字段逐一对应、无需转换层」
|
|
这个前提失效。
|
|
"""
|
|
for name in ("SubmitRequest", "SubmitResponse", "TaskResponse", "Failure"):
|
|
for field in schema_properties(name):
|
|
assert field.islower() or "_" in field, (
|
|
f"{name}.{field} 不是 snake_case"
|
|
)
|
|
assert not any(char.isupper() for char in field), (
|
|
f"{name}.{field} 含大写字母"
|
|
)
|
|
|
|
|
|
class TestEnumsMatch:
|
|
"""枚举漂移的后果是调用方收到一个它没处理的取值——而那通常走到默认分支。"""
|
|
|
|
def test_task_states(self):
|
|
declared = set(
|
|
CONTRACT["components"]["schemas"]["TaskResponse"]["properties"]["state"][
|
|
"enum"
|
|
]
|
|
)
|
|
assert declared == {state.value for state in TaskState}
|
|
|
|
def test_retry_strategies(self):
|
|
declared = set(
|
|
CONTRACT["components"]["schemas"]["Failure"]["properties"]["retry"]["enum"]
|
|
)
|
|
assert declared == {retry.value for retry in Retry}
|
|
|
|
def test_user_facing_categories(self):
|
|
declared = set(
|
|
CONTRACT["components"]["schemas"]["Failure"]["properties"]["user_facing"][
|
|
"enum"
|
|
]
|
|
)
|
|
assert declared == {category.value for category in UserFacing}
|
|
|
|
def test_service_error_codes(self):
|
|
declared = set(
|
|
CONTRACT["components"]["schemas"]["ServiceErrorEnvelope"]["properties"][
|
|
"error"
|
|
]["properties"]["code"]["enum"]
|
|
)
|
|
assert declared == {code.code for code in ServiceErrorCode}
|
|
|
|
def test_provider_error_reuses_the_failure_classification_fields(self):
|
|
"""`provider` 错误信封与 `Failure` 的分类字段必须一致。
|
|
|
|
这是刻意设计:调用方因此可以复用同一段处置逻辑,不必为「提交时被限流」
|
|
与「任务失败于限流」写两套分支。两边若漂移,那份复用就会悄悄失效。
|
|
"""
|
|
envelope = set(
|
|
CONTRACT["components"]["schemas"]["ProviderErrorEnvelope"]["properties"][
|
|
"error"
|
|
]["properties"]
|
|
)
|
|
failure = schema_properties("Failure")
|
|
assert envelope == failure | {"kind"}
|
|
|
|
|
|
class TestRequiredFieldsMatch:
|
|
def test_submit_request_required(self):
|
|
declared = set(CONTRACT["components"]["schemas"]["SubmitRequest"]["required"])
|
|
actual = {
|
|
name
|
|
for name, field in SubmitRequest.model_fields.items()
|
|
if field.is_required()
|
|
}
|
|
assert declared == actual
|
|
|
|
def test_task_response_required(self):
|
|
declared = set(CONTRACT["components"]["schemas"]["TaskResponse"]["required"])
|
|
actual = {
|
|
name
|
|
for name, field in TaskResponse.model_fields.items()
|
|
if field.is_required()
|
|
}
|
|
assert declared == actual
|