"""内部接口的测试:鉴权边界、两类错误信封、端到端编排。 全部离线——服务商调用被 `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="bad gateway") 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