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