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

672 lines
28 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": [{
"id": "hero",
"type": "image",
"url": "https://assets.invalid/hero.webp",
"hash": hashlib.sha256(b"asset").hexdigest(),
"bytes": len(b"asset"),
"mime": "image/webp",
}],
"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",
},
"engineBundle": "bundle",
}
(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 _read_game_package(root: Path) -> dict:
"""读取测试游戏包,供负样本只改变一个契约轴。"""
return json.loads((root / "game-package.json").read_text(encoding="utf-8"))
def _write_game_package(root: Path, game_package: dict) -> None:
"""按生产构建器接受的紧凑 UTF-8 JSON 口径写回测试游戏包。"""
(root / "game-package.json").write_text(
json.dumps(game_package, ensure_ascii=False, separators=(",", ":")), encoding="utf-8",
)
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),
)
def test_build_rejects_engine_bundle_without_runtime_bundle(tmp_path: Path):
"""删除外部 bundle 后,内联 engineBundle 不能被标成 committed 候选。"""
_make_game(tmp_path)
(tmp_path / "dist/bundle.iife.js").unlink()
with pytest.raises(store.ArtifactError, match="engineBundle.*runtime bundle"):
_build(tmp_path)
def test_build_rejects_runtime_bundle_without_engine_bundle(tmp_path: Path):
"""外部 bundle 存在时,GamePackage 不能省略实际运行的内联代码。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package.pop("engineBundle")
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="engineBundle.*runtime bundle"):
_build(tmp_path)
def test_build_rejects_engine_bundle_content_mismatch(tmp_path: Path):
"""GamePackage 与将要上传的 runtime bundle 必须是同一份 UTF-8 字节。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["engineBundle"] = "different-bundle"
game_package["manifest"]["bundleSize"] = len("different-bundle".encode("utf-8"))
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="engineBundle.*内容"):
_build(tmp_path)
def test_build_rejects_engine_bundle_size_mismatch(tmp_path: Path):
"""GamePackage 声明的 bundleSize 必须等于宿主实际执行文本的 UTF-8 字节数。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["manifest"]["bundleSize"] += 1
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="bundleSize.*UTF-8"):
_build(tmp_path)
def test_build_rejects_game_package_asset_hash_not_in_artifact_objects(tmp_path: Path):
"""GamePackage 不能声明对象索引中不存在的素材内容。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["assets"][0]["hash"] = "0" * 64
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="素材.*hash"):
_build(tmp_path)
def test_build_rejects_game_package_asset_bytes_mismatch(tmp_path: Path):
"""同一素材 hash 的声明字节数漂移时必须在上传前拒绝。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["assets"][0]["bytes"] += 1
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="素材.*bytes"):
_build(tmp_path)
def test_build_rejects_game_package_asset_mime_mismatch(tmp_path: Path):
"""同一素材 hash 的 MIME 漂移时必须在上传前拒绝。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["assets"][0]["mime"] = "image/png"
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="素材.*mime"):
_build(tmp_path)
def test_build_allows_distinct_logical_assets_to_reuse_one_content_hash(tmp_path: Path):
"""契约只要求逻辑 ID 唯一;相同内容由一个 artifact 对象寻址并可被多个 ID 复用。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
duplicate = dict(game_package["assets"][0])
duplicate["id"] = "hero-copy"
game_package["assets"].append(duplicate)
_write_game_package(tmp_path, game_package)
manifest = _build(tmp_path)
assert len(game_package["assets"]) == 2
assert sum(item["category"] == "asset" for item in manifest["objects"]) == 1
def test_build_rejects_duplicate_game_package_asset_id(tmp_path: Path):
"""同一逻辑 ID 不能重复,即使两个声明都精确指向同一物理内容。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
game_package["assets"].append(dict(game_package["assets"][0]))
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="素材 id 重复"):
_build(tmp_path)
def test_reused_asset_hash_still_requires_each_logical_mime_to_match(tmp_path: Path):
"""内容复用不放松元数据完整性;任一逻辑声明的 MIME 漂移仍须拒绝。"""
_make_game(tmp_path)
game_package = _read_game_package(tmp_path)
duplicate = dict(game_package["assets"][0])
duplicate["id"] = "hero-copy"
duplicate["mime"] = "image/png"
game_package["assets"].append(duplicate)
_write_game_package(tmp_path, game_package)
with pytest.raises(store.ArtifactError, match="素材 mime.*hero-copy"):
_build(tmp_path)
def test_build_rejects_duplicate_artifact_asset_hash(tmp_path: Path):
"""一个声明 hash 匹配多个物理对象时必须拒绝,不能任取第一个。"""
_make_game(tmp_path)
(tmp_path / "assets/hero-copy.webp").write_bytes(b"asset")
with pytest.raises(store.ArtifactError, match="素材.*重复"):
_build(tmp_path)
def test_build_allows_unreferenced_lazy_artifact_asset(tmp_path: Path):
"""对象索引可保留 GamePackage 尚未声明的懒加载素材。"""
_make_game(tmp_path)
(tmp_path / "assets/lazy.webp").write_bytes(b"lazy-asset")
manifest = _build(tmp_path)
assert sum(item["category"] == "asset" for item in manifest["objects"]) == 2
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_schema_rejects_provider_manifest_ref_mismatch(tmp_path: Path):
"""Schema 必须和 Python 门保持 provider 与 manifestRef 的同一档位约束。"""
_make_game(tmp_path)
manifest = _build(tmp_path)
manifest["sourceRevision"]["provider"] = "game_source_archive"
manifest["sourceRevision"]["manifestRef"] = "tier2-source-project:night-market:m4-r1"
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("game-source-archive" 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_remote_read_wraps_sdk_get_failure():
"""对象 GET 的 SDK/网络异常必须统一成可审计 ArtifactError。"""
class BrokenClient:
def get_object(self, _bucket: str, _key: str):
raise RuntimeError("network down")
with pytest.raises(store.ArtifactError, match="获取远端对象失败"):
store._read_remote(BrokenClient(), "game-artifacts", "tenants/1/probe", expected_bytes=1)
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_public_fetch_wraps_missing_manifest_sdk_error(monkeypatch: pytest.MonkeyPatch):
"""公共读取边界不得把 MinIO NoSuchKey 类型泄漏给调用方。"""
class MissingClient:
def get_object(self, _bucket: str, _key: str):
raise _NoSuchKey("missing manifest")
monkeypatch.setattr(store, "_minio_client", lambda _config: MissingClient())
with pytest.raises(store.ArtifactError, match="artifact-manifest 不存在"):
store.fetch_manifest(
{}, key="tenants/1/games/1024/versions/2048/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_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",
)