Files
patbond-ai/tests/api/test_internal_api.py
lixi 062e11b1ff
CI / ai-gates (push) Successful in 39s
feat(ai): 内部 HTTP 端点 + 服务票校验 + 契约漂移守卫(166 测试)
按 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>
2026-09-20 16:59:44 +08:00

513 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内部接口的测试:鉴权边界、两类错误信封、端到端编排。
全部离线——服务商调用被 `httpx.MockTransport` 拦下,服务票用测试期临时生成的
RSA 密钥对签发。
"""
from __future__ import annotations
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from app.api.main import create_app
from app.config import ServiceAuthSettings, Settings, VolcengineSettings
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkClient
from app.providers.volcengine.video import SeedanceVideoProvider
FAKE_API_KEY = "dummy-api-key-for-tests"
MODEL_2_5 = "doubao-seedance-2-5-260628"
MODEL_2_0 = "doubao-seedance-2-0-260128"
# ----------------------------------------------------------------- 夹具
@pytest.fixture(scope="module")
def keypair() -> tuple[str, str]:
"""测试期临时 RSA 密钥对(私钥签票、公钥配给服务)。
现场生成而非硬编码 PEM:密钥形态的 PEM 写进仓库会被防泄漏扫描拦下,而且
那条规则是对的。
"""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public_pem = (
key.public_key()
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
return private_pem, public_pem
def service_token(private_pem: str, **overrides) -> str:
"""签一张服务票;`overrides` 用于构造各种不合格的票。"""
import time
claims = {
"iss": "patbond-api",
"aud": "patbond-ai",
"sub": "service:creation-worker",
"typ": "service",
"iat": int(time.time()),
"exp": int(time.time()) + 300,
"jti": "test-jti",
}
claims.update(overrides)
return jwt.encode(claims, private_pem, algorithm="RS256")
def client_for(keypair, handler, *, with_provider: bool = True) -> TestClient:
"""建一个测试客户端,服务商调用被 handler 拦下。"""
_, public_pem = keypair
settings = Settings(
volcengine=VolcengineSettings(api_key=FAKE_API_KEY if with_provider else ""),
service_auth=ServiceAuthSettings(public_key=public_pem),
)
app = create_app(settings)
# 换掉装配好的适配器,让它的 HTTP 走 MockTransport。
if with_provider:
app.state.registry = ProviderRegistry(
[
SeedanceVideoProvider(
ArkClient(
api_key=FAKE_API_KEY,
base_url="https://ark.example.invalid/api/v3",
client=httpx.Client(transport=httpx.MockTransport(handler)),
)
)
]
)
else:
app.state.registry = ProviderRegistry([])
return TestClient(app, raise_server_exceptions=False)
def auth(keypair, **overrides) -> dict[str, str]:
private_pem, _ = keypair
return {"Authorization": f"Bearer {service_token(private_pem, **overrides)}"}
SUBMIT_BODY = {
"job_id": "0199a1b2-c3d4-7000-8000-000000000001",
"media_kind": "video",
"model": MODEL_2_5,
"prompt": "我的柯基在草地上奔跑",
}
# ------------------------------------------------------------- 鉴权边界
class TestAuthentication:
"""服务票是这一层的安全边界。失败一律返回同一个笼统原因,细节只进日志。"""
def test_missing_token_is_rejected(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post("/internal/generation/tasks", json=SUBMIT_BODY)
assert response.status_code == 401
assert response.json()["error"]["kind"] == "service"
assert response.json()["error"]["code"] == "unauthenticated"
def test_wrong_audience_is_rejected(self, keypair):
"""`aud` 强制校验是独立密钥对方案唯一的意义所在。
Java 侧现有的 JwtVerifier 只验签名、不校验 aud——正因如此才必须用独立
密钥对;如果本服务也只验签名,那对独立密钥就白搭了。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, aud="someone-else"),
)
assert response.status_code == 401
def test_wrong_issuer_is_rejected(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, iss="attacker"),
)
assert response.status_code == 401
def test_a_user_token_shape_is_rejected_even_if_signed_with_this_key(self, keypair):
"""有人误把用户密钥配到这里时的第二道防线。
用户 token 没有 `typ=service`(Java 侧的 JwtSigner 也不签 aud),故即使
签名能过也会被拦下。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, typ=None),
)
assert response.status_code == 401
def test_expired_token_is_rejected(self, keypair):
import time
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json=SUBMIT_BODY,
headers=auth(keypair, exp=int(time.time()) - 10),
)
assert response.status_code == 401
def test_missing_public_key_denies_everything_rather_than_allowing(self, keypair):
"""配置缺失必须是「全拒」而非「全过」。
前者立刻暴露,后者是一扇敞开的门。与 Java 侧 InternalAuthFilter 未配置
internal-token 时全拒 /internal/** 的既有选择一致。
"""
settings = Settings(
volcengine=VolcengineSettings(api_key=FAKE_API_KEY),
service_auth=ServiceAuthSettings(public_key=""),
)
client = TestClient(create_app(settings), raise_server_exceptions=False)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 401
def test_error_body_never_says_why(self, keypair):
"""具体原因(签名错/过期/aud 不符)只进日志。
对方是我们自己的服务,真正原因在本服务日志里;把细节回给调用方只会在
将来某天变成攻击者的提示。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
body = client.post("/internal/generation/tasks", json=SUBMIT_BODY).json()
message = body["error"]["message"]
for leak in ("signature", "aud", "audience", "expired", "iss"):
assert leak.lower() not in message.lower()
class TestHealthNeedsNoToken:
def test_health_is_outside_internal_and_unauthenticated(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={}))
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_health_reports_assembled_providers(self, keypair):
"""缺凭证时适配器不装配,而那个症状(任务永远排队)离根因很远。
让探活直接说出来能省很多排查时间。
"""
with_provider = client_for(keypair, lambda r: httpx.Response(200, json={}))
assert with_provider.get("/health").json()["providers"] == [
"volcengine-seedance"
]
without = client_for(
keypair, lambda r: httpx.Response(200, json={}), with_provider=False
)
assert without.get("/health").json()["providers"] == []
# ------------------------------------------------------------------ 提交
class TestSubmit:
def test_returns_provider_request_id(self, keypair):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/contents/generations/tasks")
return httpx.Response(200, json={"id": "cgt-20260920-abc123"})
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 200
assert response.json() == {"provider_request_id": "cgt-20260920-abc123"}
def test_field_names_are_snake_case(self, keypair):
"""契约为 snake_case——camelCase 的请求体应被拒。
`extra="forbid"` 使拼错的字段立刻报错,而不是被静默忽略(后者表现为
「我传了 durationSec 却没生效」这类极难查的问题)。
"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "durationSec": 5},
headers=auth(keypair),
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "invalid_request"
def test_user_id_is_not_accepted(self, keypair):
"""契约刻意不含 user_id(数据最小化)。传了应当被拒而不是被忽略。"""
client = client_for(keypair, lambda r: httpx.Response(200, json={"id": "x"}))
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "user_id": "u-1"},
headers=auth(keypair),
)
assert response.status_code == 400
def test_empty_content_is_rejected_locally(self, keypair):
def handler(request):
raise AssertionError("不应发出请求")
client = client_for(keypair, handler)
body = {k: v for k, v in SUBMIT_BODY.items() if k != "prompt"}
response = client.post(
"/internal/generation/tasks", json=body, headers=auth(keypair)
)
assert response.status_code == 400
assert "至少要有一个" in response.json()["error"]["message"]
def test_model_limit_violation_is_invalid_request_not_provider_error(self, keypair):
"""超出模型限制的请求**根本没发出去**,故属 service/invalid_request。
归到 provider 类会让调用方以为是上游的问题而去退避重试——而重试同样的
参数永远不会变好。
"""
def handler(request):
raise AssertionError("不应发出请求")
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks",
json={**SUBMIT_BODY, "model": MODEL_2_0, "duration_sec": 20},
headers=auth(keypair),
)
assert response.status_code == 400
error = response.json()["error"]
assert error["kind"] == "service"
assert error["code"] == "invalid_request"
assert "[4, 15]" in error["message"]
def test_no_adapter_yields_provider_unavailable(self, keypair):
"""缺凭证 → 适配器未装配。503 而非 500:这是环境问题且可恢复。"""
client = client_for(
keypair, lambda r: httpx.Response(200, json={}), with_provider=False
)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
assert response.status_code == 503
assert response.json()["error"]["code"] == "provider_unavailable"
# ------------------------------------------------------- 两类错误的分界
class TestTwoErrorKindsAreDistinct:
"""这是契约的核心设计:调用方对两类失败的反应完全不同。"""
def test_vendor_rate_limit_is_a_provider_error_with_full_classification(
self, keypair
):
def handler(request):
return httpx.Response(
429,
json={
"error": {
"code": "RateLimitExceeded.EndpointRPMExceeded",
"message": "RPM limit exceeded",
}
},
)
client = client_for(keypair, handler)
response = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
)
# 刻意不透传服务商的 429:429 既可能是「等几秒」也可能是「等到明天」,
# 状态码表达不了这个区别,retry 才是权威信号。
assert response.status_code == 502
error = response.json()["error"]
assert error["kind"] == "provider"
assert error["retry"] == "backoff"
assert error["user_facing"] == "rate_limited"
assert error["provider_code"] == "RateLimitExceeded.EndpointRPMExceeded"
def test_quota_exhausted_carries_a_different_retry_than_rate_limit(self, keypair):
"""同为服务商 429,但一个等几秒、一个等到明天。
调用方只看 retry 就能分开,不必解读厂商码。
"""
def handler(request):
return httpx.Response(
429,
json={
"error": {
"code": "QuotaExceeded",
"message": "Your account has exhausted its free trial quota.",
}
},
)
client = client_for(keypair, handler)
error = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
).json()["error"]
assert error["retry"] == "quota"
assert error["user_facing"] == "quota_exhausted"
def test_kind_is_the_discriminator_not_the_status_code(self, keypair):
"""两类错误的状态码会撞,`kind` 不会。
502 既可能是服务商挂了(provider),也可能是我们打错地址导致的解析失败
(也归 provider,但 service 类错误同样可能出现 5xx)。判别必须看 kind。
"""
def handler(request):
return httpx.Response(502, text="<html>bad gateway</html>")
client = client_for(keypair, handler)
body = client.post(
"/internal/generation/tasks", json=SUBMIT_BODY, headers=auth(keypair)
).json()
assert body["error"]["kind"] == "provider"
assert "kind" in body["error"]
# ------------------------------------------------------------------ 查询
#: 官方查询文档「返回格式」一节的原样示例(2.0 系列 720p + 16:9)。
OFFICIAL_QUERY_PAYLOAD = {
"id": "cgt-1",
"model": MODEL_2_0,
"status": "succeeded",
"content": {
"video_url": "https://v/out.mp4",
"last_frame_url": "https://v/f.png",
},
"usage": {"completion_tokens": 108900, "total_tokens": 108900},
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
"framespersecond": 24,
}
class TestGetTask:
def test_succeeded_task_is_fully_serialised(self, keypair):
client = client_for(
keypair, lambda r: httpx.Response(200, json=OFFICIAL_QUERY_PAYLOAD)
)
body = client.get(
"/internal/generation/tasks/cgt-1", headers=auth(keypair)
).json()
assert body["state"] == "succeeded"
assert body["progress"] == 100
assert body["asset_url"] == "https://v/out.mp4"
assert body["cover_url"] == "https://v/f.png"
assert (body["width_px"], body["height_px"]) == (1280, 720)
assert body["duration_ms"] == 5000
assert body["billing_tokens"] == 108900
assert body["failure"] is None
def test_asset_url_always_carries_its_expiry(self, keypair):
"""服务商 URL 只有 24 小时有效。时效随 URL 一起给出,调用方就没有理由
把它当永久地址存进 external_url。"""
client = client_for(
keypair, lambda r: httpx.Response(200, json=OFFICIAL_QUERY_PAYLOAD)
)
body = client.get(
"/internal/generation/tasks/cgt-1", headers=auth(keypair)
).json()
assert body["asset_url_expires_in_sec"] == 86400
def test_a_failed_task_is_200_not_an_error_response(self, keypair):
"""查询一个失败的任务,**HTTP 调用是成功的**,失败的是任务。
做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
"""
def handler(request):
return httpx.Response(
200,
json={
"id": "cgt-1",
"status": "failed",
"content": {},
"error": {
"code": "OutputVideoSensitiveContentDetected",
"message": "output may contain sensitive information",
},
},
)
client = client_for(keypair, handler)
response = client.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
assert response.status_code == 200
body = response.json()
assert body["state"] == "failed"
assert body["failure"]["retry"] == "never"
assert body["failure"]["user_facing"] == "content_rejected"
assert body["failure"]["provider_code"] == "OutputVideoSensitiveContentDetected"
def test_vendor_404_becomes_task_not_found_so_the_caller_stops_polling(
self, keypair
):
"""服务商只保留 7 天记录。查不到时调用方要做的是**停止轮询**,
不是退避重试——故归 service/task_not_found 而非 provider。"""
def handler(request):
return httpx.Response(
404, json={"error": {"code": "NotFound.", "message": "not found"}}
)
client = client_for(keypair, handler)
response = client.get(
"/internal/generation/tasks/cgt-gone", headers=auth(keypair)
)
assert response.status_code == 404
error = response.json()["error"]
assert error["kind"] == "service"
assert error["code"] == "task_not_found"
def test_running_task_has_zero_progress(self, keypair):
# 服务商不给百分比,故成功前恒 0。这不是缺陷——契约本就不承诺平滑进度。
def handler(request):
return httpx.Response(
200, json={"id": "cgt-1", "status": "running", "content": {}}
)
body = (
client_for(keypair, handler)
.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
.json()
)
assert body["state"] == "running"
assert body["progress"] == 0
def test_queued_maps_to_pending(self, keypair):
def handler(request):
return httpx.Response(
200, json={"id": "cgt-1", "status": "queued", "content": {}}
)
body = (
client_for(keypair, handler)
.get("/internal/generation/tasks/cgt-1", headers=auth(keypair))
.json()
)
assert body["state"] == "pending"
def test_get_requires_a_token_too(self, keypair):
client = client_for(keypair, lambda r: httpx.Response(200, json={}))
assert client.get("/internal/generation/tasks/cgt-1").status_code == 401