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

331 lines
13 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_bucket_cannot_alias_default_artifact_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""底层 source store 未显式带 artifact_bucket 时仍拒绝默认制品桶。"""
_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"}, 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-*"))
def test_public_fetch_wraps_missing_source_manifest_sdk_error(monkeypatch: pytest.MonkeyPatch):
"""公共源归档读取边界不得把 MinIO NoSuchKey 类型泄漏给 Agent。"""
class MissingClient:
def get_object(self, _bucket: str, _key: str):
raise _NoSuchKey("missing source manifest")
monkeypatch.setattr(store, "_minio_client", lambda _config: MissingClient())
with pytest.raises(store.ArtifactError, match="source-manifest 不存在"):
store.fetch_source_archive(
{"source_bucket": "game-sources"},
key="tenants/1/games/1024/source-revisions/shanhai-r1/source-manifest.json",
expected_manifest_hash="0" * 64,
expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID,
expected_revision_id=REVISION_ID,
)