增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
586 lines
23 KiB
Python
586 lines
23 KiB
Python
"""Agent 游戏内容仓库的受信任 prepare/committed checkout/commit 闭环测试。"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from scripts import game_content_repository as repository_cli
|
||
from test_game_artifact_store import _make_game
|
||
from worker import game_content_repository as repository
|
||
from worker.game_artifact_storage_store import StorageRecordError
|
||
from worker.game_artifact_store import ArtifactError
|
||
|
||
|
||
def _committed_record() -> dict:
|
||
"""构造只能来自 game_artifact_storage committed 行的可信 locator。"""
|
||
return {
|
||
"tenant_id": 1,
|
||
"game_id": 1024,
|
||
"version_id": 2048,
|
||
"artifact_bucket": "game-artifacts",
|
||
"artifact_manifest_key": "tenants/1/games/1024/versions/2048/artifact-manifest.json",
|
||
"artifact_manifest_hash": "a" * 64,
|
||
"source_bucket": "game-sources",
|
||
"source_provider": "game_source_archive",
|
||
"source_game_id": "1024",
|
||
"source_revision_id": "shanhai-r1",
|
||
"source_manifest_key": "tenants/1/games/1024/source-revisions/shanhai-r1/source-manifest.json",
|
||
"source_manifest_hash": "b" * 64,
|
||
"source_hash": "c" * 64,
|
||
"status": "committed",
|
||
}
|
||
|
||
|
||
class _Storage:
|
||
def __init__(self, record: dict | None, order: list[str] | None = None):
|
||
self.record = record
|
||
self.calls: list[str] = []
|
||
self.order = order
|
||
|
||
def _record_call(self, name: str) -> None:
|
||
"""同时记录存储局部顺序和跨控制面/对象存储的全链顺序。"""
|
||
self.calls.append(name)
|
||
if self.order is not None:
|
||
self.order.append(name)
|
||
|
||
def get_committed_record(self, **_identity):
|
||
self._record_call("get")
|
||
return self.record
|
||
|
||
@contextmanager
|
||
def version_lock(self, **_identity):
|
||
self._record_call("lock")
|
||
yield
|
||
|
||
def create_pending(self, record: dict):
|
||
self._record_call("pending")
|
||
assert record["status"] == "pending"
|
||
return {"status": "pending", "committed": False}
|
||
|
||
def commit_pending(self, record: dict, *, committed_at: datetime):
|
||
self._record_call("committed")
|
||
assert record["status"] == "pending"
|
||
assert committed_at.tzinfo is not None
|
||
return {
|
||
"status": "committed",
|
||
"committed": True,
|
||
"artifactManifestHash": record["artifact_manifest_hash"],
|
||
"sourceManifestHash": record["source_manifest_hash"],
|
||
}
|
||
|
||
|
||
class _Control:
|
||
"""记录 prepare/finalize/activate 调用,并模拟后端权威版本状态机。"""
|
||
|
||
def __init__(self, order: list[str] | None = None, *, version_id: int = 2048,
|
||
finalize_failure: Exception | None = None):
|
||
self.order = order
|
||
self.version_id = version_id
|
||
self.finalize_failure = finalize_failure
|
||
self.prepare_calls: list[dict] = []
|
||
self.finalize_calls: list[dict] = []
|
||
self.activate_calls: list[dict] = []
|
||
|
||
def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int:
|
||
if self.order is not None:
|
||
self.order.append("prepare")
|
||
self.prepare_calls.append({
|
||
"tenant_id": tenant_id, "game_id": game_id, "revision_id": revision_id,
|
||
})
|
||
return self.version_id
|
||
|
||
def finalize(self, **payload) -> int:
|
||
if self.order is not None:
|
||
self.order.append("finalize")
|
||
self.finalize_calls.append(payload)
|
||
if self.finalize_failure is not None:
|
||
raise self.finalize_failure
|
||
return 31
|
||
|
||
def activate(self, **payload) -> int:
|
||
if self.order is not None:
|
||
self.order.append("activate")
|
||
self.activate_calls.append(payload)
|
||
return payload["version_id"]
|
||
|
||
|
||
class _HttpResponse:
|
||
"""模拟 http.client 响应,保留状态、Location 和有限读取语义。"""
|
||
|
||
def __init__(self, status: int, body: bytes = b"", headers: dict[str, str] | None = None):
|
||
self.status = status
|
||
self.reason = "test"
|
||
self._body = body
|
||
self._headers = {key.lower(): value for key, value in (headers or {}).items()}
|
||
|
||
def getheader(self, name: str, default=None):
|
||
return self._headers.get(name.lower(), default)
|
||
|
||
def read(self, amount: int | None = None) -> bytes:
|
||
if amount is None:
|
||
amount = len(self._body)
|
||
data, self._body = self._body[:amount], self._body[amount:]
|
||
return data
|
||
|
||
|
||
def _install_http(
|
||
monkeypatch: pytest.MonkeyPatch, responses: list[_HttpResponse | Exception],
|
||
) -> list[dict]:
|
||
"""安装确定性 HTTP 连接,直接检查实际发出的原始 body、签名和 timeout。"""
|
||
requests: list[dict] = []
|
||
|
||
class Connection:
|
||
def __init__(self, host: str, port: int | None = None, *, timeout: float):
|
||
self.host = host
|
||
self.port = port
|
||
self.timeout = timeout
|
||
|
||
def request(self, method: str, path: str, *, body: bytes, headers: dict[str, str]):
|
||
requests.append({
|
||
"host": self.host, "port": self.port, "timeout": self.timeout,
|
||
"method": method, "path": path, "body": body, "headers": headers,
|
||
})
|
||
|
||
def getresponse(self):
|
||
response = responses.pop(0)
|
||
if isinstance(response, Exception):
|
||
raise response
|
||
return response
|
||
|
||
def close(self):
|
||
return None
|
||
|
||
monkeypatch.setattr(repository.http.client, "HTTPConnection", Connection)
|
||
return requests
|
||
|
||
|
||
def test_control_client_signs_exact_raw_json_and_returns_backend_ids(monkeypatch: pytest.MonkeyPatch):
|
||
"""HMAC 必须覆盖实际发送字节,三个写步骤只返回后端 CommonResult.data。"""
|
||
responses = [
|
||
_HttpResponse(200, b'{"code":0,"data":2048,"msg":""}'),
|
||
_HttpResponse(200, b'{"code":0,"data":31,"msg":""}'),
|
||
_HttpResponse(200, b'{"code":0,"data":2048,"msg":""}'),
|
||
]
|
||
requests = _install_http(monkeypatch, responses)
|
||
client = repository.GameContentControlClient(
|
||
"http://100.64.0.7:48090/admin-api/aigc/game-content",
|
||
"content-secret", timeout_s=15,
|
||
)
|
||
|
||
assert client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a") == 2048
|
||
raw_manifest = '{\n "schemaVersion": "1.0",\n "title": "山海行纪"\n}\n'
|
||
assert client.finalize(
|
||
tenant_id=7, game_id=1024, version_id=2048, revision_id="revision-a",
|
||
artifact_manifest_hash="a" * 64, manifest_json=raw_manifest,
|
||
) == 31
|
||
assert client.activate(
|
||
tenant_id=7, game_id=1024, version_id=2048, revision_id="revision-a",
|
||
expected_current_version_id=1023,
|
||
) == 2048
|
||
|
||
assert [request["path"] for request in requests] == [
|
||
"/admin-api/aigc/game-content/prepare",
|
||
"/admin-api/aigc/game-content/finalize",
|
||
"/admin-api/aigc/game-content/activate",
|
||
]
|
||
assert all(request["timeout"] == 15 for request in requests)
|
||
for request in requests:
|
||
expected = hmac.new(b"content-secret", request["body"], hashlib.sha256).hexdigest()
|
||
assert request["headers"]["X-Game-Content-Signature"] == expected
|
||
assert json.loads(requests[0]["body"]) == {
|
||
"tenantId": 7, "gameId": 1024, "revisionId": "revision-a",
|
||
}
|
||
assert json.loads(requests[1]["body"])["manifestJson"] == raw_manifest
|
||
assert json.loads(requests[2]["body"]) == {
|
||
"tenantId": 7,
|
||
"gameId": 1024,
|
||
"versionId": 2048,
|
||
"revisionId": "revision-a",
|
||
"expectedCurrentVersionId": 1023,
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("code", [500, "0", True])
|
||
def test_control_client_accepts_only_integer_zero_common_result(
|
||
monkeypatch: pytest.MonkeyPatch, code,
|
||
):
|
||
"""HTTP 200 不是成功证明;只有非布尔整数 code=0 才能进入写链。"""
|
||
_install_http(monkeypatch, [
|
||
_HttpResponse(200, json.dumps({"code": code, "data": 2048}).encode("utf-8")),
|
||
])
|
||
client = repository.GameContentControlClient("http://control.invalid/base", "secret", timeout_s=2)
|
||
|
||
with pytest.raises(repository.GameContentError, match="CommonResult"):
|
||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||
|
||
|
||
def test_control_client_rejects_cross_origin_redirect_without_following(monkeypatch: pytest.MonkeyPatch):
|
||
"""控制面绝不把带 HMAC 的正文或签名头转发到另一个 origin。"""
|
||
requests = _install_http(monkeypatch, [
|
||
_HttpResponse(307, headers={"Location": "http://evil.invalid/finalize"}),
|
||
])
|
||
client = repository.GameContentControlClient("http://control.invalid/base", "secret", timeout_s=2)
|
||
|
||
with pytest.raises(repository.GameContentError, match="跨 origin"):
|
||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||
assert len(requests) == 1
|
||
|
||
|
||
def test_control_client_applies_strict_timeout_and_wraps_timeout_error(monkeypatch: pytest.MonkeyPatch):
|
||
"""同一硬超时必须进入底层连接;超时只能作为稳定领域错误返回。"""
|
||
requests = _install_http(monkeypatch, [TimeoutError("timed out")])
|
||
client = repository.GameContentControlClient(
|
||
"http://control.invalid/base", "secret", timeout_s=0.25,
|
||
)
|
||
|
||
with pytest.raises(repository.GameContentError, match="网络失败:TimeoutError"):
|
||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||
assert requests[0]["timeout"] == 0.25
|
||
|
||
|
||
def test_checkout_rejects_version_without_committed_record(tmp_path: Path):
|
||
content = repository.GameContentRepository(
|
||
_Storage(None), source_config={}, artifact_config={},
|
||
)
|
||
with pytest.raises(repository.GameContentError, match="未 committed"):
|
||
content.checkout(tenant_id=1, game_id=1024, version_id=2048, target=tmp_path / "work")
|
||
|
||
|
||
def test_checkout_materializes_source_assets_and_game_package_atomically(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||
):
|
||
record = _committed_record()
|
||
source_manifest = {
|
||
"manifestHash": record["source_manifest_hash"],
|
||
"sourceHash": record["source_hash"],
|
||
}
|
||
artifact_manifest = {
|
||
"manifestHash": record["artifact_manifest_hash"],
|
||
"objects": [
|
||
{"category": "asset", "path": "assets/hero.webp"},
|
||
{"category": "runtime", "path": "runtime/bundle.js"},
|
||
{"category": "evidence", "path": "evidence/qa.md"},
|
||
],
|
||
}
|
||
|
||
monkeypatch.setattr(repository, "fetch_source_archive", lambda *_a, **_k: source_manifest)
|
||
monkeypatch.setattr(repository, "fetch_manifest", lambda *_a, **_k: artifact_manifest)
|
||
|
||
def download_source(_manifest, _config, target, **_kwargs):
|
||
(target / "src").mkdir(parents=True)
|
||
(target / "src/main.js").write_text("export const GAME = 1;\n", encoding="utf-8")
|
||
return target
|
||
|
||
def download_artifact(_manifest, _config, target, **_kwargs):
|
||
(target / "assets").mkdir(parents=True)
|
||
(target / "runtime").mkdir()
|
||
(target / "evidence").mkdir()
|
||
(target / "assets/hero.webp").write_bytes(b"asset")
|
||
(target / "runtime/bundle.js").write_text("bundle", encoding="utf-8")
|
||
(target / "evidence/qa.md").write_text("qa", encoding="utf-8")
|
||
(target / "manifest.json").write_text('{"schemaVersion":"1.0"}', encoding="utf-8")
|
||
(target / "artifact-manifest.json").write_text("{}", encoding="utf-8")
|
||
return target
|
||
|
||
monkeypatch.setattr(repository, "download_source_archive", download_source)
|
||
monkeypatch.setattr(repository, "download_manifest", download_artifact)
|
||
|
||
target = tmp_path / "agent-work"
|
||
result = repository.GameContentRepository(
|
||
_Storage(record), source_config={"source_bucket": "game-sources"},
|
||
artifact_config={"artifact_bucket": "game-artifacts"},
|
||
).checkout(tenant_id=1, game_id=1024, version_id=2048, target=target)
|
||
|
||
assert result["target"] == str(target)
|
||
assert (target / "src/main.js").is_file()
|
||
assert (target / "assets/hero.webp").read_bytes() == b"asset"
|
||
assert (target / "game-package.json").is_file()
|
||
assert not (target / "runtime").exists()
|
||
assert not (target / "evidence").exists()
|
||
assert not list(tmp_path.glob(".agent-work.partial-*"))
|
||
|
||
|
||
def test_prepare_returns_backend_version_id():
|
||
"""Agent 必须先从 Project 权威 prepare 取得版本 ID,不能自行生成生产身份。"""
|
||
control = _Control(version_id=2048)
|
||
content = repository.GameContentRepository(
|
||
_Storage(None), control_client=control, source_config={}, artifact_config={},
|
||
)
|
||
|
||
assert content.prepare(tenant_id=1, game_id=1024, revision_id="shanhai-r2") == 2048
|
||
assert control.prepare_calls == [{
|
||
"tenant_id": 1, "game_id": 1024, "revision_id": "shanhai-r2",
|
||
}]
|
||
|
||
|
||
def test_activate_uses_expected_current_and_returns_target_version_id():
|
||
"""浏览器验收后只能通过 expected-current CAS 激活,不能在 commit 内隐式切换。"""
|
||
control = _Control()
|
||
content = repository.GameContentRepository(
|
||
_Storage(None), control_client=control, source_config={}, artifact_config={},
|
||
)
|
||
|
||
assert content.activate(
|
||
tenant_id=1, game_id=1024, version_id=2048,
|
||
revision_id="shanhai-r2", expected_current_version_id=1023,
|
||
) == 2048
|
||
assert control.activate_calls == [{
|
||
"tenant_id": 1,
|
||
"game_id": 1024,
|
||
"version_id": 2048,
|
||
"revision_id": "shanhai-r2",
|
||
"expected_current_version_id": 1023,
|
||
}]
|
||
|
||
|
||
def test_activate_rejects_backend_version_mismatch():
|
||
"""控制面若返回其它版本,Agent 必须拒绝把激活结果当成成功。"""
|
||
class WrongActivationControl(_Control):
|
||
def activate(self, **payload) -> int:
|
||
super().activate(**payload)
|
||
return 4096
|
||
|
||
content = repository.GameContentRepository(
|
||
_Storage(None), control_client=WrongActivationControl(),
|
||
source_config={}, artifact_config={},
|
||
)
|
||
|
||
with pytest.raises(repository.GameContentError, match="activate.*不一致"):
|
||
content.activate(
|
||
tenant_id=1, game_id=1024, version_id=2048,
|
||
revision_id="shanhai-r2", expected_current_version_id=None,
|
||
)
|
||
|
||
|
||
def test_commit_uploads_then_pending_then_finalizes_then_database_cas(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||
):
|
||
_make_game(tmp_path)
|
||
order: list[str] = []
|
||
storage = _Storage(None, order)
|
||
control = _Control(order)
|
||
|
||
monkeypatch.setattr(
|
||
repository, "upload_source_archive",
|
||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||
)
|
||
monkeypatch.setattr(
|
||
repository, "upload_manifest",
|
||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||
)
|
||
|
||
content = repository.GameContentRepository(
|
||
storage, control_client=control,
|
||
source_config={"source_bucket": "game-sources"},
|
||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||
now=lambda: datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||
)
|
||
result = content.commit(
|
||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||
revision_id="shanhai-r2", engine="custom-canvas-littlejs",
|
||
package_type="littlejs", runtime_manifest_path="game-package.json",
|
||
creator="agent",
|
||
)
|
||
|
||
assert order == ["prepare", "lock", "source", "artifact", "pending", "finalize", "committed"]
|
||
assert storage.calls == ["lock", "pending", "committed"]
|
||
assert control.finalize_calls == [{
|
||
"tenant_id": 1,
|
||
"game_id": 1024,
|
||
"version_id": 2048,
|
||
"revision_id": "shanhai-r2",
|
||
"artifact_manifest_hash": result["artifactManifestHash"],
|
||
"manifest_json": (tmp_path / "game-package.json").read_text(encoding="utf-8"),
|
||
}]
|
||
assert result["committed"] is True
|
||
assert result["versionId"] == "2048"
|
||
assert result["runtimePackageId"] == 31
|
||
assert result["activated"] is False
|
||
assert result["sourceHash"]
|
||
assert result["artifactManifestHash"]
|
||
|
||
|
||
def test_commit_rejects_version_not_returned_by_idempotent_prepare_before_upload(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||
):
|
||
"""commit 必须复核 prepare 幂等身份;版本不一致时不能触碰对象或 V34。"""
|
||
_make_game(tmp_path)
|
||
storage = _Storage(None)
|
||
control = _Control(version_id=4096)
|
||
upload_calls: list[str] = []
|
||
monkeypatch.setattr(repository, "upload_source_archive", lambda *_a, **_k: upload_calls.append("source"))
|
||
monkeypatch.setattr(repository, "upload_manifest", lambda *_a, **_k: upload_calls.append("artifact"))
|
||
content = repository.GameContentRepository(
|
||
storage, control_client=control,
|
||
source_config={"source_bucket": "game-sources"},
|
||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||
)
|
||
|
||
with pytest.raises(repository.GameContentError, match="versionId.*不一致"):
|
||
content.commit(
|
||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||
revision_id="shanhai-r2", engine="custom-canvas-littlejs", package_type="littlejs",
|
||
)
|
||
assert upload_calls == []
|
||
assert storage.calls == []
|
||
|
||
|
||
def test_finalize_failure_leaves_v34_pending_and_never_commits(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||
):
|
||
"""后端 Finalize 失败时 pending 可供排障/重试,但绝不能暴露为 committed。"""
|
||
_make_game(tmp_path)
|
||
order: list[str] = []
|
||
storage = _Storage(None, order)
|
||
control = _Control(order, finalize_failure=repository.GameContentError("finalize failed"))
|
||
monkeypatch.setattr(
|
||
repository, "upload_source_archive",
|
||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||
)
|
||
monkeypatch.setattr(
|
||
repository, "upload_manifest",
|
||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||
)
|
||
content = repository.GameContentRepository(
|
||
storage, control_client=control,
|
||
source_config={"source_bucket": "game-sources"},
|
||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||
)
|
||
|
||
with pytest.raises(repository.GameContentError, match="finalize failed"):
|
||
content.commit(
|
||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||
revision_id="shanhai-r2", engine="custom-canvas-littlejs", package_type="littlejs",
|
||
)
|
||
assert order == ["prepare", "lock", "source", "artifact", "pending", "finalize"]
|
||
assert storage.calls == ["lock", "pending"]
|
||
|
||
|
||
def test_cas_failure_is_safe_to_retry_after_idempotent_finalize(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||
):
|
||
"""Finalize 已成功但 V34 CAS 失败时,整次 commit 可按同一修订安全重放。"""
|
||
class FailFirstCasStorage(_Storage):
|
||
def __init__(self, order: list[str]):
|
||
super().__init__(None, order)
|
||
self.cas_attempts = 0
|
||
|
||
def commit_pending(self, record: dict, *, committed_at: datetime):
|
||
self._record_call("cas")
|
||
self.cas_attempts += 1
|
||
if self.cas_attempts == 1:
|
||
raise StorageRecordError("transient CAS failed")
|
||
return {
|
||
"status": "committed", "committed": True, "versionId": str(record["version_id"]),
|
||
"artifactManifestHash": record["artifact_manifest_hash"],
|
||
"sourceManifestHash": record["source_manifest_hash"],
|
||
}
|
||
|
||
_make_game(tmp_path)
|
||
order: list[str] = []
|
||
storage = FailFirstCasStorage(order)
|
||
control = _Control(order)
|
||
monkeypatch.setattr(
|
||
repository, "upload_source_archive",
|
||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||
)
|
||
monkeypatch.setattr(
|
||
repository, "upload_manifest",
|
||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||
)
|
||
content = repository.GameContentRepository(
|
||
storage, control_client=control,
|
||
source_config={"source_bucket": "game-sources"},
|
||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||
)
|
||
|
||
kwargs = {
|
||
"root": tmp_path, "tenant_id": 1, "game_id": 1024, "version_id": 2048,
|
||
"revision_id": "shanhai-r2", "engine": "custom-canvas-littlejs", "package_type": "littlejs",
|
||
}
|
||
with pytest.raises(StorageRecordError, match="transient CAS failed"):
|
||
content.commit(**kwargs)
|
||
result = content.commit(**kwargs)
|
||
|
||
one_attempt = ["prepare", "lock", "source", "artifact", "pending", "finalize", "cas"]
|
||
assert order == one_attempt + one_attempt
|
||
assert len(control.prepare_calls) == 2
|
||
assert len(control.finalize_calls) == 2
|
||
assert result["committed"] is True
|
||
assert result["runtimePackageId"] == 31
|
||
|
||
|
||
def test_cli_prepare_prints_backend_version_id(
|
||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||
):
|
||
"""CLI 提供显式 prepare 步骤,Agent 可据后端 ID 生成匹配的 GamePackage。"""
|
||
class PreparingRepository:
|
||
def prepare(self, **kwargs):
|
||
assert kwargs == {"tenant_id": 1, "game_id": 1024, "revision_id": "shanhai-r2"}
|
||
return 2048
|
||
|
||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: PreparingRepository())
|
||
|
||
exit_code = repository_cli.main([
|
||
"prepare", "--tenant-id", "1", "--game-id", "1024", "--revision-id", "shanhai-r2",
|
||
])
|
||
|
||
assert exit_code == 0
|
||
assert capsys.readouterr().out == "2048\n"
|
||
|
||
|
||
def test_cli_activate_passes_expected_current_and_prints_version_id(
|
||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||
):
|
||
"""CLI 把人工/浏览器验收后的激活动作保持为独立显式步骤。"""
|
||
class ActivatingRepository:
|
||
def activate(self, **kwargs):
|
||
assert kwargs == {
|
||
"tenant_id": 1,
|
||
"game_id": 1024,
|
||
"version_id": 2048,
|
||
"revision_id": "shanhai-r2",
|
||
"expected_current_version_id": 1023,
|
||
}
|
||
return 2048
|
||
|
||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: ActivatingRepository())
|
||
|
||
exit_code = repository_cli.main([
|
||
"activate", "--tenant-id", "1", "--game-id", "1024", "--version-id", "2048",
|
||
"--revision-id", "shanhai-r2", "--expected-current-version-id", "1023",
|
||
])
|
||
|
||
assert exit_code == 0
|
||
assert capsys.readouterr().out == "2048\n"
|
||
|
||
|
||
@pytest.mark.parametrize("failure", [
|
||
ArtifactError("artifact failed"),
|
||
StorageRecordError("storage failed"),
|
||
])
|
||
def test_cli_wraps_all_content_domain_errors_without_traceback(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], failure: Exception,
|
||
):
|
||
"""CLI 必须把上传与 CAS 领域错误收敛成稳定退出码,不能泄漏 traceback。"""
|
||
class FailingRepository:
|
||
def checkout(self, **_kwargs):
|
||
raise failure
|
||
|
||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: FailingRepository())
|
||
|
||
exit_code = repository_cli.main([
|
||
"checkout", "--tenant-id", "1", "--game-id", "1024", "--version-id", "2048",
|
||
"--target", str(tmp_path / "work"),
|
||
])
|
||
captured = capsys.readouterr()
|
||
|
||
assert exit_code == 2
|
||
assert captured.out == f"GAME-CONTENT-ERROR:{failure}\n"
|
||
assert captured.err == ""
|