"""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 == ""