games-development-ai/tier2/gen-worker/tests/test_game_source_archive.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

302 lines
12 KiB
Python

"""引擎无关游戏源归档的契约与对象存储离线测试。"""
import json
import os
import subprocess
import sys
import unicodedata
from io import BytesIO
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
from worker import game_artifact_store as artifact_store
from worker import game_source_archive_store as store
TENANT_ID = "1"
GAME_ID = "1024"
REVISION_ID = "shanhai-r1"
def _make_source(root: Path) -> None:
"""创建包含源码、二进制源码资产、制品和证据的最小归档输入。"""
(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 / "src" / "sprite.bin").write_bytes(bytes([0, 1, 2, 255]))
(root / "assets" / "hero.webp").write_bytes(b"runtime-asset")
(root / "dist" / "bundle.iife.js").write_bytes(b"runtime-bundle")
(root / "evidence" / "qa.md").write_text("evidence", encoding="utf-8")
(root / "src" / "__pycache__").mkdir()
(root / "src" / "__pycache__" / "ignored.pyc").write_bytes(b"cache")
def _build(root: Path) -> dict:
return store.build_source_archive(
root, TENANT_ID, GAME_ID, REVISION_ID, engine="custom-canvas-littlejs",
)
def test_build_and_verify_engine_agnostic_source_archive(tmp_path: Path):
_make_source(tmp_path)
manifest = _build(tmp_path)
store.verify_source_archive(manifest)
assert manifest["schemaVersion"] == "GameSourceArchive/1"
assert manifest["keyPrefix"] == f"tenants/{TENANT_ID}/games/{GAME_ID}/source-revisions/{REVISION_ID}"
assert {item["sourcePath"] for item in manifest["objects"]} == {
"src/main.js", "src/sprite.bin",
}
assert all(item["path"].startswith("source/") for item in manifest["objects"])
assert manifest["totals"]["objects"] == 2
assert manifest["totals"]["bytes"] == len("export const GAME = 1;\n".encode()) + 4
assert manifest["sourceHash"] == store.source_hash_from_files(
[(item["sourcePath"], tmp_path / item["sourcePath"]) for item in manifest["objects"]]
)
schema_path = Path(__file__).resolve().parents[3] / "contracts/game-source-archive.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_source_hash_is_binary_safe_and_mutation_is_detected(tmp_path: Path):
_make_source(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "src" / "sprite.bin").write_bytes(bytes([0, 1, 3, 255]))
with pytest.raises(store.ArtifactError, match="源归档 hash"):
store.build_source_archive(
tmp_path, TENANT_ID, GAME_ID, REVISION_ID,
engine="custom-canvas-littlejs", expected_source_hash=manifest["sourceHash"],
)
manifest = _build(tmp_path)
manifest["objects"][0]["bytes"] += 1
with pytest.raises(store.ArtifactError, match="manifestHash"):
store.verify_source_archive(manifest)
def test_source_hash_sorts_after_nfc_normalization(tmp_path: Path):
nfd_name = unicodedata.normalize("NFD", "á.js")
first = tmp_path / nfd_name
second = tmp_path / "z.js"
first.write_bytes(b"a")
second.write_bytes(b"z")
files = [(nfd_name, first), ("z.js", second)]
assert store.source_hash_from_files(files) == artifact_store._source_archive_hash(files)[2]
def test_default_runtime_manifest_is_not_source(tmp_path: Path):
_make_source(tmp_path)
(tmp_path / "game-package.json").write_text("runtime manifest", encoding="utf-8")
manifest = _build(tmp_path)
assert "game-package.json" not in {item["sourcePath"] for item in manifest["objects"]}
def test_empty_source_tree_is_rejected_consistently(tmp_path: Path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "empty.js").write_bytes(b"")
with pytest.raises(store.ArtifactError, match="bytes|全空"):
_build(tmp_path)
def test_cli_build_reports_source_archive_identity(tmp_path: Path):
_make_source(tmp_path)
script = Path(__file__).resolve().parents[1] / "scripts/game_source_archive_sync.py"
result = subprocess.run(
[
sys.executable, str(script),
"--root", str(tmp_path),
"--tenant-id", TENANT_ID,
"--game-id", GAME_ID,
"--revision-id", REVISION_ID,
"--engine", "custom-canvas-littlejs",
],
check=False,
capture_output=True,
text=True,
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])},
)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["gameId"] == GAME_ID
assert output["objects"] == 2
assert output["sourceHash"] == _build(tmp_path)["sourceHash"]
def test_source_archive_rejects_unsafe_paths_and_symlinks(tmp_path: Path):
_make_source(tmp_path)
outside = tmp_path.parent / "outside-source-secret.bin"
outside.write_bytes(b"secret")
(tmp_path / "src" / "linked.bin").symlink_to(outside)
with pytest.raises(store.ArtifactError, match="符号链接"):
_build(tmp_path)
class _Response:
"""模拟 MinIO 响应,覆盖上传器使用的连接释放协议。"""
def __init__(self, data: bytes):
self._data = data
self._offset = 0
def read(self, size: int = -1) -> bytes:
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):
code = "NoSuchKey"
class _FakeMinio:
"""内存对象存储,验证源桶隔离、幂等和原子下载。"""
def __init__(self):
self.objects: dict[tuple[str, str], bytes] = {}
self.put_calls: list[tuple[str, str]] = []
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:
if (bucket, key) not in self.objects:
raise _NoSuchKey(key)
return _Response(self.objects[(bucket, key)])
def test_upload_is_idempotent_and_download_is_atomic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
with pytest.raises(store.ArtifactError, match="外部单写"):
store.upload_source_archive(tmp_path, manifest, config)
first = store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
second = store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
assert first["idempotent"] is False
assert second["idempotent"] is True
assert first["status"] == second["status"] == "verified_pending"
assert first["committed"] is second["committed"] is False
assert all(bucket == "game-sources" for bucket, _ in client.put_calls)
assert ("game-sources", first["sourceManifestKey"]) in client.objects
remote = store.fetch_source_archive(
config, key=first["sourceManifestKey"],
expected_manifest_hash=manifest["manifestHash"],
expected_tenant_id=TENANT_ID, expected_game_id=GAME_ID,
expected_revision_id=REVISION_ID,
)
target = tmp_path / "materialized"
store.download_source_archive(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert (target / "src" / "main.js").read_text(encoding="utf-8") == "export const GAME = 1;\n"
assert (target / "src" / "sprite.bin").read_bytes() == bytes([0, 1, 2, 255])
assert not (target / "assets" / "hero.webp").exists()
def test_source_upload_preflights_all_files_before_put(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "src" / "sprite.bin").write_bytes(b"drift")
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="hash 漂移"):
store.upload_source_archive(
tmp_path, manifest, {"source_bucket": "game-sources"}, external_single_writer=True,
)
assert client.put_calls == []
def test_source_upload_requires_explicit_source_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio())
with pytest.raises(store.ArtifactError, match="source_bucket"):
store.upload_source_archive(tmp_path, manifest, {}, external_single_writer=True)
def test_source_bucket_cannot_alias_artifact_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio())
with pytest.raises(store.ArtifactError, match="不能与 artifact"):
store.upload_source_archive(
tmp_path, manifest,
{"source_bucket": "game-artifacts", "artifact_bucket": "game-artifacts"},
external_single_writer=True,
)
def test_source_upload_wraps_sdk_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
class BrokenClient:
def get_object(self, _bucket, _key):
raise _NoSuchKey("missing marker")
def put_object(self, *_args, **_kwargs):
raise RuntimeError("network down")
monkeypatch.setattr(store, "_minio_client", lambda _config: BrokenClient())
with pytest.raises(store.ArtifactError, match="对象上传失败"):
store.upload_source_archive(
tmp_path, manifest, {"source_bucket": "game-sources"}, external_single_writer=True,
)
def test_source_archive_does_not_overwrite_different_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
changed = dict(manifest)
changed["engine"] = "littlejs"
unsigned = dict(changed)
unsigned.pop("manifestHash")
changed["manifestHash"] = artifact_store._hash_bytes(
store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned),
)
puts_before = list(client.put_calls)
with pytest.raises(store.ArtifactError, match="拒绝覆盖"):
store.upload_source_archive(tmp_path, changed, config, external_single_writer=True)
assert client.put_calls == puts_before
def test_download_failure_leaves_no_partial_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
first = next(item for item in manifest["objects"] if item["sourcePath"] == "src/main.js")
client.objects[("game-sources", first["key"])] = b"wrong"
target = tmp_path / "materialized"
with pytest.raises(store.ArtifactError, match="下载对象 hash"):
store.download_source_archive(manifest, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not target.exists()
assert not list(tmp_path.glob(".materialized.partial-*"))