"""引擎无关游戏源归档的契约与对象存储离线测试。""" 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, )