games-development-ai/tier2/gen-worker/tests/test_game_artifact_store.py
lili 17727d08c3
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 接入游戏内容对象存储与运行时提交门
冻结 GameArtifactManifest/GameSourceArchive 契约和 V34 pending/committed 状态记录,增加专用 writer 的全量回读物化校验,并让 Runtime API 在消费和发布前对账 committed、GamePackage checksum 与实际 engineBundle hash。隔离 staging 已验证真实消费拒绝未提交版本;源码桶权限仍保持 fail-closed,正式金标不切换。
2026-07-29 12:02:22 -07:00

478 lines
20 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.

"""游戏内容对象存储契约的离线测试。"""
import hashlib
import json
import subprocess
import sys
from io import BytesIO
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
from worker import game_artifact_store as store
from worker import game_source_archive_store as source_store
TENANT_ID = "1"
GAME_ID = "1024"
VERSION_ID = "2048"
def _make_game(root: Path, *, package_game_id: str = GAME_ID, package_version_id: str = VERSION_ID) -> None:
"""创建含完整 GamePackage、源工程、素材、runtime 和证据的最小游戏包。"""
(root / "src").mkdir(parents=True)
(root / "assets").mkdir(parents=True)
(root / "dist").mkdir(parents=True)
(root / "evidence").mkdir(parents=True)
(root / "src" / "main.js").write_text("export const GAME = 1;\n", encoding="utf-8")
(root / "assets" / "hero.webp").write_bytes(b"asset")
(root / "dist" / "bundle.iife.js").write_bytes(b"bundle")
(root / "dist" / "scan.json").write_text("{}", encoding="utf-8")
(root / "evidence" / "qa.md").write_text("verified", encoding="utf-8")
game_package = {
"schemaVersion": "1.0",
"gameId": package_game_id,
"versionId": package_version_id,
"templateId": "runner",
"gameConfig": {},
"assets": [],
"manifest": {
"runtimeVersion": "1.0.0",
"entry": "index.html",
"preloadPolicy": "eager",
"bundleSize": len(b"bundle"),
"checksum": "0" * 64,
},
"meta": {
"title": "契约测试游戏",
"summary": "只用于平台契约测试",
"cover": "https://assets.invalid/cover.webp",
"ageRating": "all",
},
}
(root / "game-package.json").write_text(
json.dumps(game_package, ensure_ascii=False, separators=(",", ":")), encoding="utf-8",
)
(root / "src" / "__pycache__").mkdir()
(root / "src" / "__pycache__" / "ignored.pyc").write_bytes(b"cache")
def _source_revision(root: Path, *, source_hash: str | None = None) -> dict:
"""构造指向现有 Tier2 SourceProjectStore 的不可变源修订身份。"""
if source_hash is None:
digest = hashlib.sha256()
digest.update(b"src/main.js\0")
digest.update("export const GAME = 1;\n\0".encode())
source_hash = digest.hexdigest()
return {
"provider": "tier2_source_project",
"gameId": "night-market",
"revisionId": "m4-r1",
"manifestRef": "tier2-source-project:night-market:m4-r1",
"sourceHash": source_hash,
}
def _build(root: Path) -> dict:
return store.build_manifest(
root, TENANT_ID, GAME_ID, VERSION_ID,
package_type="phaser",
runtime_manifest_path="game-package.json",
source_revision=_source_revision(root),
)
def test_build_and_verify_manifest(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
store.verify_manifest(manifest)
assert manifest["totals"]["objects"] == 4
assert manifest["totals"]["assetBytes"] == len(b"asset")
assert manifest["bundleHash"] == store._hash_bytes(b"bundle")
assert manifest["runtimeManifest"]["key"].endswith("/manifest.json")
assert manifest["sourceRevision"]["revisionId"] == "m4-r1"
assert not any(item["category"] == "source" for item in manifest["objects"])
assert not any("ignored.pyc" in item["path"] for item in manifest["objects"])
schema_path = Path(__file__).resolve().parents[3] / "contracts/game-artifact-manifest.schema.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
errors = sorted(Draft202012Validator(schema).iter_errors(manifest), key=lambda item: list(item.path))
assert not errors, [error.message for error in errors]
assert _build(tmp_path)["manifestHash"] == manifest["manifestHash"]
def test_manifest_hash_and_schema_detect_mutation(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
manifest["objects"][0]["bytes"] += 1
with pytest.raises(store.ArtifactError, match="manifestHash"):
store.verify_manifest(manifest)
manifest = _build(tmp_path)
manifest["status"] = "published"
unsigned = dict(manifest)
unsigned.pop("manifestHash")
manifest["manifestHash"] = store._hash_bytes(store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned))
with pytest.raises(store.ArtifactError, match="game-artifact-manifest.schema.json"):
store.verify_manifest(manifest)
def test_rejects_symlink_and_source_hash_drift(tmp_path: Path):
_make_game(tmp_path)
outside = tmp_path.parent / "outside-game-secret.txt"
outside.write_text("secret", encoding="utf-8")
(tmp_path / "src" / "linked.txt").symlink_to(outside)
with pytest.raises(store.ArtifactError, match="符号链接"):
_build(tmp_path)
(tmp_path / "src" / "linked.txt").unlink()
with pytest.raises(store.ArtifactError, match="源工程 hash"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID, package_type="phaser",
runtime_manifest_path="game-package.json",
source_revision=_source_revision(tmp_path, source_hash="0" * 64),
)
def test_game_package_contract_and_identity_are_enforced(tmp_path: Path):
_make_game(tmp_path, package_version_id="9999")
with pytest.raises(store.ArtifactError, match="对象版本不匹配"):
_build(tmp_path)
with pytest.raises(store.ArtifactError, match="数据库正整数"):
store.build_manifest(
tmp_path, TENANT_ID, "night-market", "m4-r1", package_type="phaser",
runtime_manifest_path="game-package.json", source_revision=_source_revision(tmp_path),
)
class _Response:
"""模拟 MinIO HTTPResponse覆盖连接释放协议。"""
def __init__(self, data: bytes):
self._data = data
self._offset = 0
self.read_sizes: list[int] = []
def read(self, size: int = -1) -> bytes:
self.read_sizes.append(size)
if size < 0:
size = len(self._data) - self._offset
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def close(self) -> None:
return None
def release_conn(self) -> None:
return None
class _NoSuchKey(Exception):
"""模拟 MinIO S3Error 的稳定 code。"""
code = "NoSuchKey"
class _NoSuchBucket(Exception):
"""模拟桶缺失,必须与对象缺失采用不同失败语义。"""
code = "NoSuchBucket"
class _FakeMinio:
"""内存对象存储,用于验证上传、幂等、可信下载和原子物化。"""
def __init__(self):
self.objects: dict[tuple[str, str], bytes] = {}
self.bucket_exists_calls = 0
self.make_bucket_calls = 0
self.put_calls: list[tuple[str, str]] = []
self.last_response: _Response | None = None
def bucket_exists(self, _bucket: str) -> bool:
self.bucket_exists_calls += 1
raise AssertionError("上传器禁止探测/列出桶")
def make_bucket(self, _bucket: str) -> None:
self.make_bucket_calls += 1
def put_object(self, bucket: str, key: str, stream: BytesIO, *, length: int, content_type: str):
data = stream.read()
assert len(data) == length
assert content_type
self.put_calls.append((bucket, key))
self.objects[(bucket, key)] = data
def get_object(self, bucket: str, key: str) -> _Response:
identity = (bucket, key)
if identity not in self.objects:
raise _NoSuchKey(key)
self.last_response = _Response(self.objects[identity])
return self.last_response
def _trusted_fetch(store_config: dict, key: str, manifest: dict) -> dict:
return store.fetch_manifest(
store_config, key=key,
expected_manifest_hash=manifest["manifestHash"],
expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID,
expected_version_id=VERSION_ID,
)
def test_upload_download_and_idempotency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
game = tmp_path / "game"
_make_game(game)
manifest = _build(game)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
first = store.upload_manifest(game, manifest, config, external_single_writer=True)
assert first["idempotent"] is False
assert first["status"] == "verified_pending"
assert first["committed"] is False
second = store.upload_manifest(game, manifest, config, external_single_writer=True)
assert second["idempotent"] is True
assert second["status"] == "verified_pending"
assert second["committed"] is False
prefix = f"tenants/{TENANT_ID}/games/{GAME_ID}/versions/{VERSION_ID}"
assert ("game-artifacts", f"{prefix}/assets/hero.webp") in client.objects
assert ("game-evidence", f"{prefix}/evidence/qa.md") in client.objects
assert not any("source/src/main.js" in key for _, key in client.objects)
remote = _trusted_fetch(config, first["artifactManifestKey"], manifest)
target = tmp_path / "materialized"
store.download_manifest(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not (target / "source").exists()
assert (target / "assets/hero.webp").read_bytes() == b"asset"
assert json.loads((target / "manifest.json").read_text(encoding="utf-8"))["schemaVersion"] == "1.0"
changed = dict(manifest)
changed["sourceRevision"] = dict(manifest["sourceRevision"])
changed["sourceRevision"]["revisionId"] = "m4-r2"
changed["sourceRevision"]["manifestRef"] = "tier2-source-project:night-market:m4-r2"
unsigned = dict(changed)
unsigned.pop("manifestHash")
changed["manifestHash"] = store._hash_bytes(store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned))
puts_before = list(client.put_calls)
marker_before = client.objects[("game-artifacts", first["artifactManifestKey"])]
with pytest.raises(store.ArtifactError, match="拒绝覆盖"):
store.upload_manifest(game, changed, config, external_single_writer=True)
assert client.put_calls == puts_before
assert client.objects[("game-artifacts", first["artifactManifestKey"])] == marker_before
def test_partial_objects_require_external_single_writer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""波次 1 可覆盖无 marker 的残留 partial但必须由调用方持有外部单写锁。"""
_make_game(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
asset = next(item for item in manifest["objects"] if item["category"] == "asset")
client.objects[("game-artifacts", asset["key"])] = b"stale-partial"
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
with pytest.raises(store.ArtifactError, match="外部单写"):
store.upload_manifest(tmp_path, manifest, config)
assert client.objects[("game-artifacts", asset["key"])] == b"stale-partial"
receipt = store.upload_manifest(tmp_path, manifest, config, external_single_writer=True)
assert receipt["status"] == "verified_pending"
assert receipt["committed"] is False
assert client.objects[("game-artifacts", asset["key"])] == b"asset"
def test_artifact_upload_preflights_runtime_manifest_before_put(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_game(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "game-package.json").write_text("drift", encoding="utf-8")
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="GamePackage manifest 本地 hash 漂移"):
store.upload_manifest(tmp_path, manifest, {
"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence",
}, external_single_writer=True)
assert client.put_calls == []
def test_trusted_anchor_and_atomic_materialization(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
game = tmp_path / "game"
_make_game(game)
manifest = _build(game)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
receipt = store.upload_manifest(game, manifest, config, external_single_writer=True)
with pytest.raises(store.ArtifactError, match="数据库期望 hash"):
store.fetch_manifest(
config, key=receipt["artifactManifestKey"], expected_manifest_hash="0" * 64,
expected_tenant_id=TENANT_ID, expected_game_id=GAME_ID, expected_version_id=VERSION_ID,
)
remote = _trusted_fetch(config, receipt["artifactManifestKey"], manifest)
asset = next(item for item in manifest["objects"] if item["category"] == "asset")
client.objects[("game-artifacts", asset["key"])] = b"wrong"
target = tmp_path / "materialized"
with pytest.raises(store.ArtifactError, match="下载对象 hash"):
store.download_manifest(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not target.exists()
assert not list(tmp_path.glob(".materialized.partial-*"))
def test_fetch_rejects_untrusted_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio())
with pytest.raises(store.ArtifactError, match="key 非法"):
store.fetch_manifest(
{}, key="https://attacker.invalid/artifact-manifest.json",
expected_manifest_hash="0" * 64, expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID, expected_version_id=VERSION_ID,
)
def test_upload_never_probes_or_creates_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_game(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
store.upload_manifest(tmp_path, _build(tmp_path), {}, external_single_writer=True)
assert client.bucket_exists_calls == 0
assert client.make_bucket_calls == 0
def test_missing_runtime_manifest_is_rejected(tmp_path: Path):
_make_game(tmp_path)
with pytest.raises(store.ArtifactError, match="GamePackage manifest 不存在"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID, package_type="phaser",
runtime_manifest_path="missing.json", source_revision=_source_revision(tmp_path),
)
def test_cli_build_reports_source_revision_hash(tmp_path: Path):
"""CLI 构建结果必须从 sourceRevision 输出源 hash不能读取不存在的顶层字段。"""
_make_game(tmp_path)
source_revision = _source_revision(tmp_path)
script = Path(__file__).resolve().parents[1] / "scripts/game_artifact_sync.py"
result = subprocess.run(
[
sys.executable, str(script),
"--root", str(tmp_path),
"--tenant-id", TENANT_ID,
"--game-id", GAME_ID,
"--version-id", VERSION_ID,
"--package-type", "phaser",
"--runtime-manifest", "game-package.json",
"--source-provider", source_revision["provider"],
"--source-game-id", source_revision["gameId"],
"--source-revision-id", source_revision["revisionId"],
"--source-manifest-ref", source_revision["manifestRef"],
"--source-hash", source_revision["sourceHash"],
],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
receipt = json.loads(result.stdout)
assert receipt["sourceHash"] == source_revision["sourceHash"]
def test_only_tier2_source_provider_is_supported(tmp_path: Path):
"""Tier0/1 尚无统一 SourceProjectStore hash 口径,禁止提前声明 provider。"""
_make_game(tmp_path)
revision = _source_revision(tmp_path)
revision.update({
"provider": "studio_source_project",
"manifestRef": "studio-source-project:night-market:m4-r1",
})
with pytest.raises(store.ArtifactError, match="sourceRevision.provider"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="phaser", runtime_manifest_path="game-package.json",
source_revision=revision,
)
def test_engine_agnostic_source_provider_binds_binary_safe_hash(tmp_path: Path):
"""LittleJS/Canvas 等游戏可绑定独立源归档,但不能复用 Tier2 文本 hash。"""
_make_game(tmp_path)
source_hash = source_store.source_hash_from_files([
("src/main.js", tmp_path / "src/main.js"),
])
manifest = store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="littlejs",
runtime_manifest_path="game-package.json",
source_revision={
"provider": "game_source_archive",
"gameId": GAME_ID,
"revisionId": "shanhai-r1",
"manifestRef": f"game-source-archive:{GAME_ID}:shanhai-r1",
"sourceHash": source_hash,
},
)
store.verify_manifest(manifest)
assert manifest["sourceRevision"]["provider"] == "game_source_archive"
assert manifest["sourceRevision"]["sourceHash"] == source_hash
def test_tier2_source_provider_cannot_bind_littlejs_package(tmp_path: Path):
_make_game(tmp_path)
with pytest.raises(store.ArtifactError, match="packageType=phaser"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="littlejs", runtime_manifest_path="game-package.json",
source_revision=_source_revision(tmp_path),
)
def test_schema_rejects_tier2_source_with_littlejs_package(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
manifest["packageType"] = "littlejs"
schema_path = Path(__file__).resolve().parents[3] / "contracts/game-artifact-manifest.schema.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
errors = list(Draft202012Validator(schema).iter_errors(manifest))
assert any("phaser" in error.message for error in errors)
def test_remote_read_stops_at_expected_bytes_plus_one():
"""远端响应超限时只多读一个字节,不把任意大对象完整载入内存。"""
client = _FakeMinio()
key = "tenants/1/games/1024/versions/2048/runtime/bundle.js"
client.objects[("game-artifacts", key)] = b"0123456789"
with pytest.raises(store.ArtifactError, match="超过期望字节数"):
store._read_remote(client, "game-artifacts", key, expected_bytes=4)
assert client.last_response is not None
assert client.last_response._offset == 5
assert client.last_response.read_sizes == [5]
def test_fetch_manifest_rejects_oversized_remote_payload(monkeypatch: pytest.MonkeyPatch):
"""artifact manifest 也必须有固定读取上限,不能因伪造 marker 耗尽内存。"""
client = _FakeMinio()
key = "tenants/1/games/1024/versions/2048/artifact-manifest.json"
client.objects[("game-artifacts", key)] = b"x" * (8 * 1024 * 1024 + 1)
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="超过期望字节数"):
store.fetch_manifest(
{}, key=key, expected_manifest_hash="0" * 64,
expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID,
expected_version_id=VERSION_ID,
)
def test_missing_bucket_is_not_treated_as_missing_marker():
"""桶缺失表示部署错误,不能降级成尚未上传。"""
class MissingBucketClient:
def get_object(self, _bucket: str, _key: str):
raise _NoSuchBucket("game-artifacts")
with pytest.raises(store.ArtifactError, match="对象存储桶不存在"):
store._existing_manifest(
MissingBucketClient(), "game-artifacts",
"tenants/1/games/1024/versions/2048/artifact-manifest.json",
)