冻结 GameArtifactManifest/GameSourceArchive 契约和 V34 pending/committed 状态记录,增加专用 writer 的全量回读物化校验,并让 Runtime API 在消费和发布前对账 committed、GamePackage checksum 与实际 engineBundle hash。隔离 staging 已验证真实消费拒绝未提交版本;源码桶权限仍保持 fail-closed,正式金标不切换。
302 lines
12 KiB
Python
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-*"))
|