games-development-ai/tier2/gen-worker/tests/test_game_content_repository.py
lili 1981a6be01
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
2026-07-30 05:21:44 -07:00

586 lines
23 KiB
Python
Raw 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.

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